1. 概述

随着响应式编程和异步请求处理在各大框架中的普及,Spring 5 也顺应趋势推出了基于 WebFlux 的 WebClient 实现。

本教程将带你掌握如何使用 WebClient 响应式地调用 REST API 接口,重点在于如何构造包含路径参数和查询参数的请求。

2. REST API 接口定义

为了便于讲解,我们先定义一组示例接口:

  • /products – 获取所有产品
  • /products/{id} – 根据 ID 获取产品
  • /products/{id}/attributes/{attributeId} – 获取产品属性
  • /products/?name={name}&deliveryDate={deliveryDate}&color={color} – 按条件搜索产品
  • /products/?tag[]={tag1}&tag[]={tag2} – 按标签获取产品(数组形式)
  • /products/?category={category1}&category={category2} – 按分类获取产品(多值参数)

这些接口涵盖了常见的 URI 参数形式。接下来我们会用 WebClient 构造对应的请求。

⚠️ 注意:数组参数在 URL 中没有统一格式,具体格式取决于服务端实现,我们会在示例中覆盖主流写法。

3. WebClient 初始化

在使用 WebClient 前,需要先创建实例。为了演示请求构建过程,我们使用 Mockito 模拟底层调用逻辑:

exchangeFunction = mock(ExchangeFunction.class);
ClientResponse mockResponse = mock(ClientResponse.class);
when(mockResponse.bodyToMono(String.class))
  .thenReturn(Mono.just("test"));

when(exchangeFunction.exchange(argumentCaptor.capture()))
  .thenReturn(Mono.just(mockResponse));

webClient = WebClient
  .builder()
  .baseUrl("https://example.com/api")
  .exchangeFunction(exchangeFunction)
  .build();

为验证请求是否正确构造,我们定义一个辅助方法:

private void verifyCalledUrl(String relativeUrl) {
    ClientRequest request = argumentCaptor.getValue();
    assertEquals(String.format("%s%s", BASE_URL, relativeUrl), request.url().toString());
    
    verify(this.exchangeFunction).exchange(request);
    verifyNoMoreInteractions(this.exchangeFunction);
}

WebClient 的请求一般如下构造:

webClient.get()
  .uri(uriBuilder -> uriBuilder
    //... building a URI
    .build())
  .retrieve()
  .bodyToMono(String.class)
  .onErrorResume(e -> Mono.empty())
  .block();

我们会大量使用 UriBuilder 来构造 URI,也可以直接传入字符串 URI。

4. 路径参数(URI Path)

路径参数是 URL 中由 / 分隔的片段。我们从最简单的 /products 开始:

webClient.get()
  .uri("/products")
  .retrieve()
  .bodyToMono(String.class)
  .onErrorResume(e -> Mono.empty())
  .block();

verifyCalledUrl("/products");

对于 /products/{id} 这样的带变量路径,使用如下方式:

webClient.get()
  .uri(uriBuilder -> uriBuilder
    .path("/products/{id}")
    .build(2))
  .retrieve()
  .bodyToMono(String.class)
  .onErrorResume(e -> Mono.empty())
  .block();

verifyCalledUrl("/products/2");

多个路径参数同理,如 /products/{id}/attributes/{attributeId}

webClient.get()
  .uri(uriBuilder -> uriBuilder
    .path("/products/{id}/attributes/{attributeId}")
    .build(2, 13))
  .retrieve()
  .bodyToMono(String.class)
  .onErrorResume(e -> Mono.empty())
  .block();

verifyCalledUrl("/products/2/attributes/13");

注意顺序:传入 build() 的参数顺序必须与路径变量顺序一致。

5. 查询参数(Query Parameters)

查询参数通常以 key=value 形式出现在 URL 的 ? 后面。

5.1 单值参数

/products/?name={name}&deliveryDate={deliveryDate}&color={color} 为例:

webClient.get()
  .uri(uriBuilder -> uriBuilder
    .path("/products/")
    .queryParam("name", "AndroidPhone")
    .queryParam("color", "black")
    .queryParam("deliveryDate", "13/04/2019")
    .build())
  .retrieve()
  .bodyToMono(String.class)
  .onErrorResume(e -> Mono.empty())
  .block();

verifyCalledUrl("/products/?name=AndroidPhone&color=black&deliveryDate=13/04/2019");

也可以使用占位符:

webClient.get()
  .uri(uriBuilder -> uriBuilder
    .path("/products/")
    .queryParam("name", "{title}")
    .queryParam("color", "{authorId}")
    .queryParam("deliveryDate", "{date}")
    .build("AndroidPhone", "black", "13/04/2019"))
  .retrieve()
  .bodyToMono(String.class)
  .block();

verifyCalledUrl("/products/?name=AndroidPhone&color=black&deliveryDate=13%2F04%2F2019");

⚠️ 注意编码差异/ 被转义成了 %2F,这取决于编码模式设置。

5.2 数组参数

数组参数格式没有统一标准,常见的有以下几种:

5.2.1 使用 [] 表示数组

适用于 /products/?tag[]={tag1}&tag[]={tag2}

webClient.get()
  .uri(uriBuilder -> uriBuilder
    .path("/products/")
    .queryParam("tag[]", "Snapdragon", "NFC")
    .build())
  .retrieve()
  .bodyToMono(String.class)
  .onErrorResume(e -> Mono.empty())
  .block();

verifyCalledUrl("/products/?tag%5B%5D=Snapdragon&tag%5B%5D=NFC");

5.2.2 多个同名参数

适用于 /products/?category={category1}&category={category2}

webClient.get()
  .uri(uriBuilder -> uriBuilder
    .path("/products/")
    .queryParam("category", "Phones", "Tablets")
    .build())
  .retrieve()
  .bodyToMono(String.class)
  .onErrorResume(e -> Mono.empty())
  .block();

verifyCalledUrl("/products/?category=Phones&category=Tablets");

5.2.3 逗号分隔字符串

适用于逗号分隔格式:

webClient.get()
  .uri(uriBuilder -> uriBuilder
    .path("/products/")
    .queryParam("category", String.join(",", "Phones", "Tablets"))
    .build())
  .retrieve()
  .bodyToMono(String.class)
  .onErrorResume(e -> Mono.empty())
  .block();

verifyCalledUrl("/products/?category=Phones,Tablets");

灵活选择:根据服务端要求选择合适的数组参数格式。

6. URL 编码模式(Encoding Mode)

WebClient 提供了多种编码模式来控制 URL 的编码行为:

模式 说明
TEMPLATE_AND_VALUES 默认值,预编码模板,变量替换时也严格编码
VALUES_ONLY 不编码模板,仅编码变量
URI_COMPONENT 替换变量后对组件进行编码
NONE 完全不编码

我们可以通过如下方式修改编码模式:

DefaultUriBuilderFactory factory = new DefaultUriBuilderFactory(BASE_URL);
factory.setEncodingMode(DefaultUriBuilderFactory.EncodingMode.URI_COMPONENT);
webClient = WebClient
  .builder()
  .uriBuilderFactory(factory)
  .baseUrl(BASE_URL)
  .exchangeFunction(exchangeFunction)
  .build();

这样可以避免 / 被编码成 %2F,从而适应某些服务端的要求。

自定义实现:你也可以实现自己的 UriBuilderFactory 来完全控制 URL 构造和编码逻辑。

7. 总结

本文介绍了如何使用 WebClient 构造包含路径参数和查询参数的请求,涵盖:

  • 基本路径参数构造
  • 单值查询参数
  • 数组参数的多种写法([]、多键、逗号分隔)
  • URL 编码模式的配置

所有示例代码已上传至 GitHub,地址如下:

🔗 GitHub 示例代码

如果你在使用 WebClient 时遇到参数构造问题,不妨参考本文方法。踩坑不怕,关键是找到最合适的写法。


原始标题:WebClient Requests with Parameters