告别 RestHighLevelClient:Elasticsearch Java 新客户端实战与源码浅析

本文主要讲解以下四个部分内容

  • 第一部分:为什么要换 & 怎么接入(背景 + 入门)
  • 第二部分:常用开发场景实战(新写法)
  • 第三部分:内部架构与源码浅析(设计思路)
  • 第四部分:避坑指南与迁移建议(实践经验)

第一部分:为什么要换 & 怎么接入(背景 + 入门)

1. 为什么要废弃 RestHighLevelClient

timeline title RestHighLevelClient 弃用时间线 2021 Q3 (v7.15) : 官方宣布 RestHighLevelClient deprecated(弃用) 2021 Q4 (v7.16) : 停止功能更新,仅修复关键 Bug 2022 Q1--Q4 (v7.17) : 维持最低限度安全性 & 兼容性维护 2023 (v8.x) : 不再推荐使用,进入维护状态;官方主推 elasticsearch-java 新客户端 2024+ : 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>
  • 这意味着什么?

    1. 版本兼容地狱(Dependency Hell) 不同模块可能需要不同版本的 ES 功能,但 RestHighLevelClient 又强依赖服务端核心 jar,最终所有模块都被迫绑在同一个版本上 。 一旦你想升级 ES 版本,很容易牵一发动全身:其它引用 elasticsearch.jar 的地方也得跟着改。
    2. 包体积大 & 升级风险高 实际上,作为一个「HTTP 客户端」,它只需要会发请求、解析 JSON 即可;但你却顺带把整套服务端核心类也塞进来了。 这会带来:
      • 可执行包体积明显变大
      • 升级 ES 时,不只是客户端换个版本,而是整个依赖树都在抖,风险自然跟着上去

简单说:RestHighLevelClient 不够「client」,太像服务端的一部分

真正在项目里重度用过 RestHighLevelClient 的人,最大直观感受往往不是「不会写 DSL」,而是:索引、更新、搜索、bulk 任意一个请求,构建起来都很别扭

  • 一堆 Request / Builder / Options,API 分散 同样是「写一个请求」,要在 IndexRequestUpdateRequestSearchRequestBulkRequest、各种 *BuilderRequestOptions 之间来回切; 路由、超时、刷新策略、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 等所有可用选项
    • 查找引用、跳转到定义,对维护复杂查询非常关键

可以用一张简单的新旧对比表总结一下整体差异:

维度 RestHighLevelClient Java API Client
依赖 依赖服务端核心 elasticsearch jar 仅依赖 client 自身 + JSON 相关库
请求写法 各种对象、Builder 嵌套,层级深 Builder + Lambda + 强类型,链式调用
响应处理 StringMap<String, Object> 为主,需手动解析 直接映射为 POJO(如 SearchResponse<T>
API 同步方式 手写维护,更新慢,容易落后于 REST API 由官方 Spec 自动生成,随 Spec 同步演进

1.5 小结:不是「必须换」,而是「早晚要换」

综上,其实可以把这节的结论概括成一句话:

RestHighLevelClient 的问题并不是「还能不能用」,而是「在今天的 Java 项目里,它已经很难优雅地维护下去」。

  • 对新项目:没必要再背一堆历史包袱,直接上新客户端 是更合理的默认选择;
  • 对老项目:即便短期内不迁,也建议开始规划,从依赖、封装层开始给自己留出「可迁移空间」。

2. 环境搭建与依赖陷阱

我使用的是spring boot3.4java21elasticsearch-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.4java21elasticsearch-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 写法的关键点只有两个:

  1. 所有子操作统一走 operations(...)
  2. 根据业务需要检查 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
);

这里有两个点值得记一下:

  1. 所有聚合都是"起名 → 写结构"aggregations("by_status", a -> a.terms(...))
  2. 子聚合也是继续在同一个 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();
}

套路:

  1. response.aggregations().get("avg_price") → 拿到 Aggregate
  2. .avg() → 转成 AvgAggregate
  3. .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 结构(字段、类型)
  • 在构建 elasticsearch-java 这个 jar 的时候,用代码生成器把这份 Spec 转成一堆 Java 类:
    • Request / Response :比如 SearchRequestSearchResponse<T>IndexRequest<T> 等;
    • Builder:每个 Request / Response 对应一个 Builder 类;
    • 变体类型(Variant / Tagged Union) :比如 QueryAggregation 这种"一种类型多种形态"的;
    • Endpoint 定义:描述某个 API 的 URL、HTTP 方法、如何序列化请求和反序列化响应。

可以简单画成这样一条链路:

text 复制代码
官方 API Spec (YAML/JSON)
          |
          | 代码生成 (gradle/maven 插件)
          v
elasticsearch-java 源码:
- XxxRequest / XxxResponse
- XxxRequest.Builder
- Endpoint<XxxRequest, XxxResponse, ErrorResponse>
- Query / Aggregation 等 Variant

为什么这件事重要?

  1. 服务端和客户端天然"同源" 服务端新增/修改 API → 先改 API Spec → 重新生成客户端 → 新版本 elasticsearch-java 发布。 不再是有人手写一堆 setXxx 补来补去。

  2. 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 里本来就这么描述的 → 生成器按结构直接映射

  3. 升级成本更可控 你要用某个新字段、新 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 这样的静态字段。
  • ElasticsearchTransport(传输层)
    • 负责"按照 Endpoint 的说明,把 Request 真正发出去,再把响应解析回来":
      • 按 Endpoint 组装 URL;
      • 调用 JSON 序列化器把请求对象转成 JSON;
      • 调用底层 HTTP 客户端;
      • 根据 HTTP 状态码决定解析为 ResponseT 还是 ErrorResponse / 抛异常。
  • RestClient(Low Level 客户端)
    • 负责"纯 HTTP"相关的东西:连接池、路由、超时、认证、重试策略等。
    • 理论上未来是可以被替换实现的(比如换 JDK HttpClient / Netty),上面三层不用改。

这套分层最大的好处是:调用写起来像一个普通的 Java 客户端,内部却有一套非常规整的元数据+传输结构,可以撑住后面的自动生成和扩展。

有了总体分层,再往下看就不会抽象。接下来我们以ElasticsearchClient.search(s -> s.···) 为例,从业务代码 一路跟到HTTP 请求,这个查询背后大致发生了几件事:

%%{init: {'theme': 'base', 'themeVariables': { 'actorBkg': '#d5e8d4', 'actorBorder': '#82b366', 'actorTextColor': '#000000', 'signalColor': '#000000', 'signalTextColor': '#000000' }}}%% sequenceDiagram autonumber participant App as 业务代码 participant ESClient as ElasticsearchClient participant Endpoint as SearchRequest._ENDPOINT participant Trans as ElasticsearchTransport
(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);
	}
}

_ENDPOINTSearchRequest 类里的一个静态字段,类型是类似这样的:

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 参数(比如 fromsizerouting 等)
  • 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);

这一套链式写法背后有两个关键角色:

  1. 不可变的 Request / Response 对象
  2. 统一的 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))
)

这背后做的事情就是:

  1. 你调用了 .term(...),Builder 把内部 kind 标记为 Kind.Term,value 存 TermQuery
  2. 序列化时,根据 kind 来决定输出 JSON 的 key 是 "term"
  3. 反序列化时,根据 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(),其类图关系如下:

classDiagram direction TD %% 顶层接口定义 class Transport { <> +performRequest() +performRequestAsync() +jsonpMapper() } class ElasticsearchTransport { <> } class TransportHttpClient { <> +performRequest() +performRequestAsync() } %% 核心抽象层 (逻辑大脑) class ElasticsearchTransportBase { <> -JsonpMapper mapper -TransportHttpClient httpClient +performRequest() #prepareTransportRequest() #getApiResponse() } %% 具体实现层 (HTTP 适配) class RestClientHttpClient { -RestClient restClient +performRequest() +performRequestAsync() } %% 用户入口层 (组装工) class RestClientTransport { -RestClient restClient +RestClientTransport(RestClient, JsonpMapper) } %% 外部依赖 class RestClient { <> %% Low Level Client } class JsonpMapper { <> } %% 关系连线 Transport <|-- ElasticsearchTransport ElasticsearchTransport <|.. ElasticsearchTransportBase ElasticsearchTransportBase <|-- RestClientTransport TransportHttpClient <|.. RestClientHttpClient %% 关键的解耦点:Base 持有接口,而非具体实现 ElasticsearchTransportBase o-- TransportHttpClient : 组合 (Bridge) ElasticsearchTransportBase o-- JsonpMapper : 组合 %% RestClientTransport 负责组装 RestClientTransport ..> RestClientHttpClient : 创建并注入给父类 RestClientHttpClient o-- RestClient : 适配 (Adapter) RestClientTransport o-- RestClient : 组合

通过类图可以看出架构的精妙之处:

整个体系的核心是 ElasticsearchTransportBase(逻辑层)、JsonpMapper(序列化层)和 TransportHttpClient(传输接口)。

这种设计实现了彻底的解耦:以后底层 HTTP 客户端如果想从 RestClient 换成别的(例如 JDK 11 HttpClient、Netty 或 OkHttp),我们只需要:

  1. 写一个 XxxHttpClient 实现 TransportHttpClient 接口;
  2. 写一个 XxxClientTransport 继承 ElasticsearchTransportBase 并组装即可。

上层的 ElasticsearchClientSearchRequestEndpoint 等业务代码一律不用修改。当然,在构造 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。

常见几种:

  1. LocalDateTimedate

    • mapping 里 publishTimedate,格式 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
  2. 数组/集合 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>
    • 如果历史数据里已经混了标量和数组,迁移期就统一用 List<String> 接收,在代码里把标量情况转成单元素列表。
  3. byte / short 等小类型

    • ES 里 statusbyte,Java 你用的是 Byte,mapping 写 byte 就行;
    • 但在 query 构建里,如果乱用 stringValue("1")doubleValue(1d) 去查 byte 字段,很容易因为类型不匹配导致查不到东西。

    建议:

    • 统一用 longValue 或直接用 FieldValue.of(1L),不要在这里搞「精确匹配」的骚操作。

1.3 Builder / null 值 / Optional 的细节

新客户端所有请求对象都是 不可变 + Builder + Lambda 写法,而且 Builder 通常不会给你「塞个 null 进去」的机会,常见坑:

  1. 在 lambda 里自己传 null:

    java 复制代码
    client.search(s -> s
        .index("product")
        .size(null) // 这种就别干了......
    );

    builder 方法一般期望非 null 值,传 null 要么直接 NPE,要么最后序列化异常。

  2. 更新时用 POJO 做 doc,却忘了 null 会被写进去

    java 复制代码
    ProductES 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,坑主要在「你怎么用」:

  1. 一次塞太多对象:

    java 复制代码
    BulkResponse 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 一批;
    • 上一层自己切片:
    java 复制代码
    Lists.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;
        });
    });
  2. 错误不检查:

    上一节你已经写了 bulk 错误遍历的逻辑,关键是:

    java 复制代码
    if (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);
}

迁移策略:

  1. 第一步:接口不动,先用旧客户端实现一版(目前已有)。
  2. 第二步:同一个接口再写一个「新客户端实现」,放在平行的 bean 里,配置文件用开关切换。
  3. 第三步:上线时先镜像 / 灰度:
    • 可以先只让新实现跑在测试环境,对比结果;
    • 或者在某些请求上同时打两个实现,比较搜索结果差异(日志比对)。

最终上层业务全都只依赖 ProductSearchRepository,下面用的是 RestHighLevelClient 还是 elasticsearch-java,对业务是透明的。

3.3 迁移优先级:先查后写

一般优先度可以这么排:

  1. 查询类接口(search / scroll / 聚合)
    • 对业务影响最直观;
    • 新客户端在 DSL 表达上明显更好维护,迁移收益也大。
  2. 读接口(get / mget)
  3. 写接口(index / update / bulk)
    • 写路径变动带来的风险更高,可以放到最后一批;
    • 特别是涉及脚本更新、pipeline、ingest 的部分,改前一定要有完备回归。
  4. 索引管理(mapping、template、ILM 等)
    • 这块一般量不大,又不常改,可以单独安排迭代慢慢迁。

总结

随着 Elasticsearch 官方在 7.15 版本宣布弃用 RestHighLevelClient,Java 生态的 ES 开发范式正在经历一场彻底的变革。本文从背景选型、实战演练、源码原理、迁移避坑 四个维度,全方位解析了新一代官方客户端 Elasticsearch Java API Client

核心要点回顾:

  1. 告别旧时代的包袱 RestHighLevelClient 因依赖臃肿、API 维护滞后等问题已不适应现代开发。新客户端基于 Official API Specification 自动生成,实现了与服务端能力的"零时差"同步,不仅彻底解耦了服务端核心 jar 包,更带来了纯粹的 HTTP 客户端体验。
  2. 重塑代码编写体验 全面摒弃了过去繁琐的嵌套对象构建,拥抱 Lambda + Builder 的流式写法。无论是基础的 CRUD,还是复杂的 Bool 组合查询与嵌套聚合,新客户端的代码结构都与 JSON DSL 高度对齐。强类型约束与 IDE 的智能补全,让编写 DSL 变得既直观又安全。
  3. 洞悉底层设计原理 透过源码视角,我们拆解了 Client (API) → Endpoint (描述) → Transport (传输) → Low-level RestClient (执行) 的分层架构。理解了自动代码生成机制与 Tagged Union(变体类型)的设计模式,让你在面对复杂报错时不再是"黑盒瞎猜",而是能精准定位到具体环节。
  4. 避坑与平滑迁移 从 Jakarta JSON 依赖冲突到 POJO Mapping 的类型陷阱,本文总结了实战中的高频痛点。对于存量项目,建议采取"抽象接口层 + 先读后写 + 双写灰度"的渐进式迁移策略,在由旧转新的过程中确保业务的绝对稳定。

结语:RestHighLevelClientElasticsearch Java API Client,不仅仅是依赖包的替换,更是开发思维的升级。掌握新客户端,不仅能让你摆脱"版本兼容地狱",更能以更优雅、更符合 Java 8+ 风格的方式构建高效的搜索应用。

相关推荐
okseekw1 小时前
Java泛型从入门到实战:原理、用法与案例深度解析
java·后端
雨中飘荡的记忆1 小时前
Spring WebFlux详解
java·后端·spring
萝卜青今天也要开心1 小时前
2025年下半年系统架构设计师考后分享
java·数据库·redis·笔记·学习·系统架构
Unstoppable221 小时前
八股训练营第 39 天 | Bean 的作用域?Bean 的生命周期?Spring 循环依赖是怎么解决的?Spring 中用到了那些设计模式?
java·spring·设计模式
程序员根根1 小时前
JavaSE 进阶:多线程核心知识点(线程创建 vs 线程安全 + 线程池优化 + 实战案例
java
阿伟*rui1 小时前
互联网大厂Java面试:音视频场景技术攻防与系统设计深度解析
java·redis·websocket·面试·音视频·高并发·后端架构
Java天梯之路1 小时前
Spring AOP:面向切面编程的优雅解耦之道
java·spring·面试
qq_348231851 小时前
Spring AI核心知识点
java·人工智能·spring
关于不上作者榜就原神启动那件事1 小时前
【java后端开发问题合集】
java·开发语言