阶段:第一阶段 / 核心概念(04–08 的补充篇)
目标:教你在官方 Java 客户端里直接用一段 DSL(JSON) 字符串发查询,
绕开TermQuery/RangeQuery/BoolQuery这一堆 Builder;
同时汇总一套「客户端使用优化建议」。
本篇是独立文档,示例不依赖任何具体项目。⚠️ 定位说明:原生 DSL 直查是builder 写法的补充,不是替代。
05/06 的 builder 适合「动态拼条件」;本篇的withJson适合「已在 Kibana 调好、想原样搬进代码」的固定查询。
1. 概念:为什么想直接写 DSL
在 05/06 篇里,一个查询要拆成一层层 lambda:
Queryq=Query.of(b->b.bool(bo->bo.filter(f->f.term(t->t.field("region").value("AP"))).filter(f->f.range(r->r.field("amount").gte(JsonData.of(1000))))));但很多时候你的 DSL 已经在Kibana Dev Tools里调通了,是一段现成的 JSON。
再手动翻译成 lambda 既费时又容易翻错。此时更想要的是:
把这段 JSON 原样塞进请求就能跑。
官方客户端(co.elastic.clients)对此有一等支持:几乎所有 Builder 都带一个withJson(Reader/InputStream)方法,能把 JSON 反序列化进当前对象。
2. PostgreSQL 对照
这就像 MyBatis / JDBC 里的两种风格:
| 风格 | ES | MyBatis / JDBC 类比 |
|---|---|---|
| Builder 拼 | Query.of(q -> q.term(...)) | MyBatis<if>动态标签逐段拼 SQL |
| 原生 DSL | withJson(new StringReader(dsl)) | 直接jdbcTemplate.query("原生 SQL 字符串") |
原生 SQL 直观、可复制粘贴、但难以安全地动态拼接;原生 DSL 也是同样的取舍。
3. ES DSL(JSON)
先给一段能在 Kibana 直接跑的完整查询体(含 query + 排序 + 分页 + 字段裁剪):
{"query":{"bool":{"filter":[{"term":{"region":"AP"}},{"range":{"amount":{"gte":1000}}}]}},"sort":[{"invoice_dt":{"order":"desc"}}],"from":0,"size":20,"_source":["id","region","amount","invoice_dt"]}只写 query 部分(后面 3 种写法会用到):
{"bool":{"filter":[{"term":{"region":"AP"}},{"range":{"amount":{"gte":1000}}}]}}4. Spring Boot 实现(3 种直查写法)
统一约定:注入官方ElasticsearchClient,返回值用Map接收(也可换成 DTO)。
@AutowiredprivateElasticsearchClientclient;4.1 写法 A:整个 SearchRequest 用一段 JSON(最省事)
把「query + 排序 + 分页 + 裁剪」整体 JSON 一次性灌进SearchRequest.Builder。
注意:JSON 里不含 index,index 仍要在 builder 上单独设置。
publicList<Map<String,Object>>searchByFullDsl(Stringindex,StringdslBody)throwsIOException{SearchRequestrequest=SearchRequest.of(s->s.index(index).withJson(newStringReader(dslBody)));// 把整段 DSL 灌进来SearchResponse<Map>resp=client.search(request,Map.class);returnresp.hits().hits().stream().map(Hit::source).filter(Objects::nonNull).collect(Collectors.toList());}调用:直接把第 3 节那段「完整查询体」JSON 传进来即可。
Stringdsl="{ \"query\": { \"bool\": { \"filter\": [ "+"{ \"term\": { \"region\": \"AP\" } }, "+"{ \"range\": { \"amount\": { \"gte\": 1000 } } } ] } }, "+"\"size\": 20 }";List<Map<String,Object>>rows=searchByFullDsl("orders_idx",dsl);4.2 写法 B:只有 query 部分用 JSON,排序/分页仍用 builder(推荐)
生产里最实用:筛选条件用 JSON(复用 Kibana),排序分页字段裁剪用 builder(更可控)。
publicList<Map<String,Object>>searchByQueryDsl(Stringindex,StringqueryJson,intsize)throwsIOException{// 只把 query 那一段 JSON 反序列化成 Query 对象Queryquery=newQuery.Builder().withJson(newStringReader(queryJson)).build();SearchResponse<Map>resp=client.search(s->s.index(index).query(query)// 复用 DSL 得到的 Query.size(size).sort(so->so.field(f->f.field("invoice_dt").order(SortOrder.Desc))).source(src->src.filter(sf->sf.includes("id","region","amount"))),Map.class);returnresp.hits().hits().stream().map(Hit::source).filter(Objects::nonNull).collect(Collectors.toList());}调用时queryJson就是第 3 节「只写 query 部分」的那段。
4.3 写法 C:从外部文件 / classpath 加载 DSL 模板
把常用 DSL 放到resources/es-dsl/*.json,运行时读进来,代码和查询解耦:
publicList<Map<String,Object>>searchByTemplate(Stringindex,StringclasspathJson)throwsIOException{try(InputStreamin=getClass().getResourceAsStream(classpathJson)){if(in==null){thrownewIllegalArgumentException("DSL 模板不存在: "+classpathJson);}SearchRequestrequest=SearchRequest.of(s->s.index(index).withJson(in));SearchResponse<Map>resp=client.search(request,Map.class);returnresp.hits().hits().stream().map(Hit::source).filter(Objects::nonNull).collect(Collectors.toList());}}// 例:resources/es-dsl/top_orders.jsonList<Map<String,Object>>rows=searchByTemplate("orders_idx","/es-dsl/top_orders.json");5. 参数化:给 DSL 模板填占位符(关键,别用字符串硬拼)
直查最大的坑是「动态值」。绝对不要用+拼用户输入到 JSON 字符串里——
既会破坏 JSON 结构,也有注入风险。推荐两种安全做法:
5.1 占位符 + 转义(简单场景)
模板里留占位符,替换前对值做 JSON 转义:
// top_orders.json: { "query": { "term": { "region": "${region}" } }, "size": ${size} }Stringdsl=template.replace("${region}",jsonEscape(userRegion))// 字符串值要转义.replace("${size}",String.valueOf(size));// 数值直接转字符串// 最简 JSON 字符串转义(生产建议直接用 Jackson,见 5.2)privateStringjsonEscape(Stringraw){returnraw.replace("\\","\\\\").replace("\"","\\\"");}5.2 混合写法:静态骨架用 JSON,动态值用 builder(最推荐)
把「结构固定的部分」用 JSON,「会变的值」仍走 builder /FieldValue,
既复用了 Kibana 又保留了类型安全——这是本篇最推荐的落地姿势:
publicQuerybuildMixed(StringstaticFilterJson,Stringregion,List<String>statusList){returnQuery.of(q->q.bool(b->{// 1) 固定的复杂片段:直接吃 JSONb.filter(newQuery.Builder().withJson(newStringReader(staticFilterJson)).build());// 2) 动态值:仍用 builder,安全if(StringUtils.isNotBlank(region)){b.filter(f->f.term(t->t.field("region").value(region)));}if(CollectionUtils.isNotEmpty(statusList)){b.filter(f->f.terms(t->t.field("status").terms(tv->tv.value(statusList.stream().map(FieldValue::of).collect(Collectors.toList())))));}returnb;}));}6. Builder vs 原生 DSL:怎么选
| 维度 | Builder(05/06 篇) | 原生 DSL(withJson) |
|---|---|---|
| 可读性 | 层层 lambda,稍啰嗦 | 就是 Kibana 里那段 JSON,直观 |
| 复用 Kibana 调好的查询 | 要手动翻译,易错 | ✅ 原样粘贴 |
| 动态拼条件(判空) | ✅ 天生擅长 | ❌ 字符串拼接危险 |
| 编译期类型/字段名检查 | ✅ 部分有 | ❌ 运行期才报错 |
| 复杂固定查询(function_score 等) | 冗长 | ✅ 简洁 |
| 注入风险 | 无 | ⚠️ 硬拼字符串有风险 |
经验法则:
- 条件基本固定、来自 Kibana → 用原生 DSL(4.1 / 4.3)。
- 条件随入参动态变化 → 用Builder(05/06 篇)。
- 又固定又动态 → 用混合写法(5.2),推荐默认选它。
7. 客户端使用优化建议(通用最佳实践)
不局限于本篇,是整套 ES Java 客户端的落地要点:
ElasticsearchClient全局单例复用:它线程安全,底层RestClient自带连接池。
千万别每次请求 new 一个,否则连接泄漏、性能骤降。- 连接池 & Keep-Alive 显式配置:在
RestClientBuilder上设setDefaultRequestConfig
(连接超时、socket 超时)和setHttpClientConfigCallback(最大连接数、Keep-Alive)。 - 一定写
size:不写默认只回 10 条,最容易被误判「数据不全」。 - 只取需要的列:用
_sourceincludes 裁剪字段(第 19 篇),减少网络与反序列化开销。 - 精确总数才开
trackTotalHits:默认封顶 10000;不需要精确总数就别开,省性能。 - 过滤条件优先
filter而非must:不打分、可缓存、更快(第 05 / 13 篇)。 - 大批量取数不要靠大
size:用search_after+ PIT(第 18 / 40 篇),避免深分页 OOM。 - 批量写用
BulkIngester攒批(第 31 篇),别单条index循环写。 - 返回类型选型:临时/灵活用
Map.class;长期维护的接口用强类型 DTO +@JsonProperty映射下划线字段。 - 超时与重试:为耗时聚合单独设更长 socket 超时;对幂等读做有限重试,写操作重试要防重复。
- 异常分类处理:
IOException(网络)与ElasticsearchException(ES 返回错误,可读error().type())区别对待并记录。 - DSL 模板外置:固定 DSL 放
resources/es-dsl/*.json(4.3),改查询不必改 Java 代码、便于评审。 - 别用字符串硬拼动态值进 DSL:一律走占位符转义或混合 builder(第 5 节)。
- 索引名集中管理:索引名/别名抽成常量或配置,避免散落魔法字符串。
- 纯精确匹配就关掉打分:字段全是
keyword、只做「命中/不命中」时,用filter/constant_score,跳过 BM25 打分且命中缓存,更快(见 7.1)。
7.1 keyword 全精确匹配:关闭打分(性能优化)
场景:如果索引字段基本都是keyword,你的查询本质是「精确匹配 / 是否命中」,
根本用不到相关性打分。这时把条件全放进filter(或constant_score),性能会实打实提升。
为什么更快(3 个原因):
| 原因 | 说明 |
|---|---|
| 跳过打分计算 | must/match要算 BM25(TF/IDF、字段长度归一…);filter只判断命中与否,是纯布尔运算,省掉大量浮点计算。 |
| 结果可缓存 | filter 上下文的结果会被缓存成bitset(node query cache),同样的 filter 再来直接复用位图,几乎零成本;打分查询不缓存。 |
| 倒排直达 | keyword是精确词项,term/terms走倒排表直接命中 posting list,非常快。 |
写法 A:bool 全放 filter(最常用)
Queryq=Query.of(b->b.bool(bo->bo.filter(f->f.term(t->t.field("region").value("AP"))).filter(f->f.terms(t->t.field("status").terms(tv->tv.value(statusList.stream().map(FieldValue::of).collect(Collectors.toList())))))));写法 B:constant_score(语义更明确:我不要打分)
Queryq=Query.of(b->b.constantScore(cs->cs.filter(f->f.term(t->t.field("region").value("AP")))));对应 DSL:
{"query":{"bool":{"filter":[{"term":{"region":"AP"}},{"terms":{"status":["PAID","SHIPPED"]}}]}}}注意事项:
- 全 filter 后每条
_score都是0(或常量),排序不能依赖_score,必须显式sort业务字段(如invoice_dt)。 keyword不分词,不能做match那种全文模糊搜索;要模糊搜就给字段加text子字段。- 提升幅度看场景:条件重复度高、并发大时,filter 缓存收益最明显;即使每次条件都不同,「跳过打分」这部分收益仍在。
8. 本项目落地示例(脱敏)
下面基于本项目 ES 场景,做了脱敏(去掉业务表名/字段名/包名),给出与本篇主题(DSL 直查/直建)契合的推荐写法。写入、深分页等内容已归位到对应篇(见 8.2)。
8.1 用原生 DSL(JSON) 建索引
建索引不手写TypeMappingbuilder,而是把整段 mapping JSON 用withJson灌进去
——正是本篇提倡的「原生 DSL 直建」思路:mapping 外置成 JSON,改结构不用改 Java 代码,和 Kibana 完全一致。
// mapping JSON 从资源文件加载(见 4.3),而不是硬编码在 Java 里privatevoidcreateIndexIfAbsent(StringindexName,StringmappingJson)throwsIOException{booleanexists=client.indices().exists(e->e.index(indexName)).value();if(exists){log.info("ES 索引已存在,跳过创建: {}",indexName);return;}CreateIndexRequestrequest=CreateIndexRequest.of(b->b.index(indexName).withJson(newStringReader(mappingJson)));// ← 整段 mapping DSL 直灌booleanacknowledged=client.indices().create(request).acknowledged();if(!acknowledged){thrownewIllegalStateException("ES 索引创建失败: "+indexName);}}8.2 写入与大结果集取数(见对应篇)
这两块和「原生 DSL 直查」主题关系不大,已归位到对应文档,避免重复:
- Bulk 批量写入(
index只在BulkRequest设一次、id 空值校验、失败只抽id + reason、
量大用BulkIngester):见第 07 篇(写操作速查)与第 31 篇(bulk 深入)。 - 大结果集取数 / 深分页(游标概念、
PIT + search_after、「取空才停」、scroll 对比):
见第 18 篇(排序分页)与第 40 篇(深分页与性能)。
9. 坑与最佳实践(本篇专属)
withJson里不要带index:SearchRequest的 index 必须用 builder 的.index(...)设;
JSON 只放 query/sort/from/size/_source 等请求体内容。Query.Builder().withJson(...)吃的是「query 那一段」,不是整个请求体(别把外层{"query": ...}也塞进去)。StringReader/InputStream用完记得关:文件流务必 try-with-resources。- JSON 非法会在解析期抛异常:先在 Kibana 跑通再搬进代码。
- 字段名/类型错误运行期才暴露:原生 DSL 没有编译期保护,务必有集成测试兜底。
下一篇
回到第二阶段查询能力主线:10-match-全文匹配.md。
遇到「已在 Kibana 调好的固定查询」时,回来用本篇的withJson直查即可。