阶段:第一阶段 / 核心概念
目标:把官方 Java 客户端里最关键的几个类彻底讲清楚------
你写查询时手里其实只在摆弄这几样东西:Query(查询条件)、SearchRequest(整个请求)、SearchResponse(结果) ,
以及你自己定义的入参 SearchCriteria(查询条件 DTO) 。
重点攻克「条件很多、要动态组合」时的
bool拼接。本篇是独立文档,示例不依赖任何具体项目。
1. 先建立整体心智:一次查询涉及哪些类
用一句 SQL 类比:
sql
SELECT * FROM orders WHERE region = 'AP' AND amount >= 1000 ORDER BY invoice_dt DESC LIMIT 20;
翻成 ES Java 客户端,牵涉到 4 类对象,各司其职:
| 类 | 角色 | SQL 类比 |
|---|---|---|
SearchCriteria(你自己写的 DTO) |
前端/上层传进来的原始条件 | HTTP 请求参数 |
Query |
纯粹的「查询条件」,即 WHERE 部分 |
WHERE region='AP' AND amount>=1000 |
SearchRequest |
整个请求:索引 + query + 排序 + 分页 + 字段裁剪 | 整条 SQL |
SearchResponse<T> |
返回结果:命中列表、总数、聚合 | 结果集 ResultSet |
数据流向:
SearchCriteria(原始入参)
│ 你写代码把它翻译成
▼
Query(WHERE 条件)
│ 塞进
▼
SearchRequest(完整请求:index/query/sort/from/size/_source)
│ client.search(request) 发出去
▼
SearchResponse<T>(结果:hits / total / aggregations)
关键认知:
Query只管「筛选条件」,排序/分页/字段裁剪不在Query里,而在SearchRequest上。很多人卡住就是因为把这两者混在一起。
2. SearchCriteria:你自己定义的查询入参 DTO
这个类不是官方类 ,是你根据业务自己写的,用来接收上层传来的条件。
它就是个普通 POJO,字段全部设计成「可空」,为空表示「这个条件不参与筛选」。
java
@Data
public class SearchCriteria {
private String region; // 精确匹配,可空
private List<String> statusList; // in 查询,可空
private BigDecimal minAmount; // 范围下限,可空
private BigDecimal maxAmount; // 范围上限,可空
private String keyword; // 全文搜索关键字,可空
private LocalDate startDate; // 日期区间,可空
private LocalDate endDate;
// 排序与分页(放这里,最后组装 SearchRequest 时用)
private String sortField = "invoice_dt";
private String sortOrder = "desc";
private int page = 1;
private int size = 20;
}
设计要点:「条件字段」和「排序分页字段」都放这个 DTO 里 ,但它们最终会去到不同地方------
条件字段进
Query,排序分页字段进SearchRequest。
3. Query:查询条件的核心
Query 是一个「联合类型(union/tagged type)」:它一次只承载一种查询,
比如 term、range、match、bool......用哪种就调哪个方法。
3.1 单条件 Query
java
// term:精确匹配 WHERE region = 'AP'
Query byRegion = Query.of(q -> q.term(t -> t.field("region").value("AP")));
// range:范围 WHERE amount >= 1000
Query byAmount = Query.of(q -> q.range(r -> r.field("amount").gte(JsonData.of(1000))));
// match:全文 WHERE material_desc @@ 'thinkpad'
Query byKeyword = Query.of(q -> q.match(m -> m.field("material_desc").query("thinkpad")));
Query.of(q -> q.xxx(...)) 里的 q 就是「选择器」,q.term(...) 表示「这个 Query 是个 term 查询」。
3.2 bool:把多个条件组合起来(重点)
复杂查询几乎都靠 bool。它有四个子句,务必记牢它们的语义:
| 子句 | 含义 | SQL 类比 | 是否影响相关性打分 |
|---|---|---|---|
must |
必须满足(AND) | AND |
✅ 参与打分 |
filter |
必须满足(AND) | AND |
❌ 不打分,可缓存,更快 |
should |
应该满足(OR) | OR |
✅ 参与打分 |
must_not |
必须不满足(NOT) | NOT / <> |
❌ 不打分 |
关键最佳实践 :
精确/范围这种「是否命中」的条件用 filter(不打分、能缓存、更快);
只有「需要按相关性排序的全文搜索」才用 must。
java
Query complex = Query.of(q -> q.bool(b -> b
// 全文搜索:要相关性打分 → 放 must
.must(m -> m.match(mt -> mt.field("material_desc").query("thinkpad x1")))
// 精确/范围过滤:不需要打分 → 放 filter(更快)
.filter(f -> f.term(t -> t.field("region").value("AP")))
.filter(f -> f.range(r -> r.field("amount").gte(JsonData.of(1000))))
// 排除条件 → must_not
.must_not(mn -> mn.term(t -> t.field("status").value("CANCELLED")))
));
3.3 should 与 minimum_should_match
should 是 OR,但默认「有 must/filter 时 should 只加分、不强制」。
若想「should 里至少满足 N 个」,要显式设 minimumShouldMatch:
java
// WHERE (channel='ONLINE' OR channel='RETAIL') ------ 至少满足 1 个
Query q = Query.of(b -> b.bool(bo -> bo
.should(s -> s.term(t -> t.field("channel").value("ONLINE")))
.should(s -> s.term(t -> t.field("channel").value("RETAIL")))
.minimumShouldMatch("1")
));
4. 复杂动态拼接:从 SearchCriteria 到 Query(核心难点)
这是你最想搞懂的部分。思路:建一个 BoolQuery.Builder,对每个入参判空,非空才 add 对应子句。
有两种等价写法,推荐第一种(直接操作 builder,最清晰)。
4.1 推荐写法:拿到 BoolQuery.Builder 逐个判空
java
public Query buildQuery(SearchCriteria c) {
BoolQuery.Builder bool = new BoolQuery.Builder();
// 1) 精确匹配:region = ?
if (StringUtils.isNotBlank(c.getRegion())) {
bool.filter(f -> f.term(t -> t.field("region").value(c.getRegion())));
}
// 2) in 查询:status IN (...)
if (CollectionUtils.isNotEmpty(c.getStatusList())) {
bool.filter(f -> f.terms(t -> t
.field("status")
.terms(tv -> tv.value(toFieldValues(c.getStatusList())))));
}
// 3) 范围:amount BETWEEN min AND max(上下限可分别为空)
if (c.getMinAmount() != null || c.getMaxAmount() != null) {
bool.filter(f -> f.range(r -> {
r.field("amount");
if (c.getMinAmount() != null) r.gte(JsonData.of(c.getMinAmount()));
if (c.getMaxAmount() != null) r.lte(JsonData.of(c.getMaxAmount()));
return r;
}));
}
// 4) 日期区间:invoice_dt BETWEEN start AND end
if (c.getStartDate() != null && c.getEndDate() != null) {
bool.filter(f -> f.range(r -> r
.field("invoice_dt")
.gte(JsonData.of(c.getStartDate().toString()))
.lte(JsonData.of(c.getEndDate().toString()))));
}
// 5) 全文搜索:需要相关性打分 → must
if (StringUtils.isNotBlank(c.getKeyword())) {
bool.must(m -> m.match(mt -> mt.field("material_desc").query(c.getKeyword())));
}
// 全部条件为空 => bool 里什么都没有 => 等价于 match_all(查全部)
return Query.of(q -> q.bool(bool.build()));
}
/** List<String> 转 ES 需要的 List<FieldValue> */
private List<FieldValue> toFieldValues(List<String> values) {
return values.stream().map(FieldValue::of).collect(Collectors.toList());
}
为什么用 BoolQuery.Builder 而不是链式 .bool(b -> b...)?
因为链式里没法方便地写 if。把 builder 提出来,就能像写 MyBatis <if> 一样自由判空。
4.2 嵌套 bool:表达 (A AND B) OR (C AND D)
bool 可以嵌套,一个 Query 里再放子 bool:
sql
WHERE (region='AP' AND amount>=1000)
OR (region='NA' AND status='PAID')
java
Query nested = Query.of(q -> q.bool(outer -> outer
.should(s -> s.bool(b1 -> b1
.filter(f -> f.term(t -> t.field("region").value("AP")))
.filter(f -> f.range(r -> r.field("amount").gte(JsonData.of(1000))))))
.should(s -> s.bool(b2 -> b2
.filter(f -> f.term(t -> t.field("region").value("NA")))
.filter(f -> f.term(t -> t.field("status").value("PAID")))))
.minimumShouldMatch("1") // 两个 should 至少满足一个
));
记忆:外层 should = OR 分支,每个分支内部再用 filter/must = AND。
5. SearchRequest:把 Query 组装成完整请求
Query 只是 WHERE。排序、分页、字段裁剪都加在 SearchRequest 上:
java
public SearchRequest buildRequest(String index, SearchCriteria c, Query query) {
return SearchRequest.of(s -> s
.index(index)
.query(query) // WHERE
.from((c.getPage() - 1) * c.getSize()) // OFFSET
.size(c.getSize()) // LIMIT
.sort(so -> so.field(f -> f // ORDER BY
.field(c.getSortField())
.order("asc".equalsIgnoreCase(c.getSortOrder())
? SortOrder.Asc : SortOrder.Desc)))
.source(src -> src.filter(sf -> sf // SELECT 指定列
.includes("id", "region", "amount", "invoice_dt")))
);
}
对照 SQL:
| SearchRequest 方法 | SQL |
|---|---|
.query(query) |
WHERE |
.from(n) |
OFFSET n |
.size(n) |
LIMIT n |
.sort(...) |
ORDER BY |
.source(includes...) |
SELECT col1, col2(而非 SELECT *) |
6. SearchResponse<T>:读取结果
client.search(request, T.class) 返回 SearchResponse<T>。
T 决定 _source 反序列化成什么:用 Map.class(灵活)或自定义 DTO(强类型)。
java
SearchResponse<Map> resp = client.search(request, Map.class);
// 1) 总命中数(相当于 COUNT(*) over 全部匹配)
long total = resp.hits().total() != null ? resp.hits().total().value() : 0L;
// 2) 遍历命中文档
List<Map<String, Object>> rows = new ArrayList<>();
for (Hit<Map> hit : resp.hits().hits()) {
Map<String, Object> source = hit.source(); // _source(文档内容)
String id = hit.id(); // _id
Double score = hit.score(); // 相关性得分(filter-only 时为 null)
rows.add(source);
}
SearchResponse 常用访问路径速查:
| 目的 | 代码 |
|---|---|
| 命中列表 | resp.hits().hits() |
单条文档内容 _source |
hit.source() |
单条 _id |
hit.id() |
单条得分 _score |
hit.score() |
| 总命中数 | resp.hits().total().value() |
| 聚合结果 | resp.aggregations()(见第 20+ 篇) |
注意:
hits().total()默认最多精确到 10000(track_total_hits)。要精确总数需在 request 里
.trackTotalHits(t -> t.enabled(true))。
7. 用强类型 DTO 接收结果(大项目推荐)
用 Map 无类型检查,字段名拼错运行期才发现。定义 DTO 更安全:
java
@Data
public class OrderDoc {
private String id;
private String region;
private BigDecimal amount;
@JsonProperty("invoice_dt") // ES 字段名与 Java 名不一致时映射
private LocalDate invoiceDt;
}
SearchResponse<OrderDoc> resp = client.search(request, OrderDoc.class);
List<OrderDoc> list = resp.hits().hits().stream()
.map(Hit::source)
.filter(Objects::nonNull)
.collect(Collectors.toList());
8. 完整串联:一个可直接复用的查询服务
把前面的类串起来,就是一个真实可用的分页查询服务:
java
@Slf4j
@Component
public class OrderSearchService {
@Autowired
private ElasticsearchClient client;
private static final String INDEX = "orders_idx";
public PageResult<Map<String, Object>> search(SearchCriteria c) throws IOException {
Query query = buildQuery(c); // 3~4 节:拼条件
SearchRequest request = buildRequest(INDEX, c, query); // 5 节:组装请求
SearchResponse<Map> resp = client.search(request, Map.class); // 6 节:执行
long total = resp.hits().total() != null ? resp.hits().total().value() : 0L;
List<Map<String, Object>> rows = resp.hits().hits().stream()
.map(Hit::source)
.filter(Objects::nonNull)
.collect(Collectors.toList());
return new PageResult<>(rows, total, c.getPage(), c.getSize());
}
// buildQuery(...) / buildRequest(...) / toFieldValues(...) 见前文
}
9. 坑与最佳实践
Query只管条件,别把排序分页塞进去 ------那些在SearchRequest上。- 能用
filter就别用must:精确/范围过滤放filter,不打分、可缓存、更快。 - 动态拼条件用
BoolQuery.Builder:提前 new 出来,逐个入参判空 add,最清晰。 - 全空条件 = 查全部 :
bool里什么都不加就等价match_all,注意别误伤(可加个默认限制)。 terms需要List<FieldValue>:字符串 list 要先FieldValue::of转换。- 范围/数值用
JsonData.of(...)包裝 :gte/lte接收JsonData。 total默认封顶 10000 :要精确总数开trackTotalHits。- 写不出来先调 DSL :Kibana 里把 JSON 调通,再逐层
Query.of(q -> q....)翻译。
下一篇
进入第二阶段查询能力:10-match-全文匹配.md。
从这里开始,每篇都会往本篇的 buildQuery 骨架里塞一种具体的 Query。