背景:
在使用阿里云OpenSearch过程中发现官方Java SDK存在以下问题:
- 搜索条件设置需要拼接字符串来实现,不太友好;
- 搜索返回结果是字符串类型,需要接入方自己解析;
- 应用结构schema只能hard coding;
- 有一定的学习成本,比如某种类型的索引查询语法有哪些限制。
改进思路:
开发一个逆向工程框架fluent-search,具有以下功能:
- 自动根据OpenSearch的应用结构schema生成代码(查询条件类和返回结果类);
- 搜索条件的封装使用流式API的方式,隐藏复杂性;用API引导用户对语法有差异的索引或属性字段设置查询或过滤条件。
接入步骤:
1. 引入SDK包:
<dependency> <groupId>com.aliyun.gts.search</groupId> <artifactId>fluent-search-processor</artifactId> <version>2.0.0-SNAPSHOT</version> </dependency>
2. 应用增加配置:
fluent: search: #目前仅支持openSearch,后续支持es等 engine: openSearch #配置ak和sk openSearchAccessKey: openSearchAccessSecret: #openSearch接入点 openSearchHost: http://opensearch-cn-shanghai.aliyuncs.com
3. 代码生成
新建一个工具类,使用如下方式生成代码:
package com.aliyun.gts.fluent.search.test; import com.aliyun.gts.fluent.search.processor.util.FluentSearchGenerator; import com.aliyuncs.exceptions.ClientException; import com.google.common.collect.Maps; import java.io.IOException; import java.util.HashMap; /** * @author zouge */ public class TestGenerator { public static void main(String[] args) throws IOException, ClientException { //配置要生成代码的应用名称和应用id HashMap<String, Long> map = Maps.newHashMap(); map.put("document_center_node", 150071013L); map.put("document_center_send_record", 150064670L); map.put("dayu_xaccount", 150053418L); map.put("dayu_xaccount_dayu_user", 150052991L); map.put("dayu_xaccount_dayu_account", 150058690L); FluentSearchGenerator build = FluentSearchGenerator.builder() //配置opensearch公网接入点 .domain("opensearch.cn-shanghai.aliyuncs.com") //配置regionId .regionId("cn-shanghai") //填入ak和sk,注意只是临时填写,生成完代码之后一定删掉,不要提交到git里! .accessKey("") .accessSecret("") //应用 .appNames(map) //生成代码放在哪个模块 .srcDir("fluent-search-test/src/main/java") //生成代码放在哪个包 .basePkg("com.aliyun.gts.fluent.search.test") .build(); build.generate(); } }
运行之后,每个应用会生成两个类:
XxxSearchParam: 这个是搜索条件的封装;
XxxSearchResult: 这个是搜索返回结果的封装;
其中Xxx表示OpenSearch控制台中的应用名称。
4. 开始搜索:
来对比一个具体的账号搜索的例子:
使用openSearch原生SDK:
/** * * @param accountSearchParam 前端传入的搜索条件对象 * @return */ @SneakyThrows public ResultResponse<Page<SearchAccountResultDTO>> searchAccountList(AccountSearchParam accountSearchParam) { SearchParams searchParams = new SearchParams();//SearchParams是搜索的核心对象 Config config = new Config(); config.setSearchFormat(SearchFormat.FULLJSON);//设置返回格式为json config.setStart((accountSearchParam.getPageNum() - 1) * accountSearchParam.getPageSize());//分页offset config.setHits(accountSearchParam.getPageSize());//分页limit config.setAppNames(Lists.newArrayList("dayu_xaccount_dayu_account"));//应用名称 searchParams.setConfig(config); //拼接query子句 StringBuilder queryStr = new StringBuilder(); queryStr.append("status:'1'"); if (accountSearchParam.getAccountId() != null) { queryStr.append(" AND id:'").append(accountSearchParam.getAccountId()).append("'"); } if (StringUtils.isNotBlank(accountSearchParam.getDisplayNameLike())) { queryStr.append(" AND display_name:'").append(accountSearchParam.getDisplayNameLike()).append("'"); } if (StringUtils.isNotBlank(accountSearchParam.getPhoneLike())) { queryStr.append(" AND phone:'").append(accountSearchParam.getPhoneLike()).append("'"); } if (!CollectionUtils.isEmpty(accountSearchParam.getUseStatus())) { String collect = accountSearchParam.getUseStatus().stream().map(Object::toString) .collect(Collectors.joining("'|'", "'", "'")); queryStr.append(" AND use_status:").append(collect); } if (accountSearchParam.getCreateTimeStart() != null) { queryStr.append(" AND gmt_create:[").append(accountSearchParam.getCreateTimeStart()).append(",4102416000000]"); } if (accountSearchParam.getCreateTimeEnd() != null) { queryStr.append(" AND gmt_create:[0,").append(accountSearchParam.getCreateTimeEnd()).append("]"); } if (accountSearchParam.getLastLoginDateStart() != null) { queryStr.append(" AND last_login_date:[").append(accountSearchParam.getLastLoginDateStart()).append(",4102416000000]"); } if (accountSearchParam.getLastLoginDateEnd() != null) { queryStr.append(" AND last_login_date:[0,").append(accountSearchParam.getLastLoginDateEnd()).append("]"); } //and (xxx or xxx) if (StringUtils.isNotBlank(accountSearchParam.getDisplayNameOrPhone())) { queryStr.append(" AND (display_name:'").append(accountSearchParam.getDisplayNameOrPhone()) .append("' OR phone:'").append(accountSearchParam.getDisplayNameOrPhone()).append("')"); } searchParams.setQuery(queryStr.toString()); //设置filter子句 StringBuilder filterStr = new StringBuilder(); filterStr.append("status=1"); if (accountSearchParam.getIsAuthenticated() != null) { filterStr.append(" AND is_authenticated=").append(accountSearchParam.getIsAuthenticated()); } if (!CollectionUtils.isEmpty(accountSearchParam.getDeptIds())) { String collect = accountSearchParam.getDeptIds().stream() .collect(Collectors.joining("\" OR dept_id=\"", " AND (dept_id=\"", "\")")); filterStr.append(collect); } searchParams.setFilter(filterStr.toString()); //设置sort子句 Sort sort = new Sort(); SortField sortField = new SortField(); sortField.setField("gmt_create"); sortField.setOrder(Order.DECREASE); sort.setSortFields(Lists.newArrayList(sortField)); searchParams.setSort(sort); //开始搜索 OpenSearch openSearch = new OpenSearch("AccessKey", "AccessSecret", "Host"); com.aliyun.opensearch.OpenSearchClient openSearchClient = new com.aliyun.opensearch.OpenSearchClient(openSearch); SearcherClient searcherClient = new SearcherClient(openSearchClient); SearchParamsBuilder searchParamsBuilder = SearchParamsBuilder.create(searchParams); SearchResult searchResult = searcherClient.execute(searchParamsBuilder); //解析返回的String String data = searchResult.getResult(); JSONObject jsonObject = JSONObject.parseObject(data); Object status = jsonObject.get("status"); if (!"OK".equals(String.valueOf(status))) { return ResultResponse.errorResult(String.valueOf(jsonObject.get("errors"))); } JSONObject result = (JSONObject) jsonObject.get("result"); Integer total = (Integer) result.get("total"); JSONArray items = (JSONArray) result.get("items"); List<SearchAccountResultDTO> searchAccountResultDTOS = new ArrayList<>(); for (Object item : items) { JSONObject obj = (JSONObject) item; JSONObject fields = (JSONObject) obj.get("fields"); SearchAccountResultDTO searchAccountResultDTO = parseResult(fields); searchAccountResultDTOS.add(searchAccountResultDTO); } return ResultResponse.succResult(new Page<>(accountSearchParam.getPageNum(), accountSearchParam.getPageSize(), searchAccountResultDTOS, total)); } /** * 从jsonObject获取DTO对象 * @param jsonObject * @return */ private SearchAccountResultDTO parseResult(JSONObject jsonObject) { SearchAccountResultDTO searchAccountResultDTO = new SearchAccountResultDTO(); searchAccountResultDTO.setId(jsonObject.getLong("id")); searchAccountResultDTO.setUserName(jsonObject.getString("display_name")); searchAccountResultDTO.setDisplayName(jsonObject.getString("display_name")); searchAccountResultDTO.setAccountId(jsonObject.getLong("id")); searchAccountResultDTO.setExternalId(jsonObject.getString("external_id")); searchAccountResultDTO.setPartnerId(jsonObject.getString("partner_id")); searchAccountResultDTO.setPartnerName(jsonObject.getString("name")); searchAccountResultDTO.setDeptName(jsonObject.getString("dept_name")); searchAccountResultDTO.setPhone(jsonObject.getString("phone")); searchAccountResultDTO.setUseStatus(jsonObject.getInteger("use_status")); searchAccountResultDTO.setIsAuthenticated(jsonObject.getInteger("is_authenticated")); searchAccountResultDTO.setGmtCreated(jsonObject.getLong("gmt_create")); searchAccountResultDTO.setLastLoginDate(jsonObject.getLong("last_login_date")); return searchAccountResultDTO; }
可以看到,使用原生sdk是很不方便的,从搜索条件构造到结果解析,大段胶水代码,总代码行数120行左右。
使用fluent-search框架:
@Autowired private com.github.zougeren.fluent.search.processor.client.OpenSearchClient openSearchClient; /** * @param accountSearchParam 前端传入的搜索条件对象 * @return */ public ResultResponse<Page<DayuXaccountDayuAccountSearchResult>> searchAccountListV2(AccountSearchParam accountSearchParam) { DayuXaccountDayuAccountSearchParam param = new DayuXaccountDayuAccountSearchParam() .selectAll() //查询所有字段,也可以指定查询哪些 .where //开始构造query子句 .andStatus().equal(1).end .andId().equal(accountSearchParam.getAccountId(), Objects::nonNull).end .andDisplayName().like(accountSearchParam.getDisplayNameLike(), o -> o != null && StringUtils.isNotBlank(o.toString())).end .andPhone().like(accountSearchParam.getPhoneLike(), o -> o != null && StringUtils.isNotBlank(o.toString())).end .andUseStatus().in(accountSearchParam.getUseStatus(), CollectionUtils::isNotEmpty).end .andGmtCreate().ge(accountSearchParam.getCreateTimeStart(), Objects::nonNull).le(accountSearchParam.getCreateTimeEnd(), Objects::nonNull).end .andLastLoginDate().ge(accountSearchParam.getLastLoginDateStart(), Objects::nonNull).le(accountSearchParam.getLastLoginDateEnd(), Objects::nonNull).end //and (xxx or xxx) .and(query -> query.andDisplayName().like(accountSearchParam.getDisplayNameOrPhone()).end.orPhone().like(accountSearchParam.getDisplayNameOrPhone()).end, () -> StringUtils.isNotBlank(accountSearchParam.getDisplayNameOrPhone())) .end .filter //构造filter子句 .andIsAuthenticated().equal(accountSearchParam.getIsAuthenticated(), Objects::nonNull).end .andDeptId().in(accountSearchParam.getDeptIds(), CollectionUtils::isNotEmpty).end .end .orderBy.gmtCreate().desc().end //设置sort子句 .limit((accountSearchParam.getPageNum() - 1) * accountSearchParam.getPageSize(), accountSearchParam.getPageSize()); //设置分页参数 //开始搜索 com.github.zougeren.fluent.search.processor.base.Page<DayuXaccountDayuAccountSearchResult> search = openSearchClient.search(param); List<DayuXaccountDayuAccountSearchResult> data = search.getData(); return ResultResponse.succResult(new Page<>(accountSearchParam.getPageNum(), accountSearchParam.getPageSize(), data, search.getTotal())); }
使用fluent-search框架,总代码行数只有30行左右,只有原生SDK的四分之一,流式API看起来也很清晰,而且隐藏了不同openSearch索引类型在构造查询子句时的语法差异细节,降低使用者的学习成本。
XxxSearchParam详细语法示例:
由于OpenSearch中数据类型较多,不同索引类型的查询语法也有很多差异,以上示例不能完整展示该框架API的所有细节,为了更详细的介绍框架语法,我建了一个OpenSearch测试应用,应用名称:zougetest,包含了OpenSearch目前支持的所有类型:
所有可建立索引的字段均建立索引,INT型字段some_int_field当使用关键字或数值分析时,query子句语法会不同,所以我建了两个索引方便测试:
所有可设置为属性字段的均设置为属性字段:
语法说明:
ZougetestSearchParam param = new ZougetestSearchParam() //查询所有字段 .selectAll() //或者指定要查询哪些字段,以end结束 .select.someIntField().someFloatField().end //query子句,每个索引字段都有 andXxx() orXxx() andNotXxx() rankXxx() 四个方法,表示四种连接符 //主要分为四类:关键字分析、地理位置分析、数值分析、其他分析 .where //关键字分析 //.rankId() //.orSomeIntArrayField() //.andNotSomeLiteralArrayField() .andSomeLiteralField() //等效于 AND some_literal_field:'hh' 每个方法都有一个含有Predicate的重载版本,后续示例不再赘述 .equal("hh", Objects::nonNull) //等效于 AND some_literal_field:'hehe'|'haha' 当前字段继续增加条件,则连接符保持和上一步的一致 .in(Lists.newArrayList("hehe", "haha")) //以end结束当前索引字段的条件 .end //地理位置分析 .orSomeGeoPointField() //等效于 OR some_geo_point_field:'point(50 51)' .point(50d, 51d) //等效于 OR some_geo_point_field:'circle(50 51,1000)' .circle(50d, 51d, 1000) //等效于 OR some_geo_point_field:'rectangle(51 52,53 54)' .rectangle(51d, 52d, 53d, 54d) .end //其他分析 //.rankSomeTextField() .rankSomeShortTextField() //等效于 RANK some_short_text_field:'haha' .like("haha") //等效于 RANK some_short_text_field:'heh'|'haha' .likes(Lists.newArrayList("heh", "haha")) //等效于 RANK some_short_text_field:"hehe" .phaseLike("hehe") //等效于 RANK some_short_text_field:"heh"|"haha" .phaseLikes(Lists.newArrayList("heh", "haha")) .end //数值分析 //.andSomeIntField() .andSomeTimestampField() //等效于 AND some_timestamp_field:[-9223372036854775808,123] .le(123L) //等效于 AND some_timestamp_field:[123,9223372036854775807] .ge(123L) //等效于 AND some_timestamp_field:[-9223372036854775808,123) .lt(123L) //等效于 AND some_timestamp_field:(123,9223372036854775807] .gt(123L) //等效于 AND some_timestamp_field:[12,23] .rangeAllInclusive(12L, 23L) //等效于 AND some_timestamp_field:(12,23) .rangeAllExclusive(12L, 23L) //等效于 AND some_timestamp_field:(12,23] .rangeLeftExclusiveRightInclusive(12L, 23L) //等效于 AND some_timestamp_field:[12,23) .rangeLeftInclusiveRightExclusive(12L, 23L) .end //实际使用场景中,四个操作符的优先级可能大家不一定记得清楚,会使用()定义优先级,所以这里有额外的四个方法来生成带括号的嵌套查询 //and() or() andNot() rank() .and(query -> query.andSomeIntField().gt(123).end .orId().equal(123).end) //结束query子句,生成queryStr时.where后紧跟的第一个条件和所有括号中的第一个条件的连接符会被trim掉 .end //filter子句,每个属性字段都有 andXxx() orXxx() 两个方法,表示两种连接符 //主要分为四类:数字、数字数组、字符串、字符串数组 .filter //数字 //.andSomeTimestampField() //.andSomeIntField() //.andSomeDoubleField() .andSomeFloatField() //等效于 AND some_float_field>123 .gt(123) //等效于 AND some_float_field>=123 .ge(123) //等效于 AND some_float_field<123 .lt(123) //等效于 AND some_float_field<=123 .le(123) //等效于 AND some_float_field=123 .equal(123) //等效于 AND in(some_float_field,"123|345") .in(Lists.newArrayList(123, 345)) //等效于 AND some_float_field!=123 .notEqual(123) //等效于 AND notin(some_float_field,"123|345") .notIn(Lists.newArrayList(123, 345)) .end //数字数组 //.andSomeGeoPointField() //.andSomeDoubleArrayField() //.andSomeIntArrayField() .orSomeFloatArrayField() //等效于 OR some_float_array_field=123 .equal(123) //等效于 OR some_float_array_field!=123 .notEqual(123) //等效于 OR (some_float_array_field=123 OR some_float_array_field=345) .in(Lists.newArrayList(123, 345)) //等效于 OR (some_float_array_field!=123 AND some_float_array_field!=345) .notIn(Lists.newArrayList(123, 345)) .end //字符串 .andSomeLiteralField() //等效于 AND some_literal_field="haha" .equal("haha") //等效于 AND some_literal_field!="haha" .notEqual("hehe") //等效于 AND (some_literal_field="heh" OR some_literal_field="jaj") .in(Lists.newArrayList("heh", "jaj")) //等效于 AND (some_literal_field!="e" AND some_literal_field!="a") .notIn(Lists.newArrayList("e", "a")) .end //字符串数组 .andSomeLiteralArrayField() //等效于 AND some_literal_array_field="haha" .equal("haha") //等效于 AND some_literal_array_field!="hah" .notEqual("hah") //等效于 AND (some_literal_array_field="hah" OR some_literal_array_field="heh") .in(Lists.newArrayList("hah", "heh")) //等效于 AND (some_literal_array_field!="a" AND some_literal_array_field!="e") .notIn(Lists.newArrayList("a", "e")) .end //同样支持嵌套查询 and() or() .and(filter -> filter.andSomeFloatField().le(123).end .orSomeDoubleField().gt(123).end) //结束filter子句,生成filterStr时.filter后紧跟的第一个条件和所有括号中的第一个条件的连接符会被trim掉 .end //排序,支持自定义列名 .orderBy("some_float_field").desc().end //或者根据具体字段排序 .orderBy.someDoubleArrayField().asc().someDoubleField().desc().end //分页参数 .limit(1, 10);
后续规划:
原生SDK中的其他高级用法(aggregate、distinct、scroll查询、底纹、热搜、下拉选)的封装还在开发中,欢迎交流~


