【Elasticsearch】使用 Java API Client 實作查詢條件與搜尋


https://unsplash.com/photos/_DyUcJalGAc

上一篇文章中,我們在 Spring Boot 的專案裡使用 ElasticSearch 的 Java API Client 示範簡單的 CRUD。而本文將專注於搜尋的功能,除了展示查詢條件要如何透過該 library 實作出來,也會提及排序與分頁的操作。



一、程式專案介紹

本文完成後的範例專案將放置於 GitHub 上,repo 連結在此

(一)Repository 層簡介

專案中實作了一個 Repository 層,名為 StudentEsRepository。其中包含了存取 ES 的邏輯,如下:

public class StudentEsRepository {
// 其餘略過
public List<Student> insert(List<Student> docs) {
// 新增多個 document
}
}

(二)資料類別介紹

本文所示範的是將學生資料存放到 ES,因此筆者設計了一個學生類別(Student),其內部還包含課程(Course)與職務(Job)的資料。

public class Student {
private String id; // 學生編號,可當作 document id
private String name; // 姓名
private List<String> departments; // 科系
private List<Course> courses; // 修習課程
private int grade; // 年級
private int conductScore; // 操行成績
private Job job; // 職務
private String introduction; // 自我介紹
private Date englishIssuedDate; // 英文檢定通過日
private String bloodType; // 血型
private List<String> phoneNumbers; // 電話號碼
// getter, setter...
public static class Course {
private String name; // 課程名稱
private int point; // 學分數
// getter, setter...
}
public static class Job {
private String name; // 職務名稱
private Boolean primary; // 是否主要 (正, 副)
// getter, setter...
}
}
view raw Student.java hosted with ❤ by GitHub

(三)範例資料

由於會在 ES 上新增 document,因此準備了以下範例資料。

[
{
"id": "103",
"name": "Vincent Zheng",
"departments": ["資訊管理", "財務金融"],
"courses": [
{ "name": "計算機概論", "point": 3 },
{ "name": "程式設計", "point": 4 },
{ "name": "投資學", "point": 3 },
{ "name": "會計學", "point": 0 }
],
"grade": 2,
"conductScore": 86,
"job": {
"name": "班長",
"primary": true
},
"introduction": "I have a blog used to record what I learn in my career. All of them are about information technology and programming.",
"englishIssuedDate": "2021-01-01",
"bloodType": "A",
"phoneNumbers": [
"0988123456",
"0224123456"
]
},
{
"id": "101",
"name": "Dora Pan",
"departments": ["財務金融"],
"courses": [
{ "name": "財金概論", "point": 3 },
{ "name": "保險學", "point": 3 },
{ "name": "投資學", "point": 3 }
],
"grade": 4,
"conductScore": 74,
"job": {
"name": "衛生股長",
"primary": null
},
"introduction": "Wealth ignores those who ignore it. So I apply knowledge about accounting in my life.",
"englishIssuedDate": "2021-04-01",
"bloodType": "B",
"phoneNumbers": [
"0912345678"
]
},
{
"id": "104",
"name": "Winnie Kuo",
"departments": ["企業管理"],
"courses": [
{ "name": "會計學", "point": 3 },
{ "name": "商業概論", "point": 1 }
],
"grade": 1,
"conductScore": 71,
"job": {
"name": "班長",
"primary": false
},
"introduction": "To lead a team in company in career, learn to lead students in university first.",
"englishIssuedDate": "2021-12-01",
"phoneNumbers": []
},
{
"id": "102",
"name": "Mario Lu",
"departments": ["會計"],
"courses": [
{ "name": "會計學", "point": 5 },
{ "name": "審計學", "point": 3 },
{ "name": "企業資源規劃", "point": 3 }
],
"grade": 3,
"conductScore": 83,
"job": {
"name": "康樂股長"
},
"introduction": "Accounting work can be done by technology. So I start to learn programming on internet.",
"englishIssuedDate": "2022-05-01",
"bloodType": "O"
}
]
view raw students.json hosted with ❤ by GitHub

筆者將這份 JSON 檔放置於程式專案中,並撰寫一個類別與方法,來讀取成我們自訂的 Student 物件。

public class SampleData {
public static List<Student> get() throws IOException {
var file = new File("students.json");
return new ObjectMapper().readValue(file, new TypeReference<>() {});
}
}
view raw SampleData.java hosted with ❤ by GitHub

(四)準備測試類別

在本文,筆者將透過撰寫測試的方式,來使用 Repository 元件發出搜尋請求。因此請在 pom 檔加入測試相關的 library。

<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
</dependency>

接著再建立一個 Spring Boot 的測試類別,將 Repository 元件注入在其中。

@RunWith(SpringRunner.class)
@SpringBootTest
public class SearchTests {
@Autowired
private StudentEsRepository repository;
@Before
public void setup() throws IOException, InterruptedException {
repository.init();
var documents = SampleData.get();
repository.insert(documents);
Thread.sleep(2000);
}
}

另外還寫了一個名為 setup 的 Before 方法做初始化,要做的事情是讓 Repository 重新建立 ES 的 index,再將範例資料新增進去。

這邊故意讓程式暫停 2000 毫秒再繼續,理由是新增 document 可能需要一段時間。若在這期間,測試程式很快地試圖去存取它,容易發生找不到 document 的情形。

筆者在測試類別再撰寫一個名為 assertDocumentIds 的方法,用來檢查搜尋到的資料是否與預期相同。

public class SearchTests {
// 其餘略過
private void assertDocumentIds(boolean ignoreOrder, List<Student> actualDocs, String... expectedIdArray) {
var expectedIds = List.of(expectedIdArray);
var actualIds = actualDocs.stream()
.map(Student::getId)
.collect(Collectors.toList());
if (ignoreOrder) {
assertTrue(expectedIds.containsAll(actualIds));
assertTrue(actualIds.containsAll(expectedIds));
} else {
assertEquals(expectedIds, actualIds);
}
}
}

其中第一個參數叫做 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 的類別,它包裝了搜尋時會用到的參數:

public class SearchInfo {
private BoolQuery boolQuery; // 查詢條件
private List<SortOptions> sortOptions = List.of(); // 排序方式
private Integer from; // 資料的跳過數量
private Integer size; // 資料的擷取數量
public static SearchInfo of(BoolQuery bool) {
var info = new SearchInfo();
info.boolQuery = bool;
return info;
}
public static SearchInfo of(Query query) {
var bool = BoolQuery.of(b -> b.filter(query));
return of(bool);
}
// library 使用 Query 類別當作條件的傳遞介面
public Query toQuery() {
return boolQuery._toQuery();
}
// getter, setter...
}

接著在 Repository 撰寫一方法,它會接收剛剛的 SearchInfo 物件,建立出搜尋請求後發出,並取得結果。

public class StudentEsRepository {
private final String indexName;
// 其餘略過
public List<Student> find(SearchInfo info) throws IOException {
var request = new SearchRequest.Builder()
.index(indexName)
.query(info.toQuery())
.sort(info.getSortOptions())
.from(info.getFrom())
.size(info.getSize())
.build();
try {
var searchResponse = client.search(request, Student.class);
return searchResponse
.hits()
.hits()
.stream()
.map(Hit::source)
.collect(Collectors.toList());
} catch (IOException e) {
// 範例為求方便,只簡單做例外處理
throw new RuntimeException(e);
}
}
}

建立 SearchRequest 物件的過程中,給予了四個重要的參數。

  • query:查詢條件。與第三、四、五節相關。
  • sort:排序方式。與第六節相關。
  • from、size:分頁方式。與第六節第二段相關。

準備好 SearchRequest 後,便交由 client 的 search 方法進行搜尋。隨後可得到 SearchResponse 物件,從中呼叫 source 方法,最終得到 document 的物件。


三、相等查詢

從這節開始,將示範如何用 library 實作出查詢條件。筆者在專案中建立一個 SearchUtils 類別,用來提供建立查詢條件的方法。

public class SearchUtils {
    // TODO
}

要注意的是,library 使用「Query」這個類別當作條件的傳遞介面。而查詢條件有許多種,也都有各自的物件類別,因此它們需要被轉換為 Query 物件,才能實際使用。

(一)與單一值相等

需建立出 TermQuery 物件,過程中會透過 field 方法給予欄位名稱,並透過 value 方法給予指定的值。

/**
* <pre>
* {
* "term": {
* "{@param field}": {@param value}
* }
* }
* </pre>
*/
public static Query createTermQuery(String field, Object value) {
var builder = new TermQuery.Builder();
if (value instanceof Integer) {
builder
.field(field)
.value((int) value); // 此方法接受 long 型態
} else if (value instanceof String){
builder
.field(field)
.value((String) value);
} else {
throw new UnsupportedOperationException("Please implement for other type additionally.");
}
return builder.build()._toQuery();
}

而 value 方法尚接受 boolean、double 型態的值,由於本文不會用到,因此以拋出例外處理。

附帶一提,上例中判斷為 Integer 型態後所呼叫的 value 方法,其所接受的型態其實是 long,而非 int。

以下範例是查詢年級為 3 的學生,寫成測試如下:

@Test
public void testTerm_NumberField() {
var query = SearchUtils.createTermQuery("grade", 3);
var searchInfo = SearchInfo.of(query);
var students = repository.find(searchInfo);
// Mario
assertDocumentIds(true, students, "102");
}

(二)與任一值相等

需利用多個「候選值」建立出一個 TermsQuery 物件。但過程比前面的 TermQuery 繁瑣。以下是範例程式:

/**
* <pre>
* {
* "terms": {
* "{@param field}": [
* {@param values[0]},
* {@param values[1]}, ...
* ]
* }
* }
* </pre>
*/
public static Query createTermsQuery(String field, Collection<?> values) {
var elem = values.stream().findAny().orElseThrow();
Stream<FieldValue> fieldValueStream;
if (elem instanceof Integer) {
fieldValueStream = values.stream()
.map(value -> FieldValue.of(b -> b.longValue((int) value)));
} else if (elem instanceof String) {
fieldValueStream = values.stream()
.map(value -> FieldValue.of(b -> b.stringValue((String) value)));
} else {
throw new UnsupportedOperationException("Please implement for other type additionally.");
}
var fieldValues = fieldValueStream.collect(Collectors.toList());
var termsQueryField = TermsQueryField.of(b -> b.value(fieldValues));
return new TermsQuery.Builder()
.field(field)
.terms(termsQueryField)
.build()
._toQuery();
}

首先利用這些候選值,建立出多個 FieldValue 物件。每個 FieldValue 物件都帶有一個值,是透過 longValue、stringValue 方法分別給予整數與字串值的。另外也有適用於 boolean 與 double 值的方法。

隨後再將這些 FieldValue 轉換成一個 TermsQueryField 物件,它相當於 DSL 中 terms 參數的候選值陣列。最後用它就能產生 TermsQuery。

以下範例是查詢就讀「資訊管理」或「企業管理」系的學生,寫成測試如下:

@Test
public void testTerms_TextField() {
var values = List.of("資訊管理", "企業管理");
var query = SearchUtils.createTermsQuery("departments.keyword", values);
var searchInfo = SearchInfo.of(query);
var students = repository.find(searchInfo);
// Vincent, Winnie
assertDocumentIds(true, students, "103", "104");
}



四、範圍查詢

(一)數值範圍

需建立 RangeQuery 物件,過程中可用 field 方法給予欄位名稱,並透過 gte 與 lte 方法給予範圍的下限與上限。其他方法尚有 gt(大於)與 lt(小於)可選擇。

/**
* <pre>
* {
* "range": {
* "{@param field}": {
* "gte": "{@param gte}",
* "lte": "{@param lte}"
* }
* }
* }
* </pre>
*/
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();
}

此處宣告的 createRangeQuery 方法接受 Number 型態的參數,因此 int、long、double 等數值型態均能適用。

定義上下限時,需傳入一個 JsonData 物件作為參數。使用它的 of 方法建立時,可傳入任何型態的值。

以下範例是查詢年級為 2 到 4 的學生,寫成測試如下:

@Test
public void testNumberRange() {
var query = SearchUtils.createRangeQuery("grade", 2, 4);
var searchInfo = SearchInfo.of(query);
var students = repository.find(searchInfo);
// Dora, Mario, Vincent
assertDocumentIds(true, students, "101", "102", "103");
}

(二)日期範圍

建立 JsonData 物件時,亦可傳入 Date 物件。此處再撰寫一個建立日期範圍條件的方法。

public static Query createRangeQuery(String field, Date gte, Date 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();
}

以下範例是查詢英文檢定通過日介於 2021-07-01 與 2022-06-30 之間的學生,寫成測試如下:

@Test
public void testDateRange() throws ParseException {
var sdf = new SimpleDateFormat("yyyy-MM-dd");
var fromDate = sdf.parse("2021-07-01");
var toDate = sdf.parse("2022-06-30");
var query = SearchUtils.createRangeQuery("englishIssuedDate", fromDate, toDate);
var searchInfo = SearchInfo.of(query);
var students = repository.find(searchInfo);
// Winnie, Mario
assertDocumentIds(true, students, "102", "104");
}


五、關鍵字查詢(全文檢索)

需建立出 MatchQuery 物件,過程中會透過 field 方法給予欄位名稱,並透過 query 方法給予關鍵字。關鍵字同樣可以給予多個,並以空格區隔。

/**
* <pre>
* {
* "bool": {
* "should": [
* {
* "match": { "{@param fields[0]}": "{@param searchText}" }
* },
* {
* "match": { "{@param fields[1]}": "{@param searchText}" }
* }
* ]
* }
* }
* </pre>
*/
public static Query createMatchQuery(Set<String> fields, String searchText) {
var bool = new BoolQuery.Builder();
fields.stream()
.map(field -> {
var matchQuery = new MatchQuery.Builder()
.field(field)
.query(searchText)
.build();
return matchQuery._toQuery();
})
.forEach(bool::should);
return bool.build()._toQuery();
}

我們假設 document 可被搜尋的欄位有多個。因此實作上是將多個 MatchQuery 放入 Bool 複合查詢的 should 參數中(OR 邏輯),以達到任一欄位有包含關鍵字,即符合條件。

以下範例是查詢 name 或 introduction 欄位中包含「vincent」或「career」字眼的資料,寫成測試如下:

@Test
public void testFullTextSearch() {
var fields = Set.of("name", "introduction");
var searchText = "vincent career";
var query = SearchUtils.createMatchQuery(fields, searchText);
var bool = BoolQuery.of(b -> b.must(query));
var searchInfo = SearchInfo.of(bool);
var students = repository.find(searchInfo);
// Winnie, Vincent
assertDocumentIds(true, students, "103", "104");
}

此處是將全文檢索的條件以「must」的方式添加到 Bool 查詢,原因是為了讓 ES 以相符程度計分。


六、欄位是否存在

需建立出 ExistsQuery 物件,過程中會透過 field 方法給予欄位名稱。

/**
* <pre>
* {
* "exists": {
* "field": {@param field}
* }
* }
* </pre>
*/
public static Query createFieldExistsQuery(String field) {
return new ExistsQuery.Builder()
.field(field)
.build()
._toQuery();
}

(一)字串欄位存在

以下範例是查詢具有血型資料的學生,寫成測試如下:

@Test
public void testTextArrayFieldExists() {
var query = SearchUtils.createFieldExistsQuery("phoneNumbers");
var searchInfo = SearchInfo.of(query);
var students = repository.find(searchInfo);
// Dora, Vincent
assertDocumentIds(true, students, "101", "103");
}

(二)字串陣列欄位存在

以下範例是查詢有提供電話號碼的學生,寫成測試如下:

@Test
public void testTextArrayFieldExists() {
var query = SearchUtils.createFieldExistsQuery("phoneNumbers");
var searchInfo = SearchInfo.of(query);
var students = repository.find(searchInfo);
// Dora, Vincent
assertDocumentIds(true, students, "101", "103");
}

(三)物件陣列欄位存在

對於這個情況,筆者就不特別示範程式了,依此類推。不過其實有另一種做法,那就是直接對物件元素的任一欄位做 exists 的檢查。



七、排序與分頁

(一)排序

在建立 SearchRequest 物件的過程中,使用 sort 方法,並傳入一至多個 SortOptions 物件,即可設定好排序規則。

以下同樣在 SearchUtils 類別撰寫方法,藉此建立代表排序規則的 SortOptions 物件。

/**
* <pre>
* {
* "{@param field}": {
* "order": {@param order},
* "mode": {@param mode}
* }
* }
* </pre>
*/
public static SortOptions createSortOption(String field, SortOrder order, SortMode mode) {
var fieldSort = new FieldSort.Builder()
.field(field)
.order(order)
.mode(mode)
.build();
return SortOptions.of(b -> b.field(fieldSort));
}
public static SortOptions createSortOption(String field, SortOrder order) {
return createSortOption(field, order, null);
}

過程中需建立出 FieldSort 物件,並將欄位名稱、排序方向(SortOrder)與陣列的取值模式(SortMode)添加上去。然而我們未必是用陣列欄位做排序,所以上面的範例程式中會有另一個略過該參數的方法。

以下範例是將學生修習課程中的最高學分數遞減排序,學分數相同者,再以姓名遞增排序。寫成測試如下:

@Test
public void testSortByMultipleFields() {
var coursePointSort = SearchUtils.createSortOption("courses.point", SortOrder.Desc, SortMode.Max);
var nameSort = SearchUtils.createSortOption("name.keyword", SortOrder.Asc);
var query = MatchAllQuery.of(b -> b)._toQuery();
var searchInfo = SearchInfo.of(query);
searchInfo.setSortOptions(List.of(coursePointSort, nameSort));
var students = repository.find(searchInfo);
// Mario -> Vincent -> Dora -> Winnie
assertDocumentIds(false, students, "102", "103", "101", "104");
}

(二)分頁

ES 的分頁是採取「skip」與「limit」的概念。也就是先跳過一定數量的資料,再取接下來的幾筆作為結果。

對 SearchRequest 添加分頁相關參數的方式相當單純,只要透過 from 與 size 方法即可。

以下範例是將學生以年級遞減排序,並取得前 2 筆資料。寫成測試如下:

@Test
public void testPaging() {
var gradeSort = SearchUtils.createSortOption("grade", SortOrder.Desc);
var query = MatchAllQuery.of(b -> b)._toQuery();
var searchInfo = SearchInfo.of(query);
searchInfo.setSortOptions(List.of(gradeSort));
searchInfo.setFrom(0);
searchInfo.setSize(2);
var students = repository.find(searchInfo);
// Dora -> Mario
assertDocumentIds(false, students, "101", "102");
}



本文的完成專案:

上一篇:【ElasticSearch 8】導入到 Spring Boot 並實作 CRUD

下一篇:【ElasticSearch 8】使用 Java API Client 實作 function score

留言