1W字|40 圖|硬核 ES 實戰(zhàn)

前言
上篇我們講到了 Elasticsearch 全文檢索的原理《別只會搜日志了,求你懂點(diǎn)原理吧》,通過在本地搭建一套 ES 服務(wù),以多個案例來分析了 ES 的原理以及基礎(chǔ)使用。這次我們來講下 Spring Boot 中如何整合 ES,以及如何在 Spring Cloud 微服務(wù)項目中使用 ES 來實現(xiàn)全文檢索,來達(dá)到搜索題庫的功能。
而且題庫的數(shù)據(jù)量是非常大的,題目的答案也是非常長的,通過 ES 正好可以解決 mysql 模糊搜索的低效性。
通過本實戰(zhàn)您可以學(xué)到如下知識點(diǎn):
Spring Boot 如何整合 ES。 微服務(wù)中 ES 的 API 使用。 項目中如何使用 ES 來達(dá)到全文檢索。
本篇主要內(nèi)容如下:

本文案例都是基于 PassJava 實戰(zhàn)項目來演示的。
Github 地址:https://github.com/Jackson0714/PassJava-Platform
為了讓大家更清晰地理解 PassJava 項目中 ES 是如何使用的,我畫了三個流程圖:
第一步:創(chuàng)建 question 索引。
首先定義 question 索引,然后在 ES 中創(chuàng)建索引。

第二步:存 question 數(shù)據(jù)進(jìn) ES 。
前端保存數(shù)據(jù)時,保存的 API 請求先經(jīng)過網(wǎng)關(guān),然后轉(zhuǎn)發(fā)到 passjava-question 微服務(wù),然后遠(yuǎn)程調(diào)用 passjava-search 微服務(wù),將數(shù)據(jù)保存進(jìn) ES 中。

第三步:從 ES 中查數(shù)據(jù)。 前端查詢數(shù)據(jù)時,先經(jīng)過網(wǎng)關(guān),然后將請求轉(zhuǎn)發(fā)給 passjava-search 微服務(wù),然后從 ES 中查詢數(shù)據(jù)。

一、Elasticsearch 組件庫介紹
在講解之前,我在這里再次提下全文檢索是什么:
全文檢索: 指以全部文本信息作為檢索對象的一種信息檢索技術(shù)。而我們使用的數(shù)據(jù)庫,如 Mysql,MongoDB 對文本信息檢索能力特別是中文檢索并沒有 ES 強(qiáng)大。所以我們來看下 ES 在項目中是如何來代替 SQL 來工作的。
我使用的 Elasticsearch 服務(wù)是 7.4.2 的版本,然后采用官方提供的 Elastiscsearch-Rest-Client 庫來操作 ES,而且官方庫的 API 上手簡單。
該組件庫的官方文檔地址:
https://www.elastic.co/guide/en/elasticsearch/client/java-rest/current/java-rest-high.html
另外這個組件庫是支持多種語言的:

注意:Elasticsearch Clients 就是指如何用 API 操作 ES 服務(wù)的組件庫。
可能有同學(xué)會提問,Elasticsearch 的組件庫中寫著 JavaScript API,是不是可以直接在前端訪問 ES 服務(wù)?可以是可以,但是會暴露 ES 服務(wù)的端口和 IP 地址,會非常不安全。所以我們還是用后端服務(wù)來訪問 ES 服務(wù)。
我們這個項目是 Java 項目,自然就是用上面的兩種:Java Rest Client 或者 Java API。我們先看下 Java API,但是會發(fā)現(xiàn)已經(jīng)廢棄了。如下圖所示:

所以我們只能用 Java REST Client 了。而它又分成兩種:高級和低級的。高級包含更多的功能,如果把高級比作MyBatis的話,那么低級就相當(dāng)于JDBC。所以我們用高級的 Client。

二、整合檢索服務(wù)
我們把檢索服務(wù)單獨(dú)作為一個服務(wù)。就稱作 passjava-search 模塊吧。
1.1 添加搜索服務(wù)模塊
創(chuàng)建 passjava-search 模塊。
首先我們在 PassJava-Platform 模塊創(chuàng)建一個 搜索服務(wù)模塊 passjava-search。然后勾選 spring web 服務(wù)。如下圖所示。
第一步:選擇 Spring Initializr,然后點(diǎn)擊 Next。

第二步:填寫模塊信息,然后點(diǎn)擊 Next。

第三步:選擇 Web->Spring Web 依賴,然后點(diǎn)擊 Next。

1.2 配置 Maven 依賴
參照 ES 官網(wǎng)配置。
進(jìn)入到 ES 官方網(wǎng)站,可以看到有低級和高級的 Rest Client,我們選擇高階的(High Level Rest Client)。然后進(jìn)入到高階 Rest Client 的 Maven 倉庫。官網(wǎng)地址如下所示:
https://www.elastic.co/guide/en/elasticsearch/client/java-rest/7.9/index.html

加上 Maven 依賴。
對應(yīng)文件路徑:\passjava-search\pom.xml
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
<version>7.4.2</version>
</dependency>
配置 elasticsearch 的版本為7.4.2
因加上 Maven 依賴后,elasticsearch 版本為 7.6.2,所以遇到這種版本不一致的情況時,需要手動改掉。
對應(yīng)文件路徑:\passjava-search\pom.xml
<properties>
<elasticsearch.version>7.4.2</elasticsearch.version>
</properties>
刷新 Maven Project 后,可以看到引入的 elasticsearch 都是 7.4.2 版本了,如下圖所示:

引入 PassJava 的 Common 模塊依賴。
Common 模塊是 PassJava 項目獨(dú)立的出來的公共模塊,引入了很多公共組件依賴,其他模塊引入 Common 模塊依賴后,就不需要單獨(dú)引入這些公共組件了,非常方便。
對應(yīng)文件路徑:\passjava-search\pom.xml
<dependency>
<groupId>com.jackson0714.passjava</groupId>
<artifactId>passjava-common</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
添加完依賴后,我們就可以將搜索服務(wù)注冊到 Nacos 注冊中心了。Nacos 注冊中心的用法在前面幾篇文章中也詳細(xì)講解過,這里需要注意的是要先啟動 Nacos 注冊中心,才能正常注冊 passjava-search 服務(wù)。
1.3 注冊搜索服務(wù)到注冊中心
修改配置文件:src/main/resources/application.properties。配置應(yīng)用程序名、注冊中心地址、注冊中心的命名中間。
spring.application.name=passjava-search
spring.cloud.nacos.config.server-addr=127.0.0.1:8848
spring.cloud.nacos.config.namespace=passjava-search
給啟動類添加服務(wù)發(fā)現(xiàn)注解:@EnableDiscoveryClient。這樣 passjava-search 服務(wù)就可以被注冊中心發(fā)現(xiàn)了。
因 Common 模塊依賴數(shù)據(jù)源,但 search 模塊不依賴數(shù)據(jù)源,所以 search 模塊需要移除數(shù)據(jù)源依賴:
exclude = DataSourceAutoConfiguration.class
以上的兩個注解如下所示:
@EnableDiscoveryClient
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
public class PassjavaSearchApplication {
public static void main(String[] args) {
SpringApplication.run(PassjavaSearchApplication.class, args);
}
}
接下來我們添加一個 ES 服務(wù)的專屬配置類,主要目的是自動加載一個 ES Client 來供后續(xù) ES API 使用,不用每次都 new 一個 ES Client。
1.4 添加 ES 配置類
配置類:PassJavaElasticsearchConfig.java
核心方法就是 RestClient.builder 方法,設(shè)置好 ES 服務(wù)的 IP 地址、端口號、傳輸協(xié)議就可以了。最后自動加載了 RestHighLevelClient。
package com.jackson0714.passjava.search.config;
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestHighLevelClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @Author: 公眾號 | 悟空聊架構(gòu)
* @Date: 2020/10/8 17:02
* @Site: www.passjava.cn
* @Github: https://github.com/Jackson0714/PassJava-Platform
*/
@Configuration
public class PassJavaElasticsearchConfig {
@Bean
// 給容器注冊一個 RestHighLevelClient,用來操作 ES
// 參考官方文檔:https://www.elastic.co/guide/en/elasticsearch/client/java-rest/7.9/java-rest-high-getting-started-initialization.html
public RestHighLevelClient restHighLevelClient() {
return new RestHighLevelClient(
RestClient.builder(
new HttpHost("192.168.56.10", 9200, "http")));
}
}
接下來我們測試下 ES Client 是否自動加載成功。
1.5 測試 ES Client 自動加載
在測試類 PassjavaSearchApplicationTests 中編寫測試方法,打印出自動加載的 ES Client。期望結(jié)果是一個 RestHighLevelClient 對象。
package com.jackson0714.passjava.search;
import org.elasticsearch.client.RestHighLevelClient;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class PassjavaSearchApplicationTests {
@Qualifier("restHighLevelClient")
@Autowired
private RestHighLevelClient client;
@Test
public void contextLoads() {
System.out.println(client);
}
}
運(yùn)行結(jié)果如下所示,打印出了 RestHighLevelClient。說明自定義的 ES Client 自動裝載成功。

1.6 測試 ES 簡單插入數(shù)據(jù)
測試方法 testIndexData,省略 User 類。users 索引在我的 ES 中是沒有記錄的,所以期望結(jié)果是 ES 中新增了一條 users 數(shù)據(jù)。
/**
* 測試存儲數(shù)據(jù)到 ES。
* */
@Test
public void testIndexData() throws IOException {
IndexRequest request = new IndexRequest("users");
request.id("1"); // 文檔的 id
//構(gòu)造 User 對象
User user = new User();
user.setUserName("PassJava");
user.setAge("18");
user.setGender("Man");
//User 對象轉(zhuǎn)為 JSON 數(shù)據(jù)
String jsonString = JSON.toJSONString(user);
// JSON 數(shù)據(jù)放入 request 中
request.source(jsonString, XContentType.JSON);
// 執(zhí)行插入操作
IndexResponse response = client.index(request, RequestOptions.DEFAULT);
System.out.println(response);
}
執(zhí)行 test 方法,我們可以看到控制臺輸出以下結(jié)果,說明數(shù)據(jù)插入到 ES 成功。另外需要注意的是結(jié)果中的 result 字段為 updated,是因為我本地為了截圖,多執(zhí)行了幾次插入操作,但因為 id = 1,所以做的都是 updated 操作,而不是 created 操作。

我們再來到 ES 中看下 users 索引中數(shù)據(jù)。查詢 users 索引:
GET users/_search
結(jié)果如下所示:

可以從圖中看到有一條記錄被查詢出來,查詢出來的數(shù)據(jù)的 _id = 1,和插入的文檔 id 一致。另外幾個字段的值也是一致的。說明插入的數(shù)據(jù)沒有問題。
"age" : "18",
"gender" : "Man",
"userName" : "PassJava"
1.7 測試 ES 查詢復(fù)雜語句
示例:搜索 bank 索引,address 字段中包含 big 的所有人的年齡分布 ( 前 10 條 ) 以及平均年齡,以及平均薪資。
1.7.1 構(gòu)造檢索條件
我們可以參照官方文檔給出的示例來創(chuàng)建一個 SearchRequest 對象,指定要查詢的索引為 bank,然后創(chuàng)建一個 SearchSourceBuilder 來組裝查詢條件??偣灿腥N條件需要組裝:
address 中包含 road 的所有人。 按照年齡分布進(jìn)行聚合。 計算平均薪資。
代碼如下所示,需要源碼請到我的 Github/PassJava 上下載。

將打印出來的檢索參數(shù)復(fù)制出來,然后放到 JSON 格式化工具中格式化一下,再粘貼到 ES 控制臺執(zhí)行,發(fā)現(xiàn)執(zhí)行結(jié)果是正確的。

用在線工具格式化 JSON 字符串,結(jié)果如下所示:

然后我們?nèi)サ羝渲械囊恍┠J(rèn)參數(shù),最后簡化后的檢索參數(shù)放到 Kibana 中執(zhí)行。
Kibana Dev Tools 控制臺中執(zhí)行檢索語句如下圖所示,檢索結(jié)果如下圖所示:

找到總記錄數(shù):29 條。
第一條命中記錄的詳情如下:
平均 balance:13136。
平均年齡:26。
地址中包含 Road 的:263 Aviation Road。
和 IDEA 中執(zhí)行的測試結(jié)果一致,說明復(fù)雜檢索的功能已經(jīng)成功實現(xiàn)。
17.2 獲取命中記錄的詳情
而獲取命中記錄的詳情數(shù)據(jù),則需要通過兩次 getHists() 方法拿到,如下所示:
// 3.1)獲取查到的數(shù)據(jù)。
SearchHits hits = response.getHits();
// 3.2)獲取真正命中的結(jié)果
SearchHit[] searchHits = hits.getHits();
我們可以通過遍歷 searchHits 的方式打印出所有命中結(jié)果的詳情。
// 3.3)、遍歷命中結(jié)果
for (SearchHit hit: searchHits) {
String hitStr = hit.getSourceAsString();
BankMember bankMember = JSON.parseObject(hitStr, BankMember.class);
}
拿到每條記錄的 hitStr 是個 JSON 數(shù)據(jù),如下所示:
{
"account_number": 431,
"balance": 13136,
"firstname": "Laurie",
"lastname": "Shaw",
"age": 26,
"gender": "F",
"address": "263 Aviation Road",
"employer": "Zillanet",
"email": "[email protected]",
"city": "Harmon",
"state": "WV"
}
而 BankMember 是根據(jù)返回的結(jié)果詳情定義的的 JavaBean。可以通過工具自動生成。在線生成 JavaBean 的網(wǎng)站如下:
https://www.bejson.com/json2javapojo/new/
把這個 JavaBean 加到 PassjavaSearchApplicationTests 類中:
@ToString
@Data
static class BankMember {
private int account_number;
private int balance;
private String firstname;
private String lastname;
private int age;
private String gender;
private String address;
private String employer;
private String email;
private String city;
private String state;
}
然后將 bankMember 打印出來:
System.out.println(bankMember);

得到的結(jié)果確實是我們封裝的 BankMember 對象,而且里面的屬性值也都拿到了。
1.7.3 獲取年齡分布聚合信息
ES 返回的 response 中,年齡分布的數(shù)據(jù)是按照 ES 的格式返回的,如果想按照我們自己的格式來返回,就需要將 response 進(jìn)行處理。
如下圖所示,這個是查詢到的年齡分布結(jié)果,我們需要將其中某些字段取出來,比如 buckets,它代表了分布在 21 歲的有 4 個。

下面是代碼實現(xiàn):
Aggregations aggregations = response.getAggregations();
Terms ageAgg1 = aggregations.get("ageAgg");
for (Terms.Bucket bucket : ageAgg1.getBuckets()) {
String keyAsString = bucket.getKeyAsString();
System.out.println("用戶年齡: " + keyAsString + " 人數(shù):" + bucket.getDocCount());
}
最后打印的結(jié)果如下,21 歲的有 4 人,26 歲的有 4 人,等等。

1.7.4 獲取平均薪資聚合信息
現(xiàn)在來看看平均薪資如何按照所需的格式返回,ES 返回的結(jié)果如下圖所示,我們需要獲取 balanceAvg 字段的 value 值。

代碼實現(xiàn):
Avg balanceAvg1 = aggregations.get("balanceAvg");
System.out.println("平均薪資:" + balanceAvg1.getValue());
打印結(jié)果如下,平均薪資 28578 元。

三、實戰(zhàn):同步 ES 數(shù)據(jù)
3.1 定義檢索模型
PassJava 這個項目可以用來配置題庫,如果我們想通過關(guān)鍵字來搜索題庫,該怎么做呢?
類似于百度搜索,輸入幾個關(guān)鍵字就可以搜到關(guān)聯(lián)的結(jié)果,我們這個功能也是類似,通過 Elasticsearch 做檢索引擎,后臺管理界面和小程序作為搜索入口,只需要在小程序上輸入關(guān)鍵字,就可以檢索相關(guān)的題目和答案。
首先我們需要把題目和答案保存到 ES 中,在存之前,第一步是定義索引的模型,如下所示,模型中有 title 和 answer 字段,表示題目和答案。
"id": {
"type": "long"
},
"title": {
"type": "text",
"analyzer": "ik_smart"
},
"answer": {
"type": "text",
"analyzer": "ik_smart"
},
"typeName": {
"type": "keyword"
}
3.2 在 ES 中創(chuàng)建索引
上面我們已經(jīng)定義了索引結(jié)構(gòu),接著就是在 ES 中創(chuàng)建索引。
在 Kibana 控制臺中執(zhí)行以下語句:
PUT question
{
"mappings" : {
"properties": {
"id": {
"type": "long"
},
"title": {
"type": "text",
"analyzer": "ik_smart"
},
"answer": {
"type": "text",
"analyzer": "ik_smart"
},
"typeName": {
"type": "keyword"
}
}
}
}
執(zhí)行結(jié)果如下所示:

我們可以通過以下命令來查看 question 索引是否在 ES 中:
GET _cat/indices
執(zhí)行結(jié)果如下圖所示:

3.3 定義 ES model
上面我們定義 ES 的索引,接著就是定義索引對應(yīng)的模型,將數(shù)據(jù)存到這個模型中,然后再存到 ES 中。
ES 模型如下,共四個字段:id、title、answer、typeName。和 ES 索引是相互對應(yīng)的。
@Data
public class QuestionEsModel {
private Long id;
private String title;
private String answer;
private String typeName;
}
3.4 觸發(fā)保存的時機(jī)
當(dāng)我們在后臺創(chuàng)建題目或保存題目時,先將數(shù)據(jù)保存到 mysql 數(shù)據(jù)庫,然后再保存到 ES 中。
如下圖所示,在管理后臺創(chuàng)建題目時,觸發(fā)保存數(shù)據(jù)到 ES 。

第一步,保存數(shù)據(jù)到 mysql 中,項目中已經(jīng)包含此功能,就不再講解了,直接進(jìn)入第二步:保存數(shù)據(jù)到 ES 中。
而保存數(shù)據(jù)到 ES 中,需要將數(shù)據(jù)組裝成 ES 索引對應(yīng)的數(shù)據(jù),所以我用了一個 ES model,先將數(shù)據(jù)保存到 ES model 中。
3.5 用 model 來組裝數(shù)據(jù)
這里的關(guān)鍵代碼時 copyProperties,可以將 question 對象的數(shù)據(jù)取出,然后賦值到 ES model 中。不過 ES model 中還有些字段是 question 中沒有的,所以需要單獨(dú)拎出來賦值,比如 typeName 字段,question 對象中沒有這個字段,它對應(yīng)的字段是 question.type,所以我們把 type 取出來賦值到 ES model 的 typeName 字段上。如下圖所示:

3.6 保存數(shù)據(jù)到 ES
我在 passjava-search 微服務(wù)中寫了一個保存題目的 api 用來保存數(shù)據(jù)到 ES 中。

然后在 passjava-question 微服務(wù)中調(diào)用 search 微服務(wù)的保存 ES 的方法就可以了。
// 調(diào)用 passjava-search 服務(wù),將數(shù)據(jù)發(fā)送到 ES 中保存。
searchFeignService.saveQuestion(esModel);
3.7 檢驗 ES 中是否創(chuàng)建成功
我們可以通過 kibana 的控制臺來查看 question 索引中的文檔。通過以下命令來查看:
GET question/_search
執(zhí)行結(jié)果如下圖所示,有一條記錄:

另外大家有沒有疑問:可以重復(fù)更新題目嗎?
答案是可以的,保存到 ES 的數(shù)據(jù)是冪等的,因為保存的時候帶了一個類似數(shù)據(jù)庫主鍵的 id。
四、實戰(zhàn):查詢 ES 數(shù)據(jù)
我們已經(jīng)將數(shù)據(jù)同步到了 ES 中,現(xiàn)在就是前端怎么去查詢 ES 數(shù)據(jù)中,這里我們還是使用 Postman 來模擬前端查詢請求。
4.1 定義請求參數(shù)
請求參數(shù)我定義了三個:
keyword:用來匹配問題或者答案。 id:用來匹配題目 id。 pageNum:用來分頁查詢數(shù)據(jù)。
這里我將這三個參數(shù)定義為一個類:
@Data
public class SearchParam {
private String keyword; // 全文匹配的關(guān)鍵字
private String id; // 題目 id
private Integer pageNum; // 查詢第幾頁數(shù)據(jù)
}
4.2 定義返回參數(shù)
返回的 response 我也定義了四個字段:
questionList:查詢到的題目列表。 pageNum:第幾頁數(shù)據(jù)。 total:查詢到的總條數(shù)。 totalPages:總頁數(shù)。
定義的類如下所示:
@Data
public class SearchQuestionResponse {
private List<QuestionEsModel> questionList; // 題目列表
private Integer pageNum; // 查詢第幾頁數(shù)據(jù)
private Long total; // 總條數(shù)
private Integer totalPages; // 總頁數(shù)
}
4.3 組裝 ES 查詢參數(shù)
調(diào)用 ES 的查詢 API 時,需要構(gòu)建查詢參數(shù)。
組裝查詢參數(shù)的核心代碼如下所示:

第一步:創(chuàng)建檢索請求。
第二步:設(shè)置哪些字段需要模糊匹配。這里有三個字段:title,answer,typeName。
第三步:設(shè)置如何分頁。這里分頁大小是 5 個。
第四步:調(diào)用查詢 api。
4.4 格式化 ES 返回結(jié)果
ES 返回的數(shù)據(jù)是 ES 定義的格式,真正的數(shù)據(jù)被嵌套在 ES 的 response 中,所以需要格式化返回的數(shù)據(jù)。
核心代碼如下圖所示:

第一步:獲取查到的數(shù)據(jù)。 第二步:獲取真正命中的結(jié)果。 第三步:格式化返回的數(shù)據(jù)。 第四步:組裝分頁參數(shù)。
4.5 測試 ES 查詢
4.5.1 實驗一:測試 title 匹配
我們現(xiàn)在想要驗證 title 字段是否能匹配到,傳的請求參數(shù) keyword = 111,匹配到了 title = 111 的數(shù)據(jù),且只有一條。頁碼 pageNum 我傳的 1,表示返回第一頁數(shù)據(jù)。如下圖所示:

4.5.2 實驗二:測試 answer 匹配
我們現(xiàn)在想要驗證 answer 字段是否能匹配到,傳的請求參數(shù) keyword = 測試答案,匹配到了 title = 測試答案的數(shù)據(jù),且只有一條,說明查詢成功。如下圖所示:

4.5.2 實驗三:測試 id 匹配
我們現(xiàn)在想要匹配題目 id 的話,需要傳請求參數(shù) id,而且 id 是精確匹配。另外 id 和 keyword 是取并集,所以不能傳 keyword 字段。
請求參數(shù) id = 5,返回結(jié)果也是 id =5 的數(shù)據(jù),說明查詢成功。如下圖所示:

五、總結(jié)
本文通過我的開源項目 passjava 來講解 ES 的整合,ES 的 API 使用以及測試。非常詳細(xì)地講解了每一步該如何做,相信通過閱讀本篇后,再加上自己的實踐,一定能掌握前后端該如何使用 ES 來達(dá)到高效搜索的目的。
當(dāng)然,ES API 還有很多功能未在本文實踐,有興趣的同學(xué)可以到 ES 官網(wǎng)進(jìn)行查閱和學(xué)習(xí)。
再次強(qiáng)調(diào):本文的代碼都是辛苦調(diào)試出來的,請不要忘記點(diǎn)贊和轉(zhuǎn)發(fā)哦~

推薦?? :1049天,100K!簡單復(fù)盤!
推薦?? :Github掘金計劃:Github上的一些優(yōu)質(zhì)項目搜羅
