這裏寫目錄標題
- ElasticSearch
- 1 DSL查詢
- 1.1 快速入門
- 1.2 葉子查詢
- 1.2.1 全文檢索
- 1.2.2 精確查詢
- 1.3 複合查詢
- 1.3.1 算法函數查詢(瞭解)
- 1.3.2 bool查詢
- 1.4 排序
- 1.5 分頁
- 1.5.1 基礎分頁
- 1.5.2 深度分頁
- 1.6 高亮
- 1.6.1 高亮原理
- 1.6.2 實現高亮
- 1.7 總結
- 2 RestClient查詢
- 2.1 快速入門
- 2.1.1 發送請求
- 2.1.2 解析響應結果
- 2.1.3 總結
- 2.2 葉子查詢
- 2.3 複合查詢
- 2.4 排序和分頁
- 2.5 高亮
- 3 數據聚合
- 3.1 DSL實現聚合
- 3.1.1 Bucket聚合
- 3.1.2 帶條件聚合
- 3.1.3 Metric聚合
- 3.1.4 總結
- 3.2 RestClient實現聚合
- 4 作業
- 4.1 實現搜索接口
- 4.1.1 SearchController
- 4.1.2 ISearchService
- 4.1.3 SearchServiceImpl
- 4.2 過濾條件聚合
- 4.2.1 SearchController
- 4.2.2 ISearchService
- 4.2.3 SearchServiceImpl
- 4.3 競價排名(瞭解)
ElasticSearch
在前面ES的學習中,我們已經導入了大量數據到elasticsearch中,實現了商品數據的存儲。不過查詢商品數據時依然採用的是根據id查詢,而非模糊搜索。
所以今天,我們來研究下elasticsearch的數據搜索功能。Elasticsearch提供了基於JSON的DSL(Domain Specific Language)語句來定義查詢條件,其JavaAPI就是在組織DSL條件。
因此,我們先學習DSL的查詢語法,然後再基於DSL來對照學習JavaAPI,就會事半功倍。
1 DSL查詢
Elasticsearch的查詢可以分為兩大類:
- 葉子查詢(Leaf query clauses):一般是在特定的字段裏查詢特定值,屬於簡單查詢,很少單獨使用。
- 複合查詢(Compound query clauses):以邏輯方式組合多個葉子查詢或者更改葉子查詢的行為方式。
1.1 快速入門
我們依然在Kibana的DevTools中學習查詢的DSL語法。首先來看查詢的語法結構:
GET /{索引庫名}/_search
{
"query": {
"查詢類型": {
// .. 查詢條件
}
}
}
説明:
GET /{索引庫名}/_search:其中的_search是固定路徑,不能修改
例如,我們以最簡單的無條件查詢為例,無條件查詢的類型是:match_all,因此其查詢語句如下:
GET /items/_search
{
"query": {
"match_all": {
}
}
}
由於match_all無條件,所以條件位置不寫即可。
執行結果如下:
你會發現雖然是match_all,但是響應結果中並不會包含索引庫中的所有文檔,而是僅有10000條。這是因為處於安全考慮,elasticsearch設置了默認的查詢頁數。
1.2 葉子查詢
葉子查詢的類型也可以做進一步細分,詳情大家可以查看官方文檔:https://www.elastic.co/guide/en/elasticsearch/reference/7.12/query-dsl.html
如圖:
這裏列舉一些常見的,例如:
- 全文檢索查詢(Full Text Queries):利用分詞器對用户輸入搜索條件先分詞,得到詞條,然後再利用倒排索引搜索詞條。例如:
match:multi_match
- 精確查詢(Term-level queries):不對用户輸入搜索條件分詞,根據字段內容精確值匹配。但只能查找keyword、數值、日期、boolean類型的字段。例如:
idstermrange
- 地理座標查詢: 用於搜索地理位置,搜索方式很多,例如:
geo_bounding_box:按矩形搜索geo_distance:按點和半徑搜索
- …略
1.2.1 全文檢索
全文檢索的種類也很多,詳情可以參考官方文檔:https://www.elastic.co/guide/en/elasticsearch/reference/7.12/full-text-queries.html
以全文檢索中的match為例,語法如下:
GET /{索引庫名}/_search
{
"query": {
"match": {
"字段名": "搜索條件"
}
}
}
示例:
與match類似的還有multi_match,區別在於可以同時對多個字段搜索,有一個字段符合條件即可,語法示例:
GET /{索引庫名}/_search
{
"query": {
"multi_match": {
"query": "搜索條件",
"fields": ["字段1", "字段2"]
}
}
}
示例:
1.2.2 精確查詢
精確查詢,英文是Term-level query,顧名思義,詞條級別的查詢。也就是説不會對用户輸入的搜索條件再分詞,而是作為一個詞條,與搜索的字段內容精確值匹配。因此推薦查找keyword、數值、日期、boolean類型的字段。例如:
- id
- price
- 城市
- 地名
- 人名
等等,作為一個整體才有含義的字段。
詳情可以查看官方文檔:https://www.elastic.co/guide/en/elasticsearch/reference/7.12/term-level-queries.html
以term查詢為例,其語法如下:
GET /{索引庫名}/_search
{
"query": {
"term": {
"字段名": {
"value": "搜索條件"
}
}
}
}
示例:
當你輸入的搜索條件不是詞條,而是短語時,由於不做分詞,你反而搜索不到:
再來看下range查詢,語法如下:
GET /{索引庫名}/_search
{
"query": {
"range": {
"字段名": {
"gte": {最小值},
"lte": {最大值}
}
}
}
}
range是範圍查詢,對於範圍篩選的關鍵字有:
gte:大於等於gt:大於lte:小於等於lt:小於
示例:
1.3 複合查詢
複合查詢大致可以分為兩類:
- 第一類:基於邏輯運算組合葉子查詢,實現組合條件,例如
- bool
- 第二類:基於某種算法修改查詢時的文檔相關性算分,從而改變文檔排名。例如:
- function_score
- dis_max
其它複合查詢及相關語法可以參考官方文檔:https://www.elastic.co/guide/en/elasticsearch/reference/7.12/compound-queries.html
1.3.1 算法函數查詢(瞭解)
當我們利用match查詢時,文檔結果會根據與搜索詞條的關聯度打分(_score),返回結果時按照分值降序排列。
例如,我們搜索 “4G手機”,結果如下:
從elasticsearch5.1開始,採用的相關性打分算法是BM25算法,公式如下:
基於這套公式,就可以判斷出某個文檔與用户搜索的關鍵字之間的關聯度,還是比較準確的。但是,在實際業務需求中,常常會有競價排名的功能。不是相關度越高排名越靠前,而是掏的錢多的排名靠前。
例如在百度中搜索Java培訓,排名靠前的就是廣告推廣。
要想認為控制相關性算分,就需要利用elasticsearch中的function score 查詢了。
基本語法:
function score 查詢中包含四部分內容:
- 原始查詢條件:query部分,基於這個條件搜索文檔,並且基於BM25算法給文檔打分,原始算分(query score)
- 過濾條件:filter部分,符合該條件的文檔才會重新算分
- 算分函數:符合filter條件的文檔要根據這個函數做運算,得到的函數算分(function score),有四種函數
- weight:函數結果是常量
- field_value_factor:以文檔中的某個字段值作為函數結果
- random_score:以隨機數作為函數結果
- script_score:自定義算分函數算法
- 運算模式:算分函數的結果、原始查詢的相關性算分,兩者之間的運算方式,包括:
- multiply:相乘
- replace:用function score替換query score
- 其它,例如:sum、avg、max、min
function score的運行流程如下:
- 1)根據原始條件查詢搜索文檔,並且計算相關性算分,稱為原始算分(query score)
- 2)根據過濾條件,過濾文檔
- 3)符合過濾條件的文檔,基於算分函數運算,得到函數算分(function score)
- 4)將原始算分(query score)和函數算分(function score)基於運算模式做運算,得到最終結果,作為相關性算分。
因此,其中的關鍵點是:
- 過濾條件:決定哪些文檔的算分被修改
- 算分函數:決定函數算分的算法
- 運算模式:決定最終算分結果
示例:給IPhone這個品牌的手機算分提高十倍,分析如下:
- 過濾條件:品牌必須為IPhone
- 算分函數:常量weight,值為10
- 算分模式:相乘multiply
對應代碼如下:
GET /items/_search
{
"query": {
"function_score": {
"query": { .... }, // 原始查詢,可以是任意條件
"functions": [ // 算分函數
{
"filter": { // 滿足的條件,品牌必須是Iphone
"term": {
"brand": "Iphone"
}
},
"weight": 10 // 算分權重為2
}
],
"boost_mode": "multipy" // 加權模式,求乘積
}
}
}
1.3.2 bool查詢
bool查詢,即布爾查詢。就是利用邏輯運算來組合一個或多個查詢子句的組合。bool查詢支持的邏輯運算有:
- must:必須匹配每個子查詢,類似“與”
- should:選擇性匹配子查詢,類似“或”
- must_not:必須不匹配,不參與算分,類似“非”
- filter:必須匹配,不參與算分
bool查詢的語法如下:
GET /items/_search
{
"query": {
"bool": {
"must": [
{"match": {"name": "手機"}}
],
"should": [
{"term": {"brand": { "value": "vivo" }}},
{"term": {"brand": { "value": "小米" }}}
],
"must_not": [
{"range": {"price": {"gte": 2500}}}
],
"filter": [
{"range": {"price": {"lte": 1000}}}
]
}
}
}
出於性能考慮,與搜索關鍵字無關的查詢儘量採用must_not或filter邏輯運算,避免參與相關性算分。
例如黑馬商城的搜索頁面:
其中輸入框的搜索條件肯定要參與相關性算分,可以採用match。但是價格範圍過濾、品牌過濾、分類過濾等儘量採用filter,不要參與相關性算分。
比如,我們要搜索手機,但品牌必須是華為,價格必須是900~1599,那麼可以這樣寫:
GET /items/_search
{
"query": {
"bool": {
"must": [
{"match": {"name": "手機"}}
],
"filter": [
{"term": {"brand": { "value": "華為" }}},
{"range": {"price": {"gte": 90000, "lt": 159900}}}
]
}
}
}
1.4 排序
elasticsearch默認是根據相關度算分(_score)來排序,但是也支持自定義方式對搜索結果排序。不過分詞字段無法排序,能參與排序字段類型有:keyword類型、數值類型、地理座標類型、日期類型等。
詳細説明可以參考官方文檔:https://www.elastic.co/guide/en/elasticsearch/reference/7.12/sort-search-results.html
語法説明:
GET /indexName/_search
{
"query": {
"match_all": {}
},
"sort": [
{
"排序字段": {
"order": "排序方式asc和desc"
}
}
]
}
示例,搜索商品,按照銷量排序,銷量一樣則按照價格升序:
GET /items/_search
{
"query": {
"match_all": {}
}
, "sort": [
{
"sold": {
"order": "desc"
},
"price": {
"order": "asc"
}
}
]
}
1.5 分頁
1.5.1 基礎分頁
elasticsearch中通過修改from、size參數來控制要返回的分頁結果:
from:從第幾個文檔開始size:總共查詢幾個文檔
類似於mysql中的limit ?, ?
官方文檔如下:https://www.elastic.co/guide/en/elasticsearch/reference/7.12/paginate-search-results.html
示例語法如下;搜索商品,查詢出銷量排名前10的商品,銷量一樣時按照價格升序:
GET /items/_search
{
"query": {
"match_all": {}
}
, "sort": [
{
"sold": {
"order": "desc"
},
"price": {
"order": "asc"
}
}
]
, "from": 0
, "size": 10
}
1.5.2 深度分頁
elasticsearch的數據一般會採用分片存儲,也就是把一個索引中的數據分成N份,存儲到不同節點上。這種存儲方式比較有利於數據擴展,但給分頁帶來了一些麻煩。
比如一個索引庫中有100000條數據,分別存儲到4個分片,每個分片25000條數據。現在每頁查詢10條,查詢第99頁。那麼分頁查詢的條件如下:
GET /items/_search
{
"from": 990, // 從第990條開始查詢
"size": 10, // 每頁查詢10條
"sort": [
{
"price": "asc"
}
]
}
從語句來分析,要查詢第990~1000名的數據。
從實現思路來分析,肯定是將所有數據排序,找出前1000名,截取其中的990~1000的部分。但問題來了,我們如何才能找到所有數據中的前1000名呢?
要知道每一片的數據都不一樣,第1片上的第9001000,在另1個節點上並不一定依然是9001000名。所以我們只能在每一個分片上都找出排名前1000的數據,然後彙總到一起,重新排序,才能找出整個索引庫中真正的前1000名,此時截取990~1000的數據即可。
如圖:
試想一下,假如我們現在要查詢的是第999頁數據呢,是不是要找第9990~10000的數據,那豈不是需要把每個分片中的前10000名數據都查詢出來,彙總在一起,在內存中排序?如果查詢的分頁深度更深呢,需要一次檢索的數據豈不是更多?
由此可知,當查詢分頁深度較大時,彙總數據過多,對內存和CPU會產生非常大的壓力。
因此elasticsearch會禁止from+ size 超過10000的請求。
針對深度分頁,elasticsearch提供了兩種解決方案:
search after:分頁時需要排序,原理是從上一次的排序值開始,查詢下一頁數據。(意思是要記錄上一次查詢的最後一條記錄的排序值,然後攜帶到下一次查詢。)官方推薦使用的方式。scroll:原理將排序後的文檔id形成快照,保存下來,基於快照做分頁。官方已經不推薦使用。
詳情見文檔:https://www.elastic.co/guide/en/elasticsearch/reference/7.12/paginate-search-results.html
總結:
大多數情況下,我們採用普通分頁就可以了。查看百度、京東等網站,會發現其分頁都有限制。例如百度最多支持77頁,每頁不足20條。京東最多100頁,每頁最多60條。
因此,一般我們採用限制分頁深度的方式即可,無需實現深度分頁。
1.6 高亮
1.6.1 高亮原理
什麼是高亮顯示呢?
我們在百度,京東搜索時,關鍵字會變成紅色,比較醒目,這叫高亮顯示:
觀察頁面源碼,你會發現兩件事情:
- 高亮詞條都被加了``標籤
em標籤都添加了紅色樣式
css樣式肯定是前端實現頁面的時候寫好的,但是前端編寫頁面的時候是不知道頁面要展示什麼數據的,不可能給數據加標籤。而服務端實現搜索功能,要是有elasticsearch做分詞搜索,是知道哪些詞條需要高亮的。
因此詞條的高亮標籤肯定是由服務端提供數據的時候已經加上的。
因此實現高亮的思路就是:
- 用户輸入搜索關鍵字搜索數據
- 服務端根據搜索關鍵字到elasticsearch搜索,並給搜索結果中的關鍵字詞條添加
html標籤 - 前端提前給約定好的
html標籤添加CSS樣式
1.6.2 實現高亮
事實上elasticsearch已經提供了給搜索關鍵字加標籤的語法,無需我們自己編碼。
基本語法如下:
GET /{索引庫名}/_search
{
"query": {
"match": {
"搜索字段": "搜索關鍵字"
}
},
"highlight": {
"fields": {
"高亮字段名稱": {
"pre_tags": "<em>",
"post_tags": "</em>"
}
}
}
}
注意:
- 搜索必須有查詢條件,而且是全文檢索類型的查詢條件,例如
match - 參與高亮的字段必須是
text類型的字段 - 默認情況下參與高亮的字段要與搜索字段一致,除非添加:
required_field_match=false
示例:
1.7 總結
查詢的DSL是一個大的JSON對象,包含下列屬性:
query:查詢條件from和size:分頁條件sort:排序條件highlight:高亮條件
示例:
2 RestClient查詢
文檔的查詢依然使用昨天學習的 RestHighLevelClient對象,查詢的基本步驟如下:
- 1)創建
request對象,這次是搜索,所以是SearchRequest - 2)準備請求參數,也就是查詢DSL對應的JSON參數
- 3)發起請求
- 4)解析響應,響應結果相對複雜,需要逐層解析
2.1 快速入門
之前説過,由於Elasticsearch對外暴露的接口都是Restful風格的接口,因此JavaAPI調用就是在發送Http請求。而我們核心要做的就是利用利用Java代碼組織請求參數,解析響應結果。
這個參數的格式完全參考DSL查詢語句的JSON結構,因此我們在學習的過程中,會不斷的把JavaAPI與DSL語句對比。大家在學習記憶的過程中,也應該這樣對比學習。
2.1.1 發送請求
首先以match_all查詢為例,其DSL和JavaAPI的對比如圖:
代碼解讀:
- 第一步,創建
SearchRequest對象,指定索引庫名 - 第二步,利用
request.source()構建DSL,DSL中可以包含查詢、分頁、排序、高亮等 query():代表查詢條件,利用QueryBuilders.matchAllQuery()構建一個match_all查詢的DSL- 第三步,利用
client.search()發送請求,得到響應
這裏關鍵的API有兩個,一個是request.source(),它構建的就是DSL中的完整JSON參數。其中包含了query、sort、from、size、highlight等所有功能:
另一個是QueryBuilders,其中包含了我們學習過的各種葉子查詢、複合查詢等:
2.1.2 解析響應結果
在發送請求以後,得到了響應結果SearchResponse,這個類的結構與我們在kibana中看到的響應結果JSON結構完全一致:
{
"took" : 0,
"timed_out" : false,
"hits" : {
"total" : {
"value" : 2,
"relation" : "eq"
},
"max_score" : 1.0,
"hits" : [
{
"_index" : "heima",
"_type" : "_doc",
"_id" : "1",
"_score" : 1.0,
"_source" : {
"info" : "Java講師",
"name" : "趙雲"
}
}
]
}
}
因此,我們解析SearchResponse的代碼就是在解析這個JSON結果,對比如下:
代碼解讀:
elasticsearch返回的結果是一個JSON字符串,結構包含:
hits:命中的結果
total:總條數,其中的value是具體的總條數值max_score:所有結果中得分最高的文檔的相關性算分hits:搜索結果的文檔數組,其中的每個文檔都是一個json對象
_source:文檔中的原始數據,也是json對象
因此,我們解析響應結果,就是逐層解析JSON字符串,流程如下:
SearchHits:通過response.getHits()獲取,就是JSON中的最外層的hits,代表命中的結果
SearchHits#getTotalHits().value:獲取總條數信息SearchHits#getHits():獲取SearchHit數組,也就是文檔數組
SearchHit#getSourceAsString():獲取文檔結果中的_source,也就是原始的json文檔數據
2.1.3 總結
文檔搜索的基本步驟是:
- 創建
SearchRequest對象 - 準備
request.source(),也就是DSL。
QueryBuilders來構建查詢條件- 傳入
request.source()的query()方法
- 發送請求,得到結果
- 解析結果(參考JSON結果,從外到內,逐層解析)
完整代碼:創建查詢商品索引庫的搜索測試代碼;如下:
hmall\search-service\src\test\java\com\hmall\search\SearchTest.java 內容如下:
package com.hmall.search;
import cn.hutool.json.JSONUtil;
import com.hmall.common.utils.BeanUtils;
import com.hmall.search.domain.po.ItemDoc;
import org.apache.http.HttpHost;
import org.elasticsearch.action.search.SearchRequest;
import org.elasticsearch.action.search.SearchResponse;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestHighLevelClient;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.search.SearchHit;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
public class SearchTest {
private RestHighLevelClient client;
//索引庫名稱
private static final String INDEX_NAME = "items";
//初始化client
@BeforeEach
public void init() {
client = new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://192.168.12.168:9200")));
}
//關閉client
@AfterEach
public void close() throws IOException {
client.close();
}
//測試match_all搜索
@Test
public void testMatchAll() throws IOException {
//1、創建搜索請求
SearchRequest request = new SearchRequest(INDEX_NAME);
//2、設置查詢參數
request.source().query(QueryBuilders.matchAllQuery());
//3、發送請求獲取響應結果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4、解析響應結果
handleResponse(response);
}
private static void handleResponse(SearchResponse response) {
System.out.println("共搜索到 " + response.getHits().getTotalHits().value + " 條數據");
//獲取查詢數組
SearchHit[] hits = response.getHits().getHits();
for (SearchHit hit : hits) {
//獲取_source(原始json數據)
String jsonStr = hit.getSourceAsString();
ItemDoc itemDoc = JSONUtil.toBean(jsonStr, ItemDoc.class);
System.out.println(itemDoc);
}
}
}
2.2 葉子查詢
所有的查詢條件都是由QueryBuilders來構建的,葉子查詢也不例外。因此整套代碼中變化的部分僅僅是query條件構造的方式,其它不動。如下的方法都是在 com.hmall.search.SearchTest 添加即可。
例如match查詢:
//測試match搜索
@Test
public void testMatch() throws IOException {
//1、創建搜索請求
SearchRequest request = new SearchRequest(INDEX_NAME);
//2、設置查詢參數
request.source().query(QueryBuilders.matchQuery("name", "脱脂牛奶"));
//3、發送請求獲取響應結果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4、解析響應結果
handleResponse(response);
}
再比如multi_match查詢:
//測試multi_match搜索
@Test
public void testMultiMatch() throws IOException {
//1、創建搜索請求
SearchRequest request = new SearchRequest(INDEX_NAME);
//2、設置查詢參數
request.source().query(QueryBuilders.multiMatchQuery("脱脂牛奶", "name", "category"));
//3、發送請求獲取響應結果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4、解析響應結果
handleResponse(response);
}
還有range查詢:
//測試range搜索
@Test
public void testRange() throws IOException {
//1、創建搜索請求
SearchRequest request = new SearchRequest(INDEX_NAME);
//2、設置查詢參數
request.source().query(QueryBuilders.rangeQuery("price").gte(10000).lte(20000));
//3、發送請求獲取響應結果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4、解析響應結果
handleResponse(response);
}
還有term查詢:
//測試term搜索
@Test
public void testTerm() throws IOException {
//1、創建搜索請求
SearchRequest request = new SearchRequest(INDEX_NAME);
//2、設置查詢參數
request.source().query(QueryBuilders.termQuery("brand", "華為"));
//3、發送請求獲取響應結果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4、解析響應結果
handleResponse(response);
}
2.3 複合查詢
複合查詢也是由QueryBuilders來構建,我們以bool查詢為例,DSL和JavaAPI的對比如圖:
完整代碼如下:
//測試bool搜索
@Test
public void testBool() throws IOException {
//1、創建搜索請求
SearchRequest request = new SearchRequest(INDEX_NAME);
//2、設置查詢參數
//2.1、創建 bool查詢對象
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
//2.2、設置 must、filter、should、must_not等
//關鍵字搜索
boolQueryBuilder.must(QueryBuilders.termQuery("name", "手機"));
//品牌過濾
boolQueryBuilder.filter(QueryBuilders.termQuery("brand", "華為"));
//價格過濾
boolQueryBuilder.filter(QueryBuilders.rangeQuery("price").lte(30000));
//2.3、將 bool查詢對象設置到search請求中
request.source().query(boolQueryBuilder);
//3、發送請求獲取響應結果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4、解析響應結果
handleResponse(response);
}
2.4 排序和分頁
之前説過,requeset.source()就是整個請求JSON參數,所以排序、分頁都是基於這個來設置,其DSL和JavaAPI的對比如下:
完整示例代碼:
//測試分頁與排序
@Test
public void testPageAndSort() throws IOException {
//頁號
int pageNo = 1;
//頁大小
int pageSize = 5;
//1、創建搜索請求
SearchRequest request = new SearchRequest(INDEX_NAME);
//2、設置查詢參數
request.source().query(QueryBuilders.matchQuery("name", "華為"));
//設置分頁參數
request.source().from((pageNo-1)*pageSize).size(pageSize);
//設置排序
request.source().sort("price", SortOrder.DESC);
//3、發送請求獲取響應結果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4、解析響應結果
handleResponse(response);
}
2.5 高亮
高亮查詢與前面的查詢有兩點不同:
- 條件同樣是在
request.source()中指定,只不過高亮條件要基於HighlightBuilder來構造 - 高亮響應結果與搜索的文檔結果不在一起,需要單獨解析
首先來看高亮條件構造,其DSL和JavaAPI的對比如圖:
代碼參考如下:
//測試高亮
@Test
public void testHighlight() throws IOException {
//1、創建搜索請求
SearchRequest request = new SearchRequest(INDEX_NAME);
//2、設置查詢參數
request.source().query(QueryBuilders.matchQuery("name", "華為"));
//設置高亮
request.source().highlighter(SearchSourceBuilder.highlight()
.field("name")
.preTags("<em>")
.postTags("</em>")
);
//3、發送請求獲取響應結果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4、解析響應結果
handleResponse(response);
}
再來看結果解析,文檔解析的部分不變,主要是高亮內容需要單獨解析出來,其DSL和JavaAPI的對比如圖:
代碼解讀:
- 第
3、4步:從結果中獲取_source。hit.getSourceAsString(),這部分是非高亮結果,json字符串。還需要反序列為ItemDTO對象 - 第
5步:獲取高亮結果。hit.getHighlightFields(),返回值是一個Map,key是高亮字段名稱,值是HighlightField對象,代表高亮值 - 第
5.1步:從Map中根據高亮字段名稱,獲取高亮字段值對象HighlightField - 第
5.2步:從HighlightField中獲取Fragments,並且轉為字符串。這部分就是真正的高亮字符串了 - 最後:用高亮的結果替換
ItemDTO中的非高亮結果
改造 handleResponse方法後如下:
private static void handleResponse(SearchResponse response) {
System.out.println("共搜索到 " + response.getHits().getTotalHits().value + " 條數據");
//獲取查詢數組
SearchHit[] hits = response.getHits().getHits();
for (SearchHit hit : hits) {
//獲取_source(原始json數據)
String jsonStr = hit.getSourceAsString();
ItemDoc itemDoc = JSONUtil.toBean(jsonStr, ItemDoc.class);
//解析高亮結果
Map<String, HighlightField> highlightFields = hit.getHighlightFields();
if (CollUtils.isNotEmpty(highlightFields)) {
HighlightField highlightField = highlightFields.get("name");
if (highlightField != null) {
String highlightStr = highlightField.getFragments()[0].string();
itemDoc.setName(highlightStr);
}
}
System.out.println(itemDoc);
}
}
3 數據聚合
聚合(aggregations)可以讓我們極其方便的實現對數據的統計、分析、運算。例如:
- 什麼品牌的手機最受歡迎?
- 這些手機的平均價格、最高價格、最低價格?
- 這些手機每月的銷售情況如何?
實現這些統計功能的比數據庫的sql要方便的多,而且查詢速度非常快,可以實現近實時搜索效果。
官方文檔:https://www.elastic.co/guide/en/elasticsearch/reference/7.12/search-aggregations.html
聚合常見的有三類:
- 桶(
Bucket) 聚合:用來對文檔做分組 TermAggregation:按照文檔字段值分組,例如按照品牌值分組、按照國家分組Date Histogram:按照日期階梯分組,例如一週為一組,或者一月為一組- 度量(
Metric) 聚合:用以計算一些值,比如:最大值、最小值、平均值等 Avg:求平均值Max:求最大值Min:求最小值Stats:同時求max、min、avg、sum等- 管道(
pipeline) 聚合:其它聚合的結果為基礎做進一步運算
注意: 參加聚合的字段必須是keyword、日期、數值、布爾類型
3.1 DSL實現聚合
與之前的搜索功能類似,我們依然先學習DSL的語法,再學習JavaAPI.
3.1.1 Bucket聚合
例如我們要統計所有商品中共有哪些商品分類,其實就是以分類(category)字段對數據分組。category值一樣的放在同一組,屬於Bucket聚合中的Term聚合。
基本語法如下:
GET /items/_search
{
"size": 0,
"aggs": {
"category_agg": {
"terms": {
"field": "category",
"size": 20
}
}
}
}
語法説明:
size:設置size為0,就是每頁查0條,則結果中就不包含文檔,只包含聚合aggs:定義聚合
category_agg:聚合名稱,自定義,但不能重複
terms:聚合的類型,按分類聚合,所以用term
field:參與聚合的字段名稱size:希望返回的聚合結果的最大數量
來看下查詢的結果:
3.1.2 帶條件聚合
默認情況下,Bucket聚合是對索引庫的所有文檔做聚合,例如我們統計商品中所有的品牌,結果如下:
可以看到統計出的品牌非常多。
但真實場景下,用户會輸入搜索條件,因此聚合必須是對搜索結果聚合。那麼聚合必須添加限定條件。
例如,我想知道價格高於3000元的手機品牌有哪些,該怎麼統計呢?
我們需要從需求中分析出搜索查詢的條件和聚合的目標:
- 搜索查詢過濾條件:
- 價格高於3000
- 分類必須是手機
- 聚合目標:統計的是品牌,肯定是對brand字段做term聚合
語法如下:
GET /items/_search
{
"query": {
"bool": {
"filter": [
{"term": {
"category": "手機"
}}
,{"range": {
"price": {
"gte": 300000
}
}
}
]
}
},
"size": 0,
"aggs": {
"brand_agg": {
"terms": {
"field": "brand",
"size": 20
}
}
}
}
聚合結果如下:
{
"took" : 2,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 11,
"relation" : "eq"
},
"max_score" : null,
"hits" : [ ]
},
"aggregations" : {
"brand_agg" : {
"doc_count_error_upper_bound" : 0,
"sum_other_doc_count" : 0,
"buckets" : [
{
"key" : "Apple",
"doc_count" : 7
},
{
"key" : "華為",
"doc_count" : 2
},
{
"key" : "三星",
"doc_count" : 1
},
{
"key" : "小米",
"doc_count" : 1
}
]
}
}
}
可以看到,結果中只剩下4個品牌了。
3.1.3 Metric聚合
我們統計了價格高於3000的手機品牌,形成了一個個桶。現在我們需要對桶內的商品做運算,獲取每個品牌價格的最小值、最大值、平均值。
這就要用到Metric聚合了,例如stat聚合,就可以同時獲取min、max、avg等結果。
語法如下:
GET /items/_search
{
"query": {
"bool": {
"filter": [
{"term": {
"category": "手機"
}}
,{"range": {
"price": {
"gte": 300000
}
}
}
]
}
},
"size": 0,
"aggs": {
"brand_agg": {
"terms": {
"field": "brand",
"size": 20
}
, "aggs": {
"stats_metric": {
"stats": {
"field": "price"
}
}
}
}
}
}
query部分就不説了,我們重點解讀聚合部分語法。
可以看到我們在brand_agg聚合的內部,我們新加了一個aggs參數。這個聚合就是brand_agg的子聚合,會對brand_agg形成的每個桶中的文檔分別統計。
stats_metric:聚合名稱
stats:聚合類型,stats是metric聚合的一種
field:聚合字段,這裏選擇price,統計價格
由於stats是對brand_agg形成的每個品牌桶內文檔分別做統計,因此每個品牌都會統計出自己的價格最小、最大、平均值。結果如下:
另外,我們還可以讓聚合按照每個品牌的價格平均值排序:
3.1.4 總結
aggs代表聚合,與query同級,此時query的作用是?
- 限定聚合的的文檔範圍
聚合必須的三要素:
- 聚合名稱
- 聚合類型
- 聚合字段
聚合可配置屬性有:
- size:指定聚合結果數量
- order:指定聚合結果排序方式
- field:指定聚合字段
3.2 RestClient實現聚合
可以看到在DSL中,aggs聚合條件與query條件是同一級別,都屬於查詢JSON參數。因此依然是利用request.source()方法來設置。
不過聚合條件的要利用AggregationBuilders這個工具類來構造。DSL與JavaAPI的語法對比如下:
聚合結果與搜索文檔同一級別,因此需要單獨獲取和解析。具體解析語法如下:
完整代碼如下:
//測試聚合
@Test
public void testAgg() throws IOException {
//1、創建搜索請求
SearchRequest request = new SearchRequest(INDEX_NAME);
//2、設置查詢參數
//設置查詢過濾條件
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery()
.filter(QueryBuilders.termQuery("category", "手機"))
.filter(QueryBuilders.rangeQuery("price").gte(300000));
request.source().query(boolQueryBuilder);
//不返回文檔
request.source().size(0);
//設置品牌聚合
request.source().aggregation(
AggregationBuilders.terms("brand_agg").field("brand").size(20)
);
//3、發送請求獲取響應結果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4、解析響應結果
Aggregations aggregations = response.getAggregations();
//獲取品牌聚合;注意下面的 Terms 是 org.elasticsearch.search.aggregations.bucket.terms.Terms
Terms brandTerms = aggregations.get("brand_agg");
//獲取桶內數據
List<? extends Terms.Bucket> buckets = brandTerms.getBuckets();
//遍歷輸出桶內數據
buckets.forEach(bucket -> {
System.out.println("-------------------------------------------------------");
System.out.println(bucket.getKeyAsString() + ":" + bucket.getDocCount());
});
}
Metric聚合代碼參考:
//測試Metric聚合
@Test
public void testAgg() throws IOException {
//1、創建搜索請求
SearchRequest request = new SearchRequest(INDEX_NAME);
//2、設置查詢參數
//設置查詢過濾條件
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery()
.filter(QueryBuilders.termQuery("category", "手機"))
.filter(QueryBuilders.rangeQuery("price").gte(300000));
request.source().query(boolQueryBuilder);
//不返回文檔
request.source().size(0);
//設置品牌聚合
request.source().aggregation(
AggregationBuilders.terms("brand_agg").field("brand").size(20)
.subAggregation(
AggregationBuilders.stats("stats_metric").field("price")
)
);
//3、發送請求獲取響應結果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4、解析響應結果
Aggregations aggregations = response.getAggregations();
//獲取品牌聚合;注意下面的 Terms 是 org.elasticsearch.search.aggregations.bucket.terms.Terms
Terms brandTerms = aggregations.get("brand_agg");
//獲取桶內數據
List<? extends Terms.Bucket> buckets = brandTerms.getBuckets();
//遍歷輸出桶內數據
buckets.forEach(bucket -> {
System.out.println("-------------------------------------------------------");
System.out.println(bucket.getKeyAsString() + ":" + bucket.getDocCount());
Aggregations aggregations1 = bucket.getAggregations();
if (aggregations1 != null) {
Stats statsMetric = aggregations1.get("stats_metric");
System.out.println("平均價格:" + statsMetric.getAvg());
System.out.println("最大價格:" + statsMetric.getMax());
System.out.println("最小价格:" + statsMetric.getMin());
}
});
}
4 作業
Elasticsearch的基本語法我們已經學完,足以應對大多數搜索業務需求了。接下來大家就可以基於學習的知識實現商品搜索的業務了。
在昨天的作業中要求大家拆分一個獨立的微服務:search-service,在這個微服務中實現搜索數據的導入、商品數據庫數據與elasticsearch索引庫數據的同步。
接下來的搜索功能也要在search-service服務中實現。
4.1 實現搜索接口
在黑馬商城的搜索頁面,輸入關鍵字,點擊搜索時,會發現前端會發起查詢商品的請求:
請求的接口信息如下:
- 請求方式:
GET - 請求路徑:
/search/list - 請求參數:
- key:搜索關鍵字
- pageNo:頁碼
- pageSize:每頁大小
- sortBy:排序字段
- isAsc:是否升序
- category:分類
- brand:品牌
- minPrice:價格最小值
- maxPrice:價格最大值
4.1.1 SearchController
修改 com.hmall.search.controller.SearchController 代碼如下:
package com.hmall.search.controller;
import com.hmall.search.domain.po.ItemDoc;
import com.hmall.search.domain.query.ItemPageQuery;
import com.hmall.search.domain.vo.PageVO;
import com.hmall.search.service.ISearchService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Api(tags = "搜索相關接口")
@RestController
@RequestMapping("/search")
@RequiredArgsConstructor
public class SearchController {
private final ISearchService searchService;
@ApiOperation("搜索商品")
@GetMapping("/list")
public PageVO<ItemDoc> search(ItemPageQuery query) {
return searchService.search(query);
}
}
4.1.2 ISearchService
修改 com.hmall.search.service.ISearchService 代碼如下:
package com.hmall.search.service;
import com.hmall.search.domain.po.ItemDoc;
import com.hmall.search.domain.query.ItemPageQuery;
import com.hmall.search.domain.vo.PageVO;
public interface ISearchService {
void saveItemById(Long itemId);
void deleteItemById(Long itemId);
PageVO<ItemDoc> search(ItemPageQuery query);
}
4.1.3 SearchServiceImpl
新增 com.hmall.search.service.impl.SearchServiceImpl 搜索方法代碼如下:
@Override
public PageVO<ItemDoc> search(ItemPageQuery query) {
PageVO<ItemDoc> pageVO = PageVO.empty(0L, 0L);
try {
//1、創建查詢請求
SearchRequest request = new SearchRequest(INDEX_NAME);
//2、設置查詢及各類參數
//創建bool查詢
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
//設置搜索關鍵字
boolean isHighlight = false;
if (StrUtil.isNotBlank(query.getKey())) {
boolQuery.must(QueryBuilders.matchQuery("name", query.getKey()));
//只有搜索了關鍵字才高亮
isHighlight = true;
}
//設置分類過濾查詢
if (StrUtil.isNotBlank(query.getCategory())) {
boolQuery.filter(QueryBuilders.termQuery("category", query.getCategory()));
}
//設置品牌過濾查詢
if (StrUtil.isNotBlank(query.getBrand())) {
boolQuery.filter(QueryBuilders.termQuery("brand", query.getBrand()));
}
//設置價格過濾查詢
if (query.getMinPrice() != null) {
boolQueryBuilder.filter(QueryBuilders.rangeQuery("price").gte(query.getMinPrice()));
}
if (query.getMaxPrice() != null) {
boolQueryBuilder.filter(QueryBuilders.rangeQuery("price").lte(query.getMaxPrice()));
}
if (isHighlight) {
//設置高亮
request.source().highlighter(SearchSourceBuilder.highlight()
.field("name")
.preTags("<em>")
.postTags("</em>")
);
}
//設置分頁
int pageNo = query.getPageNo();
int pageSize = query.getPageSize();
request.source().from((pageNo - 1) * pageSize).size(pageSize);
//設置排序
if (StrUtil.isNotBlank(query.getSortBy())) {
request.source().sort(query.getSortBy(), query.getIsAsc() ? SortOrder.ASC : SortOrder.DESC);
} else {
searchRequest.source().sort("updateTime", SortOrder.DESC);
}
//設置查詢對象
request.source().query(boolQuery);
//3、發送請求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4、解析響應結果
SearchHits hits = response.getHits();
//總記錄數
long total = hits.getTotalHits().value;
pageVO.setTotal(total);
//通過頁大小和總記錄數計算總頁數
long pages = (total % pageSize == 0) ? (total / pageSize) : (total / pageSize + 1);
pageVO.setPages(pages);
List<ItemDoc> itemDocList = new ArrayList<>(pageSize);
for (SearchHit hit : hits.getHits()) {
ItemDoc itemDoc = JSONUtil.toBean(hit.getSourceAsString(), ItemDoc.class);
//處理高亮
if (isHighlight) {
HighlightField highlightField = hit.getHighlightFields().get("name");
if (highlightField != null) {
String name = highlightField.getFragments()[0].string();
itemDoc.setName(name);
}
}
itemDocList.add(itemDoc);
}
pageVO.setList(itemDocList);
return pageVO;
} catch (IOException e) {
throw new RuntimeException("查詢es中商品失敗!", e);
}
}
4.2 過濾條件聚合
搜索頁面的過濾項目前是寫死的:
但是大家思考一下,隨着搜索條件的變化,過濾條件展示的過濾項是不是應該跟着變化。
例如搜索電視,那麼搜索結果中展示的肯定只有電視,而此時過濾條件中的分類就不能還出現手機、拉桿箱等內容。過濾條件的品牌中就不能出現與電視無關的品牌。而是應該展示搜索結果中存在的分類和品牌。
那麼問題來,我們怎麼知道搜索結果中存在哪些分類和品牌呢?
大家應該能想到,就是利用聚合,而且是帶有限定條件的聚合。用户搜索的條件是什麼,我們在對分類、品牌聚合時的條件也就是什麼,這樣就能統計出搜索結果中包含的分類、品牌了。
修改 hmall-nginx\html\hmall-portal\search.html 這個頁面的第 142 行;將 true 修改為 false。修改後:
再次搜索時,前端已經發出了請求,嘗試搜索欄中除價格以外的過濾項:
接口信息如下:
- 請求方式:
POST - 請求路徑:
/search/filters - 請求參數:
- key:搜索關鍵字
- pageNo:頁碼
- pageSize:每頁大小
- sortBy:排序字段
- isAsc:是否升序
- category:分類
- brand:品牌
- minPrice:價格最小值
- maxPrice:價格最大值
可見參數與搜索參數一致,不過這裏大家可以忽略分頁和排序參數。
返回值參考這個格式:
{
"category": ["手機", "曲面電視", "拉桿箱", "休閒鞋", "休閒鞋", "硬盤", "真皮包"],
"brand": ["希捷", "小米", "華為", "oppo", "新秀麗", "Apple","錘子"]
}
4.2.1 SearchController
修改 com.hmall.search.controller.SearchController 新增如下方法:
@ApiOperation("搜索商品分類、品牌列表")
@PostMapping("/filters")
public Map<String, List<String>> filters(@RequestBody ItemPageQuery query) {
return searchService.filter(query);
}
4.2.2 ISearchService
修改 com.hmall.search.service.ISearchService 新增如下方法:
Map<String, List<String>> filters(ItemPageQuery query);
4.2.3 SearchServiceImpl
修改 com.hmall.search.service.impl.SearchServiceImpl 新增如下方法:
@Override
public Map<String, List<String>> filters(ItemPageQuery query) {
try {
//只有當分類或品牌沒有選擇的時才有必要去查對應的數據
if (StrUtil.isBlank(query.getCategory()) || StrUtil.isBlank(query.getBrand())) {
Map<String, List<String>> resultMap = new HashMap<>();
//1、創建查詢請求
SearchRequest request = new SearchRequest(INDEX_NAME);
//2、設置參數
//是否需要查詢分類聚合數據
boolean isNeedCategoryAgg = true;
//是否需要查詢品牌聚合數據
boolean isNeedBrandAgg = true;
//設置搜索關鍵字
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
if (StrUtil.isNotBlank(query.getKey())) {
boolQuery.must(QueryBuilders.matchQuery("name", query.getKey()));
}
if (StrUtil.isNotBlank(query.getCategory())) {
boolQuery.filter(QueryBuilders.termQuery("category", query.getCategory()));
isNeedCategoryAgg = false;
}
if (StrUtil.isNotBlank(query.getBrand())) {
boolQuery.filter(QueryBuilders.termQuery("brand", query.getBrand()));
isNeedBrandAgg = false;
}
if (query.getMinPrice() != null) {
boolQueryBuilder.filter(QueryBuilders.rangeQuery("price").gte(query.getMinPrice()));
}
if (query.getMaxPrice() != null) {
boolQueryBuilder.filter(QueryBuilders.rangeQuery("price").lte(query.getMaxPrice()));
}
request.source().query(boolQuery);
//設置不返回文檔
request.source().size(0);
//設置分類聚合
if (isNeedCategoryAgg) {
TermsAggregationBuilder aggregationBuilder = AggregationBuilders.terms("category_agg").field("category").size(20);
request.source().aggregation(aggregationBuilder);
}
//設置品牌聚合
if (isNeedBrandAgg) {
TermsAggregationBuilder aggregationBuilder = AggregationBuilders.terms("brand_agg").field("brand").size(20);
request.source().aggregation(aggregationBuilder);
}
//3、發送請求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4、解析響應結果
Aggregations aggregations = response.getAggregations();
Terms categoryAgg = aggregations.get("category_agg");
if (categoryAgg != null) {
List<String> categoryList = new ArrayList<>();
for (Terms.Bucket bucket : categoryAgg.getBuckets()) {
categoryList.add(bucket.getKeyAsString());
}
resultMap.put("category", categoryList);
}
Terms brandAgg = aggregations.get("brand_agg");
if (brandAgg != null) {
List<String> brandList = new ArrayList<>();
for (Terms.Bucket bucket : brandAgg.getBuckets()) {
brandList.add(bucket.getKeyAsString());
}
resultMap.put("brand", brandList);
}
return resultMap;
}
} catch (IOException e) {
System.out.println("查詢分類、品牌聚合數據失敗!" + e);
}
return CollUtils.emptyMap();
}
4.3 競價排名(瞭解)
elasticsearch的默認排序規則是按照相關性打分排序,而這個打分是可以通過API來控制的。詳情可以參考複合查詢中的算分函數查詢(1.3.1小節)
對應的JavaAPI可以參考文檔:https://www.elastic.co/guide/en/elasticsearch/client/java-api/7.12/java-compound-queries.html
在商品的數據庫表中,已經設計了isAD字段來標記廣告商品,請利用function_score查詢在原本搜索的結果基礎上,讓這些isAD字段值為true的商品排名到最前面。
修改 com.hmall.search.service.impl.SearchServiceImpl#search 方法的如下部分: