昨天的進度是實作單一 document 的 CRUD。而本文會展示 ES 的 Java API Client 這款 library,要如何發出搜尋請求。接著會撰寫建立搜尋條件的程式,包含相等、範圍與全文檢索等。最後則示範排序與分頁。
至於 Day 29、Day 30,會整合這些搜尋條件與排序方式,完成簡易的搜尋框架。
本文假設讀者對 ES 的搜尋語法已經有概念,所以文中會專注在程式碼,不會特地對原生語法做介紹。
讓我們複習一下如何使用 ES 提供的 REST API 進行搜尋。先行了解 request body 的寫法,可幫助我們在撰寫搜尋條件的相關程式時,能有個對照。
假設 document 的結構如下。
{
"id": "100",
"studentName": "Vincent",
"courses": [
{ "name": "計算機概論", "point": 3 },
{ "name": "程式設計", "point": 4 }
]
}
則以下是一個範例的搜尋請求,定義如下。
courses
)的學分(point
)總和做遞減。相同者再以學生名字(studentName
)欄位做遞增。{
"query": {
"bool": {
"filter": [
{
"match_all": {}
}
]
}
},
"sort": [
{
"courses.point": {
"mode": "sum",
"order": "desc"
}
},
{
"studentName": "asc"
}
],
"from": 0,
"size": 5
}
那麼在程式中,要如何透過 library 進行搜尋呢?我們在 AbstractEsRepository
撰寫一方法,它會接收搜尋時所用到的參數,並建立出搜尋請求後發出,以取得結果。
public abstract class AbstractEsRepository<T extends EsDocument> {
private final ElasticsearchClient client;
private final String indexName;
private final Class<T> docClz;
// ...
public List<T> find(BoolQuery boolQuery, List<SortOptions> sortOptions, Integer from, Integer size) {
var searchReq = new SearchRequest.Builder()
.index(indexName)
.query(boolQuery._toQuery())
.sort(sortOptions)
.from(from)
.size(size)
.build();
return execute(() -> {
SearchResponse<T> searchRes = client.search(searchReq, docClz);
return searchRes
.hits()
.hits()
.stream()
.map(Hit::source)
.filter(Objects::nonNull)
.toList();
});
}
private <V> V execute(IOSupplier<V> supplier) {
// ...
}
}
BoolQuery
承載了整體的搜尋條件;SortOptions
代表排序方式,且支援多重排序;from
與 size
則用來分頁,採取的是 skip 與 limit 的概念。
準備好代表搜尋請求的 SearchRequest
物件後,便呼叫 ElasticsearchClient.search
方法進行搜尋。隨後可得到 SearchResponse
物件,從中呼叫 Hit.source
方法,最終得到 document 的物件。
在建立 SearchRequest
的過程中,BoolQuery
呼叫了 _toQuery
方法,得到 Query
物件。這是因為 library 一律用 Query
做為傳遞搜尋條件的型態。而排序條件則透過 sort
方法以 SortOptions
型態的參數傳入。
本文所要帶給讀者的,是透過撰寫程式,建立出 Query
與 SortOptions
物件。
從這節開始,將示範如何用 library 實作出搜尋條件。筆者在專案中建立一個 util 類別,用來提供建立搜尋條件的方法。
public class SearchUtils {
// TODO
}
本節要建立出 document 欄位值與「指定值相等」的條件。以下為範例程式。
public static Query createTermQuery(String field, String value) {
if (!field.endsWith(".keyword")) {
field = field.concat(".keyword");
}
return new TermQuery.Builder()
.field(field)
.value(value)
.build()
._toQuery();
}
public static Query createTermQuery(String field, Integer value) {
return new TermQuery.Builder()
.field(field)
.value(value)
.build()
._toQuery();
}
public static Query createTermQuery(String field, Object value) {
if (value instanceof String s) {
return createTermQuery(field, s);
} else if (value instanceof Integer i){
return createTermQuery(field, i);
} else {
// 可自行實作其他型態的相等條件
return new Query.Builder().build();
}
}
需建立出 TermQuery
物件,過程中會透過 field
方法給予欄位名稱,並透過 value
方法給予指定的值。以上的兩支範例程式,分別建立出 String
與 int
型態的相等條件。
要注意的是,若以字串欄位做為搜尋條件,欄位名稱別忘了加上「.keyword」的後綴,才會是要求整個值的相等。
TermQuery.Builder
也提供接受long
、double
與boolean
型態的value
方法。
以下是建立數值範圍條件的範例程式,需建立出 RangeQuery
物件。
public static Query createRangeQuery(String field, Number gte, Number lte) {
var builder = new RangeQuery.Builder().field(field);
if (gte != null) {
builder.gte(JsonData.of(gte));
}
if (lte != null) {
builder.lte(JsonData.of(lte));
}
return builder.build()._toQuery();
}
上面的方法接受 Number
型態的參數,因此 int
、long
、double
等數值型態均能適用。我們可使用 gte
、gt
、lte
與 lt
方法定義上下限,並傳入 JsonData
物件作為參數。
以下是建立日期範圍條件的範例程式。
public static Query createRangeQuery(String field, LocalDate gte, LocalDate lte) {
var builder = new RangeQuery.Builder().field(field);
if (gte != null) {
builder.gte(JsonData.of(gte));
}
if (lte != null) {
builder.lte(JsonData.of(lte));
}
return builder.build()._toQuery();
}
以下範例程式所建立的搜尋條件,是 document 具有指定的欄位。
public static Query createFieldExistsQuery(String field) {
return new ExistsQuery.Builder()
.field(field)
.build()
._toQuery();
}
本節所要示範的,說穿了就是「AND」、「OR」與「NOT」的邏輯條件。
假設 document 的結構如下。
{
"id": "100",
"name": "Vincent",
"grade": 3,
"department": "資訊管理系"
}
以下範例程式所建立的搜尋條件,是「年級落在 2 ~ 4」且「科系為資訊管理系」。
Query gradeQuery = SearchUtils.createRangeQuery("grade", 2, 4);
Query departmentQuery = SearchUtils.createTermQuery("department.keyword", "資訊管理系");
Query allQuery = new BoolQuery.Builder()
.filter(List.of(gradeQuery, departmentQuery))
.build()
._toQuery();
透過 BoolQuery
物件,能夠將多個條件包裝在一起。而使用 BoolQuery.Builder.filter
方法,則可組合出 AND 邏輯。
AND 邏輯也能使用
must
方法做到,它的特色是會給予搜尋到的 document 一個「評分」(score)。
假設 document 的結構如下。
{
"id": "100",
"name": "Vincent",
"department": "應用外語系",
"englishCertificate": {
"type": "TOEIC",
"issuedDate": "2023-01-01"
}
}
以下範例程式所建立的搜尋條件,是「擁有多益證書」或「科系為應用外語系」。
Query certificateQuery = SearchUtils.createTermQuery("englishCertificate.type.keyword", "TOEIC");
Query departmentQuery = SearchUtils.createTermQuery("department.keyword", "應用外語系");
Query allQuery = new BoolQuery.Builder()
.should(List.of(certificateQuery, departmentQuery))
.build()
._toQuery();
使用 BoolQuery.Builder.should
方法,可組合出 OR 邏輯。
假設 document 的結構如下。
{
"id": "100",
"name": "Vincent",
"grade": 1
}
以下範例程式所建立的搜尋條件為「非 1 年級」。
Query gradeQuery = SearchUtils.createTermQuery("grade", 1);
Query allQuery = new BoolQuery.Builder()
.mustNot(gradeQuery)
.build()
._toQuery();
本節的關鍵字搜尋,指的是字串欄位值若存在某個單詞,就算符合條件。以下是建立此種條件的範例程式,需建立出 MatchQuery
物件。
public static Query createMatchQuery(Set<String> fields, String searchText) {
List<Query> matchQueries = new ArrayList<>();
for (String field : fields) {
Query matchQuery = new MatchQuery.Builder()
.field(field)
.query(searchText)
.build()
._toQuery();
matchQueries.add(matchQuery);
}
return new BoolQuery.Builder()
.should(matchQueries)
.build()
._toQuery();
}
上面的程式,是傳入多個 document 欄位名稱,以及用來搜尋的文字。裡面會建立出 MatchQuery
物件,再用 OR 邏輯串起來。
在第一節第二段建立 SearchRequest
的過程中,排序方式會透過 sort
方法,傳入 SortOptions
型態的參數。
以下是建立 SortOptions
的三支程式。
public static SortOptions createSortOption(String field, SortOrder order, SortMode mode) {
var fieldSort = new FieldSort.Builder()
.field(field)
.order(order)
.mode(mode)
.build();
return new SortOptions.Builder()
.field(fieldSort)
.build();
}
public static SortOptions createSortOption(String field, SortOrder order) {
return createSortOption(field, order, null);
}
public static SortOptions createSortOption(String field, String order) {
SortOrder sortOrder;
if (SortOrder.Asc.name().equalsIgnoreCase(order)) {
sortOrder = SortOrder.Asc;
} else if (SortOrder.Desc.name().equalsIgnoreCase(order)) {
sortOrder = SortOrder.Desc;
} else {
throw new IllegalArgumentException("Unknown sort order: " + order);
}
return createSortOption(field, sortOrder);
}
前面兩支程式,除了傳入欄位名稱,還會傳入 SortOrder
與 SortMode
這兩個列舉物件。前者代表排序方向,後者代表陣列的排序依據,如加總、平均等。至於第三支程式,則是支援以字串型態傳入排序方向。
ES 的分頁是採取「skip」與「limit」的概念。也就是先跳過一定數量的資料,再取接下來的幾筆作為結果。
以下的範例程式,是建立 SearchRequest
時,設定將 document 排序後,取第 1 ~ 5 筆資料。
var searchReq = new SearchRequest.Builder()
...
.sort(...)
.from(0)
.size(5)
.build();
from
的參數值是從 0 開始。以此類推,若要取第 6 ~ 10 筆資料,則from
值為 1,size
值為 5。
本文建立了各種搜尋條件(Query
)與排序方式(SortOptions
)。接下來的兩篇文章將整合它們,實作出搜尋程式。
Ref:【ElasticSearch 8】使用 Java API Client 實作查詢條件與搜尋(改寫自本人文章)
Elasticsearch 系列的完成後專案,會在鐵人賽結束後上傳到 Github,並轉貼到這裡。
今日文章到此結束!
最後推廣一下自己的部落格,我是「新手工程師的程式教室」的作者,請多指教