Note: see the link below for the English version of this article.
https://duongnt.com/elasticsearch-dsl-kotlin
Một trong những điểm thú vị nhất của Kotlin đó là nó cho phép tạo DSL (domain specific language) một cách nhanh chóng và dễ dàng. Điều này nhờ vào một số tính năng của Kotlin như lambda with receiver, infix function, và unary function. Hôm nay, chúng ta sẽ viết một DSL đơn giản để tạo SearchRequest
và dùng nó để đọc dữ liệu từ cluster Elasticsearch.
Các bạn có thể tải code ví dụ trong bài từ đường link dưới đây.
https://github.com/duongntbk/elasticsearch-dsl-demo
Các bước chuẩn bị
Giống như trong bài trước, ta cần có một cluster Elasticsearch để chạy thử code. Các bạn có thể làm theo các bước setup trong hướng dẫn này. Chúng ta cũng sẽ dùng lại dữ liệu test trong bài trước nên tôi không copy lại chúng ở đây.
Vì sao ta cần có DSL?
Trong một bài trước đây, ta đã tìm hiểu cách viết SearchRequest
bằng library Elasticsearch API Client. Nhờ vào bộ interface đa dạng của nó, ta có thể viết được mọi loại query mình mong muốn. Tuy nhiên, vì phải hỗ trợ cho nhiều use-case khác nhau nên cú pháp của thư viện này tương đối dài và phức tạp. Đây là cách ta tạo một request bằng Elasticsearch API Client.
SearchRequest.of { s -> s
.index("footballer")
.query { q -> q
.bool { b -> b
.must { m -> m
.bool { b -> b
.should { s -> s
.term { t -> t
.field("position")
.value("rw")
.boost(2F)
}
}
.should { s -> s
.term { t -> t
.field("position")
.value("lw")
}
}
}
}
.must { m -> m
.bool { b -> b
.should { s -> s
.range { r -> r
.field("age")
.lte(JsonData.of(23))
}
}
.should { s -> s
.range { r -> r
.field("salary")
.lte(JsonData.of(200))
.boost(2F)
}
}
}
}
}
}
}
Trong bài hôm nay, ta sẽ phát triển một DSL để đơn giản hóa đoạn code ở trên. Dưới đây là phiên bản DSL ta sẽ có được sau khi kết thúc bài hôm nay.
val query = and {
+or {
+(AGE lte 23)
+((SALARY lte 200) boost 2F)
}
+or {
+((POSITION eq "rw") boost 2F)
+(POSITION eq "lw")
}
}
Đoạn code này không phải là phép màu mà nó vẫn chỉ là code Kotlin bình thường. Trong các phần tiếp theo, ta sẽ đi từng bước cho tới khi đạt được phiên bản đó. Nhưng trước hết, hãy tìm hiểu qua về lambda with receiver, một tính năng quan trọng cho phép ta viết DSL một cách ngắn gọn.
Nhắc lại về lambda with receiver
First class function trong Kotlin
Như ta đã biết, function trong Kotlin là thành viên first class. Có nghĩa là ta có thể truyền function làm tham số cho function khác, và ta cũng có thể lấy function làm giá trị trả về. Hơn nữa, bất kỳ chỗ nào ta dùng được function thì ta cũng dùng được lambda với cùng signature. Dưới đây là một hàm nhận vào lambda với một tham số có kiểu StringBuilder
. Hàm này sẽ gọi lambda và truyền vào một object StringBuilder
vừa khởi tạo, rồi trả về giá trị String
từ StringBuilder
đó.
fun demo(func: (StringBuilder) -> Unit): String {
val sb = StringBuilder()
func(sb)
return sb.toString()
}
Ta gọi hàm demo
với lambda như dưới đây. Chú ý là ta dùng tham số đặc biệt với tên it
chứ không dùng cú pháp ->
.
println(demo {
it.append("a")
it.append("b")
})
Đoạn code trên in ra ab
đúng như ta dự đoán.
Lambda với receiver
Mặc dù việc dùng tham số it
giúp ta đơn giản hóa đoạn code ở trên, ta vẫn phải lặp lại việc sử dụng tham số này mỗi khi ta cần gọi hàm từ object StringBuilder
. Thay vào đó, ta có thể quy định tham số đầu tiên của demo
là receiver. Lúc này ta có thể tham chiếu tới các member của nó mà không cần dùng từ khoá it
.
fun demo(func: StringBuilder.() -> Unit): String {
val sb = StringBuilder()
sb.func()
return sb.toString()
}
println(demo {
append("c")
append("d")
})
Đoạn code ở trên in ra cd
đúng như ta dự đoán.
Tạo DSL để đọc dữ liệu từ Elasticsearch
Tạo BoolQuery bằng từ khóa “or”
Bước đầu tiên của ta là tìm cách tạo một BoolQuery để tìm những documents thoả mãn một trong các query con của nó với cú pháp là or {}
. Ta sẽ tạo một lớp hỗ trợ gọi là OrOperator
.
class OrOperator {
// List để lưu tất cả query con của BoolQuery
private val children = mutableListOf<Query>()
// Thêm query vào danh sách các query con
fun add(childQuery: Query) {
children.add(childQuery)
}
// Tạo một BoolQuery.Builder, gọi hàm `should`, truyền vào các query con,
// rồi gọi hàm build
fun build(): Query {
val builder = BoolQuery.Builder()
builder.should(children)
return builder.build()._toQuery()
}
}
Tiếp theo, ta sẽ viết một hàm or có sử dụng lambda với receiver để thêm query vào BoolQuery.Builder
rồi gọi hàm build của nó.
fun or(
addQuery: OrOperator.() -> Unit // addQuery là một lambda với receiver
): Query {
val operator = OrOperator()
operator.addQuery()
return operator.build()
}
Sau đó ta sẽ dùng hàm này để viết query tìm một cầu thủ chạy cánh trái hoặc chạy cánh phải.
val positionTerm1 = TermQuery.Builder().field("position").value("lw").build()._toQuery()
val positionTerm2 = TermQuery.Builder().field("position").value("rw").build()._toQuery()
val query = or {
add(positionTerm1)
add(positionTerm2)
}
Cú pháp ở trên chưa dễ đọc hơn so với cú pháp mặc định là bao, nhưng ta vẫn còn nhiều chỗ có thể cải thiện.
Dùng unary function để thêm query vào danh sách query con
Hàm add
ở trên trông không được “ngầu” cho lắm, ta sẽ tìm cách thay thế nó bằng dấu +
. Vì trong trường hợp này ta chỉ có một toán tử sử dụng hàm cộng nên ta cần dùng unary function. Ta sẽ định nghĩa nó trong lớp OrOperator
.
operator fun Query.unaryPlus() {
children.add(this)
}
Giờ đây DSL của ta có dạng sau.
val positionTerm1 = TermQuery.Builder().field("position").value("lw").build()._toQuery()
val positionTerm2 = TermQuery.Builder().field("position").value("lw").build()._toQuery()
val query = or {
+positionTerm1
+positionTerm2
}
Tạo TermQuery bằng infix function
Người dùng của ta lẽ ra không cần thiết phải biết cú pháp của TermQuery
. Vì ta chỉ cần truyền vào 2 tham số khi khởi tạo TermQuery
nên ta sẽ tìm cách implement cú pháp như sau: "position" eq "lw"
. Cú pháp này gần gũi với ngôn ngữ tự nhiên hơn và cũng ngắn gọn và dễ hiểu hơn nhiều. Ta có thể thực hiện điều này bằng một infix function với tên gọi là eq
. Ta sẽ định nghĩa nó trong lớp OrOperator
để tránh không làm cho global namespace phải chứa quá nhiều thứ không cần thiết.
infix fun String.eq(value: String): Query =
TermQuery.Builder().field(this).value(value).build()._toQuery()
Dưới đây là phiên bản tiếp theo của DSL.
val query = or {
// Cú pháp này chỉ có hiệu lực bên trong khối "or {}"
+("position" eq "lw")
+("position" eq "lw")
}
Không may là ta cần phải sử dụng dấu ngoặc ở đây, nhưng điều này không làm giảm độ rõ ràng của DSL là bao.
Hỗ trợ RangeQuery
RangeQuery
và TermQuery
là tương đối giống nhau. Mặc dù RangQuery
có rất nhiều toán tử nhưng trong bài hôm nay ta chỉ quan tâm tới toán tử lte
. Ta sẽ implement nó bên trong lớp OrOperator
. Để ý là kiểu dữ liệu của biến value
giờ đây là Int
thay vì String
.
infix fun String.lte(value: Int): Query =
RangeQuery.Builder().field(this).lte(JsonData.of(value)).build()._toQuery()
Ta viết query để tìm cầu thủ trẻ hoặc rẻ như sau.
val query = or {
+("age" lte 23)
+("salary" lte 200)
}
Tạo BoolQuery với từ khoá “and”
Giờ đây ta sẽ kết hợp 2 query tạo bởi DSL ở trên bên trong một BoolQuery
. Lần này ta muốn tìm những document thoả mãn cả 2 điều kiện. Có lẽ các bạn cũng đã đoán được là ta cần tạo một lớp AndOperator
. Ta có thể copy y nguyên lớp OrOperator
và chỉ thay hàm should
bằng hàm must
. Nhưng có một cách khác tốt hơn, đó là ta sẽ chuyển tất cả phần trùng nhau của 2 lớp này vào trong một lớp trừu tượng.
abstract class BaseOperator {
protected val children = mutableListOf<Query>()
operator fun Query.unaryPlus() {
children.add(this)
}
infix fun String.eq(value: String): Query =
TermQuery.Builder().field(this).value(value).build()._toQuery()
infix fun String.lte(value: Int): Query =
RangeQuery.Builder().field(this).lte(JsonData.of(value)).build()._toQuery()
abstract fun build(): Query
Giờ đây ta chỉ cần override hàm build
ở bên trong lớp AndOperator và lớp OrOperator.
class AndOperator: BaseOperator() {
override fun build(): Query {
val builder = BoolQuery.Builder()
builder.must(children)
return builder.build()._toQuery()
}
class OrOperator: BaseOperator() {
override fun build(): Query {
val builder = BoolQuery.Builder()
builder.should(children)
return builder.build()._toQuery()
}
Ta cũng cần tạo một hàm and để sử dụng lớp AndOperator
giống như đã tạo hàm or để dùng lớp OrOperator
.
fun and(
addQuery: AndOperator.() -> Unit
): Query {
val operator = AndOperator()
operator.addQuery()
return operator.build()
}
Sử dụng cả “and” và “or” cùng lúc
Trong bước tiếp theo, ta sẽ viết một query để tìm cầu thủ chạy cánh phải hoặc chạy cánh trái, và cầu thủ đó cần phải trẻ hoặc rẻ.
val secondQuery = or {
+("position" eq "rw")
+("position" eq "lw")
}
val firstQuery = or {
+("age" lte 23)
+("salary" lte 200)
}
val query = and {
+firstQuery
+secondQuery
}
Ta cũng có thể lược bỏ bớt biến trung gian chứa query or
ở trên.
val query = and {
+or {
+("age" lte 23)
+("salary" lte 200)
}
+or {
+("position" eq "rw")
+("position" eq "lw")
}
}
Thêm query boosting vào DSL của ta
Bước cuối cùng là tìm cách hỗ trợ query boosting. Chắc các bạn vẫn nhớ là ở phần đầu bài này, chúng ta viết boost query như sau: +((SALARY lte 200) boost 2F)
. Cú pháp này rất giống với cú pháp của TermQuery
và RangeQuery
. Ta sẽ định nghĩa một hàm boost
dưới dạng infix function của kiểu Query
. Hàm này nhận vào một tham số với kiểu float
. Ta sẽ thêm nó vào lớp BaseQuery
.
infix fun Query.boost(factor: Float): Query = if (factor == 1F) {
this // ngừng xử lý nếu hệ số boost là 1
} else BoolQuery.Builder().must(this).boost(factor).build()._toQuery()
Cuối cùng DSL của ta đã có dạng gần giống như ở phần đầu bài.
val query = and {
+or {
+("age" lte 23)
+(("salary" lte 200) boost 2F)
}
+or {
+(("position" eq "rw") boost 2F)
+("position" eq "lw")
}
}
DSL và type safety
Chuyện gì sẽ xảy ra nếu như ta viết một query như sau: or { +("batman" eq "fw") }
. Rõ ràng là index của ta không có trường nào gọi là batman
, vì thế query trên sẽ gặp lỗi. Có cách nào giúp DSL phòng tránh trường hợp này không? Thật may là ta có thể đảm bảo được type safety cho DSL. Cách đơn giản nhất là định nghĩa tất cả các trường của index làm [hằng số] (https://github.com/duongntbk/elasticsearch-dsl-demo/blob/master/src/main/kotlin/Constants.kt). Lúc này, code của ta sẽ có dạng như sau.
val query = and {
+or {
+(AGE lte 23)
+((SALARY lte 200) boost 2F)
}
+or {
+((POSITION eq "rw") boost 2F)
+(POSITION eq "lw")
}
}
Tất nhiên đây chưa phải là giải pháp tối ưu nhất, vì người dùng vẫn có thể viết query mà không dùng các hằng số của ta. Nhưng ít nhất cách này sẽ giúp tránh được việc viết sai chính tả. Dưới đây là một giải pháp khác giúp đảm bảo type safety, tôi sẽ để dành việc implement nó làm bài tập cho khán giả.
- Định nghĩa một lớp gọi là
TERM
chỉ chứa một property duy nhất gọi làname
với kiểuString
. - Thêm companion object vào lớp
TERM
. Companion object này có 3 property cũng với kiểuTERM
với tên gọi lần lượt làAGE/POSITION/SALARY
. - Các property ở trên được khởi tạo với giá trị của biến
name
lần lượt làage/position/salary
. - Cập nhật infix function
eq
để sử dụng kiểuTERM
thay vìString
.infix fun TERM.eq(value: String): Query = TermQuery.Builder().field(this.name).value(value).build()._toQuery()
- Giờ đây ta có thể viết query là
+(TERM.POSITION eq "lw")
. - Nếu ta import các property từ companion object thì ta có thể viết là
+(POSITION eq "lw")
.
Kết thúc
DSL là công cụ tuyệt vời để lấp đầy khoảng cách giữa các chuyên gia trong những lĩnh vực khác và các lập trình viên. Hãy thử tưởng tượng khách hàng của ta là một công ty môi giới cầu thủ với dữ liệu khổng lồ bao gồm nhiều ứng viên trên khắp toàn cầu. Giờ đây bất kỳ một tuyển trạch viên hay huấn luyện viên nào cũng có thể tìm kiếm trong cơ sở dữ liệu này để tìm ra cầu thủ phù hợp nhất với mình mà không cần phải biết lập trình.
Về phần lập trình viên, DSL giúp ta viết code ngắn gọn, rõ ràng, dễ hiểu hơn. Nó giúp ta tập trung vào logic ở tầng lớp cao mà không cần bận tâm tới việc logic này được implement như thế nào.