iT邦幫忙

2023 iThome 鐵人賽

DAY 28
0
Software Development

救救我啊我救我!CRUD 工程師的惡補日記系列 第 28

【Elasticsearch】使用 Java API Client 建立搜尋條件與排序方式

  • 分享至 

  • xImage
  •  

昨天的進度是實作單一 document 的 CRUD。而本文會展示 ES 的 Java API Client 這款 library,要如何發出搜尋請求。接著會撰寫建立搜尋條件的程式,包含相等、範圍與全文檢索等。最後則示範排序與分頁。

至於 Day 29、Day 30,會整合這些搜尋條件與排序方式,完成簡易的搜尋框架。

本文假設讀者對 ES 的搜尋語法已經有概念,所以文中會專注在程式碼,不會特地對原生語法做介紹。


一、搜尋 API 的使用方式

(一)原生 Request Body

讓我們複習一下如何使用 ES 提供的 REST API 進行搜尋。先行了解 request body 的寫法,可幫助我們在撰寫搜尋條件的相關程式時,能有個對照。

假設 document 的結構如下。

{
    "id": "100",
    "studentName": "Vincent",
    "courses": [
        { "name": "計算機概論", "point": 3 },
        { "name": "程式設計", "point": 4 }
    ]
}

則以下是一個範例的搜尋請求,定義如下。

  • 搜尋條件:無條件。
  • 排序方式:依照修習課程(courses)的學分(point)總和做遞減。相同者再以學生名字(studentName)欄位做遞增。
  • 分頁方式:取第 1 ~ 5 個。
{
    "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 代表排序方式,且支援多重排序;fromsize 則用來分頁,採取的是 skip 與 limit 的概念。

準備好代表搜尋請求的 SearchRequest 物件後,便呼叫 ElasticsearchClient.search 方法進行搜尋。隨後可得到 SearchResponse 物件,從中呼叫 Hit.source 方法,最終得到 document 的物件。

在建立 SearchRequest 的過程中,BoolQuery 呼叫了 _toQuery 方法,得到 Query 物件。這是因為 library 一律用 Query 做為傳遞搜尋條件的型態。而排序條件則透過 sort 方法以 SortOptions 型態的參數傳入。

本文所要帶給讀者的,是透過撰寫程式,建立出 QuerySortOptions 物件。

二、相等條件

從這節開始,將示範如何用 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 方法給予指定的值。以上的兩支範例程式,分別建立出 Stringint 型態的相等條件。

要注意的是,若以字串欄位做為搜尋條件,欄位名稱別忘了加上「.keyword」的後綴,才會是要求整個值的相等。

TermQuery.Builder 也提供接受 longdoubleboolean 型態的 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 型態的參數,因此 intlongdouble 等數值型態均能適用。我們可使用 gtegtltelt 方法定義上下限,並傳入 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」的邏輯條件。

(一)AND 邏輯

假設 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)。

(二)OR 邏輯

假設 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 邏輯。

(三)NOT 邏輯

假設 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);
}

前面兩支程式,除了傳入欄位名稱,還會傳入 SortOrderSortMode 這兩個列舉物件。前者代表排序方向,後者代表陣列的排序依據,如加總、平均等。至於第三支程式,則是支援以字串型態傳入排序方向。

(二)分頁

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,並轉貼到這裡。

今日文章到此結束!
最後推廣一下自己的部落格,我是「新手工程師的程式教室」的作者,請多指教/images/emoticon/emoticon41.gif


上一篇
【Elasticsearch】導入到 Spring Boot 並使用 Java API Client 實作 CRUD
下一篇
【Elasticsearch】使用 Java API Client 完成簡易搜尋框架(上)
系列文
救救我啊我救我!CRUD 工程師的惡補日記50
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言