昨天筆者設計了自定義的 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。接著將這次鐵人賽的內容,轉載至個人部落格,或是翻新以前寫過的文章。它們都可以成為我履歷的一部份。
完成之後,應該會繼續學習對找工作有幫助的東西吧!
希望明年的這個時候,我已經在新公司穩定下來了