昨天我們知道 Java API Client 需要哪些資料來建構搜尋請求。並設計一些方法,用來產生代表搜尋條件及排序方式的物件。而接下來兩天的目標,是能透過 REST API 接收 query string,並做到整合這些程式物件,實現搜尋功能。
這兩天文章的內容偏向實際應用,已經沒有關於 Java API Client 的新的知識點了。
以下在 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 的類別、物件與參數。
以下的類別描述這支 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 ...
}
認識完本文的 REST API,讓我們來看一個範例。
GET http://localhost:8080/students
?grade=2
&sortFields=conductScore,name
&sortOrder=desc,asc
&from=0
&size=30
以上 query string 的搜尋條件為:
讓我們回顧一下昨天是如何建構出 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,產生正確的 BoolQuery
與 SortOptions
,用以建立 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」。englishCertificateType
,但太長了,query string 想取名為 engCertType
,比較簡短。這些問題會在明天的文章來解決。
Elasticsearch 系列的完成後專案,會在鐵人賽結束後上傳到 Github,並轉貼到這裡。
今日文章到此結束!
最後推廣一下自己的部落格,我是「新手工程師的程式教室」的作者,請多指教