
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... | |
} | |
} |
(三)範例資料
由於會在 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" | |
} | |
] |
筆者將這份 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<>() {}); | |
} | |
} |
(四)準備測試類別
在本文,筆者將透過撰寫測試的方式,來使用 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"); | |
} |
本文的完成專案:
留言
張貼留言