Spring WebClient 从入门到精通

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> :代表一个异步的、可能为空的 单一值。类似于 OptionalFuture,但拥有丰富的操作符(如 mapflatMaponErrorResume 等)。
  • Flux<T> :代表一个异步的、可以包含 0 到 N 个元素 的序列。类似于 ListStream 的异步版本。

你目前不需要深入了解它们的全部操作,只需要知道:

  • 当 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() 返回 T
  • blockFirst() 返回第一个元素
  • blockLast() 返回最后一个元素
java 复制代码
Post post = mono.block();          // 拿到 Post 对象
List<Post> list = flux.collectList().block(); // 拿到 List<Post>
异步非阻塞方式接收数据

通过以下几种机制:

  • 回调 :使用 subscribe 注册 Consumer,当数据到达时自动调用。

    java 复制代码
    mono.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(),然后通过 thenApplythenAccept 等组合。

    java 复制代码
    CompletableFuture<Post> future = mono.toFuture();
    future.thenAccept(post -> System.out.println(post));
  • 响应式操作符 :如 mapflatMapzip 等,在操作符链中处理数据,最终通过订阅触发。


对比总结
特性 同步阻塞 异步非阻塞
等待方式 线程阻塞等待 线程立即返回,结果通过回调通知
数据获取 直接通过方法返回值 通过回调、Future 或响应式流
线程占用 每个请求独占一个线程 少量线程处理大量请求
代码复杂度 简单直观 相对复杂,需理解回调/响应式
适用场景 低并发、定时任务、传统 MVC 高并发、I/O 密集型、WebFlux

在 WebClient 中,你可以根据场景自由选择:

  • 在传统 MVC 控制器或定时任务中,使用 .block() 同步阻塞。
  • 在高性能要求或 WebFlux 环境中,使用异步方式。

理解这两个概念,是正确使用 WebClient 和设计高并发系统的关键。

3.1 响应式类型核心操作

3.1.1 回顾:MonoFlux 是什么?
  • 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("这行代码会在订阅后立即执行,不会等待结果");

执行流程

  1. 调用 subscribe 后,WebClient 开始发起真正的 HTTP 请求(因为有人订阅了)。
  2. 当前线程不会阻塞 ,继续执行后面的 System.out.println
  3. 当 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(如获取帖子列表),可以使用 mergeconcat 等操作符,但初级阶段先掌握 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 的异步能力,通过返回 DeferredResultCompletableFuture 来释放 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 丰富的组合能力(如 thenCombineallOf 等)。

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 是等待响应的总超时(从请求发出到第一个数据包到达)。
  • ReadTimeoutHandlerWriteTimeoutHandler 是 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 的指标。


相关推荐
CodeStats1 小时前
从 CPU 指令到 JVM 进程:彻底讲透 Java 执行 main 方法时,类加载、主线程、栈帧入栈的完整底层逻辑
java·linux·开发语言
摇滚侠1 小时前
Spring 零基础入门到进阶 基于注解管理 Bean 38-43
xml·java·后端·spring·intellij-idea
SamDeepThinking1 小时前
我们当年是如何真实落地BFF的?
java·后端·架构
码语智行1 小时前
Shapefile获取空间数据和中心点坐标
java·arcgis
caoyc1 小时前
RAG 赛道全景扫描:ragflow 一骑绝尘、微软谷歌跟进乏力、下半场属于 Agent
java
Asmewill1 小时前
Centos系统docker时间同步方案
后端
屋外雨大,惊蛰出没2 小时前
深入浅出Spring Boot
java·spring boot·ioc·aop
用户8356290780512 小时前
使用 Python 操作 Word 评论和回复
后端·python