深度探索:Spring 借助 Easy - Es 開啟 ElasticSearch 操作實(shí)戰(zhàn)篇章
這篇文章原本是采用spring-boot-starter-data-elasticsearch演示如何在spring boot項(xiàng)目中使用es,經(jīng)一個(gè)讀者的建議打算將文章加以重構(gòu),改用一個(gè)更強(qiáng)大號(hào)稱傻瓜級(jí)ElasticSearch搜索引擎ORM框架Easy-Es,像操作MP一樣操作ES。
需要補(bǔ)充的是,在編寫這篇文章之前,筆者對(duì)Easy-Es文檔進(jìn)行相對(duì)詳細(xì)的閱讀,個(gè)人認(rèn)為Easy-Es1.0版本在實(shí)際項(xiàng)目中的集成和使用相對(duì)穩(wěn)定一些,所以本文將以Easy-Es1.0版本展開演示,所以為保證后續(xù)集成步驟的順利建議讀者采用2.5.x +版本的Spring Boot(筆者直接使用2.6.0)。
詳解Easy-Es集成與操作
1. 集成Easy-Es與創(chuàng)建索引
集成Easy-Es的時(shí)首先自然是引入相關(guān)依賴,以筆者為例,項(xiàng)目中引用的版本就是1.1.1版本:
<dependency>
<groupId>cn.easy-es</groupId>
<artifactId>easy-es-boot-starter</artifactId>
<version>1.1.1</version>
</dependency>
Easy-Es默認(rèn)情況下會(huì)掃描我們的文檔實(shí)體完成索引創(chuàng)建,所以我們就可以直接聲明文檔的實(shí)體類型即直接使用,以本文為例,筆者創(chuàng)建的測(cè)試文檔包含id、標(biāo)題、內(nèi)容幾個(gè)字段,因?yàn)楸景咐嘤脙?nèi)容的檢索且文本內(nèi)容多是中文,所以在進(jìn)行字段設(shè)計(jì)的時(shí)候針對(duì)內(nèi)容字段嘗試將其設(shè)置為text類型,并將索引文檔時(shí)用的分詞器設(shè)置為ik_max_word以保證切出盡可能多的詞項(xiàng)提升檢索相關(guān)性數(shù)據(jù)的概率,同時(shí)指明搜索分詞器為ik_smart以保證檢索詞匯盡可能少切割得到最相關(guān)的結(jié)果:
對(duì)應(yīng)的我們給出這段代碼示例,默認(rèn)情況下Easy-Es會(huì)將所有的字符串類型設(shè)置為keyword,由于內(nèi)容字段的特殊性,筆者通過(guò)IndexField指明索引和搜索的分詞器以達(dá)到上述效果:
@Data
public class Document {
/**
* es中的唯一id
*/
private String id;
/**
* 文檔標(biāo)題
*/
private String title;
/**
* 文檔內(nèi)容
*/
@IndexField(fieldType = FieldType.TEXT, analyzer = Analyzer.IK_MAX_WORD, searchAnalyzer = Analyzer.IK_SMART)
private String content;
}
基于文檔的實(shí)體類,編寫Es持久層mapper,和MP類似通過(guò)繼承BaseEsMapper獲得文檔操作的所有能力并通過(guò)泛型指明操作的文檔類型為Document:
public interface DocumentMapper extends BaseEsMapper<Document> {
}
完成上述操作后,在啟動(dòng)器上注明mapper的全路徑開啟自動(dòng)掃描注入:
@EsMapperScan("com.sharkChili.mapper")
完成上述配置之后將項(xiàng)目啟動(dòng),如果輸出Congratulations auto process index by Easy-Es is done !則說(shuō)明文檔自動(dòng)創(chuàng)建完成,此時(shí)我們就可以開始基本操作了:
當(dāng)然Easy-Es也支持顯示的創(chuàng)建和刪除索引,需要注意1.x版本使用的模式是平滑模式回基于原有索引進(jìn)行遷移,如果我們希望手動(dòng)創(chuàng)建索引可以將模式改為手動(dòng)模式:
easy-es.global-config.process_index_mode: manual
這里我們也直接給出使用示例:
Boolean createRes = documentMapper.createIndex();
Boolean delRes = documentMapper.deleteIndex("document");
2. 插入數(shù)據(jù)
對(duì)應(yīng)我們也給出一份插入的基礎(chǔ)使用示例,如下所示可以看到操作步驟也只是聲明一下待插入文檔的實(shí)體然后調(diào)用insert即可完成插入:
Document document = new Document();
document.setTitle("測(cè)試標(biāo)題");
document.setContent("測(cè)試的文本內(nèi)容");
Integer count = documentMapper.insert(document);
log.info("count:{}", count);
在用戶使用的角度,看起來(lái)像是操作MP一樣,實(shí)際上在執(zhí)行insert方法時(shí),Easy-Es底層也是和Mybatis類似,用到動(dòng)態(tài)代理的機(jī)制,通過(guò)掃描實(shí)體類信息獲得索引名稱,然后構(gòu)建restful風(fēng)格的API請(qǐng)求執(zhí)行文檔插入,完成后直接將生成的id結(jié)果設(shè)置到組裝實(shí)體中,并返回操作成功數(shù):
我們可以直接從BaseEsMapperImpl的insert方法的源碼中看到實(shí)現(xiàn),它首先會(huì)基于實(shí)體類調(diào)用getIndexName獲得索引名稱,然后調(diào)用insert執(zhí)行當(dāng)前文檔的插入工作:
@Override
public Integer insert(T entity) {
//......
//基于實(shí)體獲得索引名稱后調(diào)用insert進(jìn)行插入
return insert(entity, EntityInfoHelper.getEntityInfo(entityClass).getIndexName());
}
不入其內(nèi)部即可看到基于我們的實(shí)體信息構(gòu)建restful api的入?yún)ⅲㄟ^(guò)Easy-Es聚合的原生RestHighLevelClient發(fā)送POST請(qǐng)求提交文檔,如果成功則將文檔的id賦值到實(shí)體上返回給用戶:
private Integer doInsert(T entity, String indexName) {
// 基于實(shí)體構(gòu)建請(qǐng)求入?yún)? IndexRequest indexRequest = buildIndexRequest(entity, indexName);
indexRequest.setRefreshPolicy(getRefreshPolicy());
try {
//發(fā)送POST請(qǐng)求插入文檔
IndexResponse indexResponse = client.index(indexRequest, RequestOptions.DEFAULT);
//如果插入成功則將返回的id值賦值到傳入的實(shí)體上
if (Objects.equals(indexResponse.status(), RestStatus.CREATED)) {
setId(entity, indexResponse.getId());
return BaseEsConstants.ONE;
} else if (Objects.equals(indexResponse.status(), RestStatus.OK)) {
// 該id已存在,數(shù)據(jù)被更新的情況
return BaseEsConstants.ZERO;
} else {
//......
}
} catch (IOException e) {
//......
}
}
3. 查詢數(shù)據(jù)
上文提到字符串類型默認(rèn)情況下是keyword類型,所以title字段查出是精準(zhǔn)匹配的,對(duì)應(yīng)的查詢組裝如下所示通過(guò)LambdaEsQueryWrapper的eq函數(shù)指明等值查詢,檢索一條標(biāo)題為測(cè)試標(biāo)題,最終就可以將上一步插入操作的文檔返回:
String title = "測(cè)試標(biāo)題";
LambdaEsQueryWrapper<Document> wrapper = new LambdaEsQueryWrapper<>();
//底層走must term查詢
wrapper.eq(Document::getTitle, title);
Document document = documentMapper.selectOne(wrapper);
log.info(JSONUtil.toJsonStr(document));
這里我們查看eq函數(shù)的底層實(shí)現(xiàn)可以看到實(shí)現(xiàn)精準(zhǔn)匹配本質(zhì)上就是通過(guò)must查詢term為測(cè)試標(biāo)題的文檔:
@Override
public Children eq(boolean condition, String column, Object val, Float boost) {
return doIt(condition, TERM_QUERY, MUST, column, val, boost);
}
其底層操作邏輯和插入操作整體步驟是差不多的,即通過(guò)代理構(gòu)建restful api發(fā)起請(qǐng)求并將結(jié)果映射為java bean返回,這里我們就貼出selectOne操作底層的核心實(shí)現(xiàn),即位于BaseEsMapperImpl的getSearchResponse方法,它就是會(huì)基于我們的參數(shù)調(diào)用search接口,并將響應(yīng)結(jié)果返回給上層組裝成實(shí)體對(duì)象給用戶:
private SearchResponse getSearchResponse(LambdaEsQueryWrapper<T> wrapper, Object[] searchAfter) {
// 構(gòu)建es restHighLevelClient 查詢參數(shù)
SearchRequest searchRequest = new SearchRequest(getIndexNames(wrapper.indexNames));
// 用戶在wrapper中指定的混合查詢條件優(yōu)先級(jí)最高
SearchSourceBuilder searchSourceBuilder = Optional.ofNullable(wrapper.searchSourceBuilder)
.map(builder -> {
// 兼容混合查詢時(shí)用戶在分頁(yè)中自定義的分頁(yè)參數(shù)
Optional.ofNullable(wrapper.from).ifPresent(builder::from);
Optional.ofNullable(wrapper.size).ifPresent(builder::size);
return builder;
}).orElseGet(() ->
//基于我們的wrapper構(gòu)建出請(qǐng)求入?yún)? WrapperProcessor.buildSearchSourceBuilder(wrapper, entityClass));
//......
// 執(zhí)行查詢
SearchResponse response;
try {
response = client.search(searchRequest, RequestOptions.DEFAULT);
} catch (IOException e) {
throw ExceptionUtils.eee("search exception", e);
}
//將結(jié)果返回
printResponseErrors(response);
return response;
}
源碼邏輯實(shí)現(xiàn)如上所示,這里我們就看看document文檔底層代理基于我們的入?yún)⑺鶚?gòu)建的請(qǐng)求參數(shù)searchSourceBuilder ,很明顯就是一個(gè)典型的restful api參數(shù):
對(duì)于自然語(yǔ)言處理的文本檢索,也就是match查詢,Easy-Es也做了很好的封裝,對(duì)應(yīng)的使用示例如下所示:
LambdaEsQueryWrapper<Document> wrapper = new LambdaEsQueryWrapper<>();
wrapper.match(Document::getContent, "你好,這是 elasticsearch操作教程");
List<Document> documentList = documentMapper.selectList(wrapper);
if (CollUtil.isNotEmpty(documentList)) {
log.info("size:{}", documentList.size());
log.info("first data:{}", JSONUtil.toJsonStr(documentList.get(0)));
}
4. 更新和刪除數(shù)據(jù)
有了上述的基礎(chǔ),對(duì)于更新操作等操作都比較好理解了,這里我們直接貼出基于id更新操作的使用示例也是類似于主流ORM框架Mybatis,讀者可參考源碼了解使用步驟:
String id = "HVWfjpQBtr9x3QfTu299";
Document updateDocument = new Document();
updateDocument.setId(id);
updateDocument.setContent("修改后的文本內(nèi)容");
Integer count = documentMapper.updateById(updateDocument);
log.info("update count:{}", count);
刪除操作同理,這里就不多做贅述了:
LambdaEsQueryWrapper<Document> wrapper = new LambdaEsQueryWrapper<>();
String title = "測(cè)試標(biāo)題";
wrapper.eq(Document::getTitle, title);
int successCount = documentMapper.delete(wrapper);
log.info("delete count:{}", successCount);
5. 分頁(yè)查詢
和Mybatis-Plus類似,Easy-Es也針對(duì)分頁(yè)查詢做了很好的封裝,使用時(shí)我們也僅需指定頁(yè)碼和頁(yè)數(shù)即可完成查詢:
LambdaEsQueryWrapper<Document> wrapper = new LambdaEsQueryWrapper<>();
wrapper.match(Document::getContent, "你好,這是 elasticsearch操作教程");
EsPageInfo<Document> documentPageInfo = documentMapper.pageQuery(wrapper, 1, 10);
log.info("query res:{}", documentPageInfo.toString());
從分頁(yè)查詢的API即pageQuery可以看到該查詢本質(zhì)上也是服用了BaseEsMapperImpl的getSearchResponse方法,底層回基于我們傳入的參數(shù)封裝from和size并發(fā)送HTTP請(qǐng)求獲取分頁(yè)結(jié)果:
private SearchResponse getSearchResponse(LambdaEsQueryWrapper<T> wrapper, Object[] searchAfter) {
// 構(gòu)建es restHighLevelClient 查詢參數(shù)
SearchRequest searchRequest = new SearchRequest(getIndexNames(wrapper.indexNames));
// 用戶在wrapper中指定的混合查詢條件優(yōu)先級(jí)最高
SearchSourceBuilder searchSourceBuilder = Optional.ofNullable(wrapper.searchSourceBuilder)
.map(builder -> {
// 兼容混合查詢時(shí)用戶在分頁(yè)中自定義的分頁(yè)參數(shù),基于我們傳參的wrapper得到頁(yè)數(shù)和頁(yè)碼構(gòu)建from和size參數(shù)
Optional.ofNullable(wrapper.from).ifPresent(builder::from);
Optional.ofNullable(wrapper.size).ifPresent(builder::size);
return builder;
}).orElseGet(() -> WrapperProcessor.buildSearchSourceBuilder(wrapper, entityClass));
//......
// 執(zhí)行查詢
SearchResponse response;
try {
response = client.search(searchRequest, RequestOptions.DEFAULT);
} catch (IOException e) {
throw ExceptionUtils.eee("search exception", e);
}
//......
return response;
}
按照ES官方的說(shuō)法,默認(rèn)情況下超過(guò)10000條之后的數(shù)據(jù),from-size查詢是不允許的,原因是避免多分片歸并聚合所導(dǎo)致的OOM問(wèn)題,所以對(duì)于深分頁(yè),ES官方是推薦采用search_after:https://www.elastic.co/guide/en/elasticsearch/reference/8.3/paginate-search-results.html#search-after
對(duì)此我們也給出searchAfter 的使用示例:
LambdaEsQueryWrapper<Document> lambdaEsQueryWrapper = EsWrappers.lambdaQuery(Document.class);
lambdaEsQueryWrapper.size(10);
// 必須指定一種排序規(guī)則,且排序字段值必須唯一 此處我選擇用id進(jìn)行排序 實(shí)際可根據(jù)業(yè)務(wù)場(chǎng)景自由指定,不推薦用創(chuàng)建時(shí)間,因?yàn)榭赡軙?huì)相同
lambdaEsQueryWrapper.orderByDesc(Document::getId);
SAPageInfo<Document> saPageInfo = documentMapper.searchAfterPage(lambdaEsQueryWrapper, null, 10);
// 第一頁(yè)
log.info("first page:{}", saPageInfo);
// 獲取下一頁(yè)
List<Object> nextSearchAfter = saPageInfo.getNextSearchAfter();
SAPageInfo<Document> next = documentMapper.searchAfterPage(lambdaEsQueryWrapper, nextSearchAfter, 10);
log.info("second page:{}", next);