iT邦幫忙

2023 iThome 鐵人賽

DAY 29
0
Software Development

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

【Elasticsearch】使用 Java API Client 完成簡易搜尋框架(上)

  • 分享至 

  • xImage
  •  

昨天我們知道 Java API Client 需要哪些資料來建構搜尋請求。並設計一些方法,用來產生代表搜尋條件及排序方式的物件。而接下來兩天的目標,是能透過 REST API 接收 query string,並做到整合這些程式物件,實現搜尋功能。

這兩天文章的內容偏向實際應用,已經沒有關於 Java API Client 的新的知識點了。


一、設計 REST API

(一)設計 Controller

以下在 controller 提供 GET /students 的 API,它會接收多個 query string,並送往 StudentEsRepository 進行搜尋。

@RestController
public class EsController {

    @Autowired
    private StudentEsRepository studentEsRepository;

    @GetMapping("/students")
    public ResponseEntity<List<Student>> search(@ModelAttribute StudentRequestParameter param) {
        var students = studentEsRepository.find(param);
        return ResponseEntity.ok(students);
    }
}

在 Spring 的 controller 接收一個 query string,會使用叫做 @RequestParam 的 annotation。為了統一名稱,在範例程式中會以「param」或「parameter」等字眼,來命名承載 query string 的類別、物件與參數。

(二)設計 Query String

以下的類別描述這支 API 接受的基本 query string。包含用來搜尋的文字,以及排序、分頁的方式。

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.annotation.JsonIgnore;

public class RequestParameter {

    @JsonIgnore
    private String searchText;

    @JsonIgnore
    private Integer from;

    @JsonIgnore
    private Integer size;

    @JsonIgnore
    private List<String> sortFields;

    @JsonIgnore
    private List<String> sortOrders;

    public Map<String, Object> getCustomizedParamMap() {
        return new ObjectMapper().convertValue(
                this,
                new TypeReference<Map<String, Object>>() {}
        );
    }

    // getter, setter ...
}

另外還提供一個名為 getCustomizedParamMap 的方法,目的是將子類別自定義的 query string 轉為 Map(故搭配使用 @JsonIgnore 的 annotation。)。轉為 Map 是為了在 AbstractEsRepository 達到「泛用」,在第二節會用到。

以下是專門用來搜尋學生的 query string 類別,目前只接收年級參數 grade。它還繼承了來自 RequestParameter 的基本參數。在明天的文章,我們可以攜帶更多種 query string。

public class StudentRequestParameter extends RequestParameter {
    private Integer grade;

    // getter, setter ...
}

(三)API 使用範例

認識完本文的 REST API,讓我們來看一個範例。

GET http://localhost:8080/students
?grade=2
&sortFields=conductScore,name
&sortOrder=desc,asc
&from=0
&size=30

以上 query string 的搜尋條件為:

  • 年級為 2
  • 第一排序方式為依操行分數遞減
  • 第二排序方式為依名字遞增
  • 分頁方式為取前 30 筆資料

二、搜尋程式概觀

讓我們回顧一下昨天是如何建構出 ElasticsearchClient 所接收的搜尋請求。

public abstract class AbstractEsRepository<T extends EsDocument> {
    // ...

    public List<T> search(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();

        // ...
    }
}

而本文的目標,就是用第一節在 controller 所接收的 query string,產生正確的 BoolQuerySortOptions,用以建立 SearchRequest

public abstract class AbstractEsRepository<T extends EsDocument> {
    // ...

    public List<T> search(RequestParameter param) {
        SearchRequest.Builder searchBuilder = new SearchRequest.Builder();
        BoolQuery.Builder queryBuilder = new BoolQuery.Builder();
        Map<String, Object> customizedParamMap = param.getCustomizedParamMap();

        // TODO

        var searchReq = searchBuilder
                .query(queryBuilder.build()._toQuery())
                .build();

        // ...
    }
}

上面新寫的 search 方法中,宣告了一個 BoolQuery.Builder 物件,用來承載所有的搜尋條件。從接下來的小節開始,將會逐一將條件附加在它身上,慢慢完成這支搜尋程式。

三、全文檢索

為了知道「學生」這個 ES document,有哪些欄位可以被搜尋,所以在 AbstractEsRepository 宣告一個抽象方法。藉此讓子類別的 StudentEsRepository 提供欄位名稱。

import org.springframework.util.StringUtils;

public abstract class AbstractEsRepository<T extends EsDocument> {
    // ...
    protected abstract Set<String> getSearchableFields();

    public List<T> search(RequestParameter param) {
        SearchRequest.Builder searchBuilder = new SearchRequest.Builder();
        BoolQuery.Builder queryBuilder = new BoolQuery.Builder();
        Map<String, Object> customizedParamMap = param.getCustomizedParamMap();

        // 全文檢索
        assignSearchText(param.getSearchText(), queryBuilder);

        // ...
    }

    private void assignSearchText(String searchText, BoolQuery.Builder builder) {
        if (StringUtils.hasText(searchText)) {
            Set<String> fields = getSearchableFields();
            Query matchQuery = SearchUtils.createMatchQuery(fields, searchText);
            builder.must(matchQuery);
        }
    }
}

全文檢索對應的 query string 為 searchText,以上的程式從 RequestParameter 取出該值,建立出 MatchQuery 物件。

而以下是子類別自行定義可以被搜尋的欄位名稱,包含學生的名字(name)與自我介紹(introduction)。

public class StudentEsRepository extends AbstractEsRepository<Student> {
    // ...

    @Override
    protected Set<String> getSearchableFields() {
        return Set.of("name", "introduction");
    }
}

四、排序與分頁

(一)排序

接著讓我們處理排序的參數。排序欄位所對應的 query string 叫做 sortFields,而排序方向則為 sortOrders。為了支援多重排序,故兩者的資料型態均為 List<String>,且數量應一致,也就是一對一對的。

import org.springframework.util.CollectionUtils;

public abstract class AbstractEsRepository<T extends EsDocument> {
    // ...
    
    public List<T> search(RequestParameter param) {
        SearchRequest.Builder searchBuilder = new SearchRequest.Builder();
        BoolQuery.Builder queryBuilder = new BoolQuery.Builder();
        Map<String, Object> customizedParamMap = param.getCustomizedParamMap();

        // 排序
        assignSortOptions(param.getSortFields(), param.getSortOrders(), searchBuilder);

        // 全文檢索 ...

        // ...
    }

    private void assignSortOptions(List<String> sortFields, List<String> sortOrders, SearchRequest.Builder builder) {
        if (CollectionUtils.isEmpty(sortFields) || CollectionUtils.isEmpty(sortOrders)) {
            return;
        }

        var optionSize = Math.min(sortFields.size(), sortOrders.size());
        var sortOptionList = new ArrayList<SortOptions>();
        for (var i = 0; i < optionSize; i++) {
            var sortOption = SearchUtils.createSortOption(
                    sortFields.get(i), sortOrders.get(i));
            sortOptionList.add(sortOption);
        }

        builder.sort(sortOptionList);
    }
}

RequestParameter 取出排序相關的 query string 後,會建立出一至多個 SortOptions 物件,並賦予給 SearchRequest。此外還考慮到排序的參數有未成對的情形,此時便依照最小成對數(optionSize)來排序。

(二)分頁

分頁相關的 query string,處理方式就單純許多。只要取出後再放到 SearchRequest 即可,筆者就不贅述。

public abstract class AbstractEsRepository<T extends EsDocument> {
    // ...

    public List<T> search(RequestParameter param) {
        SearchRequest.Builder searchBuilder = new SearchRequest.Builder();
        BoolQuery.Builder queryBuilder = new BoolQuery.Builder();
        Map<String, Object> customizedParamMap = param.getCustomizedParamMap();

        // 排序 ...

        // 分頁
        searchBuilder
                .from(param.getFrom())
                .size(param.getSize());

        // 全文檢索 ...

        // ...
    }
}

五、相等條件

前面兩個小節取出了有關全文檢索、排序與分頁的基本 query string,它們在程式中被定義在 RequestParameter 裡。不過我們仍可在子類別的 StudentRequestParameter 自定義其他的 query string。像第一節就加入了代表年級的 grade

在本文,我們將自定義的 query string,預設視為「相等條件」,故建立出 TermQuery 物件。

public abstract class AbstractEsRepository<T extends EsDocument> {
    // ...

    public List<T> search(RequestParameter param) {
        SearchRequest.Builder searchBuilder = new SearchRequest.Builder();
        BoolQuery.Builder queryBuilder = new BoolQuery.Builder();
        Map<String, Object> customizedParamMap = param.getCustomizedParamMap();

        // 排序、分頁、全文檢索...

        // 相等條件
        assignEqualCondition(customizedParamMap, queryBuilder);

        // ...
    }
    
    private void assignEqualCondition(Map<String, Object> paramMap, BoolQuery.Builder builder) {
        paramMap.forEach((key, value) -> {
            if (value != null) {
                Query query = SearchUtils.createTermQuery(key, value);
                builder.filter(query);
            }
        });
    }
}

假設 document 結構如下。

{
    "id": "100",
    "name": "Vincent",
    "grade": 2,
    "conductScore": 85,
    "englishCertificate": {
        "type": "TOEIC",
        "issuedDate": "2023-01-01"
    }
}

grade=2 這樣子的 query string,會在上面的程式中,被當作要搜尋 grade 欄位值為 2 的 document。

以上就是針對特定欄位值,以相等條件來搜尋的做法。

那麼接下來,我們還有一些形式的搜尋條件尚未處理到。

  • 以欄位值落在特定範圍為條件。例如 conductScore 的值在「80 ~ 90」之間。
  • 以物件欄位中的值為條件。例如 englishCertificate.type 的值為「TOEIC」。
  • document 欄位名與 query string 不同。例如 document 欄位名為 englishCertificateType,但太長了,query string 想取名為 engCertType,比較簡短。

這些問題會在明天的文章來解決。


Elasticsearch 系列的完成後專案,會在鐵人賽結束後上傳到 Github,並轉貼到這裡。

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


上一篇
【Elasticsearch】使用 Java API Client 建立搜尋條件與排序方式
下一篇
【Elasticsearch】使用 Java API Client 完成簡易搜尋框架(下)+ 完賽小感言
系列文
救救我啊我救我!CRUD 工程師的惡補日記50
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言