RestTemplate 废弃后迁移 RestClient 实战:Spring Boot 4.2 升级指南

10 月 22 日 Spring Boot 4.2.0-RC1 发布,Spring Framework 7.1.0-RC1 正式给 RestTemplate 打上了 @Deprecated(since="7.1", forRemoval=true)。这篇是我梳理的渐进迁移步骤和方法对照表。

适用版本:Spring Boot 4.2.0-RC1 / Spring Framework 7.1.0-RC1;RestClient 迁移代码在 Boot 3.2+ 可用,@ImportHttpServices 需要 Boot 4.0+。


一、到底废弃到什么程度了

先把事实摆清楚,免得被标题党吓到。

项目 状态 信源
RestTemplate 类 @Deprecated(since="7.1", forRemoval=true),Javadoc 写明 "For removal in 8.0" Spring Framework 7.1.0-RC1 Javadoc
RestTemplateBuilder、RestTemplateCustomizer、RestTemplateAutoConfiguration 随之废弃,迁移到 RestClient.Builder / RestClientCustomizer Spring Boot 4.2.0-M1 Release Notes
TestRestTemplate 废弃,改用 RestTestClient(@AutoConfigureRestTestClient) 同上
MockRestServiceServerAutoConfiguration 废弃 同上

几点我的判断:

  • 现在不改不会挂。forRemoval 是给 8.0 准备的,4.2 里照常能用,只是编译会刷一屏 deprecation 警告
  • 新代码别再写 RestTemplate 了。官方 Javadoc 原话是 RestClient 才是"新的高级特性的重点",以后新功能(比如 API 版本、HTTP Interface 分组)都只往 RestClient 上加
  • 老代码可以渐进迁移。RestTemplate 和 RestClient 共用底层(请求工厂、拦截器、消息转换器),这是能平滑迁的关键

⚠️ 版本状态:截至 2026-10-23,4.2 还是 RC1,正式版尚未发布。Spring Boot 一般 5 月、11 月各发一个小版本,4.2 GA 我推测在 11 月,以官方发布为准。Spring 7.1 参考文档里有一句 "As of Spring Framework 7.0, RestTemplate is deprecated",和 Javadoc 的 since="7.1" 对不上,我以 Javadoc 注解为准。


二、先摸清项目里有多少 RestTemplate

动手前先统计影响面。在项目根目录执行:

bash 复制代码
# 统计每个文件里 RestTemplate 相关调用的次数,按次数倒序
grep -rn --include=*.java -E "RestTemplate|getForObject|postForObject|getForEntity|postForEntity|\.exchange\(" src/ \
  | awk -F: '{print $1}' | sort | uniq -c | sort -rn | head -30
bash 复制代码
# 找出所有创建 RestTemplate 的地方(Bean 定义、new、Builder)
grep -rn --include=*.java -E "new RestTemplate|RestTemplateBuilder|@Bean[^)]*RestTemplate" src/

我的经验,一个典型 Spring Boot 项目的 RestTemplate 分三类:

  1. 一两个配置类:定义 RestTemplate Bean,加拦截器、超时、消息转换器
  2. 一堆调用点 :getForObject / postForObject / exchange
  3. 测试 :MockRestServiceServer 或 TestRestTemplate

迁移顺序也按这个来,但先改调用点,后改配置,这是官方迁移指南推荐的顺序,理由下一节说。


三、第一步:用现有 RestTemplate 包一个 RestClient

RestClient.create(RestTemplate) 会复用 RestTemplate 的请求工厂、拦截器、消息转换器。这样配置类一行不动,调用点就能一个个换。

原来的配置,大部分老项目长这样:

java 复制代码
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;

@Configuration
public class HttpClientConfig {

    @Bean
    public RestTemplate restTemplate() {
        SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
        factory.setConnectTimeout(2000);
        factory.setReadTimeout(5000);
        RestTemplate restTemplate = new RestTemplate(factory);
        // 统一加 traceId 请求头
        restTemplate.getInterceptors().add((request, body, execution) -> {
            request.getHeaders().add("X-Trace-Id", MDC.get("traceId"));
            return execution.execute(request, body);
        });
        return restTemplate;
    }
}

过渡期只加一个 Bean:

java 复制代码
import org.springframework.web.client.RestClient;

@Bean
public RestClient legacyRestClient(RestTemplate restTemplate) {
    // 复用 restTemplate 的超时、拦截器、消息转换器
    return RestClient.create(restTemplate);
}

调用点注入 legacyRestClient,逐个改写。全部改完,再回头把配置挪到 RestClient.Builder 上(第五节)。先改调用点的原因:配置和调用一起改,出了问题分不清是超时没配对还是调用写错了;底层完全不变、只换 API 写法,测试一跑就知道对不对。


四、第二步:调用点改写对照表

下面是官方迁移指南里的对照表,我挑了日常最常用的部分,并补了我们项目里最常见的写法。

4.1 方法对照

RestTemplate RestClient
getForObject(url, Class, vars...) get().uri(url, vars...).retrieve().body(Class)
getForEntity(url, Class, vars...) get().uri(url, vars...).retrieve().toEntity(Class)
postForObject(url, req, Class, vars...) post().uri(url, vars...).body(req).retrieve().body(Class)
postForEntity(url, req, Class, vars...) post().uri(url, vars...).body(req).retrieve().toEntity(Class)
postForLocation(url, req, vars...) post().uri(url, vars...).body(req).retrieve().toBodilessEntity().getLocation()
put(url, req, vars...) put().uri(url, vars...).body(req).retrieve().toBodilessEntity()
delete(url, vars...) delete().uri(url, vars...).retrieve().toBodilessEntity()
exchange(url, method, entity, Class, vars...) method(m).uri(url, vars...).headers(h -> ...).body(obj).retrieve().toEntity(Class)
exchange(..., ParameterizedTypeReference, ...) ....retrieve().toEntity(ParameterizedTypeReference)
execute(url, method, callback, extractor, vars...) method(m).uri(url, vars...).exchange(ExchangeFunction)

⚠️ HttpEntity 和 RequestEntity 在 RestClient 里没有对应参数,headers 和 body 要拆开分别塞进 headers(...) 和 body(...)。这是改写时最容易漏 header 的地方。

4.2 实际改写示例

假设我们要调一个统一返回 Result<T> 的内部服务。先定义 DTO:

java 复制代码
// 下游服务的统一返回结构
public record Result<T>(int code, String msg, T data) {}

public record UserDTO(Long id, String name, String deptId) {}

public record UserQuery(String deptId, int pageNo, int pageSize) {}

GET + 路径变量

java 复制代码
// 改写前
UserDTO user = restTemplate.getForObject(
        "http://user-service/api/users/{id}", UserDTO.class, userId);

// 改写后
UserDTO user = restClient.get()
        .uri("http://user-service/api/users/{id}", userId)
        .retrieve()
        .body(UserDTO.class);

POST + 泛型返回 + 自定义 header(老代码里最长的一种)

java 复制代码
// 改写前
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Org-Id", orgId);
HttpEntity<UserQuery> entity = new HttpEntity<>(query, headers);
ResponseEntity<Result<List<UserDTO>>> resp = restTemplate.exchange(
        "http://user-service/api/users/search",
        HttpMethod.POST,
        entity,
        new ParameterizedTypeReference<Result<List<UserDTO>>>() {});
List<UserDTO> users = resp.getBody().data();

// 改写后
Result<List<UserDTO>> result = restClient.post()
        .uri("http://user-service/api/users/search")
        .contentType(MediaType.APPLICATION_JSON)
        .header("X-Org-Id", orgId)
        .body(query)
        .retrieve()
        .body(new ParameterizedTypeReference<Result<List<UserDTO>>>() {});
List<UserDTO> users = result.data();

行数差不多,但不用再手搓 HttpEntity,读起来是从上到下一条请求的样子,我更喜欢这种。查询参数也别再手动拼 ?a=1&b=2,用 uri(uriBuilder -> uriBuilder.path("/api/users").queryParam("deptId", deptId).build())。


五、第三步:把配置迁到 RestClient.Builder

调用点全换完,删掉 RestTemplate Bean,改成用 Boot 自动配置的 RestClient.Builder。

5.1 超时用配置文件

Boot 4.x 的全局 HTTP 客户端属性(Boot 4.1.2 参考文档确认):

yaml 复制代码
spring:
  http:
    clients:
      connect-timeout: 2s
      read-timeout: 5s

这组属性对 Boot 自动配置的 HTTP 客户端都生效,不用再自己 new 请求工厂。

5.2 拦截器和公共 header 用 RestClientCustomizer

java 复制代码
import org.slf4j.MDC;
import org.springframework.boot.web.client.RestClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
public class RestClientConfig {

    // 对所有注入的 RestClient.Builder 生效
    @Bean
    public RestClientCustomizer traceIdCustomizer() {
        return builder -> builder.requestInterceptor((request, body, execution) -> {
            String traceId = MDC.get("traceId");
            if (traceId != null) {
                request.getHeaders().add("X-Trace-Id", traceId);
            }
            return execution.execute(request, body);
        });
    }
}

⚠️ RestClientCustomizer 的包名我是按 Boot 3.x 的 org.springframework.boot.web.client 写的。Boot 4.0 做了模块拆分,部分类换了包,升级后以 IDE 自动导入为准。

5.3 每个下游服务一个 RestClient

Boot 提供的 RestClient.Builder 是 prototype 作用域,每次注入都是新实例,所以可以放心在里面设 baseUrl,不会互相污染:

java 复制代码
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

@Component
public class UserServiceClient {

    private final RestClient restClient;

    public UserServiceClient(RestClient.Builder builder) {
        this.restClient = builder
                .baseUrl("http://user-service")
                .defaultHeader("X-Caller", "order-service")
                .build();
    }

    public UserDTO getUser(Long id) {
        return restClient.get()
                .uri("/api/users/{id}", id)
                .retrieve()
                .body(UserDTO.class);
    }
}

⚠️ 默认请求工厂的差异:直接 RestClient.create()、classpath 上又没有 Apache HttpClient / Jetty / Reactor Netty 时,RestClient 用的是基于 JDK HttpClient 的 JdkClientHttpRequestFactory,而 RestTemplate 默认是基于 HttpURLConnection 的 SimpleClientHttpRequestFactory。官方提醒这可能带来 HTTP 层面的细微差异(比如对某些 header、重定向的处理)。迁移后联调时如果遇到"老的能通新的不通",先看这里。


六、异常处理:这里最容易出 bug

6.1 默认行为

RestClient 和 RestTemplate 一样,4xx、5xx 默认抛异常,异常都是 RestClientException 的子类。老代码里常见 catch (HttpClientErrorException.NotFound e) { return null; } 这种写法,迁移时别急着删,针对每个 catch 写一个用例确认抛出的异常类型没变。Spring 官方 issue #35620 里也有人在问两者异常是否完全一致,官方文档目前没给逐一映射表。

6.2 推荐写法:onStatus 就地处理

java 复制代码
UserDTO user = restClient.get()
        .uri("/api/users/{id}", id)
        .retrieve()
        // 404 单独转成业务上的"用户不存在"
        .onStatus(status -> status.value() == 404, (request, response) -> {
            throw new UserNotFoundException(id);
        })
        // 其他 4xx 转成业务异常,带上下游返回的 body 方便排查
        .onStatus(HttpStatusCode::is4xxClientError, (request, response) -> {
            String body = new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8);
            throw new BizException("调用用户服务失败: " + response.getStatusCode() + " " + body);
        })
        .body(UserDTO.class);

6.3 全局统一:defaultStatusHandler

每个调用都写 onStatus 太啰嗦,可以在 builder 上挂一个默认处理器:

java 复制代码
this.restClient = builder
        .baseUrl("http://user-service")
        .defaultStatusHandler(HttpStatusCode::isError, (request, response) -> {
            String body = new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8);
            throw new BizException("user-service " + request.getURI().getPath()
                    + " 返回 " + response.getStatusCode() + ": " + body);
        })
        .build();

6.4 下游 HTTP 200 但业务码失败

内部服务经常是 HTTP 永远 200、靠 Result.code 表示成败。这种用 exchange 自己拿响应:

java 复制代码
UserDTO user = restClient.get()
        .uri("/api/users/{id}", id)
        .exchange((request, response) -> {
            Result<UserDTO> result = response.bodyTo(new ParameterizedTypeReference<Result<UserDTO>>() {});
            if (result == null || result.code() != 0) {
                throw new BizException("user-service 业务失败: " + (result == null ? "空响应" : result.msg()));
            }
            return result.data();
        });

⚠️ exchange 里 onStatus 和 defaultStatusHandler 都不生效,状态码要自己判断。


七、顺手把 Feign 式调用换成 HTTP Interface

如果项目里对内部服务的调用很多,我更推荐直接上 HTTP Interface,写法跟 OpenFeign 很像,但不用额外依赖:

java 复制代码
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;
import org.springframework.web.service.annotation.PostExchange;

@HttpExchange("/api/users")
public interface UserApi {

    @GetExchange("/{id}")
    UserDTO getUser(@PathVariable Long id);

    @PostExchange("/search")
    Result<List<UserDTO>> search(@RequestHeader("X-Org-Id") String orgId, @RequestBody UserQuery query);
}

Boot 4.x 用 @ImportHttpServices 注册,按分组配置地址和超时:

java 复制代码
@SpringBootApplication
@ImportHttpServices(group = "user", types = UserApi.class)
public class OrderApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class, args);
    }
}
yaml 复制代码
spring:
  http:
    serviceclient:
      user:
        base-url: http://user-service
        connect-timeout: 2s
        read-timeout: 3s

然后直接注入 UserApi 用。分组级的 header、拦截器用 RestClientHttpServiceGroupConfigurer:

java 复制代码
@Bean
RestClientHttpServiceGroupConfigurer callerHeaderConfigurer() {
    return groups -> groups.forEachClient((group, clientBuilder) ->
            clientBuilder.defaultHeader("X-Caller", "order-service"));
}

💡 没有分组的接口会被归到名为 default 的组里。老项目用 Boot 3.x 的话没有 @ImportHttpServices,需要手动 HttpServiceProxyFactory.builderFor(RestClientAdapter.create(restClient)).build().createClient(UserApi.class)。


八、测试:MockRestServiceServer 照样能用

MockRestServiceServer.bindTo(RestClient.Builder) 从 Spring Framework 6.1 就支持,老测试改动很小:

java 复制代码
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;

import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;

class UserServiceClientTest {

    @Test
    void getUser() {
        RestClient.Builder builder = RestClient.builder();
        // 先 bind 再 build,mock 才能拦到请求
        MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
        UserServiceClient client = new UserServiceClient(builder);

        server.expect(requestTo("http://user-service/api/users/1001"))
              .andRespond(withSuccess("{\"id\":1001,\"name\":\"张三\",\"deptId\":\"D01\"}",
                      MediaType.APPLICATION_JSON));

        UserDTO user = client.getUser(1001L);

        assertThat(user.name()).isEqualTo("张三");
        server.verify();
    }
}

集成测试里的 TestRestTemplate 在 Boot 4.2 被废弃,官方给的替代是 RestTestClient 配合 @AutoConfigureRestTestClient,这部分我还没在 RC1 上实际跑过,暂不展开。


九、Boot 4.2 里其他值得注意的变化

升 4.2 不只是 RestTemplate,顺手把 Release Notes 里会影响我们的列一下(M1 到 RC1 汇总):

变化 影响 处理
spring-boot-starter-amqp 拆分 用 RabbitMQ 的项目 AMQP 0.9 改 spring-boot-starter-rabbitmq,AMQP 1.0 用 spring-boot-starter-amqp-rabbitmq
Tomcat 三个 servlet 属性迁到 server.tomcat.servlet.* 配了 use-relative-redirects 等的项目 改属性名;use-relative-redirects 默认变为 true
management.metrics.observations.ignored-meters 删除 依赖 LongTaskTimer 指标的监控 需要的话设 management.metrics.observations.include-active-long-task-timer=true
Buildpacks 默认 builder 换成 ubuntu-resolute-builder(Ubuntu 26.04) 用 spring-boot:build-image 打镜像的 默认 run image 无 shell,要 shell 就显式指定 builder
Docker Compose 支持 MySQL/MariaDB 随机 root 密码 本地用 Docker Compose 起库的 可以去掉 compose 里的明文密码
4.1 中废弃的 API 被移除 所有项目 先在 4.1 上清掉 deprecation 警告再升
依赖升级 所有项目 Framework 7.1.0-RC1、Security 7.2.0-RC1、Data 2026.1.0-RC1、Hibernate 7.4.13.Final、MySQL Connector/J 26.7.0

十、迁移速查清单

步骤 做什么 验证
1 grep 统计 RestTemplate 调用点和定义处 列出清单
2 加 RestClient.create(restTemplate) 过渡 Bean 启动正常
3 逐个改调用点,HttpEntity 拆成 headers() + body() 每改一个跑对应用例
4 核对 catch 的异常类型 补 4xx/5xx 用例
5 超时改 spring.http.clients.*,拦截器改 RestClientCustomizer 联调看 header、超时
6 删 RestTemplate Bean 全局 grep 无残留
7 高频内部调用改 HTTP Interface(可选) 接口用例通过

十一、常见问题

Q:4.2 发布后 RestTemplate 还能用多久?

Javadoc 写的是 8.0 删除。Spring Framework 8.0 什么时候出,目前没有官方时间表,我不乱猜。能确定的是 4.2 和后续 4.x 都还能用,只是有警告。

Q:直接全局替换能不能一把梭?

不建议。exchange 那种带 HttpEntity 的写法、以及依赖特定异常类型的 catch,正则替换很容易漏 header 或改坏错误处理。我的做法是让 Claude Code 按文件批量改,但每个文件改完都要过一遍对应的单测。

Q:WebClient 要不要也换?

不用。WebClient 没被废弃,异步、流式场景官方仍然推荐它。只是很多项目为了同步调用硬引了 WebFlux 再 .block(),这种可以换成 RestClient,顺便把 webflux 依赖去掉。

Q:Spring Cloud OpenFeign 怎么办?

OpenFeign 不在这次废弃范围内,继续能用。新服务我会优先用 HTTP Interface,老的 Feign 客户端不急着动。


参考链接


迁移中遇到新坑欢迎评论区补充。

相关推荐
aibo8603081 小时前
基于 Spring Boot 的茶铺管理系统设计与实现
java·spring boot·后端
Thomas.Sir2 小时前
第4课:配置文件详解:application.yml/properties,多环境配置、配置分组、配置校验
java·spring boot
杨运交4 小时前
082-缓存模块-基于 Redis Lua 脚本实现分布式锁与原子操作实战
spring boot
aibo8603084 小时前
基于 Spring Boot + Vue3 的博物馆藏品管理系统设计与实现
java·spring boot·后端
zzu123zsw5 小时前
把条件校验收敛到枚举接口:Spring Boot 统一断言模式与零污染异常切面工程化
java·spring boot·后端·架构设计
code_slave(码畜)5 小时前
微服务架构落地:分库分表实战(下) —— 分布式主键、跨分片查询与分页聚合防坑
数据库·spring boot·分布式·spring cloud·微服务·架构·分库分表
zzu123zsw6 小时前
面对多 RPC 扇入超时崩溃:深入 asyncTool 核心 DAG 编排与线程池隔离防护
java·网络·spring boot·网络协议·rpc·并发编程
aibo8603087 小时前
基于 Spring Boot + Vue3 的爱心众筹管理系统设计与实现
java·spring boot·后端
aibo8603088 小时前
基于 Spring Boot + Vue3 的餐饮食材采购与成本分析系统设计与实现
java·spring boot·后端