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

https://duongnt.com/elasticsearch-api-client-kotlin

Đọc dữ liệu với Kotlin và Elasticsearch API Client

Thư viện Elasticsearch Java API Client là giải pháp được khuyên dùng khi ta muốn tương tác với Elasticsearch. Thư viện này cho phép ta viết request và đọc response một cách tự nhiên. Nó cũng hỗ trợ strong typing và xử lý dữ liệu trong định dạng JSON. Và nhờ vào tính tương thích giữa Java và Kotlin, ta có thể dùng thư viện này trong các dự án Kotlin mà không cần nhiều thay đổi.

Hôm nay, chúng ta sẽ dùng Elasticsearch API Client và Kotlin để tìm kiếm dữ liệu trong cluster ta đã thiết lập trong bài trước. Ta cũng sẽ tìm hiểu hai cách tạo request với thư viện mới này.

Các bạn có thể tải code ví dụ trong bài từ đường link dưới đây.

https://github.com/duongntbk/elasticsearchclient-demo

Các bước chuẩn bị

Thiết lập và tạo dữ liệu trong cluster Elasticsearch

Các bạn có thể tham khảo cách thiết lập một cluster Elasticsearch trong link này. Chúng ta sẽ sử dụng lại đúng dữ liệu test trong bài trước.

PUT footballer/_bulk
{ "create": { } }
{ "name": "Ronaldo","position":"fw", "age": 38, "salary": 4430}
{ "create": { } }
{ "name": "Messi","position":"fw", "age": 36, "salary": 1440}
{ "create": { } }
{ "name": "Sancho","position":"lw", "age": 23, "salary": 373}
{ "create": { } }
{ "name": "Antony","position":"lw", "age": 23, "salary": 200}
{ "create": { } }
{ "name": "Salah","position":"rw", "age": 30, "salary": 350}
{ "create": { } }
{ "name": "Vinicius Junior","position":"lw", "age": 22, "salary": 354}
{ "create": { } }
{ "name": "Mahrez","position":"rw", "age": 32, "salary": 160}
{ "create": { } }
{ "name": "Rashford","position":"fw", "age": 25, "salary": 247}
{ "create": { } }
{ "name": "Bukayo Saka","position":"rw", "age": 21, "salary": 70}
{ "create": { } }
{ "name": "Gnabry","position":"rw", "age": 27, "salary": 365}

Cài các dependency

Như ta thấy trong file build.gradle.kts, ta chỉ cần cài thêm 3 thư viện khác.

implementation("org.elasticsearch.client:elasticsearch-rest-client:8.8.1")
implementation ("co.elastic.clients:elasticsearch-java:8.8.1")
implementation ("com.fasterxml.jackson.core:jackson-databind:2.12.3")

Kết nối tới cluster

Code để kết nối tới cluster được lưu trong file ElasticsearchClientWrapper. Dưới đây là các phần đáng chú ý.

Cung cấp CA Fingerprint để xác nhận đúng cluster ta muốn kết nối. Đồng thời ta cũng phải cung cấp tài khoản và mật khẩu.

val sslContext = TransportUtils.sslContextFromCaFingerprint(fingerprint)
val credsProv = BasicCredentialsProvider()
credsProv.setCredentials(AuthScope.ANY, UsernamePasswordCredentials(login, password))

Cluster của ta chỉ nhận kết nối HTTPS, vì thế ta phải dùng protocol https ở đây.

.builder(HttpHost("localhost", 9200, "https"))

Vì certificate của ta được tự ký nên ta cần phải bỏ qua bước xác nhận tên miền. Ta chỉ nên làm điều này trong môi trường test.

.setSSLHostnameVerifier { _, _ -> true } // DANGER!!

Lớp ElasticsearchClientWrapper có implement interface Closeable. Chú ý là ta cần gọi hàm close trên object RestClientTransport thay vì object ElasticsearchClient.

transport.close()

Dùng lớp wrapper để gửi request và nhận response

Gửi request

Như ta thấy ở đây, lớp wrapper của ta chuyển tiếp request tới object thuộc lớp ElasticsearchClient. Response nhận về sẽ có dạng JSON, vì thế ta cần truyền vào một lớp POJO để đọc dữ liệu. Ta sẽ dùng data class Footballer trong bài ngày hôm nay.

val response = client.search(request, Footballer::class.java)

Chú ý: lớp wrapper chỉ nhận request dưới dạng object SearchRequest. Nhưng lớp ElasticsearchClient có thể nhận cả lambda để tạo request. Chúng ta không xét tới trường hợp này trong bài hôm nay.

Đọc response

Response của ta có dạng SearchResponse<TDocument>, hay cụ thể hơn là SearchResponse<Footballer>. Tôi đã viết hàm này để in ra thông tin từ response.

In tổng số document trả về.

println("Hits: ${response.hits().total()?.value()}")

Với từng document, ta in ra tên và điểm số.

for (hit in response.hits().hits()) {
    println("Name: ${hit.source()?.name}, Score: ${hit.score()}")
}

Viết một số query đơn giản

Sử dụng DSL sẵn có

Theo mặc định, ta có thể tạo SearchRequest với DSL sẵn có (Domain Specific Language). Cú pháp của nó rất giống với cú pháp của request HTTP ta gửi qua Kibana trong bài trước.

Ví dụ, đây là request HTTP để tìm tất cả document có name == Rashford.

GET /footballer/_search
{
  "query" : {
    "match" : { "name": "Rashford" }
  }
}

Còn đây là cách ta viết request tương tự cho Elasticsearch API client.

val request = SearchRequest.of { s -> s
    .index("footballer")
    .query { q -> q
        .match { t -> t
            .field("name")
            .query("Rashford")
        }
    }
}

Đây là kết quả trả về từ cluster Elasticsearch, được in bởi hàm printResults. Có thể thấy kết quả này đúng như ta mong đợi.

Hits: 1
Name: Rashford, Score: 2.1382177

Sử dụng các lớp QueryBuilder

DSL kể trên rất tiện lợi, nhưng đôi khi code sử dụng QueryBuilder sẽ đơn giản hơn, đặc biệt là với những người đã quen với HighLevelRestClient. Thật may là client mới cũng hỗ trợ tạo request với QueryBuilder.

Ta sẽ tạo một object SearchRequest tương đương với request HTTP sau đây.

GET /footballer/_search
{
  "query" : {
    "bool" : {
      "should": [
        { "term": { "position": "lw" }},
        { "term": { "position": { "value": "rw", "boost": 2 }}}
      ]
    }
  }
}

Như ta thấy, request trên bao gồm 2 object TermQuery gộp lại bằng toán tử should và nằm trong một BoolQuery. Ngoài ra TermQuery thứ 2 còn có hệ số boost là 2. Ta sẽ viết lại nó bằng QueryBuilder.

val term1 = TermQuery.Builder().field("position").value("lw").build()._toQuery()
val term2 = TermQuery.Builder().field("position").value("rw").boost(2F).build()._toQuery()
val boolQuery = BoolQuery.Builder()
    .should(term1, term2)
    .build()
    ._toQuery()

val request = SearchRequest.Builder()
    .index("footballer")
    .query(boolQuery)
    .build()

Request trên sẽ in kết quả sau ra console. Có thể thấy là các cầu thủ chạy cánh phải đã được boost.

Hits: 7
Name: Salah, Score: 1.7876358
Name: Mahrez, Score: 1.7876358
Name: Bukayo Saka, Score: 1.7876358
Name: Gnabry, Score: 1.7876358
Name: Sancho, Score: 1.1451323
Name: Antony, Score: 1.1451323
Name: Vinicius Junior, Score: 1.1451323

Sử dụng đồng thời 2 phương pháp

Sau khi đọc qua phần trên, có thể các bạn sẽ tự hỏi phương án này tốt hơn? Ta nên dùng DSL hay QueryBuilder? Trong phần lớn các trường hợp, việc sử dụng đồng thời cả 2 phương án sẽ giúp code của ta ngắn gọn và dễ đọc hơn.

Dưới đây là request HTTP cuối cùng ta dùng trong bài trước.

GET /footballer/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "bool": {
            "should": [
              { "term": { "position": { "value": "rw", "boost": 2 }}},
              { "term": { "position": "lw" }}
            ]
          }
        },
        {
          "bool": {
            "should": [
              { "range": { "age": { "lte": 23 }}},
              { "range": { "salary": {"lte": 200, "boost": 2 }}}
            ]
          }
        }
      ]
    }
  }
}

Ta có thể tạo object SearchRequest tương ứng chỉ với DSL hay với QueryBuilder. Nhưng như ta thấy, phiên bản DSL tương đối khó đọc, còn phiên bản QueryBuilder lại khó cho thấy cấu trúc của request.

Thay vào đó, ta có kết quả tốt nhất nhờ kết hợp cả 2 phương pháp kể trên. Đầu tiên ta tạo các node lá bằng QueryBuilder.

val positionTerm1 = TermQuery.Builder().field("position").value("rw").boost(2F).build()._toQuery()
val positionTerm2 = TermQuery.Builder().field("position").value("lw").build()._toQuery()
val ageRange = RangeQuery.Builder().field("age").lte(JsonData.of(23)).build()._toQuery()
val salaryRange = RangeQuery.Builder().field("salary").lte(JsonData.of(200)).boost(2F).build()._toQuery()

Sau đó ta dùng DSL để tạo cấu trúc tổng quát cho request và thêm vào các node lá đã tạo ở trên.

val request = SearchRequest.of { s -> s
    .index("footballer")
    .query { q -> q
        .bool { b -> b
            .must { m -> m
                .bool { b -> b
                    .should(positionTerm1)
                    .should(positionTerm2)
                }
            }
            .must { m -> m
                .bool { b -> b
                    .should(ageRange)
                    .should(salaryRange)
                }
            }
        }
    }
}

Kết quả thu được giống hệt với bài lần trước.

Hits: 5
Name: Bukayo Saka, Score: 4.787636
Name: Antony, Score: 4.145132
Name: Mahrez, Score: 3.7876358
Name: Sancho, Score: 2.1451323
Name: Vinicius Junior, Score: 2.1451323

Kết thúc

Thư viện API Client mới giúp việc đọc dữ liệu từ cluster Elasticsearch trở nên đơn giản hơn. DSL của nó cho phép ta bảo tồn được cấu trúc của request mặc dù ta cần chút thời gian để làm quen với nó. Còn những class QueryBuilder lại là phương pháp thân thuộc giúp ta giữ code dễ đọc, nhất là khi query trở nên phức tạp hơn.

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

Leave a Reply