https://unsplash.com/photos/_DyUcJalGAc
上一篇文章中,我們在 Spring Boot 的專案裡使用 ElasticSearch 的 Java API Client 示範簡單的 CRUD。而本文將專注於搜尋的功能,除了展示查詢條件要如何透過該 library 實作出來,也會提及排序與分頁的操作。
一、程式專案介紹
本文完成後的範例專案將放置於 GitHub 上,repo 連結在此。
(一)Repository 層簡介
專案中實作了一個 Repository 層,名為 StudentEsRepository。其中包含了存取 ES 的邏輯,如下:
(二)資料類別介紹
本文所示範的是將學生資料存放到 ES,因此筆者設計了一個學生類別(Student),其內部還包含課程(Course)與職務(Job)的資料。
(三)範例資料
由於會在 ES 上新增 document,因此準備了以下範例資料。
筆者將這份 JSON 檔放置於程式專案中,並撰寫一個類別與方法,來讀取成我們自訂的 Student 物件。
(四)準備測試類別
在本文,筆者將透過撰寫測試的方式,來使用 Repository 元件發出搜尋請求。因此請在 pom 檔加入測試相關的 library。
<dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> </dependency>
接著再建立一個 Spring Boot 的測試類別,將 Repository 元件注入在其中。
另外還寫了一個名為 setup 的 Before 方法做初始化,要做的事情是讓 Repository 重新建立 ES 的 index,再將範例資料新增進去。
這邊故意讓程式暫停 2000 毫秒再繼續,理由是新增 document 可能需要一段時間。若在這期間,測試程式很快地試圖去存取它,容易發生找不到 document 的情形。
筆者在測試類別再撰寫一個名為 assertDocumentIds 的方法,用來檢查搜尋到的資料是否與預期相同。
其中第一個參數叫做 ignoreOrder,用以控制檢查時是否要忽略順序。本文第六節在排序的測試情境中會傳入 false。
二、Search API 的使用方式
讓我們複習一下如何使用 ES 提供的 REST API 進行搜尋。假設 index 名稱為「student」,則以下是一個範例請求,包含:
- 查詢條件:無條件
- 排序方式:將 courses 陣列欄位的 point 欄位加總後遞減
- 分頁方式:取前 3 個
POST /student/_search { "query": { "bool": { "filter": [ "match_all": {} ] } }, "sort": [ { "courses.point": { "mode": "sum", "order": "desc" } } ], "from": 0, "size": 3 }
那麼在程式中,要如何透過 library 建立出搜尋的 request 呢?為了方便,筆者定義一個叫做 SearchInfo 的類別,它包裝了搜尋時會用到的參數:
接著在 Repository 撰寫一方法,它會接收剛剛的 SearchInfo 物件,建立出搜尋請求後發出,並取得結果。
建立 SearchRequest 物件的過程中,給予了四個重要的參數。
- query:查詢條件。與第三、四、五節相關。
- sort:排序方式。與第六節相關。
- from、size:分頁方式。與第六節第二段相關。
準備好 SearchRequest 後,便交由 client 的 search 方法進行搜尋。隨後可得到 SearchResponse 物件,從中呼叫 source 方法,最終得到 document 的物件。
三、相等查詢
從這節開始,將示範如何用 library 實作出查詢條件。筆者在專案中建立一個 SearchUtils 類別,用來提供建立查詢條件的方法。
public class SearchUtils { // TODO }
要注意的是,library 使用「Query」這個類別當作條件的傳遞介面。而查詢條件有許多種,也都有各自的物件類別,因此它們需要被轉換為 Query 物件,才能實際使用。
(一)與單一值相等
需建立出 TermQuery 物件,過程中會透過 field 方法給予欄位名稱,並透過 value 方法給予指定的值。
而 value 方法尚接受 boolean、double 型態的值,由於本文不會用到,因此以拋出例外處理。
附帶一提,上例中判斷為 Integer 型態後所呼叫的 value 方法,其所接受的型態其實是 long,而非 int。
以下範例是查詢年級為 3 的學生,寫成測試如下:
(二)與任一值相等
需利用多個「候選值」建立出一個 TermsQuery 物件。但過程比前面的 TermQuery 繁瑣。以下是範例程式:
首先利用這些候選值,建立出多個 FieldValue 物件。每個 FieldValue 物件都帶有一個值,是透過 longValue、stringValue 方法分別給予整數與字串值的。另外也有適用於 boolean 與 double 值的方法。
隨後再將這些 FieldValue 轉換成一個 TermsQueryField 物件,它相當於 DSL 中 terms 參數的候選值陣列。最後用它就能產生 TermsQuery。
以下範例是查詢就讀「資訊管理」或「企業管理」系的學生,寫成測試如下:
四、範圍查詢
(一)數值範圍
需建立 RangeQuery 物件,過程中可用 field 方法給予欄位名稱,並透過 gte 與 lte 方法給予範圍的下限與上限。其他方法尚有 gt(大於)與 lt(小於)可選擇。
此處宣告的 createRangeQuery 方法接受 Number 型態的參數,因此 int、long、double 等數值型態均能適用。
定義上下限時,需傳入一個 JsonData 物件作為參數。使用它的 of 方法建立時,可傳入任何型態的值。
以下範例是查詢年級為 2 到 4 的學生,寫成測試如下:
(二)日期範圍
建立 JsonData 物件時,亦可傳入 Date 物件。此處再撰寫一個建立日期範圍條件的方法。
以下範例是查詢英文檢定通過日介於 2021-07-01 與 2022-06-30 之間的學生,寫成測試如下:
五、關鍵字查詢(全文檢索)
需建立出 MatchQuery 物件,過程中會透過 field 方法給予欄位名稱,並透過 query 方法給予關鍵字。關鍵字同樣可以給予多個,並以空格區隔。
我們假設 document 可被搜尋的欄位有多個。因此實作上是將多個 MatchQuery 放入 Bool 複合查詢的 should 參數中(OR 邏輯),以達到任一欄位有包含關鍵字,即符合條件。
以下範例是查詢 name 或 introduction 欄位中包含「vincent」或「career」字眼的資料,寫成測試如下:
此處是將全文檢索的條件以「must」的方式添加到 Bool 查詢,原因是為了讓 ES 以相符程度計分。
六、欄位是否存在
需建立出 ExistsQuery 物件,過程中會透過 field 方法給予欄位名稱。
(一)字串欄位存在
以下範例是查詢具有血型資料的學生,寫成測試如下:
(二)字串陣列欄位存在
以下範例是查詢有提供電話號碼的學生,寫成測試如下:
(三)物件陣列欄位存在
對於這個情況,筆者就不特別示範程式了,依此類推。不過其實有另一種做法,那就是直接對物件元素的任一欄位做 exists 的檢查。
七、排序與分頁
(一)排序
在建立 SearchRequest 物件的過程中,使用 sort 方法,並傳入一至多個 SortOptions 物件,即可設定好排序規則。
以下同樣在 SearchUtils 類別撰寫方法,藉此建立代表排序規則的 SortOptions 物件。
過程中需建立出 FieldSort 物件,並將欄位名稱、排序方向(SortOrder)與陣列的取值模式(SortMode)添加上去。然而我們未必是用陣列欄位做排序,所以上面的範例程式中會有另一個略過該參數的方法。
以下範例是將學生修習課程中的最高學分數遞減排序,學分數相同者,再以姓名遞增排序。寫成測試如下:
(二)分頁
ES 的分頁是採取「skip」與「limit」的概念。也就是先跳過一定數量的資料,再取接下來的幾筆作為結果。
對 SearchRequest 添加分頁相關參數的方式相當單純,只要透過 from 與 size 方法即可。
以下範例是將學生以年級遞減排序,並取得前 2 筆資料。寫成測試如下:
本文的完成專案:
留言
張貼留言