Spring Boot 3.2 RestClient 实战:替代 RestTemplate 调外部接口,超时、连接池与错误处理

Spring Boot 3.2 RestClient 实战:替代 RestTemplate 调外部接口,超时、连接池与错误处理

调外部接口这件事,RestTemplate 用了十年,但它的 API 冗长、异常处理反人类,而 WebClient 又强制你引入 WebFlux 一整套响应式栈,同步场景下水土不服。Spring 6.1 / Spring Boot 3.2 给了第三个选择:RestClient------同步、链式、基于和 WebClient 一样的底层,却不用你写一行响应式代码。这篇手把手把它配到生产可用。

先看 RestTemplate 的痛在哪

一个「带超时、带错误处理」的 GET 请求,RestTemplate 大概长这样:

java 复制代码
// RestTemplate:配置和调用是割裂的
RestTemplate rt = new RestTemplate();
try {
    ResponseEntity<User> resp = rt.getForEntity(
        "https://api.example.com/users/{id}", User.class, 42);
    User user = resp.getBody();
} catch (HttpClientErrorException e) {
    // 4xx 在这里
} catch (HttpServerErrorException e) {
    // 5xx 在这里,还得分开两个 catch
}

超时配置要单独在构造 RestTemplate 时通过 ClientHttpRequestFactory 设,和调用点分离;错误又拆成 HttpClientErrorException / HttpServerErrorException 两个异常。写多了很啰嗦。

RestClient 的基础用法

RestClient 是链式的,读起来像一句话:

java 复制代码
RestClient client = RestClient.create();

User user = client.get()
        .uri("https://api.example.com/users/{id}", 42)
        .retrieve()                 // 触发请求
        .body(User.class);          // 反序列化响应体

retrieve() 默认对 4xx/5xx 抛 RestClientResponseException。如果你想拿到完整响应(状态码、header),用 toEntity:

java 复制代码
ResponseEntity<User> entity = client.get()
        .uri("https://api.example.com/users/{id}", 42)
        .retrieve()
        .toEntity(User.class);

int status = entity.getStatusCode().value();
User body = entity.getBody();

关键:配一个能上生产的 RestClient Bean

裸 RestClient.create() 用的是 JDK 默认的 HttpURLConnection,没有连接池、没有合理超时,高并发下会不断新建连接、慢接口会把线程拖死。生产环境务必换成 Apache HttpClient 5 并显式配连接池和超时。

先加依赖(Spring Boot 已管版本,不用写版本号):

xml 复制代码
<dependency>
    <groupId>org.apache.httpcomponents.client5</groupId>
    <artifactId>httpclient5</artifactId>
</dependency>

然后把它做成一个 Bean 复用------别在每次调用时新建 RestClient,那样连接池根本复用不起来:

java 复制代码
@Configuration
public class RestClientConfig {

    @Bean
    public RestClient externalApiClient() {
        // 连接池:控制总连接数和每个路由(目标主机)的连接数
        PoolingHttpClientConnectionManager cm =
                PoolingHttpClientConnectionManagerBuilder.create()
                        .setMaxConnTotal(100)      // 池子总上限
                        .setMaxConnPerRoute(20)     // 单个目标主机上限
                        .build();

        // 超时:连接超时 + 响应超时,两个都要设
        RequestConfig requestConfig = RequestConfig.custom()
                .setConnectionRequestTimeout(Timeout.ofSeconds(2)) // 从池里拿连接的等待上限
                .setResponseTimeout(Timeout.ofSeconds(5))          // 等响应的上限
                .build();

        CloseableHttpClient httpClient = HttpClients.custom()
                .setConnectionManager(cm)
                .setDefaultRequestConfig(requestConfig)
                .build();

        var factory = new HttpComponentsClientHttpRequestFactory(httpClient);

        return RestClient.builder()
                .baseUrl("https://api.example.com")  // 统一前缀,调用处只写路径
                .requestFactory(factory)
                .defaultHeader("User-Agent", "my-service/1.0")
                .build();
    }
}

三个超时各管一段,缺一不可:connectionRequestTimeout 是「从连接池里借连接」的等待上限(池满时保护你不被无限阻塞),connect(可在 factory 上设)是 TCP 握手上限,responseTimeout 是「发出请求后等对方回数据」的上限。只设一个都会在某种慢故障下裸奔。

错误处理:onStatus 精准兜住失败

retrieve() 默认把 4xx/5xx 都抛异常,但异常里的 body 常常被吞掉。用 onStatus 可以针对状态码段自定义处理,把对方返回的错误信息读出来:

java 复制代码
User user = client.get()
        .uri("/users/{id}", id)
        .retrieve()
        .onStatus(HttpStatusCode::is4xxClientError, (req, resp) -> {
            // 读出对方返回的错误 body,别让它白白丢掉
            String errBody = new String(resp.getBody().readAllBytes(),
                                        StandardCharsets.UTF_8);
            if (resp.getStatusCode().value() == 404) {
                throw new UserNotFoundException(id);
            }
            throw new ExternalApiException("客户端错误: " + errBody);
        })
        .onStatus(HttpStatusCode::is5xxServerError, (req, resp) -> {
            // 5xx 通常可重试,抛一个可识别的异常给上层做退避重试
            throw new ExternalApiRetryableException(
                    "对方服务异常: " + resp.getStatusCode());
        })
        .body(User.class);

把「客户端错误」和「可重试的服务端错误」分成不同异常类型,上层就能针对性决定是直接失败还是退避重试,而不是笼统 catch 一个 RestClientException。

POST 与传对象

发 JSON 只需 body(对象),Jackson 自动序列化,contentType 也会自动带上:

java 复制代码
CreateOrderResponse resp = client.post()
        .uri("/orders")
        .contentType(MediaType.APPLICATION_JSON)
        .body(new CreateOrderRequest("SKU-123", 2))
        .retrieve()
        .body(CreateOrderResponse.class);

一个容易忽略的坑:baseUrl 拼接与前导斜杠

设了 baseUrl("https://api.example.com/api/v1") 后,调用处的 uri 写法会影响最终地址:

java 复制代码
// baseUrl = https://api.example.com/api/v1
.uri("/users")    // → https://api.example.com/users   ❌ 前导斜杠会覆盖 baseUrl 的路径部分
.uri("users")     // → https://api.example.com/api/v1/users  ✅

/users 这种带前导斜杠的绝对路径会把 baseUrl 里的 /api/v1 覆盖掉,这是 URI 解析规范的行为,不是 bug。用了带路径的 baseUrl 时,拼接段不要加前导斜杠。

小结

  • RestClient(Spring Boot 3.2+)是同步 HTTP 调用的新首选:链式 API、不依赖 WebFlux,底层与 WebClient 同源。
  • 生产环境必须 换 Apache HttpClient 5 并配连接池,把 RestClient 做成 Bean 复用 ;裸 create() 无池无超时,高并发会崩。
  • 三个超时各管一段------借连接、TCP 握手、等响应,一个都不能少。
  • 用 onStatus 把 4xx/5xx 分类成不同业务异常,并读出对方 error body,别让失败信息被吞。
  • baseUrl 带路径时,拼接段别加前导斜杠,否则会覆盖掉基础路径。
  • 记忆点:RestClient 好写,但真正决定它能不能扛生产的,是那个带连接池和超时的 Bean。
相关推荐
不停喝水5 小时前
【前端转全栈java速通课】 项目实战④7-11节 操作数据库-登录-注册-修改密码-注销-mubatis-plus简化crud-
java·前端·数据库
y1su5 小时前
Leetcode 二分模板
java·数据结构·算法·leetcode·排序算法
~光~~5 小时前
【嵌入式linux学习】Cache 策略:Write-Through / Write-Back + Allocation
linux·学习·spring
霸道流氓气质6 小时前
多Agent通信机制与协议设计完全指南:从FIPA-ACL到A2A/MCP的Java生产级实战
java·开发语言
007张三丰6 小时前
C/C++ 内存管理详解:从内存分布到 new/delete 底层原理
java·c语言·c++·内存管理
骇客野人6 小时前
Java BIO / NIO / AIO 完整详解 + 编程技巧
java·开发语言·nio
霸道流氓气质6 小时前
OpenTelemetry 入门与实战:Java Agent、Spring Boot Starter与LLM调用追踪示例
java·开发语言·spring boot
m0_587383006 小时前
24 小时自助健身房系统软件开发实战指南与案例解析
java·spring boot·spring·系统架构·需求分析
栖凤6 小时前
Java 的 try-catch 在 Agent 里失效了,我用了 5 个模式才兜住
java·前端·python