Note 21. 弹性外部调用:RestClient 与 Spring Retry 重试机制

Note 21. 弹性外部调用:RestClient 与 Spring Retry 重试机制

摘要: 本章我们将通过 RestClient 掌握 Spring Boot 3.2+ 推荐的现代 HTTP 调用方式,取代过时的 RestTemplate。我们将深入探讨如何处理 HTTP 状态码,如何将响应体反序列化为对象。更重要的是,我们将引入 Spring Retry,为脆弱的网络调用穿上“防弹衣”,实现基于策略(指数退避)的自动重试与降级处理。

本章学习路径

  1. 工具革新:从 RestTemplate 迁移到流式 API 的 RestClient
  2. 调用实战:配置超时时间,发起 GET/POST 请求,处理异常状态码。
  3. 弹性重试:引入 Spring Retry,区分“可重试”与“不可重试”异常。
  4. 兜底策略:使用 @Recover 实现最终失败后的降级逻辑。

21.1. 时代的接力棒:从 RestTemplate 到 RestClient

21.1.1. 为什么要换工具?

  • RestTemplate:经典的模板方法模式(Template Method),API 设计于 15 年前,此时已进入 维护模式(不再添加新功能)。它的代码显得比较臃肿,重载方法过多。
  • WebClient:基于 Reactive 响应式流。虽然功能强大,但它引入了 Netty 和 Reactor 依赖。如果在传统的 Servlet(Tomcat)项目中使用,会显得“过重”且风格不搭。
  • RestClient (Spring Boot 3.2+)最佳选择。它提供了类似 WebClient 的 流式 (Fluent) API,但底层依然基于同步的 Servlet 栈。它轻量、现代、易读。

21.1.2. 风格对比

场景:根据 ID 查询用户。

1
2
3
4
5
6
7
8
9
RestTemplate template = new RestTemplate();
String url = "https://api.example.com/users/{id}";

try {
// 必须手动指定类,异常处理依赖 try-catch
UserDTO user = template.getForObject(url, UserDTO.class, 1);
} catch (HttpClientErrorException e) {
// 4xx 错误处理
}
1
2
3
4
5
6
7
8
9
10
RestClient client = RestClient.create();

UserDTO user = client.get()
.uri("https://api.example.com/users/{id}", 1)
.retrieve()
// 优雅的链式状态码处理
.onStatus(HttpStatusCode::is4xxClientError, (req, resp) -> {
throw new BusinessException("参数错误");
})
.body(UserDTO.class);

21.2. [实战] 集成 RestClient

21.2.1. 全局配置

虽然 RestClient.create() 可以直接用,但最佳实践是配置一个带 超时时间Base URL 的全局 Bean。

文件路径: src/main/java/com/example/demo/config/HttpClientConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package com.example.demo.config;

import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;

import java.time.Duration;

@Configuration
public class HttpClientConfig {

/**
* 配置一个全局的 RestClient
* 底层可以使用 HttpComponents (Apache) 或 SimpleClient (JDK)
* Spring Boot 默认会自动检测并适配
*/
@Bean
public RestClient restClient(RestClient.Builder builder) {
// RestClient.Builder 是 Spring Boot 自动配置好的,预装了消息转换器
return builder
.baseUrl("https://jsonplaceholder.typicode.com") // 设置基础 URL
.defaultHeader("User-Agent", "Spring-Boot-Demo/1.0") // 设置默认 Header
// 设置超时 (利用 requestFactory)
// 注意:更精细的超时控制建议引入 Apache HttpClient 依赖
.build();
}
}

21.2.2. 编写调用服务

我们来模拟调用一个外部 API(JSONPlaceholder)。

DTO 定义:
src/main/java/com/example/demo/dto/PostDTO.java

1
2
3
4
5
6
7
@Data
public class PostDTO {
private Long id;
private Long userId;
private String title;
private String body;
}

Service 实现:
src/main/java/com/example/demo/service/RemoteService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Service
@Slf4j
public class RemoteService {

@Autowired
private RestClient restClient;

public PostDTO getPostById(Long id) {
log.info("开始调用外部 API 查询文章: {}", id);
return restClient.get()
.uri("/posts/{id}", id)
.retrieve()
.body(PostDTO.class);
}
}

21.3. 弹性设计:Spring Retry 自动重试

网络调用是脆弱的。对方服务可能正在重启(503),或者网络抖动(Timeout)。这时候,重试 往往能解决问题。

但是,并不是所有错误都应该重试

  • 404 Not Found:重试 100 次也是 404,不应重试。
  • 503 Service Unavailable:稍后重试可能成功,应该重试。

21.3.1. 引入依赖与开启

Spring Retry 是独立模块。

文件路径: pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
<dependencies>
<!-- 重试核心库 -->
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
<!-- 重试依赖 AOP -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
</dependencies>

开启注解:
在启动类或配置类加 @EnableRetry

1
2
3
@EnableRetry
@SpringBootApplication
public class DemoApplication { ... }

21.3.2. 改造 Service:精准重试

我们使用 @Retryable 注解来赋予方法重试能力。

文件路径: src/main/java/com/example/demo/service/RemoteService.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.web.client.HttpServerErrorException;
import java.io.IOException;

@Service
@Slf4j
public class RemoteService {

@Autowired
private RestClient restClient;

/**
* @Retryable 配置详解:
* value: 指定哪些异常需要重试 (如 网络IO异常, 5xx服务器错误)
* maxAttempts: 最大尝试次数 (含第一次,默认为 3)
* backoff: 退避策略 (delay=2000, multiplier=2 -> 等2秒,等4秒...)
*/
@Retryable(
retryFor = {IOException.class, HttpServerErrorException.class},
noRetryFor = {HttpClientErrorException.class}, // 4xx 不重试
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2)
)
public PostDTO getPostById(Long id) {
log.info("尝试调用外部 API..."); // 这行日志会打印多次
return restClient.get()
.uri("/posts/{id}", id)
.retrieve()
// 碰到 5xx 抛出 HttpServerErrorException (触发重试)
// 碰到 4xx 抛出 HttpClientErrorException (不重试)
.toEntity(PostDTO.class)
.getBody();
}

/**
* @Recover: 兜底方法 (Fallback)
* 当重试 3 次依然失败时,执行此方法。
* 要求:返回值类型必须与原方法一致,第一个参数是异常类型。
*/
@Recover
public PostDTO recover(Exception e, Long id) {
log.error("重试多次失败,执行兜底逻辑。ID: {}, 异常: {}", id, e.getMessage());
// 返回一个空对象,或者查询本地缓存
PostDTO fallback = new PostDTO();
fallback.setId(id);
fallback.setTitle("服务暂时不可用 (降级数据)");
return fallback;
}
}

21.4. 验证测试:模拟故障

为了验证重试机制,我们需要一个“不稳定”的 API。我们可以利用 httpbin.org 或者手动抛出异常来模拟。

21.4.1. 编写测试 Case

我们在测试类中 Mock 一下 RestClient 的行为(或者简单点,直接让 Service 抛异常)。

这里为了演示方便,我们暂时修改 Service 代码,手动抛出异常:

1
2
3
4
5
// 临时修改代码用于测试
public PostDTO getPostById(Long id) {
log.info("调用 API...");
throw new HttpServerErrorException(HttpStatus.SERVICE_UNAVAILABLE); // 模拟 503
}

21.4.2. 观察日志

启动测试,调用该方法。

控制台输出

1
2
3
4
[INFO] 调用 API...  (第1次,时间 00:00:01)
[INFO] 调用 API... (第2次,时间 00:00:02 - 等待了1秒)
[INFO] 调用 API... (第3次,时间 00:00:04 - 等待了2秒)
[ERROR] 重试多次失败,执行兜底逻辑...

我们可以看到,指数退避 (Exponential Backoff) 生效了,重试次数也符合预期,最后成功走到了兜底逻辑,没有让异常直接崩坏前端页面。


21.5. 本章总结与弹性调用速查

摘要回顾
本章我们完成了外部调用的现代化升级。我们抛弃了陈旧的 RestTemplate,拥抱了语义清晰的 RestClient。更重要的是,我们通过 Spring Retry 为系统增加了韧性,学会了区分“瞬时故障”和“永久故障”,并配置了合理的重试策略和降级方案。

遇到以下 3 种外部调用场景时,请直接参考代码模版:

1. 场景一:简单的 GET 请求

需求:调用天气 API,带参数。
代码

1
2
3
4
WeatherDto data = restClient.get()
.uri("/weather?city={city}", "Beijing")
.retrieve()
.body(WeatherDto.class);

2. 场景二:POST 提交 JSON 并处理错误

需求:提交订单,如果 400 抛业务异常,其他抛系统异常。
代码

1
2
3
4
5
6
7
8
9
restClient.post()
.uri("/orders")
.contentType(MediaType.APPLICATION_JSON)
.body(orderDto)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (req, res) -> {
throw new BusinessException("参数错误");
})
.toBodilessEntity();

3. 场景三:高可用调用 (重试)

需求:调用短信网关,网络波动时自动重试。
代码

1
2
3
4
5
@Retryable(retryFor = IOException.class, maxAttempts = 3)
public void sendSms() { ... }

@Recover
public void saveToDb(IOException e) { ... } // 失败后存库,稍后人工重试

4. 核心避坑指南

  1. @Recover 方法不执行

    • 现象:重试耗尽后,直接抛出了异常,没进兜底方法。
    • 原因@Recover 方法的 返回值类型 必须和原方法一致(或是其子类)。如果原方法返回 PostDTO,兜底方法返回 void,Spring 找不到对应的方法。
    • 对策:检查返回值签名。
  2. 重试陷入死循环

    • 现象:一直重试,直到 StackOverflow 或超时。
    • 原因:把 BusinessException400 BadRequest 加入了重试列表。这些是逻辑错误,重试一万次也是错的。
    • 对策:只重试 网络异常 (IOException)服务端临时异常 (503/504)
  3. AOP 自调用失效 (老生常谈)

    • 现象:在 Service 内部 this.getPostById(),重试不生效。
    • 原因@Retryable 也是基于 AOP 代理。
    • 对策:从 Controller 调用,或注入 self