Chuyển tới nội dung chính

Truy vấn Elasticsearch

Dựng truy vấn Elasticsearch theo phong cách fluent hoặc theo annotation trên DTO, kèm customizer RestClient và exception handler. Dùng khi service cần tìm kiếm dữ liệu trên Elasticsearch mà không phải viết tay BoolQuery của Elasticsearch Java client.

Module chỉ dựng truy vấn và chuyển đổi kết quả; việc khai báo index/mapping, ghi dữ liệu và quản lý vòng đời index thuộc về ứng dụng.

Khi nào sử dụng

  • Cần dựng truy vấn Elasticsearch động từ điều kiện tìm kiếm của người dùng, nhiều điều kiện tùy chọn.
  • Muốn khai báo điều kiện tìm kiếm bằng annotation trên bean DTO thay vì viết tay BoolQuery.
  • Cần gom điều kiện vào các nhóm bool (must, should, mustNot, filter) và chuyển thành NativeQuery của Spring Data Elasticsearch.
  • Cần chuyển SearchHits thành Page/SearchPage thống nhất với các tầng khác.
  • Cần chuẩn hoá phản hồi lỗi khi Elasticsearch trả lỗi về client.

Cài đặt

<dependency>
<groupId>vn.govex.cloud</groupId>
<artifactId>govex-cloud-data-elasticsearch</artifactId>
</dependency>

Version quản lý qua BOM — xem Cài đặt.

Module kéo theo spring-boot-starter-data-elasticsearch (kèm Elasticsearch Java client và spring-data-elasticsearch), govex-cloud-commongovex-cloud-data-common qua transitive dependency. govex-cloud-error là dependency provided: service cần có govex-cloud-error trên classpath thì exception handler của module mới được đăng ký.

Cấu hình

Kết nối Elasticsearch dùng cấu hình chuẩn của Spring Boot, không có property riêng govex.elasticsearch.*. Ứng dụng có thể khai báo một hoặc nhiều URI:

PropertyMô tảMặc định
spring.elasticsearch.urisDanh sách URI kết nối Elasticsearchhttp://localhost:9200
spring.elasticsearch.usernameTài khoản xác thực; khi có giá trị, module bật basic auth cho RestClient(rỗng)
spring.elasticsearch.passwordMật khẩu tương ứng với tài khoản(rỗng)
spring:
elasticsearch:
uris: https://es.example.com:9200
username: elastic
password: ${ES_PASSWORD}

Các property chuẩn khác của Spring Boot (connection-timeout, socket-timeout, path-prefix...) cũng dùng được vì module chỉ tùy biến RestClient trên nền auto-configuration sẵn có.

RestClient

Module không tự tạo bean RestClient; Spring Boot tự tạo RestClient/ElasticsearchClient từ các property trên. Auto-configuration của module đăng ký một RestClientBuilderCustomizer (ElasticsearchConfiguration) để:

  • Gắn basic auth bằng spring.elasticsearch.username/password khi tài khoản có giá trị.
  • Đặt header Content-Type: application/json mặc định cho request.
  • Thêm header phản hồi X-Elastic-Product: Elasticsearch — giúp client elasticsearch-java mới tương thích với Elasticsearch server cũ hơn.

Customizer này có điều kiện @ConditionalOnMissingBean(RestClientBuilderCustomizer.class): nếu ứng dụng tự khai báo RestClientBuilderCustomizer, customizer của module không được áp dụng (kể cả phần basic auth).

Exception handler

Khi govex-cloud-error có trên classpath, module đăng ký hai handler để trả lỗi Elasticsearch theo cấu trúc ErrorResponse chung:

HandlerBắt ngoại lệCách ánh xạ
ElasticsearchExceptionHandlerco.elastic.clients.elasticsearch._types.ElasticsearchExceptionHTTP status theo response; mã lỗi theo type() của root cause; thông báo theo reason()
UncategorizedElasticsearchExceptionHandlerorg.springframework.data.elasticsearch.UncategorizedElasticsearchExceptionNếu cause là ElasticsearchException thì chuyển cho handler trên; ngược lại trả lỗi theo cấu trúc chung

Cả hai đều là bean có @ConditionalOnMissingBean, có thể thay thế bằng bean cùng loại nếu service cần xử lý riêng.

Thành phần chính

Thành phầnVai trò
EsQueriesĐiểm vào: where(), whereLambda(), ofBean(...), ofBeanLambda(...); nạp các QueryBuilder qua SPI
EsQueryBuilderLớp cơ sở fluent: bốn nhóm điều kiện must/should/mustNot/filter, các điều kiện dựng sẵn, chuyển sang NativeQuery
DefaultEsQueryBuilderBuilder điều kiện theo tên trường dạng chuỗi
TypedEsQueryBuilderBuilder điều kiện theo tham chiếu getter (SFunction); lambda() chuyển builder chuỗi sang builder getter và giữ nguyên điều kiện
EsQueryHints/EsQueryHintKhai báo gợi ý theo trường (ignore, entityType) — xem mục Field hints
SearchSupportsChuyển SearchHits sang Page/SearchPage, bóc tách SearchHit
SearchPageImpl, EmptySearchPageCài đặt SearchPage có kết quả và rỗng
NestedGeoDistanceSortTùy chọn sắp xếp theo khoảng cách địa lý, hỗ trợ trường nested
EsQueryExceptionNgoại lệ khi dựng truy vấn sai kiểu dữ liệu hoặc thiếu tham số

Sử dụng

Bước 1 — Khai báo index và repository

Khai báo entity Spring Data Elasticsearch như bình thường; module không tạo index/mapping hộ ứng dụng.

@Document(indexName = "van_ban")
public class VanBan {

@Id
private String id;

@Field(type = FieldType.Keyword)
private String soKyHieu;

@Field(type = FieldType.Text)
private String trichYeu;

@Field(type = FieldType.Keyword)
private String loaiVanBanId;

@Field(type = FieldType.Keyword)
private String trangThai;

@Field(type = FieldType.Date, format = DateFormat.date)
private LocalDate ngayBanHanh;

// getters/setters
}

public interface VanBanRepository extends ElasticsearchRepository<VanBan, String> {
}

Bước 2 — Dựng truy vấn fluent theo tên trường

Dùng EsQueries.where() khi biết tên trường dạng chuỗi. Các method không truyền cờ match tự bỏ qua điều kiện khi giá trị null/rỗng:

Pageable pageable = PageRequest.of(0, 20);

NativeQuery query = EsQueries.<VanBan>where()
.equal("trangThai", "DA_BAN_HANH")
.like("trichYeu", keyword)
.in("loaiVanBanId", loaiVanBanIds)
.between("ngayBanHanh", tuNgay, denNgay)
.toNativeQuery(pageable);

Mỗi điều kiện có ba biến thể:

  • equal(column, val): tự bật/tắt theo giá trị đầu vào (ObjectUtils.isNotEmpty).
  • equal(boolean match, column, val): tự quyết định bật/tắt bằng cờ match.
  • orEqual(...): đưa điều kiện vào nhóm should thay vì must.

Các điều kiện or* tương ứng: orEqual, orNotEqual, orGreatThan, orGreatThanOrEqualTo, orLessThan, orLessThanOrEqualTo, orLike, orNotLike, orStartsWith, orEndsWith, orBetween, orNotBetween, orIsNull, orIsNotNull, orIn, orNotIn.

Bước 3 — Dựng truy vấn fluent theo getter

Dùng EsQueries.whereLambda() khi muốn tham chiếu trường qua method reference getter, an toàn kiểu và không phụ thuộc chuỗi:

NativeQuery query = EsQueries.<VanBan>whereLambda()
.equal(VanBan::getTrangThai, "DA_BAN_HANH")
.greatThanOrEqualTo(VanBan::getNgayBanHanh, tuNgay)
.toNativeQuery(pageable);

Tên trường được suy ra từ getter qua LambdaUtils.getFieldName. Có thể chuyển builder chuỗi sang builder getter mà giữ nguyên các điều kiện đã thêm bằng DefaultEsQueryBuilder.lambda().

Bước 4 — Dựng truy vấn từ DTO annotation

Khai báo điều kiện ngay trên field của bean tìm kiếm, sau đó dùng EsQueries.ofBean(...). Annotation điều kiện nằm trong package vn.govex.cloud.api, dùng chung với Truy vấn JPA nâng cao:

public class VanBanSearchParam {

@Like("trichYeu")
private String keyword;

@In("loaiVanBanId")
private List<String> loaiVanBanIds;

@Between("ngayBanHanh")
private DateRange ngayBanHanh;

@Equals("trangThai")
private String trangThai;

// getters/setters
}

NativeQuery query = EsQueries.ofBean(searchParam).toNativeQuery(pageable);

Cách hoạt động của ofBean:

  • Đọc toàn bộ field (kể cả field kế thừa) qua getter; field không có getter hoặc giá trị null bị bỏ qua.
  • Giá trị đầu vào được lấy từ field; điều kiện gắn vào nhóm must mặc định.
  • Field có nhiều annotation điều kiện được gộp thành một truy vấn con rồi ghép vào must của truy vấn cha.
  • value của annotation là tên trường trong index; để trống thì dùng tên field của DTO.

Dùng biến thể getter khi muốn khai báo bằng method reference — ngoài ra còn có thể thêm điều kiện fluent sau khi dựng từ bean:

TypedEsQueryBuilder<VanBan> builder = EsQueries.ofBeanLambda(searchParam);

NativeQuery query = builder
.equal(VanBan::getTrangThai, "DA_BAN_HANH")
.toNativeQuery(pageable);

Bước 5 — Chạy truy vấn và chuyển kết quả

SearchHits<VanBan> hits = elasticsearchOperations.search(query, VanBan.class);

Page<VanBan> page = SearchSupports.pageFor(hits, pageable);
// hoặc giữ nguyên SearchHit: SearchPage<VanBan> searchPage = SearchSupports.searchPageFor(hits, pageable);

SearchSupports hỗ trợ:

MethodÝ nghĩa
pageFor(SearchHits, Pageable)Trả về Page dữ liệu nội dung (đã bóc SearchHit), pageable có thể null
searchPageFor(SearchHits, Pageable)Trả về SearchPage giữ nguyên SearchHits bên cạnh nội dung trang
unwrapSearchHits(Object)Bóc nội dung khỏi SearchHit, SearchHits, List, Stream, SearchPage, SearchHitsIterator; gán luôn nội dung inner hit vào field tương ứng trên entity
empty(Pageable)Trả về SearchPage rỗng nhưng giữ thông tin phân trang của yêu cầu

Điều kiện và annotation

Bảng điều kiện fluent

Các method có mặt trên cả DefaultEsQueryBuilder (tên trường chuỗi) và TypedEsQueryBuilder (getter):

MethodĐiều kiện ElasticsearchNhóm mặc định
equaltermmust
notEqualterm trong mustNotmust
greatThanOrEqualTorange với gtemust
greatThanrange với gtmust
lessThan/lessThanOrEqualTorange với lt/ltemust
likewildcard dạng *value*must
notLikewildcard dạng *value* trong mustNotmust
startsWith/endsWithwildcard dạng value*/*valuemust
between/notBetweenrange gte + lte (mở được một đầu)must
in/notInquery_string với danh sách giá trịmust
isNullexists + mustNot wildcard *must
isNotNullwildcard *must
cảnh báo

greatThan (không phải greaterThan — tên method hiện tại là greatThan) dựng range query thiếu tên trường nên hiện ném MissingRequiredPropertyException. Dùng greatThanOrEqualTo hoặc between thay thế cho đến khi lỗi này được sửa.

Hai điểm cần lưu ý khi dùng:

  • in/notIn dựng query_string với giá trị được escape, phù hợp trường text; muốn khớp terms trên trường keyword thì dùng annotation @In(fieldType = "keyword") (xem bảng dưới).
  • isNull/isNotNull hiện thực bằng exists + wildcard *, phù hợp trường kiểu chuỗi (keyword/text).

Annotation điều kiện

Khi dùng ofBean, các annotation sau được phân giải qua SPI (META-INF/services) và builder tương ứng:

AnnotationĐiều kiện Elasticsearch
@Equalsterm
@Inquery_string; đặt fieldType = "keyword" để dùng terms
@Betweenrange gte/lte; nhận mảng, List hoặc BetweenValue tối đa 2 phần tử, mở được một đầu
@GreaterThanEqual/@LessThan/@LessThanEqualrange với gte/lt/lte
@Likewildcard *value*, escape ký tự đại diện trong giá trị
@LikePatternwildcard nguyên văn theo pattern (không escape)
@StartsWith/@EndsWithwildcard value*/*value, escape ký tự đại diện
@LikeInNhiều wildcard *item* ghép bằng should
@LikeOrLikequery_string trên nhiều trường, tên trường phân tách bằng dấu phẩy
@IsNullbool gồm existsmustNot wildcard *; field giá trị phải là Boolean.TRUE
@IsNotNullwildcard *; field giá trị phải là Boolean.TRUE
@Matchmatch với operator, fuzziness, maxExpansions, prefixLength, boost...
@MatchPhrasematch_phrase với slop, analyzer, zeroTermsQuery
@Nestednested với path, scoreMode, ignoreUnmapped; dựng truy vấn con từ bean hoặc từ các annotation đi kèm
@GeoDistancegeo_distance từ GeoDistanceRange gồm tâm (lat,lon) và khoảng cách
@MustGộp các annotation điều kiện con thành một truy vấn must
@ShouldGộp các annotation điều kiện con thành một truy vấn should
@FilterGộp các annotation điều kiện con thành một truy vấn filter
cảnh báo

@GreaterThan được đăng ký trong SPI nhưng builder hiện thiếu tên trường nên cũng lỗi như greatThan; dùng @GreaterThanEqual/@Between thay thế.

Mọi annotation điều kiện đều có thuộc tính:

  • value: tên trường trong index; để trống thì dùng tên field của DTO.
  • operator: Operator.And (mặc định, gắn vào must) hoặc Operator.Or (gắn vào should).
  • not: đảo điều kiện; And + not gắn vào mustNot, Or + not gắn vào should dưới dạng bool.mustNot.

Gộp nhóm điều kiện

@Must, @Should, @Filter nhận các annotation điều kiện con theo từng thuộc tính. Mỗi điều kiện con dùng chính giá trị của field đang gắn annotation:

public class VanBanSearchParam {

@Must(equals = @Equals("trangThai"), like = @Like("trichYeu"))
private String tuKhoa;

@Filter(equals = @Equals("loaiVanBanId"))
private String loaiVanBanId;

@Should(equals = @Equals("soKyHieu"), like = @Like("trichYeu"))
private String timNhanh;
}

Annotation phủ định và @Or

Các annotation phủ định @NotEquals, @NotBetween, @NotEndsWith, @NotIn, @NotLike, @NotLikePattern, @NotStartsWith đều dùng được với ofBean: chúng được khai báo bằng @AliasFor trên annotation gốc với not = true, và EsQueries phân giải về annotation gốc để tìm builder. Ví dụ @NotEquals("kind") tương đương @Equals(value = "kind", not = true).

Bảy builder phủ định trong source (NotEqualQueryBuilder, NotLikeQueryBuilder...) không được đăng ký trong file SPI, nhưng điều này không chặn dùng annotation phủ định; các builder phủ định chỉ được dùng trực tiếp bởi fluent API (notEqual, notLike, notIn, notBetween).

@Or không có builder trong SPI; để ghép điều kiện OR, dùng operator = Operator.Or trên từng annotation hoặc các method or* của fluent API.

Field hints

EsQueryHints cho phép khai báo gợi ý theo từng trường với hai thông tin: ignore (bỏ qua trường) và entityType (kiểu entity chứa trường), nhận cả tên trường dạng chuỗi lẫn getter. API có mặt để đối xứng với govex-cloud-data-jpa:

EsQueryHints hints = EsQueryHints.field("internalNote")
.ignore(true)
.field(VanBan::getTrangThai)
.build();

NativeQuery query = EsQueries.ofBeanLambda(searchParam, hints).toNativeQuery(pageable);
cảnh báo

Bản Elasticsearch hiện chưa áp dụng EsQueryHints khi dựng truy vấn (ofBean(bean, hints) nhận tham số nhưng chưa đọc giá trị). Muốn bỏ qua một trường, hãy để giá trị field đó null hoặc không gắn annotation điều kiện; không dựa vào ignore để loại trừ.

Phân trang, sắp xếp

  • Truyền Pageable (kèm Sort) vào toNativeQuery(pageable); NativeQuery mang theo phân trang và sắp xếp cho ElasticsearchOperations.
  • Khi cần cấu hình thêm cho truy vấn, dùng toNativeQueryBuilder(pageable) để lấy NativeQueryBuilder và gọi tiếp các API của Spring Data Elasticsearch:
NativeQuery query = EsQueries.<VanBan>where()
.equal("trangThai", "DA_BAN_HANH")
.toNativeQueryBuilder(pageable)
.withSort(Sort.by(Sort.Direction.DESC, "ngayBanHanh"))
.build();
  • Sắp xếp theo khoảng cách địa lý trên trường nested dùng NestedGeoDistanceSort (tương ứng GeoDistanceSort kèm nested_path):
NestedGeoDistanceSort viTriSort = new NestedGeoDistanceSort.Builder()
.field("viTri")
.nestedPath("donVis")
.location(GeoLocation.of(l -> l.latlon(ll -> ll.lat(21.0278).lon(105.8342))))
.order(SortOrder.Asc)
.unit(DistanceUnit.Kilometers)
.build();

NativeQuery query = EsQueries.<VanBan>where()
.equal("trangThai", "DA_BAN_HANH")
.toNativeQueryBuilder(pageable)
.withSort(new SortOptions(viTriSort))
.build();
  • build() trả về Query bool; nếu builder chưa có điều kiện nào, truy vấn là bool rỗng (tương đương khớp mọi bản ghi). Dùng hasClauses() để kiểm tra trước khi thực thi, tránh quét toàn bộ index ngoài ý muốn.
  • Phân trang kiểu from/size chỉ hiệu quả trong giới hạn index.max_result_window của Elasticsearch (mặc định 10.000). Nhu cầu duyệt sâu hơn cần chiến lược riêng (search_after...) ở tầng gọi; module không cung cấp sẵn.

Luồng hoạt động

Lưu ý

  • Module không tạo, cập nhật hay quản lý mapping index; ứng dụng tự khai báo @Document/@Field và tạo index/mapping.
  • Annotation điều kiện dùng chung với tầng JPA (vn.govex.cloud.api), nhưng tập annotation được hỗ trợ và cách sinh truy vấn có khác — xem bảng annotation phía trên.
  • ofBean chỉ đọc được field có getter; field chỉ có setter hoặc không có getter bị bỏ qua.
  • @Between chỉ nhận mảng, List hoặc BetweenValue (các lớp DateRange, DoubleRange đều kế thừa BetweenValue); nhiều hơn 2 phần tử hoặc cả hai đầu rỗng sẽ ném EsQueryException.
  • Giá trị so sánh của @Between, @GreaterThanEqual, @LessThan, @LessThanEqual phải thuộc kiểu Comparable.
  • @Must/@Should/@Nested hỗ trợ thêm functionScore với scoreMode, boostMode, minScore, maxBoost; hàm tính điểm tùy biến (@ScoreFunction) chưa được hỗ trợ và sẽ ném UnsupportedOperationException.
  • Builder giữ trạng thái trong lúc dựng truy vấn — không chia sẻ một instance giữa nhiều luồng.
  • Exception handler chỉ hoạt động khi service có govex-cloud-error trên classpath.