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ànhNativeQuerycủa Spring Data Elasticsearch. - Cần chuyển
SearchHitsthànhPage/SearchPagethố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-common và govex-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:
| Property | Mô tả | Mặc định |
|---|---|---|
spring.elasticsearch.uris | Danh sách URI kết nối Elasticsearch | http://localhost:9200 |
spring.elasticsearch.username | Tài khoản xác thực; khi có giá trị, module bật basic auth cho RestClient | (rỗng) |
spring.elasticsearch.password | Mậ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/passwordkhi tài khoản có giá trị. - Đặt header
Content-Type: application/jsonmặc định cho request. - Thêm header phản hồi
X-Elastic-Product: Elasticsearch— giúp clientelasticsearch-javamớ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:
| Handler | Bắt ngoại lệ | Cách ánh xạ |
|---|---|---|
ElasticsearchExceptionHandler | co.elastic.clients.elasticsearch._types.ElasticsearchException | HTTP status theo response; mã lỗi theo type() của root cause; thông báo theo reason() |
UncategorizedElasticsearchExceptionHandler | org.springframework.data.elasticsearch.UncategorizedElasticsearchException | Nế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ần | Vai trò |
|---|---|
EsQueries | Điểm vào: where(), whereLambda(), ofBean(...), ofBeanLambda(...); nạp các QueryBuilder qua SPI |
EsQueryBuilder | Lớ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 |
DefaultEsQueryBuilder | Builder điều kiện theo tên trường dạng chuỗi |
TypedEsQueryBuilder | Builder đ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/EsQueryHint | Khai báo gợi ý theo trường (ignore, entityType) — xem mục Field hints |
SearchSupports | Chuyển SearchHits sang Page/SearchPage, bóc tách SearchHit |
SearchPageImpl, EmptySearchPage | Cài đặt SearchPage có kết quả và rỗng |
NestedGeoDistanceSort | Tùy chọn sắp xếp theo khoảng cách địa lý, hỗ trợ trường nested |
EsQueryException | Ngoạ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ómshouldthay 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
mustmặ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
mustcủa truy vấn cha. valuecủ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 Elasticsearch | Nhóm mặc định |
|---|---|---|
equal | term | must |
notEqual | term trong mustNot | must |
greatThanOrEqualTo | range với gte | must |
greatThan | range với gt | must |
lessThan/lessThanOrEqualTo | range với lt/lte | must |
like | wildcard dạng *value* | must |
notLike | wildcard dạng *value* trong mustNot | must |
startsWith/endsWith | wildcard dạng value*/*value | must |
between/notBetween | range gte + lte (mở được một đầu) | must |
in/notIn | query_string với danh sách giá trị | must |
isNull | exists + mustNot wildcard * | must |
isNotNull | wildcard * | must |
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/notIndựngquery_stringvới giá trị được escape, phù hợp trườngtext; muốn khớptermstrên trườngkeywordthì dùng annotation@In(fieldType = "keyword")(xem bảng dưới).isNull/isNotNullhiện thực bằngexists+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 |
|---|---|
@Equals | term |
@In | query_string; đặt fieldType = "keyword" để dùng terms |
@Between | range gte/lte; nhận mảng, List hoặc BetweenValue tối đa 2 phần tử, mở được một đầu |
@GreaterThanEqual/@LessThan/@LessThanEqual | range với gte/lt/lte |
@Like | wildcard *value*, escape ký tự đại diện trong giá trị |
@LikePattern | wildcard nguyên văn theo pattern (không escape) |
@StartsWith/@EndsWith | wildcard value*/*value, escape ký tự đại diện |
@LikeIn | Nhiều wildcard *item* ghép bằng should |
@LikeOrLike | query_string trên nhiều trường, tên trường phân tách bằng dấu phẩy |
@IsNull | bool gồm exists và mustNot wildcard *; field giá trị phải là Boolean.TRUE |
@IsNotNull | wildcard *; field giá trị phải là Boolean.TRUE |
@Match | match với operator, fuzziness, maxExpansions, prefixLength, boost... |
@MatchPhrase | match_phrase với slop, analyzer, zeroTermsQuery |
@Nested | nested với path, scoreMode, ignoreUnmapped; dựng truy vấn con từ bean hoặc từ các annotation đi kèm |
@GeoDistance | geo_distance từ GeoDistanceRange gồm tâm (lat,lon) và khoảng cách |
@Must | Gộp các annotation điều kiện con thành một truy vấn must |
@Should | Gộp các annotation điều kiện con thành một truy vấn should |
@Filter | Gộp các annotation điều kiện con thành một truy vấn filter |
@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àomust) hoặcOperator.Or(gắn vàoshould).not: đảo điều kiện;And+notgắn vàomustNot,Or+notgắn vàoshoulddưới dạngbool.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);
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èmSort) vàotoNativeQuery(pageable);NativeQuerymang theo phân trang và sắp xếp choElasticsearchOperations. - Khi cần cấu hình thêm cho truy vấn, dùng
toNativeQueryBuilder(pageable)để lấyNativeQueryBuildervà 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 ứngGeoDistanceSortkèmnested_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ềQuerybool; 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ùnghasClauses()để 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/sizechỉ hiệu quả trong giới hạnindex.max_result_windowcủ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/@Fieldvà 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. ofBeanchỉ đọc được field có getter; field chỉ có setter hoặc không có getter bị bỏ qua.@Betweenchỉ nhận mảng,ListhoặcBetweenValue(các lớpDateRange,DoubleRangeđều kế thừaBetweenValue); nhiều hơn 2 phần tử hoặc cả hai đầu rỗng sẽ némEsQueryException.- Giá trị so sánh của
@Between,@GreaterThanEqual,@LessThan,@LessThanEqualphải thuộc kiểuComparable. @Must/@Should/@Nestedhỗ trợ thêmfunctionScorevớiscoreMode,boostMode,minScore,maxBoost; hàm tính điểm tùy biến (@ScoreFunction) chưa được hỗ trợ và sẽ némUnsupportedOperationException.- 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-errortrên classpath.