Spring 6.1 正式版发布之后,我第一时间把手上几个还在用 RestTemplate 的老项目翻出来做了一轮改造试验。原因很简单:Spring 官方在这次版本里把 REST 客户端调用这件事重新梳理了一遍,明确给出了四条路线——RestClient、WebClient、RestTemplate、HTTP Interface。这四个东西不是简单的"新老交替"关系,而是各自面向不同场景的四种武器。很多同学一看到"新特性"三个字就急着把老代码全换成 RestClient,结果换完发现流式场景还是要 WebClient,声明式场景还是要 HTTP Interface,绕了一圈又回到原点。所以这篇内容我不打算写成 API 手册,而是按我实际踩坑的顺序,把这四种方式的定位、选型逻辑、核心代码、参数细节和避坑经验完整拆一遍,适合正在做 Spring 6.x 升级、或者准备重构服务间调用层的朋友参考,新手也能跟着代码直接跑起来。
1. 四种调用方式到底怎么选:先搞清楚定位再动手
1.1 一张表看懂四者的本质差异
在动手写代码之前,我建议先把这张对照表刻在脑子里。选型错了,后面写再多代码都是白费功夫。
| 调用方式 | 编程模型 | 是否阻塞 | 所属模块 | 典型场景 |
|---|---|---|---|---|
| RestTemplate | 同步阻塞 | 阻塞 | spring-web | 老项目维护、简单同步调用 |
| RestClient | 同步阻塞 | 阻塞 | spring-web | 新项目同步调用首选 |
| WebClient | 响应式非阻塞 | 非阻塞 | spring-webflux | 高并发、流式、异步编排 |
| HTTP Interface | 声明式接口 | 取决于底层 | spring-web | 像 Feign 一样的声明式调用 |
这张表里最关键的一列是"编程模型"。RestTemplate 和 RestClient 都是同步阻塞的,调用线程会一直挂在那里等结果;WebClient 基于 Reactor,返回的是 Mono/Flux,线程不会被占住;HTTP Interface 本身不实现通信,它是一层声明式代理,底层可以挂 RestClient、WebClient 或者 RestTemplate。
我见过太多人把 WebClient 当成"新版 RestTemplate"来用,然后在里面写.block(),这就等于把响应式的优势全部扔掉了,还额外背上了 Reactor 的学习成本。所以选型的第一原则是:先确定你的调用是同步还是异步,再决定用哪个客户端。
1.2 为什么 Spring 要同时保留四条路线
这个问题我在升级过程中反复问过自己。按常理,新版本应该淘汰旧的,为什么 RestTemplate 还在?后来想明白了,这是 Spring 团队对"存量生态"的妥协,也是对"场景差异"的尊重。
RestTemplate 从 Spring 3.0 就存在了,十几年下来,无数项目的代码里都是它。如果 Spring 6.1 直接把它标记为废弃,那升级成本会劝退一大半用户。所以官方的做法是:RestTemplate 进入维护模式,不再加新功能,但继续可用;同时推出 RestClient 作为同步调用的现代化替代,API 更流畅、更符合链式编程习惯;WebClient 继续负责响应式场景;HTTP Interface 则填补了"声明式调用"这块空白,让不喜欢手写 HTTP 细节的人可以像调本地方法一样调远程接口。
这个布局其实很清晰:同步用 RestClient,异步用 WebClient,声明式用 HTTP Interface,老代码继续 RestTemplate。四条路线各司其职,不存在谁完全替代谁的问题。
1.3 选型决策树:三个问题定方案
我总结了一个简单的决策流程,实际项目中按这个走基本不会错:
- 你的调用需要非阻塞或流式处理吗?需要 → WebClient,结束。不需要 → 进入第 2 步。
- 你希望像调本地方法一样调远程接口、减少样板代码吗?希望 → HTTP Interface(底层选 RestClient 或 WebClient)。不希望 → 进入第 3 步。
- 是新项目还是老项目?新项目 → RestClient。老项目且改动成本高 → 继续 RestTemplate,逐步迁移。
这个决策树背后的逻辑是:非阻塞是硬需求,优先级最高;声明式是开发体验优化,属于锦上添花;同步调用的新老之争则取决于迁移成本。把这三点想清楚,选型就不会纠结。
提示:HTTP Interface 不是独立的通信实现,它必须绑定一个底层客户端。所以严格来说,实际参与网络通信的只有 RestTemplate、RestClient、WebClient 三个,HTTP Interface 是"第四种写法"而非"第四个引擎"。
2. RestClient:同步调用的新门面,我为什么优先推荐它
2.1 RestClient 的设计思路与核心优势
RestClient 是 Spring 6.1 引入的同步 HTTP 客户端,官方定位是"RestTemplate 的现代化替代"。它的核心设计思路是** fluent API + 不可变构建**,也就是链式调用风格,每一步返回新的构建器对象,最终retrieve()或exchange()才真正发起请求。
我为什么优先推荐它?三个理由。第一,API 流畅,restClient.get().uri("/users/{id}", id).retrieve().body(User.class)一行搞定,比 RestTemplate 的getForObject(url, User.class, id)可读性强太多,尤其是带请求头、带查询参数的复杂场景。第二,它天然支持RestClient.Builder注入,和 Spring Boot 的自动配置无缝衔接,想加拦截器、改超时、配连接池都很自然。第三,它是同步的,没有 Reactor 的心智负担,团队里刚入行的同学也能快速上手。
对比 RestTemplate,RestClient 最大的改进在于错误处理。RestTemplate 默认对 4xx/5xx 抛异常,想拿到响应体做业务判断很别扭;RestClient 提供onStatus()钩子,可以精细控制哪些状态码抛异常、哪些交给业务处理,这一点在实际对接第三方接口时特别有用。
2.2 最小可用示例:从创建到发起请求
先看最基础的用法。创建 RestClient 有两种方式,一种是直接RestClient.create(),适合临时脚本;另一种是注入RestClient.Builder,适合正式项目。
// 方式一:直接创建(简单场景) RestClient client = RestClient.create(); // 方式二:注入 Builder(推荐,可统一配置) @Service public class UserService { private final RestClient restClient; public UserService(RestClient.Builder builder) { this.restClient = builder .baseUrl("https://api.example.com") .defaultHeader("X-App-Key", "demo-key") .build(); } public User getUser(Long id) { return restClient.get() .uri("/users/{id}", id) .retrieve() .body(User.class); } }这里有个细节值得说:uri("/users/{id}", id)用的是 URI 模板变量,RestClient 会自动做 URL 编码。我早期图省事直接字符串拼接"/users/" + id,结果遇到 id 里带特殊字符时请求直接 400,排查了半天。养成用模板变量的习惯,能省掉一大类编码问题。
2.3 请求头、查询参数与请求体的完整写法
实际项目里,一个请求往往要带认证头、追踪 ID、查询参数、JSON 请求体。RestClient 把这些都做成了链式方法,写起来很顺:
public Order createOrder(OrderRequest req, String token) { return restClient.post() .uri(uriBuilder -> uriBuilder .path("/orders") .queryParam("source", "web") .build()) .header("Authorization", "Bearer " + token) .header("X-Trace-Id", UUID.randomUUID().toString()) .contentType(MediaType.APPLICATION_JSON) .body(req) .retrieve() .body(Order.class); }注意uriBuilder那个 lambda,它适合参数较多或需要动态拼接的场景。如果参数固定,直接用uri("/orders?source=web")也行,但涉及用户输入的值一定要走queryParam,让框架帮你编码。
请求体这块,.body(req)会自动根据contentType选择消息转换器。Spring 默认注册了 Jackson,所以传对象就是 JSON。如果对方接口要求表单格式,把contentType改成MediaType.APPLICATION_FORM_URLENCODED,然后传MultiValueMap即可。
2.4 错误处理:onStatus 是我最喜欢的一个钩子
RestTemplate 时代,想对 404 做特殊处理,得写ResponseErrorHandler,代码又长又绕。RestClient 的onStatus()直接把这个痛点解决了:
public User getUserSafely(Long id) { return restClient.get() .uri("/users/{id}", id) .retrieve() .onStatus(HttpStatusCode::is4xxClientError, (request, response) -> { if (response.getStatusCode().value() == 404) { throw new UserNotFoundException(id); } throw new BizException("客户端错误: " + response.getStatusCode()); }) .onStatus(HttpStatusCode::is5xxServerError, (request, response) -> { throw new RemoteServiceException("下游服务异常"); }) .body(User.class); }这段代码的意图很明确:404 转成业务异常UserNotFoundException,其他 4xx 转成通用业务异常,5xx 转成远程服务异常。这样上层调用方就能按异常类型做不同处理,而不是拿到一个笼统的HttpClientErrorException再去解析状态码。
注意:
onStatus里抛出的异常会原样向上传播,不会被包装。所以自定义异常最好继承RuntimeException,避免被 Spring 的异常转换机制二次处理。
2.5 超时与连接池:别用默认值上生产
RestClient 本身不管理连接池,它依赖底层的ClientHttpRequestFactory。默认情况下用的是 JDK 的HttpURLConnection,没有连接池,超时也是无限等待。这在生产环境是灾难。
我的做法是显式配置JdkClientHttpRequestFactory(基于 Java 11+ 的 HttpClient)或者 Apache HttpClient 5:
@Bean public RestClient restClient() { var requestFactory = new JdkClientHttpRequestFactory(); requestFactory.setReadTimeout(Duration.ofSeconds(5)); return RestClient.builder() .requestFactory(requestFactory) .baseUrl("https://api.example.com") .build(); }读超时设 5 秒是我根据下游接口的 P99 延迟定的,一般建议是 P99 的 1.5 到 2 倍。连接超时则通过底层 HttpClient 配置,通常 2 到 3 秒足够。这两个值不设,等于把服务稳定性交给运气,我见过因为下游卡住导致整个线程池被拖垮的案例,教训很深刻。
3. WebClient:响应式场景的唯一正解
3.1 WebClient 的适用边界:什么时候必须用它
WebClient 属于 spring-webflux 模块,返回Mono或Flux,天生非阻塞。它的适用边界很清晰:高并发、流式传输、需要并行编排多个远程调用。
举个我实际遇到的场景:一个聚合接口要同时调用户服务、订单服务、库存服务,三个调用互不依赖。用 RestClient 串行调,总耗时是三者之和;用 WebClient 的Mono.zip并行调,总耗时是三者最大值。当每个调用 200ms 时,串行 600ms,并行 200ms,差距三倍。这种场景下 WebClient 不是"更好",而是"必须"。
另一个场景是流式响应,比如下游返回 SSE 或者大文件流。RestClient 会把整个响应读进内存,WebClient 可以用Flux<DataBuffer>边收边处理,内存占用完全不是一个量级。
3.2 基础用法与线程模型说明
@Service public class ReactiveUserService { private final WebClient webClient; public ReactiveUserService(WebClient.Builder builder) { this.webClient = builder .baseUrl("https://api.example.com") .defaultHeader("X-App-Key", "demo-key") .build(); } public Mono<User> getUser(Long id) { return webClient.get() .uri("/users/{id}", id) .retrieve() .onStatus(HttpStatusCode::is4xxClientError, resp -> Mono.error(new UserNotFoundException(id))) .bodyToMono(User.class); } }注意这里返回的是Mono<User>,方法调用时并不会真正发请求,只有订阅(subscribe)时才触发。这是响应式编程最容易被误解的地方。很多同学写完发现"请求没发出去",就是因为忘了订阅,或者在上层把 Mono 丢掉了。
线程模型上,WebClient 基于 Netty(默认)或 Reactor Netty,事件循环线程数默认是 CPU 核数。这意味着少量线程就能支撑大量并发连接,但也意味着你不能在响应式链路里做阻塞操作,否则事件循环线程被卡住,整个客户端的吞吐会断崖式下跌。
3.3 并行编排:Mono.zip 的实战用法
public Mono<AggregatedResult> aggregate(Long userId) { Mono<User> userMono = userService.getUser(userId); Mono<List<Order>> ordersMono = orderService.getOrders(userId).collectList(); Mono<Integer> stockMono = stockService.getStock(userId); return Mono.zip(userMono, ordersMono, stockMono) .map(tuple -> new AggregatedResult( tuple.getT1(), tuple.getT2(), tuple.getT3())); }Mono.zip会并行订阅三个 Mono,全部完成后合并结果。这里有个坑:如果其中一个 Mono 报错,整个 zip 会立即失败,其他两个即使成功也拿不到结果。如果业务上允许部分失败,要用onErrorResume给每个 Mono 兜底,返回一个默认值,保证 zip 能正常完成。
3.4 阻塞调用的正确姿势:什么时候可以 block
我必须强调:在响应式链路里调用.block()是反模式。但现实是,很多老项目是同步架构,只在某个局部想用 WebClient 的并行能力,这时候在边界处 block 一次是可以接受的。
正确姿势是把 block 限制在最外层,且设置超时:
public AggregatedResult aggregateBlocking(Long userId) { return aggregate(userId) .block(Duration.ofSeconds(3)); }block(Duration)带超时参数,避免无限等待。如果不带参数,一旦下游不响应,线程会永久挂起。这个细节我在代码评审里见过太多次遗漏,一定要养成带超时的习惯。
注意:在 WebFlux 的 Controller 里绝对不能 block,会直接抛异常。只有在同步的 Service 边界、且确认当前线程不是事件循环线程时,才可以谨慎使用。
4. RestTemplate:老将不死,但别再往它身上加新功能
4.1 RestTemplate 的现状与维护策略
RestTemplate 在 Spring 6.1 里依然可用,但官方已经明确它进入维护模式——不再新增功能,只做必要的 bug 修复。这不代表它不能用,而是说新项目没必要选它。
我手上有个跑了六年的老系统,里面几十个 Service 全是 RestTemplate。这次升级我的策略是:不动存量,新代码用 RestClient,等某个模块大改时顺手迁移。这种渐进式策略比一次性全量替换稳妥得多,风险可控,测试成本也低。
4.2 典型用法与它绕不开的痛点
@Bean public RestTemplate restTemplate(RestTemplateBuilder builder) { return builder .rootUri("https://api.example.com") .setConnectTimeout(Duration.ofSeconds(3)) .setReadTimeout(Duration.ofSeconds(5)) .additionalInterceptors(new TraceInterceptor()) .build(); }RestTemplate 的痛点主要集中在三处。第一,API 设计偏"方法重载爆炸",getForObject、getForEntity、exchange各种组合,参数顺序容易记混。第二,错误处理要靠自定义ResponseErrorHandler,代码量大。第三,请求头的设置方式不统一,有时候用HttpHeaders,有时候用RequestEntity,风格割裂。
这些痛点在 RestClient 里都被解决了,所以我的建议很直接:新代码不要再用 RestTemplate,老代码按需迁移。
4.3 从 RestTemplate 迁移到 RestClient 的对照表
迁移时最实用的是一张方法对照表,我整理了自己迁移过程中用到的映射关系:
| RestTemplate 写法 | RestClient 等价写法 |
|---|---|
getForObject(url, T.class) | get().uri(url).retrieve().body(T.class) |
getForEntity(url, T.class) | get().uri(url).retrieve().toEntity(T.class) |
postForObject(url, req, T.class) | post().uri(url).body(req).retrieve().body(T.class) |
exchange(url, POST, entity, T.class) | method(POST).uri(url).body(...).retrieve().toEntity(T.class) |
ResponseErrorHandler | onStatus(...) |
迁移时有个容易忽略的点:RestTemplate 的getForEntity返回的ResponseEntity包含状态码和响应头,RestClient 的toEntity同样保留这些信息,但方法名从body变成toEntity,别写错了。
5. HTTP Interface:声明式调用,像 Feign 一样的体验
5.1 HTTP Interface 的核心机制
HTTP Interface 是 Spring 6 引入的声明式 HTTP 客户端。你定义一个 Java 接口,加上@HttpExchange注解,Spring 会自动生成代理实现,把方法调用翻译成 HTTP 请求。这个思路和 OpenFeign 几乎一样,但它是 Spring 原生的,不需要额外引入 Feign 依赖。
它的核心机制是动态代理 + 注解解析。启动时,HttpServiceProxyFactory扫描接口上的注解,为每个方法生成请求模板;调用时,代理拦截方法调用,根据模板组装请求,交给底层客户端(RestClient 或 WebClient)执行,再把响应反序列化成方法返回值。
这个设计的价值在于:把 HTTP 细节从业务代码里彻底剥离。业务层只看到接口方法,不关心 URL、请求头、序列化,测试时也容易 mock。
5.2 定义接口与绑定底层客户端
@HttpExchange("/users") public interface UserApi { @GetExchange("/{id}") User getUser(@PathVariable Long id); @PostExchange User createUser(@RequestBody User user); @GetExchange List<User> listUsers(@RequestParam("page") int page, @RequestParam("size") int size); }接口定义好之后,需要创建代理实例并绑定底层客户端:
@Configuration public class HttpInterfaceConfig { @Bean public UserApi userApi(RestClient.Builder builder) { RestClient restClient = builder .baseUrl("https://api.example.com") .build(); HttpServiceProxyFactory factory = HttpServiceProxyFactory .builderFor(RestClientAdapter.create(restClient)) .build(); return factory.createClient(UserApi.class); } }这里的关键是RestClientAdapter.create(restClient),它把 RestClient 适配成 HTTP Interface 的底层引擎。如果底层想用 WebClient,换成WebClientAdapter.create(webClient)即可,接口定义完全不用改。这种"接口与实现分离"的设计,让切换底层客户端变得非常轻量。
5.3 注解体系与参数绑定规则
HTTP Interface 的注解分两层:接口级用@HttpExchange定义公共路径前缀,方法级用@GetExchange、@PostExchange、@PutExchange、@DeleteExchange、@PatchExchange定义具体操作。
参数绑定规则需要记牢:
@PathVariable:绑定路径变量,对应 URI 模板里的{id}@RequestParam:绑定查询参数@RequestBody:绑定请求体@RequestHeader:绑定请求头- 无注解的简单类型参数:默认按查询参数处理(这点和 Spring MVC 不同,容易踩坑)
最后一条我要特别提醒:在 Spring MVC 的 Controller 里,无注解参数默认是查询参数,但在 HTTP Interface 里行为一致,不过如果参数是复杂对象且无注解,会被当成请求体。这个规则我一开始没搞清楚,写了个getUser(UserQuery query)结果请求体被塞了对象,GET 请求带 body 直接报错。GET 请求的参数一定要显式加@RequestParam或@PathVariable。
5.4 与 OpenFeign 的取舍
很多团队已经在用 OpenFeign,会问要不要换成 HTTP Interface。我的看法是:如果现有 Feign 用得好,没必要为了"原生"而迁移。Feign 生态成熟,和 Spring Cloud 集成度高,服务发现、负载均衡、熔断降级都有现成方案。
HTTP Interface 的优势在于轻量、无额外依赖、和 Spring 6 的客户端体系一致。适合的场景是:项目没有引入 Spring Cloud、只是简单调几个外部接口、或者想减少依赖数量。如果项目已经在 Spring Cloud 体系里,Feign 依然是更省心的选择。
6. 实操踩坑实录:这些问题我替你踩过了
6.1 常见问题速查表
| 问题现象 | 根因 | 解决方案 |
|---|---|---|
| RestClient 请求 400,URL 里有特殊字符 | 字符串拼接未编码 | 改用uri(template, vars)模板变量 |
| WebClient 请求不发出 | 忘记订阅 Mono | 确保返回值被订阅或 block |
| HTTP Interface 启动报找不到代理 | 未配置HttpServiceProxyFactory | 补上工厂 Bean |
| 超时后线程不释放 | 未配置读超时 | 显式设置setReadTimeout |
| 响应中文乱码 | 未指定字符集 | 设置Accept头和转换器字符集 |
| 大文件 OOM | 同步客户端全量读入内存 | 改用 WebClient 流式处理 |
6.2 三个我印象最深的坑
第一个坑:RestClient 的默认超时是无限。我在测试环境跑得好好的,上了预发环境,下游某个接口偶发卡顿,结果调用方线程全部挂起,线程池十分钟就满了。排查时才发现 RestClient 默认没有读超时。这个坑的教训是:任何 HTTP 客户端上生产前,必须显式配置连接超时和读超时,没有例外。
第二个坑:WebClient 在同步方法里 block 导致死锁。有个同学在@Async方法里用 WebClient 然后 block,而@Async的线程池和 Reactor 的事件循环线程有交叉,结果互相等待。后来改成在响应式链路里全程不 block,用then、flatMap编排,问题消失。响应式的链路要么全响应式,要么在边界处一次性 block,中间不能混。
第三个坑:HTTP Interface 的路径拼接。接口级@HttpExchange("/users")加方法级@GetExchange("/{id}"),最终路径是/users/{id},这个没问题。但如果接口级写成@HttpExchange("/users/")带尾斜杠,方法级又带前斜杠,会拼出/users//{id}双斜杠,某些网关会直接 404。路径拼接时统一不带尾斜杠,方法级带前斜杠,这个约定要团队统一。
6.3 性能与稳定性上的几条经验
第一,连接池一定要配。RestClient 和 RestTemplate 底层如果用 JDK HttpClient,默认没有连接池,每次请求新建连接,高并发下 TIME_WAIT 堆积严重。换成 Apache HttpClient 5 并配置PoolingHttpClientConnectionManager,最大连接数和每路由连接数按下游 QPS 估算,一般最大连接数 = 下游峰值 QPS × 平均响应时间(秒) × 1.5。
第二,重试要谨慎。GET 请求可以重试,POST 请求重试可能导致重复下单。如果要用重试,确保接口幂等,或者用幂等键。Spring 6 的RestClient本身不带重试,需要自己包一层或者用 Resilience4j。
第三,日志要能定位问题。请求日志至少记录:目标 URL、方法、状态码、耗时、追踪 ID。追踪 ID 通过拦截器透传,这样一次调用链路在日志里能串起来。我见过太多团队只记 URL 不记耗时,出问题时根本不知道慢在哪。
7. 我的最终选型建议与迁移路线
7.1 新项目的默认组合
如果让我从零搭一个新项目,我的默认组合是:同步调用用 RestClient,声明式接口用 HTTP Interface 绑定 RestClient,需要并行的局部用 WebClient。这个组合覆盖了 95% 的场景,依赖干净,学习曲线平缓。
具体落地时,我会在配置类里统一构建一个RestClientBean,配好 baseUrl、超时、拦截器、连接池,然后所有 Service 注入它。声明式接口则单独建一个api包,每个下游服务一个接口,通过HttpServiceProxyFactory统一创建代理。
7.2 老项目的渐进迁移路线
老项目不要一刀切。我的路线是三步走:第一步,先把 RestTemplate 的超时和连接池补齐,这是止血,成本最低收益最高;第二步,新写的 Service 一律用 RestClient,形成示范;第三步,等某个模块因为业务需求要大改时,顺手把该模块的 RestTemplate 迁到 RestClient,用前面那张对照表逐方法替换,配合集成测试验证。
这个路线的好处是风险分散,每一步都可回滚。我实际执行下来,一个中等规模的项目,三个月内迁移了 60% 的调用点,没有出过一次线上事故。
7.3 团队协作上的两个约定
最后分享两个团队层面的约定,比技术选型更重要。第一,统一封装。不要让每个开发自己 new RestClient,统一在一个配置类里构建,超时、拦截器、错误处理全部标准化,避免各写各的。第二,统一异常体系。远程调用的异常要转换成团队统一的业务异常,带上下游服务名和错误码,这样上层处理逻辑一致,排查时也能快速定位是哪个下游出的问题。
这两个约定看起来是"管理问题",但实际决定了这套客户端体系能不能长期稳定运行。技术选型只是起点,工程规范才是终点。我在多个项目里验证过,凡是这两点做到位的团队,远程调用的故障率明显更低,排查效率也高得多。