Note: see the link below for the English version of this article.

https://duongnt.com/elasticsearch-dsl-kotlin

Tạo DSL với Kotlin để đọc dữ liệu từ Elasticsearch

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 demoreceiver. 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

RangeQueryTermQuery 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 TermQueryRangeQuery. 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ểu String.
  • Thêm companion object vào lớp TERM. Companion object này có 3 property cũng với kiểu TERM 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ểu TERM 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.

A software developer from Vietnam and is currently living in Japan.

Leave a Reply