本文主要讲解以下四个部分内容
- 第一部分:为什么要换 & 怎么接入(背景 + 入门)
- 第二部分:常用开发场景实战(新写法)
- 第三部分:内部架构与源码浅析(设计思路)
- 第四部分:避坑指南与迁移建议(实践经验)
第一部分:为什么要换 & 怎么接入(背景 + 入门)
1. 为什么要废弃 RestHighLevelClient
很多人对于「RestHighLevelClient 要被废弃」的第一反应都是:"好好的为什么要换?我现在用得也挺顺手的。" 如果只把它理解成「官方说它 deprecated 了,所以要迁」,那这节就太浪费了。更真实的原因是:它的设计本身,已经越来越不适合现在的 Java / Spring 项目形态。
下面从几个角度拆开说。
1.1 依赖耦合太重:从 HTTP 客户端变成「半个服务端」
RestHighLevelClient 最大的问题之一,就是它对服务端核心包org.elasticsearch:elasticsearch的强依赖:
-
典型项目里,你会看到类似依赖:
xml<dependency> <groupId>org.elasticsearch</groupId> <artifactId>elasticsearch</artifactId> <version>${es.version}</version> </dependency> <dependency> <groupId>org.elasticsearch.client</groupId> <artifactId>elasticsearch-rest-high-level-client</artifactId> <version>${es.version}</version> </dependency> -
这意味着什么?
- 版本兼容地狱(Dependency Hell) 不同模块可能需要不同版本的 ES 功能,但 RestHighLevelClient 又强依赖服务端核心 jar,最终所有模块都被迫绑在同一个版本上 。 一旦你想升级 ES 版本,很容易牵一发动全身:其它引用
elasticsearch.jar的地方也得跟着改。 - 包体积大 & 升级风险高 实际上,作为一个「HTTP 客户端」,它只需要会发请求、解析 JSON 即可;但你却顺带把整套服务端核心类也塞进来了。 这会带来:
- 可执行包体积明显变大
- 升级 ES 时,不只是客户端换个版本,而是整个依赖树都在抖,风险自然跟着上去
- 版本兼容地狱(Dependency Hell) 不同模块可能需要不同版本的 ES 功能,但 RestHighLevelClient 又强依赖服务端核心 jar,最终所有模块都被迫绑在同一个版本上 。 一旦你想升级 ES 版本,很容易牵一发动全身:其它引用
简单说:RestHighLevelClient 不够「client」,太像服务端的一部分。
1.2 请求构建割裂又臃肿:Index / Update / Search / Bulk 都不好写
真正在项目里重度用过 RestHighLevelClient 的人,最大直观感受往往不是「不会写 DSL」,而是:索引、更新、搜索、bulk 任意一个请求,构建起来都很别扭。
- 一堆 Request / Builder / Options,API 分散 同样是「写一个请求」,要在
IndexRequest、UpdateRequest、SearchRequest、BulkRequest、各种*Builder和RequestOptions之间来回切; 路由、超时、刷新策略、pipeline 等公共能力被拆到不同类里,同一个概念在不同请求上的写法都不一样,完全靠记忆和 Ctrl+Click 找入口。 - 构建方式不统一,嵌套层级多 有的用静态工厂(
QueryBuilders/AggregationBuilders),有的先new再一堆setXxx,有的还得自己setSource塞 JSON; 一个稍复杂的链路(Index + Update + Search + Bulk 混在一起)就会变成 Request 套 Request、Builder 套 Builder 的对象树,改一个条件、加一个字段都要从外到里捋一遍构建过程。 - 和 REST 文档脱节,DSL 难在代码里复现 官方文档永远给 HTTP/JSON 示例,但 Java 侧没有一套「贴合 DSL 的统一模型」: 同一个字段在不同 Request 里的名字 / 写法都可能不同,有些 REST 参数甚至没有显式方法,只能各种查寻写法; 一旦涉及脚本更新、嵌套查询、pipeline 聚合、mixed bulk 等复杂场景,经常会发现 Java 里找不到顺手的表达方式,最后退回
Map或纯 JSON。
1.3 API 手写维护:和服务端 REST API 越走越远
RestHighLevelClient 的 API 是人为写出来的,这意味着:
- 每当服务端增加新 API / 新参数:
- 需要客户端维护者手动补上新方法、参数、数据结构
- 版本上晚一步,就会出现「服务端文档里有这个参数,但 Java 客户端没地方写」的尴尬情况
- 实际开发中你可能遇到:
- 文档里写
min_doc_count可以这么用,但你在 Java 里死活找不到对应字段 - REST API 已经支持某个新聚合、新查询类型,客户端却迟迟不支持
- 文档里写
本质上是:服务端 API 以一种节奏演进,客户端却只能「追着改」。 对于一个号称「官方 Java 客户端」的库来说,这一点长期来看是站不住脚的。
1.4 新客户端的目标:更轻、更「贴」DSL、更可演进
带着上面这些痛点,再来看新客户端(Elasticsearch Java API Client)就容易理解多了。它的几个核心设计目标,其实都在精准对旧版痛点开刀:
- 基于 API Specification 自动代码生成
- 所有 Request / Response / Builder / Endpoint 都是根据官方 API Spec 生成
- 服务端新增或变更 API,只要更新 Spec → 重新生成即可
- 服务端和客户端可以做到「自然同步」,不再手写追着补
- 彻底去掉对服务端核心 jar 的依赖
- 只依赖 client 本身 + JSON 相关依赖
- 真正变成一个「纯粹的 HTTP 客户端」,升级/替换风险明显下降
- 强类型 + Fluent Lambda Builder
- 请求使用链式 Builder + Lambda 写法,结构和 JSON DSL 几乎一一对应
- 所有字段都是强类型,字段名、枚举值、嵌套结构都受 IDE 和编译器保护
- 重构字段名、封装查询逻辑时,也可以享受 Java 生态的重构能力
- 对 IDE 友好
- 自动补全 DSL 结构:输入
query(q -> q.之后,IDE 会列出 term、match、bool 等所有可用选项 - 查找引用、跳转到定义,对维护复杂查询非常关键
- 自动补全 DSL 结构:输入
可以用一张简单的新旧对比表总结一下整体差异:
| 维度 | RestHighLevelClient | Java API Client |
|---|---|---|
| 依赖 | 依赖服务端核心 elasticsearch jar |
仅依赖 client 自身 + JSON 相关库 |
| 请求写法 | 各种对象、Builder 嵌套,层级深 | Builder + Lambda + 强类型,链式调用 |
| 响应处理 | String和Map<String, Object> 为主,需手动解析 |
直接映射为 POJO(如 SearchResponse<T>) |
| API 同步方式 | 手写维护,更新慢,容易落后于 REST API | 由官方 Spec 自动生成,随 Spec 同步演进 |
1.5 小结:不是「必须换」,而是「早晚要换」
综上,其实可以把这节的结论概括成一句话:
RestHighLevelClient 的问题并不是「还能不能用」,而是「在今天的 Java 项目里,它已经很难优雅地维护下去」。
- 对新项目:没必要再背一堆历史包袱,直接上新客户端 是更合理的默认选择;
- 对老项目:即便短期内不迁,也建议开始规划,从依赖、封装层开始给自己留出「可迁移空间」。
2. 环境搭建与依赖陷阱
我使用的是spring boot3.4、java21和elasticsearch-java8.15来搭建演示项目的。
这里主要提讲一下核心依赖和依赖陷阱。
2.1 核心依赖怎么配?
新 Java 客户端只需要一个坐标:
xml
<dependency>
<groupId>co.elastic.clients</groupId>
<artifactId>elasticsearch-java</artifactId>
<version>${elasticsearch.java.version}</version>
<!--本文演示代码均使用该版本<version>8.15.5</version>-->
</dependency>
它会自动带进来:
org.elasticsearch.client:elasticsearch-rest-client(底层 HTTP 客户端)jakarta.json:jakarta.json-api等 JSON-P 相关依赖
👉 **结论:**正常情况下你不需要自己再单独引 low-level client,也不要随手把这些传递依赖删掉。
2.2 JSON 依赖:最常见的 ClassNotFound 坑
新客户端用的是 JSON-P 标准(jakarta.json)+ JacksonJsonpMapper。
如果你在 pom 里:
- 用
<exclusions>把jakarta.json-api排除了,或者 - 用版本管理把它替换成了不兼容的版本,
运行时很容易直接报:
text
ClassNotFoundException: jakarta.json.Json
👉 结论:
- 不要把
jakarta.json-api当"重复 JSON 依赖"随手 exclude。 - 需要统一版本,用 BOM /
<dependencyManagement>对齐,而不是一刀切排除。
3. 核心对象初始化(三件套)
新 Java 客户端不再是一个"大一统"的 RestHighLevelClient,而是清晰拆成三层核心对象:
RestClient--- HTTP 传输层:负责连接池、超时、认证等纯 HTTP 能力;ElasticsearchTransport--- 对象 ↔ JSON ↔ HTTP 的桥梁:负责序列化、反序列化,以及调用底层 HTTP;ElasticsearchClient--- 对外暴露的 Typed API 门面 :你真正调用的search/index/update/delete等方法都在这里。
典型初始化代码如下:
java
RestClient restClient = RestClient.builder(
new HttpHost("localhost", 9200)
).build();
ElasticsearchTransport transport = new RestClientTransport(
restClient,
new JacksonJsonpMapper()//负责 JSON 序列化与反序列化(基于 JSON-P + Jackson)
);
ElasticsearchClient client = new ElasticsearchClient(transport);
简单理解这三层的分工就是:
RestClient只关心"怎么发 HTTP 请求",ElasticsearchTransport负责"对象和 JSON 如何互转",ElasticsearchClient只关注"提供尽量贴近 DSL 的强类型 API"。
在第三部分的源码浅析里,会沿着这三层结构进一步展开:Client → Endpoint → Transport → LowLevel RestClient,把这一套调用链路完整拆开。
第二部分:常用开发场景实战(新写法)
第一部分从背景、依赖配置和客户端初始化几个方面,把 Elasticsearch-Java 新客户端的整体轮廓搭好了。
从这一部分开始,视角回到具体使用层面,围绕索引、CRUD、查询与聚合等常见场景,给出更贴近 DSL、类型安全的新客户端写法,方便在实际项目中逐步替换旧方案。
下面的代码实战都是基于spring boot3.4、java21和elasticsearch-java8.15开发。
0. 示例数据模型 & 索引约定
这里定义一个商品类 ProductES
java
@JsonInclude(JsonInclude.Include.NON_NULL)
class ProductES {
Long id;
String name;
Byte status; //0下架,1上架
Double price;
List<String> categories;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "GMT+8")
@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
LocalDateTime publishTime;
}
以及对应的索引product,约定其中id也会作为文档id:
sh
PUT /product
{
"mappings": {
"properties": {
"id": {"type": "long"},
"name": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
"fields": { "keyword": {"type": "keyword"}}
},
"status": {"type": "byte"},
"price": {"type": "double"},
"categories": {"type": "keyword"},
"publishTime": {
"type": "date",
"format": "strict_date_optional_time||epoch_millis"
}
}
}
}
1. 文档 CRUD & Bulk
本节演示新客户端CRUD和批量操作的基本写法,下面这些例子都默认:
- 已经拿到
ElasticsearchClient client - 使用前面定义的
ProductES和索引product
核心就是:所有操作都统一成「client.xxx + builder + lambda」这一套心智模型。
1.1 index:写入 / 覆盖文档
点击查看DSL
json
PUT /product/_doc/1
{
"id": 1,
"name": "Apple iPhone 15",
"status": 1,
"price": 5999.0,
"publishTime": "2025-11-30T12:34:56" // 实际为当前时间
}
elasticsearch-java写法:
java
// 写入一个商品文档(新增或覆盖)
ProductES product = new ProductES();
product.setId(1L);
product.setName("Apple iPhone 15");
product.setStatus((byte) 1);
product.setPrice(5999.0);
product.setPublishTime(LocalDateTime.now());
IndexResponse indexResponse = client.index(i -> i
.index("product") // 目标索引
.id(product.getId().toString()) // 文档 id
.document(product) // 会被序列化为 JSON
);
// 可选:根据响应做一点日志
String docId = indexResponse.id();
long version = indexResponse.version();
1.2 get:按 id 获取文档
点击查看DSL
bash
GET /product/_doc/1
elasticsearch-java写法:
java
// 按 id 查询,并反序列化成 ProductES
GetResponse<ProductES> getResponse = client.get(g -> g
.index("product")
.id("1"),
ProductES.class // 目标类型
);
if (getResponse.found()) {
ProductES source = getResponse.source(); // 直接拿到强类型对象
// 使用 source ...
} else {
// 文档不存在
}
1.3 update:部分字段更新
点击查看DSL
bash
POST /product/_update/1
{
"doc": {
"price": 5299.0
}
}
elasticsearch-java写法:
java
// 方式一:用 POJO 作为 doc,只更新非 null 字段
ProductES patch = new ProductES();
patch.setPrice(5299.0);
UpdateResponse<ProductES> updateResponse = client.update(u -> u
.index("product")
.id("1")
.doc(patch), // 默认null也会覆盖更新,添加@JsonInclude(JsonInclude.Include.NON_NULL)注解ProductES后,就会只更新非 null 字段
ProductES.class
);
// 方式二:用 Map 作为 doc
Map<String, Object> doc = new HashMap<>();
doc.put("price", 5299.0);
UpdateResponse<ProductES> updateResponse2 = client.update(u -> u
.index("product")
.id("1")
.doc(doc),
ProductES.class
);
1.4 delete:删除文档
点击查看DSL
bash
DELETE /product/_doc/1
elasticsearch-java写法:
java
DeleteResponse deleteResponse = client.delete(d -> d
.index("product")
.id("1")
);
if (deleteResponse.result() == Result.NotFound) {
// 没找到要删的文档,可以做一点日志
}
1.5 bulk:批量操作与错误检查
点击查看DSL
bash
POST /_bulk
{"index":{"_index":"product","_id":"1"}}
{"id":1,"name":"...","status":1,"price":5999.0,"publishTime":"2025-11-30T12:34:56"}
{"index":{"_index":"product","_id":"2"}}
{"id":2,"name":"...","status":1,"price":3999.0,"publishTime":"2025-11-30T12:35:10"}
{"delete":{"_index":"product","_id":"2"}}
elasticsearch-java bulk 写法的关键点只有两个:
- 所有子操作统一走
operations(...) - 根据业务需要检查
bulkResponse.errors()和失败项
java
List<ProductES> products = List.of(product1, product2, product3);
// 简单的批量 index 示例
BulkResponse bulkResponse = client.bulk(b -> {
for (ProductES p : products) {
b.operations(op -> op
.index(idx -> idx
.index("product")
.id(p.getId().toString())
.document(p)
)
);
}
return b;
});
// 混合 index + delete 示例
BulkResponse mixedBulk = client.bulk(b -> b
.operations(op -> op
.index(idx -> idx
.index("product")
.id("1")
.document(product1)
)
)
.operations(op -> op
.delete(del -> del
.index("product")
.id("2")
)
)
);
// 统一的错误检查逻辑
if (bulkResponse.errors()) {
bulkResponse.items().stream()
.filter(item -> item.error() != null)
.forEach(item -> {
String opType = item.operationType().toString(); // index/update/delete...
String docId = item.id();
String reason = item.error().reason(); // 失败原因
log.error("Bulk {} doc {} failed: {}", opType, docId, reason);
});
}
2. 复杂查询
查询这块的心智模型可以先记成一句话:
所有查询,最后都长成一个
client.search(...),里面塞一个query(...),再按 DSL 结构去拼。
这一小节围绕 ProductES + product 索引,按从简单到复杂的顺序,分别实现新客户端的以下查询:
- 全文匹配:
match/match_phrase - 精确匹配:
term/terms - 范围匹配:
range - 组合条件:
must/should/filter/must_not - 分页 + 排序
- 查询 + 聚合
2.1 全文匹配:match / match_phrase
name 字段在 mapping 里是 text + ik 分词,所以典型文本搜索都会落在 match 系列上。
2.1.1 match:按关键词检索
点击查看DSL
bash
POST /product/_search
{
"query": {
"match": {
"name": "iphone 17"
}
}
}
elasticsearch-java 写法:
java
SearchResponse<ProductES> response = client.search(s -> s
.index("product")
.query(q -> q
.match(m -> m
.field("name")
.query("iphone 17")
)
),
ProductES.class
);
// 结果列表
response.hits().hits().stream().map(hit -> hit.source()).toList();
这种写法基本一眼能从 JSON DSL 映射过来:
query.match.field/query一层一层往里走。
2.1.2 match_phrase:短语匹配
当你希望"词序也要一致"时,就要用 match_phrase,比如搜索包含"Glassy S26"完整短语的商品名:
点击查看DSL
bash
POST /product/_search
{
"query": {
"match_phrase": {
"name": {
"query": "三星 Glassy S26",
"slop": 1
}
}
}
}
elasticsearch-java 写法:
java
SearchResponse<ProductES> response = client.search(s -> s
.index("product")
.query(q -> q
.matchPhrase(mp -> mp
.field("name")
.query("三星 Glassy S26")
.slop(1) // 允许词之间有最多 1 个位置的"错位"
)
),
ProductES.class
);
2.2 精确匹配:term / terms
term / terms 适合做 精确值匹配,不会对查询值做分词处理。典型场景:
- 状态字段:
status(0 下架 / 1 上架) - keyword 字段:
name.keyword、精确类目等
2.2.1 term:单值匹配
需求:查所有上架商品(status = 1)。
点击查看DSL
bash
POST /product/_search
{
"query": {
"term": {
"status": 1
}
}
}
elasticsearch-java 写法:
java
SearchResponse<ProductES> response = client.search(s -> s
.index("product")
.query(q -> q
.term(t -> t
.field("status")
.value(v -> v.longValue(1L)) // byte/short/int/long 内部都会转成 FieldValue,其他类型可以对应用v.doubleValue()、v.stringValue()等等
)
),
ProductES.class
);
2.2.2 terms:多值匹配
需求:查 status 在 [0, 1] 范围内的所有商品(比如 0=下架、1=上架,只是过滤掉一些"非法状态")。
点击查看DSL
bash
POST /product/_search
{
"query": {
"terms": {
"status": [0, 1]
}
}
}
elasticsearch-java 写法:
java
List<FieldValue> statuses = List.of(
FieldValue.of(0L),
FieldValue.of(1L)
);
SearchResponse<ProductES> response = client.search(s -> s
.index("product")
.query(q -> q
.terms(t -> t
.field("status")
.terms(v -> v.value(statuses))
)
),
ProductES.class
);
如果是字符串,比如精确匹配商品名(name.keyword):
点击查看DSL
bash
POST /product/_search
{
"query": {
"terms": {
"name.keyword": [
"Apple iPhone 15",
"Galaxy s26 Utral"
]
}
}
}
java
List<FieldValue> names = List.of(
FieldValue.of("苹果 iPhone 17"),
FieldValue.of("三星 s26 Utral")
);
SearchResponse<ProductES> response = client.search(s -> s
.index("product")
.query(q -> q
.terms(t -> t
.field("name.keyword")
.terms(v -> v.value(names))
)
),
ProductES.class
);
2.3 范围查询:range
范围查询最典型的就是数值/时间区间过滤,比如价格、发布时间等。
2.3.1 按价格区间过滤
需求:查价格在 [3000, 6000] 之间的上架商品。
点击查看DSL
yaml
POST /product/_search
{
"query": {
"range": {
"price": {
"gte": 3000,
"lte": 6000
}
}
}
}
**elasticsearch-java 写法:**
java
// elasticsearch-java 8.0 ~ 8.14.x写法
import co.elastic.clients.json.JsonData;
SearchResponse<ProductES> response = client.search(s -> s
.index("product")
.query(q -> q
.range(r -> r
.field("price")
.gte(JsonData.of(3000))
.lte(JsonData.of(6000))
)
),
ProductES.class
);
// elasticsearch-java 8.15+
SearchResponse<ProductES> response = client.search(s -> s
.index("product")
.query(q -> q
.range(r -> r
.number(nr -> nr.field("price")
.gte(minPrice)
.lte(maxPrice)
)
)
),
ProductES.class
);
这里
elasticsearch-java 8.0 ~ 8.14.x写法后后续版本有差异,前者跟DSL映射更好,后续版本类型控制更好。
2.3.2 按发布时间过滤
需求:查 2025-01-01 之后发布的商品。
点击查看DSL
bash
POST /product/_search
{
"query": {
"range": {
"publishTime": {
"gte": "2025-01-01T00:00:00"
}
}
}
}
elasticsearch-java 写法:
java
// elasticsearch-java 8.0 ~ 8.14.x写法
SearchResponse<ProductES> response = client.search(s -> s
.index("product")
.query(q -> q
.range(r -> r
.field("publishTime")
.gte(JsonData.of("2025-01-01T00:00:00"))
)
),
ProductES.class
);
// elasticsearch-java 8.15+
SearchResponse<ProductES> response = client.search(s -> s
.index("product")
.query(q -> q
.range(r -> r
.date(dr -> dr
.field("publishTime")
.gte("2025-01-01T00:00:00")
)
)
),
ProductES.class
);
2.4 组合查询:must / should / filter / must_not
真实业务里,查询很少是"一个条件解决战斗",更多是各种条件混在一起,比如:
查「上架」的
iPhone 17商品(优先匹配度最好的),价格在[4000, 8000],排除"二手"类目。
点击查看DSL
bash
POST /product/_search
{
"query": {
"bool": {
"should": [
{ "term": { "name.keyword": { "value": "iPhone 17", "boost": 5 } } },
{ "match_phrase": { "name": { "query": "iPhone 17", "boost": 3 } } },
{ "match": { "name": { "query": "iPhone 17", "boost": 1 } } }
],
"minimum_should_match": 1,
"filter": [
{ "term": { "status": 1 } },
{ "range": { "price": { "gte": 4000, "lte": 8000 } } }
],
"must_not": [
{ "term": { "categories": "二手" } }
]
}
}
}
elasticsearch-java 写法:
java
// should 条件
List<Query> shouldQueries = List.of(
Query.of(q -> q.term(t -> t
.field("name.keyword")
.value(v -> v.stringValue("iPhone 17"))
.boost(5.0f))),
Query.of(q -> q.matchPhrase(mp -> mp.field("name").query("iPhone 17").boost(3.0f))),
Query.of(q -> q.match(m -> m.field("name").query("iPhone 17").boost(1.0f)))
);
// filter条件
List<Query> filterQueries = List.of(
Query.of(q -> q.term(t -> t.field("status").value(v -> v.longValue(1L)))),
Query.of(q -> q.range(r -> r
.number(nr -> nr
.field("price")
.gte(4000d)
.lte(8000d)
)))
);
// 查询
SearchResponse<ProductES> response = client.search(s -> s
.index("product")
.query(q -> q
.bool(b -> b
.should(shouldQueries)
.minimumShouldMatch("1")
.filter(filterQueries)
.mustNot(n -> n
.term(t -> t
.field("categories")
.value(v -> v.stringValue("二手"))
)
)
)
),
ProductES.class
);
打分相关的条件放
must/should,纯过滤(状态、区间)放filter,这样缓存友好、性能也更稳定。DSL和代码映射关系也是不错的,同时注意这里should和filter的写法,如果must多条件也可以这样写
2.5 分页与排序
分页 / 排序其实和 query 写在同一个 search 里,只是多加几个参数而已。
常规分页的 DSL 大致是:
点击查看DSL
css
POST /product/_search
{
"from": 0,
"size": 10,
"sort": [
{ "publishTime": { "order": "desc" } },
{ "price": { "order": "asc" } }
],
"query": { ... }
}
elasticsearch-java 写法:
java
import co.elastic.clients.elasticsearch._types.SortOrder;
int pageNo = 1;
int pageSize = 10;
// 多排序条件写法构建
List<SortOptions> sortFields = List.of(
SortOptions.of(sort -> sort.field(f -> f.field("publishTime").order(SortOrder.Desc))),
SortOptions.of(sort -> sort.field(f -> f.field("price").order(SortOrder.Asc)))
);
SearchResponse<ProductES> response = client.search(s -> s
.index("product")
.from((pageNo - 1) * pageSize)
.size(pageSize)
// 单排序条件直接这么写
/*.sort(sort -> sort
.field(f -> f
.field("publishTime")
.order(SortOrder.Desc)
)
)*/
// 多排序条件写法
.sort(sortFields)
.query(q -> q
.match(m -> m
.field("name")
.query("iphone")
)
),
ProductES.class
);
深分页(比如
from特别大)会有性能问题,这时候需要使用scroll/search_after,这里就不做深入了。
2.6 查询 + 聚合(简单示例)
最后补一个"查询 + 聚合 "的典型例子,感受一下新客户端在 聚合构建 这块的写法。
2.6.1 求整体平均价格
需求:不关心命中明细,只想看看当前所有商品的平均价格。
点击查看DSL
bash
POST /product/_search
{
"size": 0,
"aggs": {
"avg_price": {
"avg": { "field": "price" }
}
}
}
**elasticsearch-java 写法(只构建请求):**
java
SearchResponse<Void> response = client.search(s -> s
.index("product")
.size(0) // 只做统计,不要命中文档
.aggregations("avg_price", a -> a
.avg(avg -> avg.field("price"))
),
Void.class // 不关心文档内容,用 Void 吃掉 hits
);
这里先只演示"怎么写聚合"。聚合结果怎么解析 ,我会放到后面的"响应处理"小节统一讲,那里一起把
response.aggregations()的各种访问方式拆开。
2.6.2 terms + 子聚合示例(分组统计)
再举个稍复杂一点但很常见的场景:按状态分组,统计每个状态下的平均价格。
点击查看DSL
bash
POST /product/_search
{
"size": 0,
"aggs": {
"by_status": {
"terms": {
"field": "status"
},
"aggs": {
"avg_price": {
"avg": { "field": "price" }
}
}
}
}
}
elasticsearch-java 写法(同样先只看构建):
java
SearchResponse<Void> response = client.search(s -> s
.index("product")
.size(0)
.aggregations("by_status", a -> a
.terms(t -> t.field("status"))
.aggregations("avg_price", sub -> sub
.avg(avg -> avg.field("price"))
)
),
Void.class
);
这里有两个点值得记一下:
- 所有聚合都是"起名 → 写结构" :
aggregations("by_status", a -> a.terms(...)) - 子聚合也是继续在同一个 builder 上
aggregations("子聚合名", ...)一层一层往下写,和 JSON DSL 基本一模一样。
其他聚合查询写法跟avg类似。
3. 响应处理
本节处理如何 从SearchResponse<T> 里拿查询结果; 怎么从 aggregations() 里拿聚合结果。
3.1 查询结果解析(hits)
SearchResponse<T> 和 ES _search 的结构基本一一对应,你可以把它简单理解成:
response.hits()→hits节点response.hits().hits()→hits.hits数组- 每个
Hit<T>里有_id/_score/_source等信息
一个常用模板:
java
SearchResponse<ProductES> response = client.search(..., ProductES.class);
// 总命中数(注意 null 判断)
long total = response.hits().total() != null
? response.hits().total().value()
: 0L;
// 遍历每条命中
response.hits().hits().forEach(hit -> {
String docId = hit.id(); // 文档 _id
Float score = hit.score(); // 相关性得分,纯 filter 查询可能为 null
ProductES p = hit.source(); // 反序列化后的业务对象
// 在这里直接用 p 即可
// log.info("命中 docId={}, name={}, price={}", docId, p.getName(), p.getPrice());
});
顺带可以把一些状态信息也拿出来:
java
// 是否超时
Boolean timedOut = response.timedOut();
// 分片情况
if (response.shards() != null && response.shards().failed() > 0) {
// response.shards().failures() 里有失败分片的详细信息
}
这个模板基本可以复用到所有 search 结果的处理逻辑里: "先 total,再遍历 hits 拿 _id + _score + 业务对象 T"。
3.2 聚合结果解析(aggregations)
聚合结果统一从 response.aggregations() 进去,本质是: 先按名字拿到 Aggregate,再根据类型取对应子对象。
✅ 约定:构建请求时给聚合起的名字,要和解析时
get("xxx")的 key 完全一致。
3.2.1 单值聚合:平均价格 avg_price
解析2.6.1的平均值:
java
Double avgPrice = null;
Aggregate agg = response.aggregations().get("avg_price");
if (agg != null && agg.avg() != null) {
avgPrice = agg.avg().value();
}
套路:
response.aggregations().get("avg_price")→ 拿到Aggregate.avg()→ 转成AvgAggregate.value()→ 拿到真正的 double 值
换成 sum / min / max / cardinality 也是同理,改方法名即可。
3.2.2 terms + 子聚合:按状态分组的平均价格
解析2.6.2的分组 + 子聚合:
java
Aggregate byStatusAgg = response.aggregations().get("by_status");
if (byStatusAgg != null && byStatusAgg.lterms() != null) {
LongTermsAggregate lterms = byStatusAgg.lterms(); // long 类型 terms 聚合
lterms.buckets().array().forEach(bucket -> {
long status = bucket.key(); // 分组 key:status 值
long docCount = bucket.docCount(); // 该分组下文档数
// 子聚合 avg_price
Aggregate avgAgg = bucket.aggregations().get("avg_price");
Double avgPrice = (avgAgg != null && avgAgg.avg() != null)
? avgAgg.avg().value()
: null;
// 结果放进 Map / DTO / 直接打印
log.info("status: {}, docCount: {}, avgPrice: {}", status, docCount, avgPrice);
resultMap.put((byte) status, avgPrice);
});
}
这里注意两点:
status是数值字段,所以用的是lterms()(long terms); 如果按字符串字段分组(比如categories.keyword),就是sterms()。- 子聚合继续复用上一小节的套路:
bucket.aggregations().get("avg_price")→.avg()→.value()。
第三部分:内部架构与源码浅析(设计思路)
前两部分已经把一件事做完了:解释清楚为什么要从 RestHighLevelClient 迁到新客户端,并用索引、CRUD、搜索等实战场景,让你对"日常怎么写"有了直观感受。 从这一部分开始,我们把视角从"会用"切到"看懂":顺着源码去拆它的分层结构和 API 生成方式,搞清这个客户端到底是怎么运转的,方便后面排错、封装和避坑时心里有数。
1. API 是怎么来的?(自动代码生成机制)
老客户端最大的硬伤之一,就是 API 手写维护:服务端 REST API 一演进,客户端就得有人慢慢去补方法、加字段,版本一不跟进就出现"文档里有字段、Java 里没地方写"的尴尬。
新客户端(elasticsearch-java)从一开始的设计,就是要把这一块彻底自动化:
- 官方维护一份 Elasticsearch API Specification(YAML/JSON)
- 描述每个 API 的:
- HTTP 方法(GET/POST/PUT/DELETE...)
- 路径模板(
/{index}/_search这种) - 路径参数 / QueryString 参数
- Request Body 结构(字段、类型、是否必填)
- Response Body 结构(字段、类型)
- 描述每个 API 的:
- 在构建
elasticsearch-java这个 jar 的时候,用代码生成器把这份 Spec 转成一堆 Java 类:- Request / Response :比如
SearchRequest、SearchResponse<T>、IndexRequest<T>等; - Builder:每个 Request / Response 对应一个 Builder 类;
- 变体类型(Variant / Tagged Union) :比如
Query、Aggregation这种"一种类型多种形态"的; - Endpoint 定义:描述某个 API 的 URL、HTTP 方法、如何序列化请求和反序列化响应。
- Request / Response :比如
可以简单画成这样一条链路:
text
官方 API Spec (YAML/JSON)
|
| 代码生成 (gradle/maven 插件)
v
elasticsearch-java 源码:
- XxxRequest / XxxResponse
- XxxRequest.Builder
- Endpoint<XxxRequest, XxxResponse, ErrorResponse>
- Query / Aggregation 等 Variant
为什么这件事重要?
-
服务端和客户端天然"同源" 服务端新增/修改 API → 先改 API Spec → 重新生成客户端 → 新版本
elasticsearch-java发布。 不再是有人手写一堆setXxx补来补去。 -
DSL 结构与 JSON 完全对齐 你在 JSON 里怎么看 DSL:
json{ "query": { "bool": { "must": [ { "match": { "name": "iphone" } } ] } } }在 Java 里就是一层一层对应到:
java.query(q -> q .bool(b -> b .must(m -> m .match(mm -> mm.field("name").query("iphone")) ) ) )这不是某个开发者"拍脑袋设计得很像",而是 Spec 里本来就这么描述的 → 生成器按结构直接映射。
-
升级成本更可控 你要用某个新字段、新 API,只需要升级
elasticsearch-java版本。 不再是"服务端文档都更新了,Java 客户端还停在几小版本以前"。
一句话总结:新客户端的 API 不是人一行一行写出来的,而是从官方 Spec 生成的,这也是它敢号称"贴 REST 文档"的底气所在。
2. 总体分层:API、Endpoint、Transport、Low-level
第一部分我们已经讲过"核心三件套":
RestClient--- HTTP 传输层ElasticsearchTransport--- 对象 ↔ JSON ↔ HTTP 桥梁ElasticsearchClient--- Typed API 门面
在源码层面,再往下拆一层,就会看到整体结构大概长这样:
text
ElasticsearchClient ← 你在业务代码里用的 client
(API 层:search/index 等方法)
|
| 绑定静态 Endpoint
v
Endpoint<RequestT, ResponseT, ErrorT>
(描述 URL、HTTP method、参数映射、序列化方式)
|
| 交给 Transport 执行
v
ElasticsearchTransport
(根据 Endpoint 把请求发出去 & 解析响应)
|
| 使用底层 HTTP 客户端
v
RestClient (Low Level)
(真正执行 HTTP 调用:连接池、重试、压缩等)
每一层都故意"只管自己那份事":
- ElasticsearchClient(API 层)
- 对外暴露第二部分用到的各种方法:
search/index/update/bulk...... - 只关心"我要调用哪个 Endpoint、传什么 Request、期望什么 Response 类型"。
- 对外暴露第二部分用到的各种方法:
- Endpoint(描述层)
- 这是每个 API 对应的"说明书":
- 路径模板:
/{index}/_search、/_bulk... - HTTP 方法:GET/POST...
- 哪些字段放 path,哪些字段放 query 参数,哪些写进 body;
- Request/Response 用哪个(反)序列化器。
- 路径模板:
- 对应到代码里,就是类似
SearchRequest._ENDPOINT这样的静态字段。
- 这是每个 API 对应的"说明书":
- ElasticsearchTransport(传输层)
- 负责"按照 Endpoint 的说明,把 Request 真正发出去,再把响应解析回来":
- 按 Endpoint 组装 URL;
- 调用 JSON 序列化器把请求对象转成 JSON;
- 调用底层 HTTP 客户端;
- 根据 HTTP 状态码决定解析为
ResponseT还是ErrorResponse/ 抛异常。
- 负责"按照 Endpoint 的说明,把 Request 真正发出去,再把响应解析回来":
- RestClient(Low Level 客户端)
- 负责"纯 HTTP"相关的东西:连接池、路由、超时、认证、重试策略等。
- 理论上未来是可以被替换实现的(比如换 JDK HttpClient / Netty),上面三层不用改。
这套分层最大的好处是:调用写起来像一个普通的 Java 客户端,内部却有一套非常规整的元数据+传输结构,可以撑住后面的自动生成和扩展。
3. 一个 search 调用的完整链路
有了总体分层,再往下看就不会抽象。接下来我们以ElasticsearchClient.search(s -> s.···) 为例,从业务代码 一路跟到HTTP 请求,这个查询背后大致发生了几件事:
(ElasticsearchTransportBase) participant HttpClient as TransportHttpClient
(RestClientHttpClient) participant LLRC as RestClient participant ES as Elasticsearch集群 App->>ESClient: search activate ESClient ESClient->>ESClient: 构建 SearchRequest ESClient->>Endpoint: 读取静态字段 _ENDPOINT Endpoint-->>ESClient: JsonEndpoint ESClient->>Trans: performRequest activate Trans Trans->>Trans: prepareTransportRequest Trans->>HttpClient: performRequest activate HttpClient HttpClient->>LLRC: performRequest activate LLRC LLRC->>ES: HTTP POST /_search ES-->>LLRC: HTTP Response(JSON) deactivate LLRC LLRC-->>HttpClient: org.elasticsearch.client.Response deactivate HttpClient HttpClient-->>Trans: TransportHttpClient.Response Trans->>Trans: getApiResponse deactivate Trans Trans-->>ESClient: SearchResponse deactivate ESClient ESClient-->>App: SearchResponse
3.1 第一步:Lambda → Builder → SearchRequest
ElasticsearchClient.search(s -> s.···) 的实现如下:
java
public class ElasticsearchClient {
public final <TDocument> SearchResponse<TDocument> search(
Function<SearchRequest.Builder, ObjectBuilder<SearchRequest>> fn, Class<TDocument> tDocumentClass)
throws IOException, ElasticsearchException {
// fn就是写的lambda表达式,这里执行fn
return search(fn.apply(new SearchRequest.Builder()).build(), tDocumentClass);
}
}
也就是说,这段代码:
java
client.search(s -> s
.index("product")
.query(...)
, ProductES.class);
本质等价于手动 new Builder 再调用 build:
java
SearchRequest.Builder builder = new SearchRequest.Builder();
builder.index("product");
builder.query(...);
SearchRequest request = builder.build();
SearchResponse<ProductES> response = client.search(request, ProductES.class);
只不过 Lambda 写法更自然一些。
3.2 第二步:Endpoint 决定 URL / Method / Body
上面的search(fn.apply(new SearchRequest.Builder()).build(), tDocumentClass)实现如下:
java
public class ElasticsearchClient {
private final ElasticsearchTransport transport;
// search最终调到这里
public <TDocument> SearchResponse<TDocument> search(SearchRequest request, Class<TDocument> tDocumentClass)
throws IOException, ElasticsearchException {
@SuppressWarnings("unchecked")
// 静态的Endpoint
JsonEndpoint<SearchRequest, SearchResponse<TDocument>, ErrorResponse> endpoint = (JsonEndpoint<SearchRequest, SearchResponse<TDocument>, ErrorResponse>) SearchRequest._ENDPOINT;
// 包装成一个"知道如何反序列化成 SearchResponse<TDocument> 的 endpoint"
endpoint = new EndpointWithResponseMapperAttr<>(endpoint,
"co.elastic.clients:Deserializer:_global.search.Response.TDocument", getDeserializer(tDocumentClass));
// 交给 transport + endpoint 执行
return this.transport.performRequest(request, endpoint, this.transportOptions);
}
}
_ENDPOINT是SearchRequest 类里的一个静态字段,类型是类似这样的:
java
public class SearchRequest {
public static final Endpoint<SearchRequest, SearchResponse<?>, ErrorResponse> _ENDPOINT = new SimpleEndpoint<>("es/search", ....) // 通过生成器拼出来的一大坨配置
// 构造方法
public SimpleEndpoint(
String id,
Function<RequestT, String> method,
Function<RequestT, String> requestUrl,
Function<RequestT, Map<String, String>> pathParameters,
Function<RequestT, Map<String, String>> queryParameters,
Function<RequestT, Map<String, String>> headers,
boolean hasResponseBody,
JsonpDeserializer<ResponseT> responseParser ) {
this(
//...
);
}
}
这个 _ENDPOINT 里面会描述:
- 如果
index字段不为空:- 路径就是
/{index}/_search
- 路径就是
- 如果
index为空:- 路径就是
/_search
- 路径就是
- HTTP 方法一般是
POST - 哪些字段是 query 参数(比如
from、size、routing等) - Request Body 怎么序列化(
query/sort/aggs等) - Response 用哪个反序列化器解析成
SearchResponse<TDocument>
最后经过new EndpointWithResponseMapperAttr包装的endpoint,让查询结果知道该反序列化成 SearchResponse<TDocument>。
3.3 第三步:Transport 落地为 HTTP 请求
上面this.transport.performRequest(request, endpoint, this.transportOptions)进入方法ElasticsearchTransportBase.performRequest(),如下:
java
public abstract class ElasticsearchTransportBase implements ElasticsearchTransport {
private final TransportHttpClient httpClient;
private final Instrumentation instrumentation;
@Override
public final <RequestT, ResponseT, ErrorT> ResponseT performRequest(
RequestT request,
Endpoint<RequestT, ResponseT, ErrorT> endpoint,
@Nullable TransportOptions options
) throws IOException {
// 为这次调用创建一个埋点上下文(带上 request 和 endpoint 信息)
try (Instrumentation.Context ctx = instrumentation.newContext(request, endpoint)) {
// 把上下文绑定到当前线程,方便后面任何地方通过 Instrumentation 拿到上下文
try (Instrumentation.ThreadScope ts = ctx.makeCurrent()) {
TransportOptions opts = options == null ? transportOptions : options;
// 基于高层 RequestT + Endpoint 元数据,构造传输层用的 HTTP 请求抽象
// 这里会计算出 method、path、query、headers、body 等
TransportHttpClient.Request req = prepareTransportRequest(request, endpoint);
// 发送 HTTP 之前的埋点 hook(可以记录 URL / method / headers 等)
ctx.beforeSendingHttpRequest(req, options);
// 真正发 HTTP 的地方:
// - endpoint.id():标识是哪一个 API,用于日志和监控
// - node:这里传 null,让底层自己决定路由到哪个节点
// - req:刚刚准备好的传输层请求
// - opts:本次请求生效的 TransportOptions(超时、重试、header 等)
TransportHttpClient.Response resp = httpClient.performRequest(endpoint.id(), null, req, opts);
// 收到 HTTP 响应之后的埋点 hook(可以记录状态码、耗时等)
ctx.afterReceivingHttpResponse(resp);
// 根据 endpoint 上配置的 response 反序列化逻辑,
// 用 JsonpMapper 把 HTTP body 解析成 API 层的 ResponseT
ResponseT apiResponse = getApiResponse(resp, endpoint);
// 解码完 API 响应之后的埋点 hook
ctx.afterDecodingApiResponse(apiResponse);
// 返回最终的 API 响应(例如 SearchResponse<T>)
return apiResponse;
} catch (Throwable throwable){
// 整个调用过程中出现的任何异常都记录到埋点上下文
ctx.recordException(throwable);
throw throwable;
}
}
}
}
java
public class RestClientHttpClient implements TransportHttpClient {
private final RestClient restClient;
@Override
public Response performRequest(String endpointId, @Nullable Node node, Request request, TransportOptions options) throws IOException {
// 将通用的 TransportOptions 适配成 RestClient 专用的 RestClientOptions
RestClientOptions rcOptions = RestClientOptions.of(options);
// 把抽象的 TransportHttpClient.Request 转成 low-level RestClient 的 Request:
// 设置 method、path、query 参数、headers、body 等
org.elasticsearch.client.Request restRequest = createRestRequest(request, rcOptions);
// 使用 Apache 的 RestClient 真正执行 HTTP 请求
org.elasticsearch.client.Response restResponse = restClient.performRequest(restRequest);
// 用 RestResponse 包装一层,适配成 TransportHttpClient.Response 再往上传
return new RestResponse(restResponse);
}
}
以上全部组合起来看,其实就一条线:
text
业务代码:
client.search(s -> s.index("product").query(...), ProductES.class)
↓ Lambda 构建 SearchRequest
ElasticsearchClient.search(...)
↓ 带上 SearchRequest + SearchRequest._ENDPOINT
ElasticsearchTransport.performRequest(...)
↓ 按 Endpoint 组装 HTTP 请求 & 解析响应
底层 RestClient.performRequest(...)
↓ 真正发 HTTP
Elasticsearch 返回 JSON
本质就是"一个 API = 一份 Endpoint 说明书 + 一次 Transport 执行"。
4. Builder & ObjectBuilder:Lambda 写法背后的设计
前面我们已经多次写过这种 Lambda Builder:
java
client.search(s -> s
.index("product")
.size(10)
.query(...)
, ProductES.class);
这一套链式写法背后有两个关键角色:
- 不可变的 Request / Response 对象
- 统一的 Builder 接口:
ObjectBuilder<T>
4.1 ObjectBuilder 接口
源码里 ObjectBuilder<T> 非常简单,大概就是:
java
public interface ObjectBuilder<T> {
T build();
}
所有 XxxRequest.Builder / XxxResponse.Builder 都实现了这个接口。
4.2 Request / Builder 的典型结构
以 SearchRequest 为例:
java
public class SearchRequest {
@Nullable
private final List<String> index;
@Nullable
private final Query query;
@Nullable
private final Integer from;
@Nullable
private final Integer size;
// ... 其它很多字段省略
// 构造函数私有或包级,只能通过 Builder 构造
private SearchRequest(Builder builder) {
this.index = builder.index;
this.query = builder.query;
this.from = builder.from;
this.size = builder.size;
// ...
}
public static class Builder implements ObjectBuilder<SearchRequest> {
private List<String> index;
private Query query;
private Integer from;
private Integer size;
// ...
public Builder index(String index) {
this.index = Collections.singletonList(index);
return this;
}
public Builder index(List<String> index) {
this.index = index;
return this;
}
public Builder query(Query query) {
this.query = query;
return this;
}
public Builder from(Integer from) {
this.from = from;
return this;
}
public Builder size(Integer size) {
this.size = size;
return this;
}
@Override
public SearchRequest build() {
// 这里可以做必填字段校验
return new SearchRequest(this);
}
}
}
了解这套结构再看 Lambda 写法就很好理解了:
java
// Lambda 写法
client.search(s -> s.index("product").size(10), ProductES.class);
// 等价展开
SearchRequest.Builder builder = new SearchRequest.Builder();
builder.index("product");
builder.size(10);
SearchRequest request = builder.build();
client.search(_b -> request, ProductES.class);
所以:
- 为什么 Request 对象本身是不可变的? 避免一旦被传递到其它地方,又被改来改去,调试非常痛苦。
- 为什么要统一走 Builder? 方便自动生成;新增字段只要在 Builder 里加个链式方法,不会破坏现有调用; 也方便配合 Lambda(你不用自己 new Builder,只要实现一个函数)。
5. Variant / Tagged Union:如何表达 Query 多态
Elasticsearch DSL 里有一个典型难题:很多地方都是"同一个字段,不同形态" ,比如 query:
json
"query": {
"term": { ... }
}
"query": {
"match": { ... }
}
"query": {
"bool": { ... }
}
在 JSON 世界里没问题,但在 Java 里,如果你写成:
java
class Query {
Map<String, Object> content;
}
那就跟旧客户端一样,IDE 完全帮不了你,容易乱成一锅粥。
新客户端的做法是:用 Variant / Tagged Union 来表达多态。可以简单理解成:
- 有一个统一的
Query类型; - 内部有一个"当前是哪种 Query 的枚举 + 真正的子类型对象"。
Qeury的简化如下:
java
public class Query implements OpenTaggedUnion<Query.Kind, Object>, AggregationVariant, JsonpSerializable {
public enum Kind implements JsonEnum {
Term, Match, Bool, Boosting,
// ... 还有很多
}
private final Kind kind;
private final Object value;
private Query(Kind kind, Object value) {
this.kind = kind;
this.value = value;
}
public boolean isTerm() { return kind == Kind.Term; }
public boolean isMatch() { return kind == Kind.Match; }
public boolean isBool() { return kind == Kind.Bool; }
public TermQuery term() { return (TermQuery) value; }
public MatchQuery match() { return (MatchQuery) value; }
public BoolQuery bool() { return (BoolQuery) value; }
// Builder / of(...) 之类方法省略
}
配套的 Builder 会长成你在第二部分用过的样子:
java
Query q = Query.of(b -> b
.term(m -> m
.field("status")
.query("1")
)
);
或者在 SearchRequest.Builder 里写:
java
.query(q -> q
.term(t -> t.field("status").value(1))
)
这背后做的事情就是:
- 你调用了
.term(...),Builder 把内部kind标记为Kind.Term,value 存TermQuery; - 序列化时,根据
kind来决定输出 JSON 的 key 是"term"; - 反序列化时,根据 JSON 的结构,反推回来当前应该是
TermQuery/MatchQuery/BoolQuery等。
好处非常直接:
- IDE 完整补全 / 类型检查 你只能在
term()下写 term 的字段,在match()下写 match 的字段,不存在随便塞一个奇怪的结构进去。 - JSON 映射干净 序列化出来的 JSON 结构和官方 DSL 一致,没有额外的包装字段。
6. Transport & RestClientTransport:传输层解耦
最后让我们把视角拉回到传输层。在前面第3节中我们看到,最终发起 HTTP 调用的是 RestClientHttpClient 中的 RestClient.performRequest()。
这里的关键在于:ElasticsearchTransportBase 并不直接依赖具体的 RestClient ,它内部持有的字段是接口类型的 TransportHttpClient。
而 RestClientTransport 作为 ElasticsearchTransportBase 的默认实现类,它扮演了"组装者"的角色:它的核心职责就是在构造时创建一个 RestClientHttpClient 实例,并将其注入给父类 ElasticsearchTransportBase。
从 Transport 到最终发送请求的 RestClientHttpClient.performRequest(),其类图关系如下:
通过类图可以看出架构的精妙之处:
整个体系的核心是 ElasticsearchTransportBase(逻辑层)、JsonpMapper(序列化层)和 TransportHttpClient(传输接口)。
这种设计实现了彻底的解耦:以后底层 HTTP 客户端如果想从 RestClient 换成别的(例如 JDK 11 HttpClient、Netty 或 OkHttp),我们只需要:
- 写一个
XxxHttpClient实现TransportHttpClient接口; - 写一个
XxxClientTransport继承ElasticsearchTransportBase并组装即可。
上层的 ElasticsearchClient、SearchRequest、Endpoint 等业务代码一律不用修改。当然,在构造 ElasticsearchTransport 时,别忘了传入一个 JsonpMapper 的实现类实例来处理 JSON 转换。
到这里,第三部分就把新客户端的"内部骨骼"大致拆完了:
- API 不是手写,而是从官方 Spec 生成;
- Request/Response 用 Builder + 不可变对象承载;
- Query/聚合等复杂 DSL 用 Variant / Tagged Union 表达;
- 每个 API 对应一个 Endpoint,Endpoint 描述了 URL / method / 序列化方式;
- Transport 和底层 HTTP 客户端、JSON 库都解耦,可以替换实现。
有了这套认知,在处理问题的时候,就不再是黑盒,而是------你大概知道每一种"怪问题"会出现在哪一层。
第四部分:避坑指南与迁移建议(实践经验)
前面三部分都是「怎么用」和「它内部怎么运转」。这部分讲讲实际项目里最容易踩的坑,怎么绕过去;老项目怎么一步步从 RestHighLevelClient 迁过来。
1. 常见坑:新客户端特有的雷
1.1 JSON / 依赖相关(ClassNotFound 的高发区)
典型现象:
- 启动/运行时报:
text
ClassNotFoundException: jakarta.json.Json
原因:
新客户端走的是 JSON-P (jakarta.json) + JsonpMapper ,不是传统的 com.fasterxml.jackson.databind.ObjectMapper 直上。 jakarta.json-api 被你在 pom 里 exclude 或被别的 BOM 覆盖掉版本,就会直接炸。
避坑建议:
elasticsearch-java自己带的 JSON 依赖不要乱exclusions:
xml
<dependency>
<groupId>co.elastic.clients</groupId>
<artifactId>elasticsearch-java</artifactId>
<version>8.15.5</version>
</dependency>
- 确实有版本统一诉求,用
dependencyManagement对齐,而不是随手排除:
xml
<dependencyManagement>
<dependencies>
<dependency>
<groupId>jakarta.json</groupId>
<artifactId>jakarta.json-api</artifactId>
<version>2.1.2</version>
</dependency>
</dependencies>
</dependencyManagement>
1.2 POJO ↔ 索引 mapping 不匹配
你在第二部分里已经用 ProductES + product 索引演示过;新客户端最大的特点就是 SearchResponse<T> 强类型化,一旦类字段和 mapping 不对齐,不是「值有点怪」,而是直接解析失败 / 字段为 null。
常见几种:
-
LocalDateTime↔date- mapping 里
publishTime是date,格式strict_date_optional_time||epoch_millis - Java 里用的是
LocalDateTime配 Jackson 时间序列化注解:
java@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "GMT+8") @JsonSerialize(using = LocalDateTimeSerializer.class) @JsonDeserialize(using = LocalDateTimeDeserializer.class) private LocalDateTime publishTime;坑点:
- 写入时自己手动用
ObjectMapper转 JSON、再通过BinaryData.of(String),很容易跟JsonpMapper的配置不一致; - 最好就是直接用
document(product),完全交给JsonpMapper。
- mapping 里
-
数组/集合 vs 标量
- mapping:
"categories": {"type": "keyword"} - Java:既可以是
String categories;,也可以是List<String> categories;,关键在于索引里的 JSON 长什么样 :- 如果写:
"categories": "phone"→ 用String categories; - 如果写:
"categories": ["phone","digital"]→ 用List<String> categories;
- 如果写:
建议:
- 选定一种形态并保持一致:
- 只存一个分类 → 始终用标量 JSON +
String; - 要多分类 → 始终用数组 JSON +
List<String>。
- 只存一个分类 → 始终用标量 JSON +
- 如果历史数据里已经混了标量和数组,迁移期就统一用
List<String>接收,在代码里把标量情况转成单元素列表。
- mapping:
-
byte/short等小类型- ES 里
status是byte,Java 你用的是Byte,mapping 写byte就行; - 但在 query 构建里,如果乱用
stringValue("1")或doubleValue(1d)去查byte字段,很容易因为类型不匹配导致查不到东西。
建议:
- 统一用
longValue或直接用FieldValue.of(1L),不要在这里搞「精确匹配」的骚操作。
- ES 里
1.3 Builder / null 值 / Optional 的细节
新客户端所有请求对象都是 不可变 + Builder + Lambda 写法,而且 Builder 通常不会给你「塞个 null 进去」的机会,常见坑:
-
在 lambda 里自己传 null:
javaclient.search(s -> s .index("product") .size(null) // 这种就别干了...... );builder 方法一般期望非 null 值,传 null 要么直接 NPE,要么最后序列化异常。
-
更新时用 POJO 做 doc,却忘了 null 会被写进去
javaProductES patch = new ProductES(); patch.setPrice(5299.0); // 其它字段都保持 null client.update(u -> u .index("product") .id("1") .doc(patch), ProductES.class );取决于序列化配置,有的会只输出非 null 字段,有的可能把 null 也写进去,把原字段清掉。你要搞清楚你这边的
JsonpMapper是什么配置。建议:
- 更新逻辑要么走脚本,要么走
Map<String, Object>,显式控制字段; - 如果用 POJO patch,建议在 JSON 层明确配置「忽略 null」,不要依赖默认行为。
- 更新逻辑要么走脚本,要么走
1.4 Bulk / 大对象 / 内存
新客户端底层本质还是 NDJSON + HTTP,坑主要在「你怎么用」:
-
一次塞太多对象:
javaBulkResponse resp = client.bulk(b -> { for (ProductES p : bigList) { b.operations(op -> op.index(idx -> idx .index("product").id(p.getId().toString()).document(p) )); } return b; });bigList特别大(几万、几十万),会在内存里把整包 NDJSON 拼完再发;- 你自己的集合 + 底层 NDJSON buffer,很容易顶爆内存。
建议:
- 控制单批次 size,比如 1k / 5k 一批;
- 上一层自己切片:
javaLists.partition(allProducts, 1000).forEach(batch -> { client.bulk(b -> { for (ProductES p : batch) { b.operations(op -> op .index(idx -> idx.index("product").id(p.getId().toString()).document(p) )); } return b; }); }); -
错误不检查:
上一节你已经写了 bulk 错误遍历的逻辑,关键是:
javaif (bulkResponse.errors()) { bulkResponse.items().stream() .filter(item -> item.error() != null) ... }**结论:**不管旧客户端还是新客户端,bulk 必须检查
errors(),否则一半成功一半失败你都不知道。
2. 版本兼容 & 部署层面的坑
2.1 客户端版本 vs 服务端版本
实战经验比较朴素:
- 客户端版本「不高于」服务端太多 一般比较稳:
- ES 8.15 → 客户端 8.15.x;
- ES 8.11 → 客户端 8.11.x 或 8.12.x,一般还好;
- 避免「服务端 7.x,客户端 8.x」这种跨代组合,虽然 HTTP 理论上能通,但 Spec 已经对不上了。
建议:
- 新项目:跟着服务端版本走同一个 minor 或相邻 minor;
- 老项目:规划好 ES 升级节奏,不要先把客户端拉到很新的版本,服务端还停在远古版本。
2.2 Spring Boot / Jakarta 相关
你现在用的是 Spring Boot 3.4 + Java 21,已经是 Jakarta 世界了,相对舒服。 坑主要出在「项目里同时混着老 ES 依赖」:
- 不要让项目里再出现
org.elasticsearch:elasticsearch+RestHighLevelClient的旧组合跟新客户端抢 classpath; - 如果非要共存一段时间:
- 强制用 BOM 锁住两个 client 所需的版本;
- 把旧客户端的依赖封在单独的 module 里,避免和新客户端在一个 classpath 上互相污染。
3. 迁移建议:从 RestHighLevelClient 到 Java API Client
3.1 一次性全迁 vs 增量迁移
一次性重写 的理想情况很少能达成,实战更推荐「按模块 / 按接口分批迁移」,否则:
- mapping、脚本、pipeline 这些都要一起动,回归量爆炸;
- 业务逻辑里很多地方直接 new 各种
*Request,重写成本极高。
**推荐的做法:**先抽象出你自己的 ES 访问层。
3.2 封一层自己的 ES Service 接口
比如你现在商品搜索这块,可以先封一个接口:
java
public interface ProductSearchRepository {
void index(ProductES product);
Optional<ProductES> findById(Long id);
void deleteById(Long id);
Page<ProductES> search(ProductSearchCondition condition);
}
迁移策略:
- 第一步:接口不动,先用旧客户端实现一版(目前已有)。
- 第二步:同一个接口再写一个「新客户端实现」,放在平行的 bean 里,配置文件用开关切换。
- 第三步:上线时先镜像 / 灰度:
- 可以先只让新实现跑在测试环境,对比结果;
- 或者在某些请求上同时打两个实现,比较搜索结果差异(日志比对)。
最终上层业务全都只依赖 ProductSearchRepository,下面用的是 RestHighLevelClient 还是 elasticsearch-java,对业务是透明的。
3.3 迁移优先级:先查后写
一般优先度可以这么排:
- 查询类接口(search / scroll / 聚合)
- 对业务影响最直观;
- 新客户端在 DSL 表达上明显更好维护,迁移收益也大。
- 读接口(get / mget)
- 写接口(index / update / bulk)
- 写路径变动带来的风险更高,可以放到最后一批;
- 特别是涉及脚本更新、pipeline、ingest 的部分,改前一定要有完备回归。
- 索引管理(mapping、template、ILM 等)
- 这块一般量不大,又不常改,可以单独安排迭代慢慢迁。
总结
随着 Elasticsearch 官方在 7.15 版本宣布弃用 RestHighLevelClient,Java 生态的 ES 开发范式正在经历一场彻底的变革。本文从背景选型、实战演练、源码原理、迁移避坑 四个维度,全方位解析了新一代官方客户端 Elasticsearch Java API Client。
核心要点回顾:
- 告别旧时代的包袱
RestHighLevelClient因依赖臃肿、API 维护滞后等问题已不适应现代开发。新客户端基于 Official API Specification 自动生成,实现了与服务端能力的"零时差"同步,不仅彻底解耦了服务端核心 jar 包,更带来了纯粹的 HTTP 客户端体验。 - 重塑代码编写体验 全面摒弃了过去繁琐的嵌套对象构建,拥抱 Lambda + Builder 的流式写法。无论是基础的 CRUD,还是复杂的 Bool 组合查询与嵌套聚合,新客户端的代码结构都与 JSON DSL 高度对齐。强类型约束与 IDE 的智能补全,让编写 DSL 变得既直观又安全。
- 洞悉底层设计原理 透过源码视角,我们拆解了 Client (API) → Endpoint (描述) → Transport (传输) → Low-level RestClient (执行) 的分层架构。理解了自动代码生成机制与 Tagged Union(变体类型)的设计模式,让你在面对复杂报错时不再是"黑盒瞎猜",而是能精准定位到具体环节。
- 避坑与平滑迁移 从 Jakarta JSON 依赖冲突到 POJO Mapping 的类型陷阱,本文总结了实战中的高频痛点。对于存量项目,建议采取"抽象接口层 + 先读后写 + 双写灰度"的渐进式迁移策略,在由旧转新的过程中确保业务的绝对稳定。
结语: 从 RestHighLevelClient 到 Elasticsearch Java API Client,不仅仅是依赖包的替换,更是开发思维的升级。掌握新客户端,不仅能让你摆脱"版本兼容地狱",更能以更优雅、更符合 Java 8+ 风格的方式构建高效的搜索应用。