昨天筆者設計了自定義的 REST API,期望透過藉由接收 qurey string,就能達到搜尋的效果。而該文文末提出的問題,其實都圍繞在「如何將 query string 轉化為 ES 的各種搜尋條件」。本文會設計「翻譯」的機制,用來完成這件「轉化」的工作。
本文內容與昨天一樣,偏向實際應用,不會介紹有關 Java API Client 新的知識點。
讓我們回顧一下昨天文末提到的問題。假設 ES 的 document 結構如下。
{
"id": "100",
"name": "Vincent",
"grade": 1,
"bloodType": "A",
"englishCertificate": {
"type": "TOEIC",
"issuedDate": "2023-01-01"
}
}
並且呼叫 controller 的 REST API 進行搜尋時,攜帶了以下的 query string。
GET http://localhost:8080/students?grade=1&bloodType=A
則 grade=1
和 bloodType=A
會被我們的程式,視為要搜尋「年級為 2」且「血型為 A」的學生。
請考慮以下的搜尋條件:
grade
)落在「1 ~ 3」。englishCertificate.type
)為「TOEIC」。bloodType
)未知,也就是欄位沒有值。我們在昨天的文章規定,除了全文檢索、排序與分頁,其他自定義的 query string 都預設當作「相等條件」,因此以上的搜尋需求尚無法做到。
而本文為了解決此問題,將會設計「翻譯」的機制。舉例來說,假設攜帶以下的 query string。
GET http://localhost:8080/students
?gradeFrom=1
&gradeTo=3
&engCert=TOEIC
&bloodTypeExist=false
並且在 controller 用以下的類別來接收。
public class StudentRequestParameter extends RequestParameter {
private Integer gradeFrom;
private Integer gradeTo;
private String engCert;
private Boolean bloodTypeExist;
// getter, setter ...
}
則我們期望這個翻譯機制,能夠將它們轉化為上面提到的各種搜尋條件。其實昨天的「全文檢索」本身也算是在做翻譯這件事。接下來的小節就開始實作吧!
首先針對第一節提到的 grade
欄位,設計「數值範圍」的翻譯器。藉由這個範例,後續也能對翻譯器要撰寫什麼程式,有所依循。
public class NumberRangeTranslator {
private final String docField;
private final String gteKey;
private final String lteKey;
public NumberRangeTranslator(String gteKey, String lteKey, String docField) {
// ...
}
public Set<String> getRequestParamKeys() {
return Set.of(gteKey, lteKey);
}
public Query toEsQuery(Map<String, Object> requestParam) {
var gteValue = (Integer) requestParam.get(gteKey);
var lteValue = (Integer) requestParam.get(lteKey);
return SearchUtils.createRangeQuery(docField, gteValue, lteValue);
}
}
這個翻譯器類別從建構子接收了三個參數。gteKey
與 lteKey
是 query string 中「大於等於」和「小於等於」的 key 名稱。docField
則是 document 的欄位名稱。
而 toEsQuery
方法,會接收承載 query string 的 Map(從 RequestParameter.getCustomizedParamMap
方法得來)。隨後根據自定義的邏輯,建立出代表 ES 搜尋條件的 Query
物件。以此為例,這裡呼叫了 Day 28 實作的 SearchUtils.createRangeQuery
方法,建立出範圍條件
最後是 getRequestParamKeys
方法,它會在整合到 AbstractEsRepository
時,控制要將哪些 query string 傳遞給翻譯器的 toEsQuery
方法。到了第五節會比較清楚。
本節將針對第一節提到的 englishCertificate.type
欄位,設計「欄位對應」的翻譯器。它適合應用在 document 欄位名與 query string 的 key 不同的情形。比方說想用簡短的 key 名稱,去搜尋名稱很長,或階層深入的 document 欄位。
public class KeyMappingTranslator {
private final String paramKey;
private final String docField;
public KeyMappingTranslator(String paramKey, String docField) {
// ...
}
public String getRequestParamKey() {
return paramKey;
}
public Query toEsQuery(Map<String, Object> requestParam) {
var value = requestParam.get(paramKey);
return SearchUtils.createTermQuery(docField, value);
}
}
這個翻譯器類別從建構子接收了兩個參數,其中 paramKey
是 query string 的 key。而 toEsQuery
方法,會建立出代表相等條件的 TermQuery
物件後回傳。
為了在第五節將各種翻譯器整合到 AbstractEsRepository
,以達到泛用,我們需要設計一個介面。
正如同先前為了將
StudentRequestParameter
整合到AbstractEsRepository
,於是繼承了RequestParameter
,甚至還提供方法轉換成 Map。
觀察前面兩個翻譯器的例子,它們都具有兩個方法:
Query
物件為此,以下的翻譯器介面,提供了這兩個方法。
public interface ParameterTranslator {
Set<String> getRequestParamKeys();
Query toEsQuery(Map<String, Object> requestParam);
}
接著讓第二、三節的 NumberRangeTranslator
與 KeyMappingTranslator
翻譯器去實作。
public class NumberRangeTranslator implements ParameterTranslator {
// ...
@Override
public Set<String> getRequestParamKeys() {
return Set.of(gteKey, lteKey);
}
@Override
public Query toEsQuery(Map<String, Object> requestParam) {
// ...
}
}
public class KeyMappingTranslator implements ParameterTranslator {
// ...
@Override
public Set<String> getRequestParamKeys() {
return Set.of(paramKey);
}
@Override
public Query toEsQuery(Map<String, Object> requestParam) {
// ...
}
}
回顧一下昨天在 AbstractEsRepository
實作到一半的搜尋程式。
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: 翻譯
// 其餘視為相等條件
assignEqualCondition(requestParamMap, queryBuilder);
// ...
}
}
目前已經對全文檢索、排序與分頁相關的 query string 做處理。而本文實作的翻譯器,所用到的 query string 不能直接被當作相等條件,因此要在 assignEqualCondition
方法之前,進行翻譯的工作。
為了得知每一種 document 需要做什麼樣的翻譯,我們在 AbstractEsRepository
宣告抽象方法來取得翻譯器,並讓子類別實作。
public abstract class AbstractEsRepository<T extends EsDocument> {
// ...
protected abstract Set<ParameterTranslator> getParameterTranslators();
}
以下是子類別 StudentEsRepository
提供的翻譯器。
public class StudentEsRepository extends AbstractEsRepository<Student> {
// ...
@Override
protected Set<ParameterTranslator> getParameterTranslators() {
return Set.of(
new NumberRangeTranslator("gradeFrom", "gradeTo", "grade"),
new KeyMappingTranslator("engCert", "englishCertificate.type.keyword")
);
}
}
以上的翻譯需求參考自第一節。gradeFrom
與 gradeTo
這兩個 query string,會用來建立出範圍條件,搜尋 document 的 grade
欄位。而 engCert
則用來搜尋名稱較長的深層欄位。
以下宣告一個叫做 assignTranslatedCondition
的方法,它會將 query string 傳遞給翻譯器來處理。取得翻譯結果的 Query
物件後,附加到 BoolQuery
上。
public abstract class AbstractEsRepository<T extends EsDocument> {
// ...
protected abstract Set<ParameterTranslator> getParameterTranslators();
private void assignTranslatedCondition(Map<String, Object> paramMap, BoolQuery.Builder builder) {
Set<ParameterTranslator> translators = getParameterTranslators();
translators.forEach(translator -> {
var subParam = new HashMap<String, Object>();
translator.getRequestParamKeys().forEach(key -> {
var value = paramMap.remove(key);
if (value != null) {
subParam.put(key,value);
}
});
if (!subParam.isEmpty()) {
var query = translator.toEsQuery(subParam);
builder.filter(query);
}
});
}
}
程式流程中,會先取得子類別(如 StudentEsRepository
)定義好的翻譯器。再讓每個翻譯器輪流從 query string 中取出自己需要的值,組成「子 Map」(subParam
)。接著將子 Map 傳入翻譯器的 toEsQuery
方法取得結果。
此處取出 query string 時,使用了
Map.remove
方法。這是因為如果不把值從 Map 中移除,它們後續會被當作相等條件,意外地由assignEqualCondition
方法處理。
最後將這個 assignTranslatedCondition
方法,加入到搜尋程式中,就能支援翻譯了!
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();
// 排序、分頁、全文檢索 ...
// 翻譯
assignTranslatedCondition(requestParamMap, queryBuilder);
// 其餘視為相等條件
assignEqualCondition(requestParamMap, queryBuilder);
// ...
}
}
若在 document 的 model 類別宣告 getter 方法,那麼在儲存到 ES 時,library 會自動在 document 添加該欄位。本節來看看這項特色可以有怎樣的應用。
回顧這四天 Elasticsearch 小系列文章的各種範例,首先整理一下目前 document 的 model 類別,也就是 Student
,有哪些欄位。
public class Student implements EsDocument {
private String id;
private String name;
private int grade;
private LocalDate birthday;
private String bloodType;
private String introduction;
private EnglishCertificate englishCertificate;
private Set<Course> courses = Set.of();
public int getTotalCoursePoint() {
return courses.stream()
.mapToInt(Course::getPoint)
.sum();
}
// getter, setter ...
}
public class EnglishCertificate {
private String type;
private LocalDate issuedDate;
// getter, setter ...
}
public class Course {
private String name;
private int point;
// getter, setter ...
}
筆者特地在前面的 Student
類別,宣告了叫做 getTotalCoursePoint
的方法,目的是計算「修習課程的總學分」。如此一來,document 便會多出 totalCoursePoint
欄位,如下。
{
"id": "100",
"courses": [
{ "name": "計算機概論", "point": 3 },
{ "name": "程式設計", "point": 4 }
]
"totalCoursePoint": 7,
"...": "..."
}
以此為例,有了新欄位,若想依據修習課程的總學分來排序學生,就能使用以下的 query string 了。
GET http://localhost:8080/students?sortFields=totalCoursePoint&sortOrders=desc
添加的自定義欄位,不僅能用來排序,還可用在搜尋,是實用的小技巧。
當然也可以設計一套機制,來翻譯排序相關的 query string。但受限於鐵人賽時程關係,筆者就不進行程式實作。
Elasticsearch 系列的完成後專案,會在鐵人賽結束後上傳到 Github,並轉貼到這裡。
這次的鐵人賽系列文章到此結束!由於是在待業期間參加鐵人賽,所以有餘力將大多數文章都寫得蠻長的。過程中除了複習前公司有用過,但印象逐漸模糊的東西,也學習大大小小的新知識。
筆者比較有成就感的文章,是 Day 6 ~ 7 的「HashMap 原理」。以及 Day 27 ~ 30 的「Elasticsearch」,雖然沒有實作出 function score,有點美中不足就是了(但還是可以另外寫啦)。另外也將 Day 22 ~ 26 的「Spring Security」重新梳理一次,畢竟這是部落格中觀看數偏高的,更要好好維護。
筆者後續的個人計畫,會將這幾個小系列的完成後程式專案,做適度的優化後,上傳到 Github。接著將這次鐵人賽的內容,轉載至個人部落格,或是翻新以前寫過的文章。它們都可以成為我履歷的一部份。
完成之後,應該會繼續學習對找工作有幫助的東西吧!
希望明年的這個時候,我已經在新公司穩定下來了