2726 字
约 9 分钟
22
S13-X04 · RestHighLevelClient 与 Elasticsearch Java Client 的迁移

同一段查询逻辑,在 7.x 的 RestHighLevelClient 里是一堆 setter,在 8.x 的 Elasticsearch Java Client 里是一串 lambda builder。前者 7.15 起标记废弃,后者是 8.x 唯一在维护的官方 Java 客户端。迁移不是换个 import 就完事:依赖坐标、请求构造、响应解析、异常模型四处都会断。

下面按迁移的实际顺序拆:先认清三代客户端的边界,再逐层对照代码,最后处理 Spring Boot 3 的版本对齐和检查清单。


1. 三代客户端的坐标与生命周期

代际 类 依赖坐标 状态
一代 TransportClient org.elasticsearch.client:transport 7.0 废弃,8.0 移除
二代 RestHighLevelClient org.elasticsearch.client:elasticsearch-rest-high-level-client 7.15 起废弃,7.17 是最后的版本线
三代 ElasticsearchClient co.elastic.clients:elasticsearch-java 8.x 主推

TransportClient 走的是节点间二进制协议(9300 端口),要感知集群拓扑,客户端与服务器版本必须严格一致。它被移除的原因是这套协议不做向前兼容承诺,每次大版本升级都要重编客户端。

RestHighLevelClient 改走 HTTP(9200),把协议问题转成了 REST 兼容性问题,这是它能活很久的原因。代价是它把整个 org.elasticsearch:elasticsearch 核心包拖进依赖树——那是个几十 MB 的服务器端 jar,客户端只需要其中的查询 DSL 类。

ElasticsearchClient 把 DSL 类重新生成了一遍(co.elastic.clients.elasticsearch._types.query_dsl),不再依赖服务器核心包。请求/响应对象全部是不可变 POJO + builder,序列化交给 JSON-P(jakarta.json)。


2. 依赖坐标差异

二代的 pom:

<dependency>
  <groupId>org.elasticsearch.client</groupId>
  <artifactId>elasticsearch-rest-high-level-client</artifactId>
  <version>7.17.22</version>
</dependency>
<!-- 传递依赖里会带进 org.elasticsearch:elasticsearch 与 elasticsearch-rest-client -->

三代的 pom 需要三个坐标,缺一个就在运行期炸 NoClassDefFoundError 或 JsonpMappingException:

<dependency>
  <groupId>co.elastic.clients</groupId>
  <artifactId>elasticsearch-java</artifactId>
  <version>8.15.3</version>
</dependency>
<dependency>
  <groupId>jakarta.json</groupId>
  <artifactId>jakarta.json-api</artifactId>
  <version>2.1.3</version>
</dependency>
<dependency>
  <groupId>org.eclipse.parsson</groupId>
  <artifactId>parsson</artifactId>
  <version>1.1.7</version>
</dependency>
坐标 作用 漏了会怎样
elasticsearch-java 客户端本体 + 生成的 DSL 类 编译不过
jakarta.json-api JSON-P 接口 NoClassDefFoundError: jakarta/json/Json
parsson JSON-P 的运行时实现 JsonProvider 找不到,序列化时抛 JsonException
elasticsearch-rest-client 底层 HTTP(三代仍依赖它) 一般随 elasticsearch-java 传递进来,不用手写

jakarta.json 是 javax.json 的改名版(Jakarta EE 9 之后)。如果项目里还有 javax.json 的旧包,两个实现会在 ServiceLoader 阶段抢 JsonProvider,报"provider 不是唯一"。


3. 请求对象构造方式

维度 RestHighLevelClient ElasticsearchClient
入口对象 SearchRequest SearchRequest(同名,不同包)
构造方式 new SearchRequest("article") + setter SearchRequest.of(b -> b.index("article"))
查询体 SearchSourceBuilder 独立对象 直接挂在 SearchRequest 的 query 字段
可变性 可变,可反复改 不可变,每次 .of() 出新对象
字段名 字符串("title")易拼错 仍是字符串,但类型检查更严(FieldValue)
泛型 无(getSourceAsString) SearchResponse<T> 按类反序列化

三代最大的写法变化是没有独立的 source builder:SearchSourceBuilder 这个概念被吸收了,query、from、size、sort、aggregations 都是 SearchRequest 自己的字段。


4. 同一个 bool + match 查询,两种写法

需求:在 article 索引里查 title 含"Netty"且 status 为 published 的文档,取前 10 条。

二代:

SearchSourceBuilder source = new SearchSourceBuilder();
source.query(QueryBuilders.boolQuery()
        .must(QueryBuilders.matchQuery("title", "Netty"))
        .filter(QueryBuilders.termQuery("status", "published")));
source.from(0).size(10);
source.sort("publish_time", SortOrder.DESC);

SearchRequest request = new SearchRequest("article");
request.source(source);
SearchResponse response = client.search(request, RequestOptions.DEFAULT);

三代:

SearchRequest request = SearchRequest.of(s -> s
        .index("article")
        .from(0)
        .size(10)
        .query(q -> q.bool(b -> b
                .must(m -> m.match(mm -> mm.field("title").query("Netty")))
                .filter(f -> f.term(t -> t.field("status").value("published")))))
        .sort(so -> so.field(fs -> fs.field("publish_time").order(SortOrder.Desc))));

SearchResponse<Article> response = client.search(request, Article.class);

三段对照值得记住:

概念 二代 三代
bool 查询 QueryBuilders.boolQuery() q.bool(b -> ...)
must 子句 .must(QueryBuilder) .must(m -> m.match(...)),接收 Function 或 Query
字段值 String / 基本类型 FieldValue.of(...),多数场景有重载可直接传字面量
排序 SortBuilder + SortOrder.DESC SortOrder.Desc(枚举命名改了大小写)
类型绑定 运行时 getSourceAsString() 编译期 SearchResponse<Article>

must 接收的是 Function<Query.Builder, ObjectBuilder<Query>>,所以嵌套一层 m.match(mm -> ...)——这是三代写起来"括号变多"的根源,也是它能在编译期校验字段名的原因。

上面的代码按 elasticsearch-java 8.15 的 API 形态编写;8.x 早期版本里 SortOrder 与 FieldValue 的重载略有差异,升级小版本时按对应版本的 javadoc 核对一次。


5. 响应解析差异

二代:

SearchHits hits = response.getHits();
long total = hits.getTotalHits().value;
for (SearchHit hit : hits.getHits()) {
    String json = hit.getSourceAsString();          // 自己反序列化
    Map<String, Object> map = hit.getSourceAsMap(); // 或拿 Map
    float score = hit.getScore();
}

三代:

HitsMetadata<Article> meta = response.hits();
long total = meta.total().value();
for (Hit<Article> hit : meta.hits()) {
    Article doc = hit.source();      // 已按 Article.class 反序列化
    Double score = hit.score();      // 注意是 Double,可能是 null
}
差异点 二代 三代
命中集合 response.getHits().getHits() response.hits().hits()
总数 getTotalHits().value hits().total().value()
文档对象 getSourceAsString() / getSourceAsMap() hit.source(),泛型反序列化
评分 float,无命中时为 NaN Double,可为 null
取字段 手动解析 JSON hit.fields()(需在请求里 docvalue_fields 或 fields)

最容易踩的是评分:二代 getScore() 返回 float,三代 score() 返回 Double 且可能为 null(比如 size=0 的聚合请求、或 track_scores=false 的排序查询)。直接拆箱会 NPE。

另一个坑是 hit.source() 需要 Jackson 能反序列化目标类。三代默认用 JacksonJsonpMapper,如果类里有 LocalDateTime,得自己注册 JavaTimeModule,否则抛 InvalidDefinitionException。


6. 异常模型

场景 二代 三代
索引不存在 ElasticsearchStatusException(404) ElasticsearchException,error().type() 为 index_not_found_exception
查询语法错 ElasticsearchStatusException(400) 同上,type() 为 parsing_exception
版本冲突 ElasticsearchStatusException(409) 同上,type() 为 version_conflict_engine_exception
连接失败 IOException TransportException(IOException 的子类)
获取原始响应 e.getResponse() 返回 Response e.response()

三代统一成一个 co.elastic.clients.elasticsearch._types.ElasticsearchException,把 HTTP 状态码收进 ErrorCause:

try {
    client.index(i -> i.index("article").id("1").document(article));
} catch (ElasticsearchException e) {
    String type = e.error().type();     // index_not_found_exception / version_conflict...
    int status = e.status();            // 404 / 409
    if ("version_conflict_engine_exception".equals(type)) {
        // 走重试或乐观锁重建
    }
} catch (TransportException e) {
    // 网络层,可重试
}

二代把所有非 2xx 都压成 ElasticsearchStatusException,只能靠 getResponse().getStatusLine() 判状态码;三代给了结构化的 error().type(),分支判断不用再解析字符串。


7. Spring Boot 3 下的版本对齐

Spring Data Elasticsearch 5.x(对应 Spring Boot 3.x)内部已经切到 ElasticsearchClient,RestHighLevelClient 不再是它的依赖。这意味着一个 Boot 3 项目里可能同时存在两套客户端——旧代码用 RHLC、新代码用新客户端——两套各自建连接池,各自吃线程。

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>

版本关系要按你所用 Boot 版本的 spring-boot-dependencies POM 核对,大致是这条链:

层 谁管版本 注意
Spring Boot BOM spring-data-bom 决定 Spring Data 大版本
Spring Data Elasticsearch 声明 elasticsearch-java 版本 5.x 对应 8.x 客户端
应用 pom 可覆盖 覆盖后要保证客户端 minor 不高于服务端

覆盖的写法是显式钉版本,不要靠传递依赖碰运气:

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>co.elastic.clients</groupId>
      <artifactId>elasticsearch-java</artifactId>
      <version>8.15.3</version>
    </dependency>
  </dependencies>
</dependencyManagement>

如果要保留 RHLC 对接 8.x 服务端,必须加兼容头,否则 8.x 会拒掉 7.x 的 Content-Type:

RequestOptions options = RequestOptions.DEFAULT.toBuilder()
        .addHeader("Accept", "application/vnd.elasticsearch+json; compatible-with=7")
        .addHeader("Content-Type", "application/vnd.elasticsearch+json; compatible-with=7")
        .build();

这条兼容路径是过渡手段,不是长期方案:RHLC 7.17 之后不再发版,8.x 的新 API 它根本不知道。


8. 迁移检查清单

# 检查项 判定方式
1 依赖树里没有 org.elasticsearch:elasticsearch 核心包 mvn dependency:tree 搜 org.elasticsearch
2 没有残留 org.elasticsearch.client.RestHighLevelClient 引用 grep -rn "RestHighLevelClient" src/
3 请求从 SearchRequest + SearchSourceBuilder 改成单层 builder 编译期能过即可
4 SortOrder.DESC 全部改成 SortOrder.Desc 编译错误会直接指出
5 hit.getScore() 的拆箱点改成判空 静态扫描 score() 调用
6 捕获块从 ElasticsearchStatusException 换成 ElasticsearchException + error().type() Review 全部 catch
7 自定义 JsonpMapper 注册了 JavaTimeModule 跑一次带时间字段的读写
8 连接池参数在新客户端重建(RestClientBuilder 的 setHttpClientConfigCallback) 检查 Bean 定义
9 关闭旧客户端的 close() 调用 容器销毁阶段观察是否报错

9. 排错表

现象 原因 处理
启动报 NoClassDefFoundError: jakarta/json/Json 只加了 elasticsearch-java,漏 jakarta.json-api 补坐标
序列化时 JsonException: provider not found 有 api 没有实现 加 parsson
IllegalArgumentException: Provider ... not found 或 provider 冲突 同时存在 javax.json 与 jakarta.json 排除旧包
JsonpMappingException / InvalidDefinitionException 目标类有 LocalDateTime 等类型,mapper 没配模块 自定义 JacksonJsonpMapper 并注册 JavaTimeModule
查询返回 0 条但服务端 _search 有数据 索引名/字段名拼错,三代不会在编译期校验字符串 用 _analyze/_mapping 对齐字段名
NullPointerException 在评分处 hit.score() 为 Double 且可能为 null 判空或用 track_scores
4xx 全部抛同一个异常、无法区分 还在按二代思路 catch ElasticsearchStatusException 改 catch ElasticsearchException 读 error().type()
8.x 服务端拒绝 7.x 客户端请求 Content-Type 不带兼容版本 加 compatible-with=7 头,或直接迁移

10. 边界:什么时候不必迁移

场景 建议 理由
集群还停在 7.17 且两年内不升级 留在 RHLC,只做版本钉死 迁移收益为零,风险不小
只做 _cat、_cluster/health 这类运维调用 直接用 elasticsearch-rest-client 或 curl 不需要 DSL 层
重度依赖 SearchSourceBuilder 的动态拼装 迁移要重写拼装逻辑,评估工时 三代不可变 builder 不适合"边算边加"
大量聚合、脚本字段 迁移后逐个验证 Aggregation 泛型 聚合 DSL 的泛型嵌套最深,最容易写错
只需要写入日志 考虑 Bulk API 封装的轻量客户端 全量客户端对单场景是过重依赖

一句话:7.17 服务端 + RHLC 是能跑的组合,8.x 服务端 + RHLC 是能跑但不该跑的组ation。真正必须迁移的时点,是服务端跨过 8.0 那一刻。


11. 自测

  1. RestHighLevelClient 为什么要把整个服务器核心包拖进依赖树,三代怎么绕开的?
  2. 三代只加 elasticsearch-java 一个坐标会在哪一步失败,报什么?
  3. q.bool(b -> b.must(m -> m.match(...))) 里两层 lambda 分别对应原来哪两个对象?
  4. hit.score() 在什么情况下返回 null,直接拆箱会怎样?
  5. 三代如何区分"索引不存在"和"版本冲突",二代靠什么?
  6. Spring Boot 3 项目里同时存在两套客户端,会带来哪些隐性成本?
S13-X04 · RestHighLevelClient 与 Elasticsearch Java Client 的迁移
https://www.clxhxhhr.top/posts/5657/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。