第一阶段 09 · 原生 DSL 直查与客户端优化(withJson 直接吃 JSON,不依赖 TermQuery 等 Builder)
2026/7/31 18:02:07 网站建设 项目流程

阶段:第一阶段 / 核心概念(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 里的两种风格:

风格ESMyBatis / JDBC 类比
Builder 拼Query.of(q -> q.term(...))MyBatis<if>动态标签逐段拼 SQL
原生 DSLwithJson(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 客户端的落地要点:

  1. ElasticsearchClient全局单例复用:它线程安全,底层RestClient自带连接池。
    千万别每次请求 new 一个,否则连接泄漏、性能骤降。
  2. 连接池 & Keep-Alive 显式配置:在RestClientBuilder上设setDefaultRequestConfig
    (连接超时、socket 超时)和setHttpClientConfigCallback(最大连接数、Keep-Alive)。
  3. 一定写size:不写默认只回 10 条,最容易被误判「数据不全」。
  4. 只取需要的列:用_sourceincludes 裁剪字段(第 19 篇),减少网络与反序列化开销。
  5. 精确总数才开trackTotalHits:默认封顶 10000;不需要精确总数就别开,省性能。
  6. 过滤条件优先filter而非must:不打分、可缓存、更快(第 05 / 13 篇)。
  7. 大批量取数不要靠大size:用search_after+ PIT(第 18 / 40 篇),避免深分页 OOM。
  8. 批量写用BulkIngester攒批(第 31 篇),别单条index循环写。
  9. 返回类型选型:临时/灵活用Map.class;长期维护的接口用强类型 DTO +@JsonProperty映射下划线字段。
  10. 超时与重试:为耗时聚合单独设更长 socket 超时;对幂等读做有限重试,写操作重试要防重复。
  11. 异常分类处理IOException(网络)与ElasticsearchException(ES 返回错误,可读error().type())区别对待并记录。
  12. DSL 模板外置:固定 DSL 放resources/es-dsl/*.json(4.3),改查询不必改 Java 代码、便于评审。
  13. 别用字符串硬拼动态值进 DSL:一律走占位符转义或混合 builder(第 5 节)。
  14. 索引名集中管理:索引名/别名抽成常量或配置,避免散落魔法字符串。
  15. 纯精确匹配就关掉打分:字段全是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"]}}]}}}

注意事项

  1. 全 filter 后每条_score都是0(或常量),排序不能依赖_score,必须显式sort业务字段(如invoice_dt)。
  2. keyword不分词,不能做match那种全文模糊搜索;要模糊搜就给字段加text子字段。
  3. 提升幅度看场景:条件重复度高、并发大时,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. 坑与最佳实践(本篇专属)

  1. withJson里不要带indexSearchRequest的 index 必须用 builder 的.index(...)设;
    JSON 只放 query/sort/from/size/_source 等请求体内容。
  2. Query.Builder().withJson(...)吃的是「query 那一段」,不是整个请求体(别把外层{"query": ...}也塞进去)。
  3. StringReader/InputStream用完记得关:文件流务必 try-with-resources。
  4. JSON 非法会在解析期抛异常:先在 Kibana 跑通再搬进代码。
  5. 字段名/类型错误运行期才暴露:原生 DSL 没有编译期保护,务必有集成测试兜底。

下一篇

回到第二阶段查询能力主线:10-match-全文匹配.md
遇到「已在 Kibana 调好的固定查询」时,回来用本篇的withJson直查即可。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询