Spring WebClient 从入门到精通
第一阶段:入门与基础概念
欢迎开始 WebClient 的学习之旅!第一阶段的目标是让你快速上手,理解 WebClient 的核心思想,并能用最简单的方式发送 HTTP 请求。我们将会从零开始,一步步搭建环境、编写代码,并解释背后的概念。
1.1 WebClient 概述
1.1.1 什么是 WebClient?
WebClient 是 Spring 5 引入的非阻塞、响应式的 HTTP 客户端,属于 Spring WebFlux 模块。它可以用来发送 HTTP 请求(GET、POST、PUT、DELETE 等)并处理响应。
在它之前,Spring 生态中主力的 HTTP 客户端是 RestTemplate 。但 RestTemplate 存在一些不足:
- 阻塞式:每个请求会独占一个线程,直到响应返回。高并发下线程数量激增,导致内存和 CPU 开销大。
- 功能较原始:错误处理、重试、超时等需要开发者自己编写。
- 官方已标记为维护状态:Spring 团队推荐在新项目中使用 WebClient。
1.1.2 阻塞 vs 非阻塞
- 阻塞(Blocking):当你调用一个方法去执行耗时操作(如 HTTP 请求),当前线程会一直等待,直到操作完成。这段时间线程不能做任何其他事。
- 非阻塞(Non-blocking) :当你发起耗时操作时,线程立即返回,不会被挂起。当操作真正完成时,会通过回调或事件通知的方式告知结果。这样,同一个线程可以同时管理多个 I/O 操作,极大地提高了资源利用率。
1.1.3 响应式编程的"两个新朋友":Mono 和 Flux
WebClient 基于 Reactor 库,它引入了两个核心类型:
Mono<T>:代表一个异步的、可能为空的 单一值。类似于Optional或Future,但拥有丰富的操作符(如map、flatMap、onErrorResume等)。Flux<T>:代表一个异步的、可以包含 0 到 N 个元素 的序列。类似于List或Stream的异步版本。
你目前不需要深入了解它们的全部操作,只需要知道:
- 当 WebClient 期望获得一个对象(如
Post)时,它会返回Mono<Post>。 - 当期望获得一个对象列表时,它会返回
Flux<Post>。
1.1.4 WebClient 也能"同步"操作吗?
很多初学者会问:WebClient 既然是响应式的,是不是只能异步?能不能像 RestTemplate 那样同步阻塞地获取结果?
答案是:当然可以!
虽然 WebClient 底层是非阻塞 的,但它提供了 .block() 方法,允许你将响应式流(Mono/Flux)转换回同步阻塞模式 。调用 .block() 后,当前线程会暂停等待,直到 HTTP 响应返回或超时。这使得你可以在传统的 Spring MVC 项目、定时任务或单元测试中,像使用 RestTemplate 一样简单地使用 WebClient,同时享受到它更丰富的功能(如过滤器、重试、连接池等)。
1.2 环境搭建
1.2.1 创建 Spring Boot 项目(或打开已有项目)
确保你的项目使用 Spring Boot 2.x 或 3.x。然后在 pom.xml 中添加依赖:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
即使你的项目目前是 Spring MVC(依赖
spring-boot-starter-web),也可以同时引入webflux,两者可以共存。
1.2.2 创建 WebClient 实例
有两种方式创建 WebClient:
方式一:简单创建(使用默认配置)
java
WebClient webClient = WebClient.create("https://jsonplaceholder.typicode.com");
方式二:使用 Builder 定制(推荐)
java
WebClient webClient = WebClient.builder()
.baseUrl("https://jsonplaceholder.typicode.com")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build();
解释:
baseUrl:设置基础 URL,后续请求可以使用相对路径。defaultHeader:添加默认的请求头,每次请求都会带上。
在实际项目中,通常会将 WebClient 配置为一个 Spring Bean,方便在 Service 中注入使用:
java
@Configuration
public class WebClientConfig {
@Bean
public WebClient webClient() {
return WebClient.builder()
.baseUrl("https://jsonplaceholder.typicode.com")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build();
}
}
1.3 第一个 GET 请求(同步方式)
我们先以同步阻塞的方式使用 WebClient,因为这种方式最贴近传统编程习惯,也最容易理解。后续再逐步探索异步。
1.3.1 定义 POJO 类(用于接收响应数据)
假设我们要调用的是 JSONPlaceholder 的 /posts/1 接口,返回数据格式如下:
json
{
"userId": 1,
"id": 1,
"title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
"body": "quia et suscipit..."
}
我们在项目中创建对应的 Java 类:
java
public class Post {
private Integer userId;
private Integer id;
private String title;
private String body;
// 必须提供无参构造器(用于反序列化)
public Post() {}
// 可选:全参构造器、getter/setter、toString
// 使用 Lombok 可以简化:@Data @NoArgsConstructor
}
1.3.2 编写 Service 发送请求
创建一个 Service 类,注入 WebClient,编写一个方法获取帖子:
java
@Service
public class PostService {
private final WebClient webClient;
// 构造器注入
public PostService(WebClient webClient) {
this.webClient = webClient;
}
public Post getPostById(Integer id) {
return webClient.get()
.uri("/posts/{id}", id) // 设置请求路径,{id}会被替换
.retrieve() // 获取响应体(简单方式)
.bodyToMono(Post.class) // 将响应体转为 Mono<Post>
.block(); // 阻塞等待结果返回
}
}
关键点解释:
webClient.get():创建一个 GET 请求的构建器。.uri("/posts/{id}", id):指定请求的 URI,可以使用占位符{id},后面传入实际参数。.retrieve():直接获取响应体(如果只需要状态码和 body,这是最简单的方式)。如果想要更细粒度的控制(如访问响应头),可以用.exchangeToMono()。.bodyToMono(Post.class):将响应体 JSON 反序列化为Post对象,包装成Mono<Post>。.block():阻塞当前线程 ,直到Mono发出数据或错误。在同步场景中,我们最终需要真正的Post对象,所以调用block()。
1.3.3 在 Controller 中调用
java
@RestController
public class PostController {
private final PostService postService;
public PostController(PostService postService) {
this.postService = postService;
}
@GetMapping("/post/{id}")
public Post getPost(@PathVariable Integer id) {
return postService.getPostById(id);
}
}
启动应用,访问 http://localhost:8080/post/1,应该能看到返回的 JSON 数据。
1.3.4 简单的异常处理
上面的代码如果遇到网络错误、4xx/5xx 响应,会抛出异常。我们可以添加 try-catch 来处理:
java
public Post getPostById(Integer id) {
try {
return webClient.get()
.uri("/posts/{id}", id)
.retrieve()
.bodyToMono(Post.class)
.block();
} catch (Exception e) {
// 记录日志,根据业务返回默认值或抛出业务异常
log.error("调用失败,id: {}", id, e);
return null; // 或者抛出自定义异常
}
}
注意 :这里的异常可能包括 WebClientResponseException(HTTP 4xx/5xx)、ReactiveTimeoutException(超时)等。更精细的错误处理会在后续阶段学习。
第二阶段:深入请求与响应处理
欢迎进入第二阶段!在第一阶段我们学会了使用 WebClient 发送同步 GET 请求。现在,我们将全面掌握 WebClient 的各种请求方式、参数传递、灵活的响应处理,以及文件上传下载等实用技能。学完这一阶段,你将能够应对绝大多数第三方 API 调用的需求。
2.1 不同 HTTP 方法
WebClient 支持所有常见的 HTTP 方法,通过相应的方法名即可创建请求构建器:.get()、.post()、.put()、.delete()、.patch() 等。
2.1.1 GET 请求(回顾)
java
Mono<Post> mono = webClient.get()
.uri("/posts/{id}", 1)
.retrieve()
.bodyToMono(Post.class);
2.1.2 POST 请求------发送 JSON 数据
java
// 创建要发送的对象
Post newPost = new Post(null, 1, "新标题", "新内容");
Mono<Post> createdPostMono = webClient.post()
.uri("/posts")
.bodyValue(newPost) // 直接传入对象,自动序列化为 JSON
.retrieve()
.bodyToMono(Post.class);
// 同步获取结果
Post createdPost = createdPostMono.block();
说明:
.bodyValue(Object)是最简单的发送方式,适用于将对象序列化为 JSON。- 如果需要更精细的控制(如自定义 Content-Type),可以使用
.contentType(MediaType.APPLICATION_JSON)。
2.1.3 PUT 请求------更新资源
java
Post updatedPost = new Post(1, 1, "更新标题", "更新内容");
Mono<Post> result = webClient.put()
.uri("/posts/{id}", 1)
.bodyValue(updatedPost)
.retrieve()
.bodyToMono(Post.class)
.block();
2.1.4 DELETE 请求
java
Mono<Void> voidMono = webClient.delete()
.uri("/posts/{id}", 1)
.retrieve()
.bodyToMono(Void.class); // DELETE 通常无响应体,用 Void 表示
voidMono.block(); // 阻塞直到完成(可能只是收到响应)
2.2 请求参数与头信息
2.2.1 URI 模板变量(路径参数)
我们已经在 GET 中使用过:
java
.uri("/posts/{id}", id) // 按顺序替换
.uri("/users/{userId}/posts/{postId}", userId, postId) // 多个参数
也可以使用 Map 命名参数:
java
Map<String, Object> params = new HashMap<>();
params.put("userId", 1);
params.put("postId", 2);
.uri("/users/{userId}/posts/{postId}", params)
2.2.2 查询参数(Query Parameters)
有两种方式添加查询参数:
方式一:直接在 URI 字符串中拼接
java
.uri("/posts?userId=1&_sort=id&_order=desc")
方式二:使用 UriBuilder(推荐,更灵活)
java
.uri(uriBuilder -> uriBuilder
.path("/posts")
.queryParam("userId", 1)
.queryParam("_sort", "id")
.queryParam("_order", "desc")
.build())
2.2.3 动态添加请求头
java
Mono<Post> result = webClient.get()
.uri("/posts/1")
.header("X-Custom-Header", "my-value")
.header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.retrieve()
.bodyToMono(Post.class);
2.2.4 全局默认 Header
在构建 WebClient 时设置:
java
WebClient webClient = WebClient.builder()
.baseUrl("...")
.defaultHeader(HttpHeaders.USER_AGENT, "MyApp/1.0")
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer token") // 静态 token,动态可用过滤器
.build();
如果需要动态添加 Header(如每次请求携带不同的 JWT),可以使用 ExchangeFilterFunction(将在第五阶段学习)。
2.3 处理响应
2.3.1 获取完整响应(包括状态码和头)
使用 .toEntity() 或 .toEntityList() 可以得到 ResponseEntity 包装的对象,包含状态码、响应头和响应体。
java
Mono<ResponseEntity<Post>> entityMono = webClient.get()
.uri("/posts/1")
.retrieve()
.toEntity(Post.class);
ResponseEntity<Post> entity = entityMono.block();
HttpStatus status = entity.getStatusCode();
HttpHeaders headers = entity.getHeaders();
Post body = entity.getBody();
对于列表:
java
Mono<ResponseEntity<List<Post>>> listEntityMono = webClient.get()
.uri("/posts")
.retrieve()
.toEntityList(Post.class);
2.3.2 处理泛型类型(如 List<Post>)
如果直接使用 .bodyToMono(List.class),由于 Java 泛型擦除,无法正确反序列化列表中的元素类型。必须使用 ParameterizedTypeReference:
java
Flux<Post> postFlux = webClient.get()
.uri("/posts")
.retrieve()
.bodyToFlux(Post.class); // 直接获取 Flux<Post>,适用于流式处理
// 如果需要 List<Post>,可以收集
Mono<List<Post>> listMono = webClient.get()
.uri("/posts")
.retrieve()
.bodyToFlux(Post.class)
.collectList();
// 或者使用 ParameterizedTypeReference(一次性获得 Mono<List<Post>>)
Mono<List<Post>> listMono2 = webClient.get()
.uri("/posts")
.retrieve()
.bodyToMono(new ParameterizedTypeReference<List<Post>>() {});
2.3.3 获取原始响应数据(String、byte\[\])
有时我们需要直接拿到原始响应内容(例如非 JSON 接口):
java
// 获取 String
Mono<String> stringMono = webClient.get()
.uri("/posts/1")
.retrieve()
.bodyToMono(String.class);
// 获取字节数组
Mono<byte[]> byteMono = webClient.get()
.uri("/posts/1")
.retrieve()
.bodyToMono(byte[].class);
2.3.4 获取响应头但不关心 body
如果只需要状态码和头,可以使用 .toBodilessEntity():
java
Mono<ResponseEntity<Void>> entity = webClient.head()
.uri("/posts/1")
.retrieve()
.toBodilessEntity();
2.4 文件上传与下载
2.4.1 上传文件(multipart/form-data)
使用 MultipartBodyBuilder 构建 multipart 请求体:
java
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.http.codec.multipart.FilePart;
MultipartBodyBuilder builder = new MultipartBodyBuilder();
builder.part("file", new FileSystemResource("/path/to/file.txt"));
builder.part("description", "a text file");
Mono<String> response = webClient.post()
.uri("/upload")
.contentType(MediaType.MULTIPART_FORM_DATA)
.bodyValue(builder.build()) // 直接传入 MultipartBodyBuilder 构建的 map
.retrieve()
.bodyToMono(String.class);
如果使用 FilePart(Spring WebFlux 中的文件部件),可以:
java
FilePart filePart = ...; // 从请求中获取
builder.part("file", filePart);
2.4.2 下载文件
下载文件时,通常希望将响应体作为 Resource 处理,然后写入本地文件。
java
Mono<Resource> resourceMono = webClient.get()
.uri("/files/sample.pdf")
.retrieve()
.bodyToMono(Resource.class);
// 阻塞方式保存
Resource resource = resourceMono.block();
try (InputStream is = resource.getInputStream();
FileOutputStream os = new FileOutputStream("downloaded.pdf")) {
IOUtils.copy(is, os); // 需要 commons-io 或手动复制
}
或者使用 .exchangeToMono() 获取 ClientResponse 并手动处理流(适合大文件流式写入):
java
webClient.get()
.uri("/files/sample.pdf")
.exchangeToMono(response -> {
if (response.statusCode().is2xxSuccessful()) {
return response.bodyToMono(Resource.class);
} else {
return response.createError();
}
})
.block(); // 后续保存同上
第三阶段:异步与响应式编程
本阶段我们将深入理解异步非阻塞 编程模型,学习如何用 Mono/Flux 的各种操作符组合异步调用,并掌握在 Spring MVC 中实现异步处理的方法。学完本阶段,你将能够编写出高性能、非阻塞的 HTTP 客户端代码。
3.0 核心概念:同步阻塞 vs 异步非阻塞
在编程中,同步(Synchronous) 和 异步(Asynchronous) 描述了调用方与被调用方之间的交互方式,而阻塞(Blocking) 和 非阻塞(Non-blocking) 则描述了调用方在等待结果期间的行为。两者常常关联,但概念上有所不同。
3.0.1 同步阻塞
定义 :
调用方发起一个操作后,必须一直等待该操作完成,才能继续执行后面的代码。在等待期间,调用方(通常是线程)被挂起,无法做其他任何事情。
生活中的类比 :
你打电话给客服,然后拿着话筒一直等对方处理,直到对方给出答复你才挂断。期间你无法做其他事。
WebClient 中的体现 :
使用 .block() 方法,当前线程会阻塞直到 HTTP 响应返回或超时。
java
// 同步阻塞方式
Post post = webClient.get()
.uri("/posts/1")
.retrieve()
.bodyToMono(Post.class)
.block(); // <- 当前线程在这里阻塞,直到拿到结果
System.out.println(post); // 只有拿到结果后才能执行
特点:
- 代码简单直观,像传统方法调用一样。
- 每个请求占用一个线程,高并发下线程数激增,内存和上下文切换开销大。
- 适合低并发、定时任务、
传统 MVC控制器等场景。
3.0.2 异步非阻塞
定义 :
调用方发起一个操作后,立即返回 ,可以继续执行后续代码。当操作完成时,调用方通过回调、事件、Future 或响应式流等方式得到通知并获取结果。
生活中的类比 :
你发一条短信询问问题,然后继续做自己的事。当对方回复时,手机会通知你,你再查看内容。
WebClient 中的体现 :
使用 .subscribe() 注册回调,或直接返回 Mono/Flux 让框架处理。
java
// 异步非阻塞方式
Mono<Post> postMono = webClient.get()
.uri("/posts/1")
.retrieve()
.bodyToMono(Post.class);
postMono.subscribe(
post -> System.out.println("收到帖子:" + post), // onNext
error -> System.err.println("出错了:" + error) // onError
);
System.out.println("这条语句会立即执行,不会等待 HTTP 响应!");
特点:
- 发起请求的线程立即释放,可以处理其他任务。
- 结果通过回调函数在未来的某个时间点被处理。
- 线程利用率高,少量线程即可处理大量并发请求。
- 适合高并发 I/O 密集型应用、响应式架构(Spring WebFlux)。
3.0.3 如何接收数据?
同步阻塞方式接收数据
直接通过方法返回值获得,例如:
block()返回TblockFirst()返回第一个元素blockLast()返回最后一个元素
java
Post post = mono.block(); // 拿到 Post 对象
List<Post> list = flux.collectList().block(); // 拿到 List<Post>
异步非阻塞方式接收数据
通过以下几种机制:
-
回调 :使用
subscribe注册Consumer,当数据到达时自动调用。javamono.subscribe(post -> System.out.println(post)); -
返回
Mono/Flux给框架 :在 Spring WebFlux 控制器中,直接返回Mono<Post>,框架会异步地将结果写回响应。java@GetMapping("/post/{id}") public Mono<Post> getPost(@PathVariable Integer id) { return webClient.get().uri("/posts/{id}", id).retrieve().bodyToMono(Post.class); } -
转换为
CompletableFuture:使用.toFuture(),然后通过thenApply、thenAccept等组合。javaCompletableFuture<Post> future = mono.toFuture(); future.thenAccept(post -> System.out.println(post)); -
响应式操作符 :如
map、flatMap、zip等,在操作符链中处理数据,最终通过订阅触发。
对比总结
| 特性 | 同步阻塞 | 异步非阻塞 |
|---|---|---|
| 等待方式 | 线程阻塞等待 | 线程立即返回,结果通过回调通知 |
| 数据获取 | 直接通过方法返回值 | 通过回调、Future 或响应式流 |
| 线程占用 | 每个请求独占一个线程 | 少量线程处理大量请求 |
| 代码复杂度 | 简单直观 | 相对复杂,需理解回调/响应式 |
| 适用场景 | 低并发、定时任务、传统 MVC | 高并发、I/O 密集型、WebFlux |
在 WebClient 中,你可以根据场景自由选择:
- 在传统 MVC 控制器或定时任务中,使用
.block()同步阻塞。 - 在高性能要求或 WebFlux 环境中,使用异步方式。
理解这两个概念,是正确使用 WebClient 和设计高并发系统的关键。
3.1 响应式类型核心操作
3.1.1 回顾:Mono 和 Flux 是什么?
Mono<T>:代表一个将来会完成的 异步任务,最终产生 0 或 1 个类型为 T 的值。Flux<T>:代表一个异步序列,可以产生 0 到 N 个类型为 T 的值。
它们的共同点是:都是惰性 的,只有在被订阅 (subscribe())后才会开始执行。.block() 其实是一种特殊的订阅,它会一直等待直到结果到达。
3.1.2 深入理解 subscribe()
之前我们可能对 subscribe() 感到困惑,现在仔细看看它的作用:
java
Mono<Post> postMono = webClient.get().uri("/posts/1").retrieve().bodyToMono(Post.class);
// 订阅,定义收到数据后做什么
postMono.subscribe(
post -> System.out.println("收到帖子:" + post), // onNext
error -> System.err.println("出错了:" + error), // onError
() -> System.out.println("完成") // onComplete (Mono也有)
);
System.out.println("这行代码会在订阅后立即执行,不会等待结果");
执行流程:
- 调用
subscribe后,WebClient 开始发起真正的 HTTP 请求(因为有人订阅了)。 - 当前线程不会阻塞 ,继续执行后面的
System.out.println。 - 当 HTTP 响应返回后,Reactor 会调度一个线程执行你提供的回调(
post -> ...)。
这就是非阻塞的精髓:发起请求的线程可以继续做其他事,等结果回来再处理。
3.1.3 常用的转换操作符
map:同步转换数据
java
Mono<Post> postMono = webClient.get().uri("/posts/1").retrieve().bodyToMono(Post.class);
Mono<String> titleMono = postMono.map(post -> post.getTitle()); // 将 Post 转为 String
map 中的函数是同步执行的(在数据到达的线程上执行),不应在里面做耗时操作。
flatMap:异步转换(返回一个新的 Mono/Flux)
java
Mono<User> userMono = postMono.flatMap(post ->
webClient.get().uri("/users/{id}", post.getUserId()).retrieve().bodyToMono(User.class)
);
flatMap 用于将一个异步任务的结果作为输入,发起另一个异步任务,并扁平化 返回的 Mono/Flux。它可以实现异步任务的链式调用。
问:flatMap 是什么意思?
flatMap 用于将一个异步操作的结果作为输入,发起另一个异步操作,并将内部 Mono/Flux 的结果"拍平"到外层流。
- 例如,先获取帖子,再用帖子里的 userId 获取用户,最终得到
Mono<User>,而不是Mono<Mono<User>>。它是链式异步调用的关键操作符。
问:flatMap 阻塞线程也可以使用吗?
绝对不能! flatMap 内部的代码运行在 event loop 线程上,一旦阻塞(如调用 Thread.sleep() 或 block()),会挂起该线程,导致整个 reactor 无法处理其他请求。如果需要执行阻塞操作(如 JDBC 查询),必须使用 publishOn 切换到专门的线程池,例如 Schedulers.boundedElastic()。
组合多个异步调用
假设我们需要同时获取帖子详情和该帖子的作者信息,然后合并返回:
java
Mono<Post> postMono = webClient.get().uri("/posts/1").retrieve().bodyToMono(Post.class);
Mono<User> userMono = webClient.get().uri("/users/1").retrieve().bodyToMono(User.class);
// 使用 zip 等待两个 Mono 都完成,然后合并
Mono<Tuple2<Post, User>> zipped = Mono.zip(postMono, userMono);
Mono<String> result = zipped.map(tuple -> {
Post post = tuple.getT1();
User user = tuple.getT2();
return post.getTitle() + " by " + user.getName();
});
zip 会等待所有参与的 Mono 完成,然后将结果组合成一个 TupleN 对象。
如果处理的是 Flux(如获取帖子列表),可以使用 merge 或 concat 等操作符,但初级阶段先掌握 Mono 组合即可。
3.2 异步调用实践
3.2.1 在 Spring WebFlux 控制器中直接返回 Mono/Flux
如果你正在使用 Spring WebFlux(响应式栈),控制器可以直接返回 Mono<T> 或 Flux<T>,框架会自动进行非阻塞处理,无需调用 .block()。
java
@RestController
public class PostController {
private final WebClient webClient;
public PostController(WebClient webClient) {
this.webClient = webClient;
}
@GetMapping("/posts/{id}")
public Mono<Post> getPost(@PathVariable Integer id) {
return webClient.get()
.uri("/posts/{id}", id)
.retrieve()
.bodyToMono(Post.class);
}
@GetMapping("/posts")
public Flux<Post> getPosts() {
return webClient.get()
.uri("/posts")
.retrieve()
.bodyToFlux(Post.class);
}
}
在这种情况下,整个请求处理链路都是非阻塞的,从接收请求到调用外部 API 再到响应客户端,没有线程会被阻塞。
3.2.2 在 Spring MVC 中如何实现异步处理?
你的项目可能仍然是 Spring MVC(基于 Servlet),但依然可以利用 WebClient 的异步能力,通过返回 DeferredResult 或 CompletableFuture 来释放 Tomcat 线程。
方式一:返回 DeferredResult
java
@GetMapping("/post/{id}")
public DeferredResult<Post> getPost(@PathVariable Integer id) {
DeferredResult<Post> deferredResult = new DeferredResult<>(5000L); // 5秒超时
webClient.get()
.uri("/posts/{id}", id)
.retrieve()
.bodyToMono(Post.class)
.subscribe(
post -> deferredResult.setResult(post), // 成功时设置结果
error -> deferredResult.setErrorResult(error) // 失败时设置错误
);
return deferredResult; // 立即返回,Tomcat 线程释放
}
方式二:返回 CompletableFuture(更简洁)
Spring MVC 自动支持将 CompletableFuture 作为异步返回值。我们可以利用 Mono 的 .toFuture() 方法轻松转换:
java
@GetMapping("/post/{id}")
public CompletableFuture<Post> getPost(@PathVariable Integer id) {
return webClient.get()
.uri("/posts/{id}", id)
.retrieve()
.bodyToMono(Post.class)
.toFuture(); // 将 Mono 转为 CompletableFuture
}
这种方式代码更少,并且可以利用 CompletableFuture 丰富的组合能力(如 thenCombine、allOf 等)。
3.2.3 注意事项
- 在异步回调(如
subscribe的 lambda)中,如果需要执行数据库操作等阻塞任务,建议使用publishOn切换到专门的线程池,避免阻塞 Netty 的 event loop 线程。 - 对于
CompletableFuture方式,Spring 内部会管理超时,但也可以在Mono链上使用.timeout()提前控制。
3.3 将 Mono 转为 CompletableFuture
.toFuture() 是 Mono 提供的一个方法,返回一个 CompletableFuture<T>。这使得我们可以在不引入 Reactor API 的地方使用标准的 Java 异步编程。
java
CompletableFuture<Post> future = webClient.get()
.uri("/posts/1")
.retrieve()
.bodyToMono(Post.class)
.toFuture();
// 可以使用 thenApply, exceptionally 等
future.thenApply(Post::getTitle)
.thenAccept(System.out::println)
.exceptionally(e -> {
System.err.println("Error: " + e);
return null;
});
注意 :CompletableFuture 默认使用 ForkJoinPool.commonPool() 执行回调,如果需要在特定线程池执行,可以使用 thenApplyAsync 并传入自定义 Executor。
第四阶段:错误处理与容错
在实际开发中,调用第三方 API 总会遇到各种意外:网络抖动、服务不可用、超时、返回错误状态码......如果不对这些情况进行妥善处理,你的应用就会变得脆弱。
本阶段将系统学习 WebClient 的错误处理机制,包括错误码处理 、降级恢复 、重试 和超时控制。
4.1 错误处理基础:onStatus 处理 HTTP 错误码
4.1.1 为什么需要 onStatus?
默认情况下,WebClient 对 4xx 和 5xx 响应会抛出 WebClientResponseException 或其子类。但很多时候,我们需要根据不同的状态码执行不同的逻辑,比如:
- 404 时返回一个默认值,而不是抛出异常。
- 500 时记录告警并重试。
- 其他客户端错误时记录日志并抛出业务异常。
.onStatus() 正是为此而生,它允许你针对特定的 HTTP 状态码(或状态码范围)自定义处理方式。
4.1.2 基本用法
java
Mono<Post> result = webClient.get()
.uri("/posts/{id}", 999) // 假设这个 id 不存在,返回 404
.retrieve()
.onStatus(HttpStatus::is4xxClientError, response -> {
// 可以读取错误响应体
return response.bodyToMono(String.class)
.flatMap(errorBody -> {
System.err.println("客户端错误:" + errorBody);
return Mono.error(new RuntimeException("自定义异常:" + errorBody));
});
})
.onStatus(HttpStatus::is5xxServerError, response -> {
return response.bodyToMono(String.class)
.flatMap(errorBody -> {
System.err.println("服务端错误:" + errorBody);
return Mono.error(new RuntimeException("服务端异常,请稍后重试"));
});
})
.bodyToMono(Post.class)
.block();
解释:
onStatus的第一个参数是一个Predicate<HttpStatus>,用于判断哪些状态码需要处理。- 第二个参数是一个
Function<ClientResponse, Mono<? extends Throwable>>,你可以返回一个Mono,表示要抛出的异常。如果返回Mono.empty(),则异常不会传播(但流可能结束,需配合其他操作符)。 - 实际上,你可以在
onStatus中直接返回一个Mono.error(...),也可以先读取响应体再决定如何包装。
4.1.3 更精细的状态码匹配
如果你只想处理特定的状态码(如 404),可以:
java
.onStatus(status -> status == HttpStatus.NOT_FOUND, response ->
response.bodyToMono(String.class).flatMap(body -> {
log.warn("资源不存在:{}", body);
return Mono.empty(); // 返回 empty 表示不抛出异常,后续可用 switchIfEmpty 提供默认值
}))
但注意:返回 Mono.empty() 会导致流中没有任何数据,后续必须用 .switchIfEmpty() 提供备选,否则订阅者会收到 onComplete 而没有数据。
4.1.4 结合统一响应格式
如果你已经有一个全局的 R 统一响应类,可以利用它来解析错误:
java
.onStatus(HttpStatus::isError, response ->
response.bodyToMono(R.class).flatMap(r -> {
// r 可能包含错误码和消息
return Mono.error(new BusinessException(r.getCode(), r.getMessage()));
}))
4.2 降级与恢复
4.2.1 onErrorResume:遇到错误时切换到另一个流
当发生任何异常(包括网络错误、超时、onStatus 抛出的异常)时,onErrorResume 可以捕获并返回一个备用的 Publisher,让流继续往下走。
java
Mono<Post> fallback = webClient.get()
.uri("/posts/fallback") // 备用的 API 或本地缓存
.retrieve()
.bodyToMono(Post.class)
.onErrorReturn(new Post(0, "默认标题", "默认内容")); // 也可以直接返回默认值
Mono<Post> result = webClient.get()
.uri("/posts/999")
.retrieve()
.bodyToMono(Post.class)
.onErrorResume(e -> {
log.error("调用失败,切换到降级数据", e);
return fallback; // 返回备用 Mono
});
4.2.2 onErrorReturn:直接返回一个默认值
如果降级逻辑只是返回一个固定的默认对象,可以用 onErrorReturn:
java
Mono<Post> result = webClient.get()
.uri("/posts/999")
.retrieve()
.bodyToMono(Post.class)
.onErrorReturn(new Post(0, "默认标题", "默认内容"));
4.2.3 onErrorMap:转换异常类型
有时你需要将底层异常转换为自定义的业务异常,以便上层统一处理:
java
Mono<Post> result = webClient.get()
.uri("/posts/999")
.retrieve()
.bodyToMono(Post.class)
.onErrorMap(WebClientResponseException.NotFound.class,
e -> new ResourceNotFoundException("帖子不存在"))
.onErrorMap(TimeoutException.class,
e -> new ServiceUnavailableException("服务超时"));
4.3 重试机制
4.3.1 简单重试:retry()
retry(long) 可以在发生错误时重新订阅,即重新发起请求。它会一直重试直到成功或达到最大次数。
java
Mono<Post> result = webClient.get()
.uri("/posts/1")
.retrieve()
.bodyToMono(Post.class)
.retry(3); // 出错时重试最多 3 次
缺点:没有退避策略,可能会在服务恢复前加重负载。
4.3.2 高级重试:retryWhen() + Retry
Reactor 提供了 Retry 类,支持退避、抖动、基于异常类型的过滤等。
java
import reactor.util.retry.Retry;
Mono<Post> result = webClient.get()
.uri("/posts/1")
.retrieve()
.bodyToMono(Post.class)
.retryWhen(Retry.fixedDelay(3, Duration.ofSeconds(2)) // 最多重试3次,每次间隔2秒
.filter(throwable -> throwable instanceof TimeoutException) // 只对超时重试
.onRetryExhaustedThrow((retryBackoffSpec, retrySignal) ->
new RuntimeException("重试耗尽")));
退避策略选项:
Retry.fixedDelay(maxAttempts, delay):固定延迟。Retry.backoff(maxAttempts, minBackoff):指数退避,可配置最大退避时间和抖动。- 自定义:使用
Retry.max(maxAttempts)配合.doBeforeRetry()等。
4.3.3 结合断路器模式
虽然 WebClient 本身没有内置断路器,但可以配合 Resilience4j 或 Spring Cloud Circuit Breaker 使用。在重试耗尽后抛出异常,再由断路器接管。
4.4 超时控制
WebClient 的超时可以在两个层面设置:
- 连接超时、响应超时 :通过底层
HttpClient配置。 - 操作超时 :通过 Reactor 的
timeout()操作符。
4.4.1 通过 HttpClient 配置超时
java
import io.netty.channel.ChannelOption;
import reactor.netty.http.client.HttpClient;
import java.time.Duration;
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) // 连接超时 5 秒
.responseTimeout(Duration.ofSeconds(3)) // 响应超时 3 秒
.doOnConnected(conn ->
conn.addHandlerLast(new ReadTimeoutHandler(5)) // 读超时 5 秒
.addHandlerLast(new WriteTimeoutHandler(5))); // 写超时 5 秒
WebClient webClient = WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
注意:
option(ChannelOption.CONNECT_TIMEOUT_MILLIS)是 Netty 的连接超时。responseTimeout是等待响应的总超时(从请求发出到第一个数据包到达)。ReadTimeoutHandler和WriteTimeoutHandler是 Netty 的 handler,分别控制读/写空闲超时。
4.4.2 通过 timeout() 操作符设置整体超时
这个超时是从订阅开始到发出数据或错误的时限,可以覆盖整个操作(包括重试等)。
java
Mono<Post> result = webClient.get()
.uri("/posts/1")
.retrieve()
.bodyToMono(Post.class)
.timeout(Duration.ofSeconds(5)) // 5 秒内未完成则抛出 TimeoutException
.onErrorResume(TimeoutException.class, e -> Mono.just(fallbackPost));
优点 :粒度细,可以在不同的阶段设置不同的超时。
缺点 :需要手动处理 TimeoutException。
第五阶段:高级特性与性能调优
本阶段我们将深入探讨 WebClient 的高级特性,包括过滤器 、连接池管理 、测试技巧 以及性能优化。这些技能将帮助你在生产环境中构建可靠、高效的 HTTP 客户端。
5.1 过滤器(ExchangeFilterFunction)
5.1.1 什么是过滤器?
ExchangeFilterFunction 是 WebClient 提供的一个拦截器接口,允许你在请求发出前和响应返回后执行自定义逻辑。它类似于 Servlet 中的 Filter,可以用于:
- 日志记录:记录请求 URL、方法、耗时等。
- 添加动态请求头:如每次请求携带不同的 JWT Token。
- 监控与统计:记录请求次数、成功率等。
- 修改请求或响应:例如对请求体加密、对响应体解密。
5.1.2 实现一个简单的日志过滤器
你可以通过实现 ExchangeFilterFunction 接口或直接使用 lambda 来创建过滤器。过滤器接收 ClientRequest 和下一个 ExchangeFunction,返回 Mono<ClientResponse>。
java
import org.springframework.web.reactive.function.client.ClientRequest;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import reactor.core.publisher.Mono;
public class LoggingFilter implements ExchangeFilterFunction {
@Override
public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
// 请求前:记录请求信息
System.out.println("Request: " + request.method() + " " + request.url());
long start = System.currentTimeMillis();
// 继续执行下一个过滤器(或实际的 HTTP 调用)
return next.exchange(request)
.doOnNext(response -> {
// 响应后:记录耗时和状态码
long duration = System.currentTimeMillis() - start;
System.out.println("Response: " + response.statusCode() + " in " + duration + "ms");
});
}
}
5.1.3 注册过滤器
在构建 WebClient 时,通过 .filter() 方法添加过滤器:
java
WebClient webClient = WebClient.builder()
.baseUrl("http://localhost:8080")
.filter(new LoggingFilter())
.build();
如果有多个过滤器,它们会按添加顺序执行。
5.1.4 使用 lambda 简化
java
WebClient webClient = WebClient.builder()
.filter((request, next) -> {
System.out.println("Request: " + request.method() + " " + request.url());
return next.exchange(request);
})
.build();
5.1.5 实战:动态添加认证头
假设你的 Token 会定期刷新,需要在每次请求时从某个地方获取最新的 Token。过滤器可以实现这个需求:
java
public class AuthFilter implements ExchangeFilterFunction {
private final TokenProvider tokenProvider;
public AuthFilter(TokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
@Override
public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
// 获取最新 token(可能异步)
return Mono.fromCallable(() -> tokenProvider.getToken())
.map(token -> ClientRequest.from(request)
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.build())
.flatMap(next::exchange);
}
}
注意:这里使用了 Mono.fromCallable 将同步的 getToken() 包装为 Mono,避免阻塞。
5.1.6 过滤器的最佳实践
- 不要阻塞 :过滤器内部应避免阻塞操作,如果需要执行阻塞代码,使用
publishOn切换到合适线程池。 - 异常处理 :可以在过滤器中捕获异常并记录,但通常异常处理应该由业务层的
onErrorResume完成。 - 性能影响:过滤器会为每个请求执行,因此应保持轻量。
5.2 连接池与资源管理
WebClient 底层默认使用 Reactor Netty 作为 HTTP 客户端,它内置了高性能的连接池。合理配置连接池可以显著提升并发性能。
5.2.1 为什么需要连接池?
- 减少连接建立开销:TCP 握手、TLS 协商耗时。
- 控制资源使用:限制最大连接数,避免耗尽系统资源。
- 提高响应速度:复用连接减少延迟。
5.2.2 配置 HttpClient 连接池
通过 HttpClient 来配置连接池参数:
java
import reactor.netty.http.client.HttpClient;
import reactor.netty.resources.ConnectionProvider;
import java.time.Duration;
// 自定义连接池配置
ConnectionProvider provider = ConnectionProvider.builder("my-pool")
.maxConnections(100) // 最大连接数
.pendingAcquireMaxCount(50) // 等待队列最大长度
.pendingAcquireTimeout(Duration.ofSeconds(30)) // 获取连接超时
.maxIdleTime(Duration.ofSeconds(20)) // 连接最大空闲时间
.build();
HttpClient httpClient = HttpClient.create(provider)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
.responseTimeout(Duration.ofSeconds(5));
WebClient webClient = WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
参数说明:
maxConnections:连接池最大连接数,默认通常为 CPU 核数 × 2 或更大。pendingAcquireMaxCount:当连接池用尽时,最多允许多少个请求排队等待,超过则抛出异常。pendingAcquireTimeout:排队等待获取连接的最大时间。maxIdleTime:连接空闲多久后被关闭释放。
5.2.3 配置代理
如果你的服务需要经过代理服务器访问外部 API:
java
HttpClient httpClient = HttpClient.create()
.proxy(proxy -> proxy.type(ProxyProvider.Proxy.HTTP)
.host("proxy.example.com")
.port(8080)
.username("user") // 如果需要认证
.password(pass -> "pass"));
5.2.4 SSL/TLS 配置
跳过证书验证(仅用于测试!)
java
import javax.net.ssl.SSLException;
import reactor.netty.tcp.SslProvider;
import io.netty.handler.ssl.SslContextBuilder;
import io.netty.handler.ssl.util.InsecureTrustManagerFactory;
SslContext sslContext = SslContextBuilder.forClient()
.trustManager(InsecureTrustManagerFactory.INSTANCE)
.build();
HttpClient httpClient = HttpClient.create()
.secure(sslSpec -> sslSpec.sslContext(sslContext));
加载自定义证书
将证书文件放入 classpath,然后加载:
java
SslContext sslContext = SslContextBuilder.forClient()
.trustManager(new File("path/to/truststore.jks"))
.build();
5.3 测试 WebClient 代码
测试 WebClient 的最佳实践是使用 MockWebServer(来自 OkHttp 的模拟服务器库)。它可以启动一个真正的 HTTP 服务器,让你精确控制响应,验证请求。
5.3.1 添加依赖
xml
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>mockwebserver</artifactId>
<version>4.11.0</version>
<scope>test</scope>
</dependency>
5.3.2 编写单元测试
下面测试一个带有重试逻辑的 WebClient 调用:
java
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.test.StepVerifier;
import java.io.IOException;
class WebClientTest {
private MockWebServer mockWebServer;
private WebClient webClient;
@BeforeEach
void setUp() throws IOException {
mockWebServer = new MockWebServer();
mockWebServer.start();
webClient = WebClient.builder()
.baseUrl(mockWebServer.url("/").toString())
.build();
}
@AfterEach
void tearDown() throws IOException {
mockWebServer.shutdown();
}
@Test
void testRetryOnServerError() {
// 模拟第一次返回 500,第二次返回 200
mockWebServer.enqueue(new MockResponse().setResponseCode(500));
mockWebServer.enqueue(new MockResponse()
.setResponseCode(200)
.setBody("{\"id\":1,\"title\":\"Success\"}")
.addHeader("Content-Type", "application/json"));
Mono<Post> result = webClient.get()
.uri("/posts/1")
.retrieve()
.bodyToMono(Post.class)
.retry(1); // 重试一次
StepVerifier.create(result)
.expectNextMatches(post -> post.getId() == 1 && "Success".equals(post.getTitle()))
.verifyComplete();
// 验证请求确实发生了两次
assertEquals(2, mockWebServer.getRequestCount());
}
}
5.3.3 测试超时和降级
java
@Test
void testTimeoutFallback() {
// 模拟延迟 10 秒的响应
mockWebServer.enqueue(new MockResponse().setBodyDelay(10, TimeUnit.SECONDS));
Mono<Post> result = webClient.get()
.uri("/posts/1")
.retrieve()
.bodyToMono(Post.class)
.timeout(Duration.ofSeconds(2))
.onErrorReturn(new Post(0, "Fallback", ""));
StepVerifier.create(result)
.expectNextMatches(post -> post.getId() == 0)
.verifyComplete();
}
5.3.4 测试请求头、参数
你可以通过 mockWebServer.takeRequest() 获取实际收到的请求,验证头信息、URL 等:
java
@Test
void testHeaders() {
mockWebServer.enqueue(new MockResponse().setResponseCode(200));
webClient.get()
.uri("/test")
.header("X-Custom", "value")
.retrieve()
.bodyToMono(Void.class)
.block();
RecordedRequest request = mockWebServer.takeRequest();
assertEquals("value", request.getHeader("X-Custom"));
}
5.4 性能对比与监控(可选)
5.4.1 WebClient vs RestTemplate
- WebClient(非阻塞):少量线程处理大量并发请求,适合 I/O 密集型场景。
- RestTemplate(阻塞):每个请求占用一个线程,高并发下线程数激增,内存和 CPU 开销大。
可以通过简单的 JMH 基准测试或在相同线程池下压测来对比性能差异。
5.4.2 集成 Micrometer 监控
Micrometer 是 Spring Boot 默认的监控门面,可以收集 WebClient 的指标(请求数、耗时、错误率)。
添加依赖:
xml
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-core</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
在 WebClient 中启用 Micrometer 观察:
java
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
@Bean
public WebClient webClient(MeterRegistry meterRegistry) {
return WebClient.builder()
.filter(ExchangeFilterFunction.ofMetricsProcessor(meterRegistry, "my.webclient"))
.build();
}
然后可以在 Prometheus 中看到类似 my_webclient_requests_seconds_count 的指标。