第一阶段 05 · Java 客户端查询类详解(Query / SearchCriteria / Response 与复杂拼接)

阶段:第一阶段 / 核心概念

目标:把官方 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)」:它一次只承载一种查询,

比如 termrangematchbool......用哪种就调哪个方法。

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 shouldminimum_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. 坑与最佳实践

  1. Query 只管条件,别把排序分页塞进去 ------那些在 SearchRequest 上。
  2. 能用 filter 就别用 must :精确/范围过滤放 filter,不打分、可缓存、更快。
  3. 动态拼条件用 BoolQuery.Builder:提前 new 出来,逐个入参判空 add,最清晰。
  4. 全空条件 = 查全部bool 里什么都不加就等价 match_all,注意别误伤(可加个默认限制)。
  5. terms 需要 List<FieldValue> :字符串 list 要先 FieldValue::of 转换。
  6. 范围/数值用 JsonData.of(...) 包裝gte/lte 接收 JsonData
  7. total 默认封顶 10000 :要精确总数开 trackTotalHits
  8. 写不出来先调 DSL :Kibana 里把 JSON 调通,再逐层 Query.of(q -> q....) 翻译。

下一篇

进入第二阶段查询能力:10-match-全文匹配.md

从这里开始,每篇都会往本篇的 buildQuery 骨架里塞一种具体的 Query

相关推荐
rannn_1111 小时前
【力扣hot100】哈希表专题——从两数之和到最长连续序列
java·算法·leetcode·哈希
观远数据1 小时前
云原生BI的战略价值:不是技术选择,而是业务弹性的决定因素
java·人工智能·云原生
Full Stack Developme1 小时前
Tomcat 设计原理
java·tomcat
心平气和量大福大1 小时前
C#-WPF-控件-LiveChart图表-线性2(LineSeries)-数据绑定
开发语言·c#·wpf
gongzhxu1 小时前
JetBrains IDEA开发环境搭建
java·ide·intellij-idea
CTA终结者2 小时前
近期AI量化学习,把规则改写接到策略开发
人工智能·python
有Li2 小时前
使用整合电子健康记录的大语言模型智能体实现前列腺癌患者教育个性化文献速递/医学智能体前沿
人工智能·python·机器学习·语言模型·医学生
lingran__2 小时前
C++_stack和queue和priority_queue(容器适配器)
开发语言·c++
程序员三明治2 小时前
【AI】RAG 生成阶段的最后一公里:Prompt 设计、幻觉抑制与引用对齐
java·人工智能·ai·大模型·llm·prompt·rag