Appearance
Spring In Action 6th:开发响应式 API
1. 使用 Spring WebFlux
典型的 Servlet Web 框架(如 Spring MVC)是阻塞且多线程的,每个连接使用一个线程。在处理请求时,会从线程池中获取一个工作线程来处理请求。与此同时,请求线程会被阻塞,直到工作线程通知它处理完成。
因此,在大量请求的情况下,阻塞式 Web 框架无法有效扩展。慢速工作线程会加剧问题,因为工作线程需要更长时间才能返回到池中,准备处理另一个请求。在某些用例中,这种安排是完全可以接受的。事实上,这几乎是过去十多年来大多数 Web 应用程序的开发方式。但是,时代在变化。
这些 Web 应用程序的客户已经从偶尔浏览网站的人变成了经常消费内容并使用与 HTTP API 协作的应用程序的人。而且,如今的物联网(甚至没有人类参与)产生了汽车、喷气发动机等非传统客户端,它们不断地与 Web API 交换数据。随着越来越多的客户端消费 Web 应用程序,可伸缩性比以往任何时候都更加重要。
相比之下,异步 Web 框架通过更少的线程(通常每个 CPU 核心一个线程)实现了更高的可伸缩性。通过应用一种称为事件循环的技术(Event Looping),如图 1.1 所示,这些框架能够在每个线程上处理许多请求,使每个连接的成本更加经济。

在事件循环中,所有操作都被视为事件,包括请求以及来自数据库和网络等密集操作的回调。当需要进行昂贵的操作时,事件循环会注册一个回调函数来并行执行该操作,同时继续处理其他事件。
当操作完成时,它被事件循环视为一个事件,与请求一样处理。因此,异步 Web 框架能够在处理大量请求时更好地扩展,使用更少的线程,从而减少了线程管理的开销。
Spring 提供了一个基于 Project Reactor 的非阻塞、异步 Web 框架,以满足 Web 应用程序和 API 对更高可扩展性的需求。让我们来看一下 Spring WebFlux —— Spring 的响应式 Web 框架。
1.1. Spring WebFlux 介绍
当 Spring 团队考虑在 Web 层添加响应式编程模型时,很快就意识到在 Spring MVC 中进行这样的工作将会非常困难。这将涉及分支代码以决定是否以响应式方式处理请求。实质上,结果将是两个 Web 框架打包在一起,使用 if 语句来区分响应式和非响应式。
与其试图将响应式编程模型硬塞到 Spring MVC 中,Spring 团队决定创建一个单独的响应式 Web 框架,尽可能多地借鉴 Spring MVC。Spring WebFlux 就是这样产生的。图 1.2 展示了 Spring 中可用的完整 Web 开发堆栈。

在图 1.2 的左侧,你可以看到在 Spring Framework 2.5 版本中引入的 Spring MVC 栈。Spring MVC 位于 Java Servlet API 的顶部,它需要一个 Servlet 容器(如 Tomcat)来执行。
相比之下,Spring WebFlux(在右侧)并没有与 Servlet API 相关联,所以它建立在 Reactive HTTP API 的基础之上。并且,因为 Spring WebFlux 并没有与 Servlet API 耦合,所以它不需要一个 Servlet 容器来运行。相反,它可以运行在任何非阻塞的 Web 容器上,包括 Netty、Undertow、Tomcat、Jetty,或者任何 Servlet 3.1 或更高版本的容器。
图 1.2 中最值得注意的是左上角的框,它代表了 Spring MVC 和 Spring WebFlux 之间的公共组件,主要是用来定义控制器的注解。因为 Spring MVC 和 Spring WebFlux 共享相同的注解,所以在很多方面,Spring WebFlux 与 Spring MVC 几乎没有区别。
右上角的框代表了一种替代的编程模型,它使用函数式编程范式来定义控制器,而不是使用注解。我们将在定义函数式请求处理程序小节中更多地讨论 Spring 的函数式 Web 编程模型。
Spring MVC 和 Spring WebFlux 最重要的区别在于你需要添加哪个依赖项到你的构建中。当你使用 Spring WebFlux 时,你需要添加 Spring Boot WebFlux 启动器依赖,而不是标准的 Web 启动器(例如,spring-boot-starter-web)。在项目的 pom.xml 文件中,它看起来是这样的:
XML
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>1
2
3
4
2
3
4
Note:与大多数 Spring Boot 的 starter 依赖一样,这个 starter 也可以通过在 Initializr 中勾选 Reactive Web 复选框来添加到项目中。
使用 WebFlux 而不是 Spring MVC 的一个有趣的副作用是,WebFlux 的默认嵌入式服务器是 Netty 而不是 Tomcat。Netty 是少数几个异步、事件驱动的服务器之一,非常适合像 Spring WebFlux 这样的响应式 Web 框架。
除了使用不同的 starter 依赖之外,Spring WebFlux 控制器方法通常接受和返回响应式类型,比如 Mono 和 Flux,而不是领域类型和集合。Spring WebFlux 控制器还可以处理 RxJava 类型,比如 Observable、Single 和 Completable。
Tip:响应式 Spring MVC?
虽然 Spring WebFlux 控制器通常返回
Mono和Flux,但这并不意味着 Spring MVC 不能使用响应式类型。如果你愿意,Spring MVC 控制器方法也可以返回Mono或Flux。不同之处在于这些类型的使用方式。Spring WebFlux 是一个真正的响应式 Web 框架,允许请求在事件循环中处理,而 Spring MVC 是基于 Servlet 的,依赖多线程处理多个请求。
让我们通过重写 Taco Cloud 的一些 API 控制器来让 Spring WebFlux 发挥作用。
1.2. 编写响应式 Controller
作为提醒,我们回顾下之前编写的 TacoController 中的代码片段:
Java
@RestController
@RequestMapping(path="/api/tacos", produces="application/json")
@CrossOrigin(origins="*")
public class TacoController {
...
@GetMapping(params="recent")
public Iterable<Taco> recentTacos() {
PageRequest page = PageRequest.of(0, 12, Sort.by("createdAt").descending());
return tacoRepo.findAll(page).getContent();
}
...
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
如代码所示,recentTacos() 控制器处理 /api/tacos?recent 的 HTTP GET 请求,以返回最近创建的 taco 列表。更具体地说,它返回了一个 Taco 类型的可迭代对象。这主要是因为这是从仓库的 findAll() 方法返回的,或者更准确地说,是从 findAll() 返回的 Page 对象的 getContent() 方法返回的。
这样做是可以的,但 Iterable 不是一个响应式类型。你无法对其应用任何响应式操作,也不能让框架将其作为响应式类型来利用,以便在多个线程上拆分工作。你希望的是 recentTacos() 返回一个 Flux<Taco>。
这里有一个简单但有些局限的选择,那就是将 recentTacos() 重写,从返回 Iterable 改为 Flux。与此同时,你可以放弃分页代码,并将其替换为在 Flux 上调用 take(),如下所示:
Java
@GetMapping(params="recent")
public Flux<Taco> recentTacos() {
return Flux.fromIterable(tacoRepo.findAll()).take(12);
}1
2
3
4
2
3
4
使用 Flux.fromIterable(),你将 Iterable<Taco> 转换为 Flux<Taco>。现在你正在使用一个 Flux,你可以使用 take() 操作将返回的 Flux 限制为最多 12 个 Taco 对象。这不仅使代码更简洁,而且处理的是一个响应式的 Flux,而不是普通的 Iterable。
到目前为止,编写响应式代码已经是一个成功的举措。但如果仓库一开始就给你一个 Flux,这样你就不需要进行转换了,那将会更好。如果是这样的话,recentTacos() 可以这样写:
Java
@GetMapping(params="recent")
public Flux<Taco> recentTacos() {
return tacoRepo.findAll().take(12);
}1
2
3
4
2
3
4
这样更好!理想情况下,一个响应式控制器应该是一个端到端的响应式栈的顶端,包括控制器、仓库、数据库以及可能存在其中的任何服务。这样的端到端响应式栈如图 1.3 所示:

这样一个端到端的栈需要仓库返回 Flux 而不是 Iterable。我们将在下一章中探讨编写响应式仓库,但在这里提前看一下响应式 TacoRepository 的样子:
Java
public interface TacoRepository
extends ReactiveCrudRepository<Taco, Long> {
}1
2
3
2
3
然而,此时最重要的要注意的是,除了使用 Flux 而不是 Iterable,以及如何获取这个 Flux,定义响应式 WebFlux 控制器的编程模型与非响应式的 Spring MVC 控制器并无不同。两者都在类级别使用 @RestController 和高级别的 @RequestMapping 进行注解。而且都有使用 @GetMapping 在方法级别进行注解的请求处理函数。真正的区别在于处理程序方法返回的类型。
另一个重要的观察是,虽然你从仓库得到了一个 Flux<Taco>,但你可以在不调用 subscribe() 的情况下返回它。实际上,框架会为你调用 subscribe()。这意味着当处理 /api/tacos?recent 的请求时,recentTacos() 方法将被调用,并且在甚至还没有从数据库获取数据之前就已经返回了!
1.2.1. 返回单个值
举个例子,考虑一下我们之前在 TacoController 中 tacoById() 方法的代码:
Java
@GetMapping("/{id}")
public Taco tacoById(@PathVariable("id") Long id) {
Optional<Taco> optTaco = tacoRepo.findById(id);
if (optTaco.isPresent()) {
return optTaco.get();
}
return null;
}1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
在这里,这个方法处理 /tacos/{id} 的 GET 请求,并返回一个 Taco 对象。因为仓库的 findById() 返回一个 Optional,你还必须编写一些笨拙的代码来处理它。但假设一下,findById() 返回的是一个 Mono<Taco> 而不是 Optional<Taco>。在这种情况下,你可以将控制器的 tacoById() 重写为这样:
Java
@GetMapping("/{id}")
public Mono<Taco> tacoById(@PathVariable("id") Long id) {
return tacoRepo.findById(id);
}1
2
3
4
2
3
4
哇!这简单多了。然而,更重要的是,通过返回 Mono<Taco> 而不是 Taco,你使得 Spring WebFlux 能够以响应式的方式处理响应。因此,你的 API 在面对大量请求时将会有更好的扩展性。
1.2.2. 使用 RxJava 类型
值得指出的是,虽然在使用 Spring WebFlux 时,Reactor 类型如 Flux 和 Mono 是一个自然的选择,但你也可以选择使用 RxJava 类型如 Observable 和 Single。例如,假设在 TacoController 和后端仓库之间有一个服务,该服务使用 RxJava 类型进行处理。在这种情况下,你可以像这样编写 recentTacos() 方法:
Java
@GetMapping(params="recent")
public Observable<Taco> recentTacos() {
return tacoService.getRecentTacos();
}1
2
3
4
2
3
4
类似地,tacoById() 方法可以被写成处理 RxJava 的 Single,而不是 Mono,如下所示:
Java
@GetMapping("/{id}")
public Single<Taco> tacoById(@PathVariable("id") Long id) {
return tacoService.lookupTaco(id);
}1
2
3
4
2
3
4
此外,Spring WebFlux 控制器方法还可以返回 RxJava 的 Completable,它相当于 Reactor 的 Mono<Void>。WebFlux 还可以返回 RxJava 的 Flowable 作为 Observable 或 Reactor 的 Flux 的替代。
1.2.3. 处理响应式输入
到目前为止,我们只关注了控制器方法返回的响应式类型。但是在 Spring WebFlux 中,你也可以接受 Mono 或 Flux 作为处理程序方法的输入。为了演示这一点,考虑 TacoController 中 postTaco() 的原始实现,如下所示:
Java
@PostMapping(consumes="application/json")
@ResponseStatus(HttpStatus.CREATED)
public Taco postTaco(@RequestBody Taco taco) {
return tacoRepo.save(taco);
}1
2
3
4
5
2
3
4
5
在最初的实现中,postTaco() 不仅返回一个简单的 Taco 对象,而且还接受一个与请求体中的内容绑定的 Taco 对象。这意味着在请求负载完全解析并用于实例化 Taco 对象之前,postTaco() 无法被调用。这也意味着在调用仓库的 save() 方法返回之前,postTaco() 无法返回。简而言之,请求被阻塞了两次:当进入 postTaco() 时,以及在 postTaco() 内部。但是通过对 postTaco() 应用一些响应式编程,可以使其成为一个完全非阻塞的请求处理方法,如下所示:
Java
@PostMapping(consumes="application/json")
@ResponseStatus(HttpStatus.CREATED)
public Mono<Taco> postTaco(@RequestBody Mono<Taco> tacoMono) {
return tacoRepo.saveAll(tacoMono).next();
}1
2
3
4
5
2
3
4
5
在这里,postTaco() 接受一个 Mono<Taco> 并调用仓库的 saveAll() 方法,该方法接受任何 Reactive Streams Publisher 的实现,包括 Mono 或 Flux。saveAll() 方法返回一个 Flux<Taco>,但因为你从一个 Mono 开始,你知道 Flux 中最多只会发布一个 Taco。因此,你可以调用 next() 来获得从 postTaco() 返回的 Mono<Taco>。
通过接受 Mono<Taco> 作为输入,该方法会立即被调用,而不必等待从请求体中解析出 Taco。并且因为仓库也是响应式的,它会接受一个 Mono 并立即返回一个 Flux<Taco>,从中你可以调用 next() 并返回得到的 Mono<Taco>。这一切甚至在请求被处理之前就完成了!
或者,你也可以这样实现 postTaco():
Java
@PostMapping(consumes="application/json")
@ResponseStatus(HttpStatus.CREATED)
public Mono<Taco> postTaco(@RequestBody Mono<Taco> tacoMono) {
return tacoMono.flatMap(tacoRepo::save);
}1
2
3
4
5
2
3
4
5
这种方法将事情颠倒过来,使得 tacoMono 成为动作的驱动者。tacoMono 中包含的 Taco 通过 flatMap() 传递给了仓库的 save() 方法,从而产生了一个新的 Mono<Taco> 并返回。
无论哪种方法都可以很好地工作,而且可能还有其他几种方法可以编写 postTaco()。选择最适合你的方式并且最符合你的逻辑的方式即可。
Spring WebFlux 是 Spring MVC 的一个绝佳替代方案,它提供了使用与 Spring MVC 相同的开发模型编写响应式 Web 应用程序的选项。但是 Spring 还有一个新的技巧。让我们看看如何使用 Spring 的函数式编程风格来创建响应式 API。
2. 定义函数式请求处理程序
Spring MVC 的基于注解的编程模型自 Spring 2.5 以来就存在,并且广受欢迎。然而,它也存在一些缺点。
首先,任何基于注解的编程都涉及到对注解应该做什么以及如何做的定义分离。注解本身定义了 “做什么”,而 “如何做” 则在框架代码的其他地方定义。这种分离使得在进行任何定制或扩展时编程模型变得复杂,因为这些变化需要在注解外部的代码中进行。此外,调试这样的代码也很棘手,因为你无法在注解上设置断点。
另外,随着 Spring 的不断普及,从其他语言和框架转到 Spring 的开发人员可能会发现基于注解的 Spring MVC(以及 WebFlux)与他们已经了解的东西大不相同。作为 WebFlux 的替代方案,Spring 提供了一种用于定义响应式 API 的函数式编程模型。
这种新的编程模型更像是一个库,而不像是一个框架,它允许你在没有注解的情况下将请求映射到处理器代码。使用 Spring 的函数式编程模型编写 API 包括以下四种主要类型:
RequestPredicate:声明将被处理的请求类型;RouterFunction:声明匹配请求应该如何路由到处理器代码;ServerRequest:表示 HTTP 请求,包括对头部和主体信息的访问;ServerResponse:表示 HTTP 响应,包括头部和主体信息;
作为将所有这些类型汇集在一起的一个简单示例,考虑以下 Hello World 示例:
Java
@Configuration
public class RouterFunctionConfig {
@Bean
public RouterFunction<?> helloRouterFunction() {
return RouterFunctions.route(
RequestPredicates.GET("/hello"),
request -> ServerResponse.ok().body(Mono.just("Hello World!"), String.class));
}
}1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
在这个 @Configuration 类中,你有一个返回类型为 RouterFunction<?> 的 @Bean 方法。如前所述,RouterFunction 声明了一个或多个 RequestPredicate 对象与处理匹配请求的函数之间的映射关系。
RouterFunctions 的 route() 方法接受两个参数:一个 RequestPredicate 和一个处理匹配请求的函数。在这种情况下,RequestPredicates 中的 GET() 方法声明了一个 RequestPredicate,用于匹配 /hello 路径的 HTTP GET 请求。
至于处理函数,它被编写为一个 lambda 表达式,尽管它也可以是一个方法引用。虽然它没有显式声明,但处理器 lambda 接受一个 ServerRequest 作为参数。它使用 ServerResponse 的 ok() 和 BodyBuilder 的 body() 方法创建了一个 ServerResponse,这个 ServerResponse 是从 ok() 返回的。这样做是为了创建一个带有 HTTP 200(OK)状态码和内容为 Hello World! 的响应体。
如此编写的 helloRouterFunction() 方法声明了一个 RouterFunction,它只处理一种类型的请求。但是,如果你需要处理不同类型的请求,你不需要编写另一个 @Bean 方法,虽然你可以这样做。你只需要调用 andRoute() 来声明另一个 RequestPredicate 到函数的映射关系。
例如,下面是你如何为 /bye 的 HTTP GET 请求添加另一个处理程序的方式:
Java
@Bean
public RouterFunction<?> helloRouterFunction() {
return RouterFunctions.route(
RequestPredicates.GET("/hello"),
request -> ServerResponse.ok().body(Mono.just("Hello World!"), String.class))
.andRoute(
RequestPredicates.GET("/bye"),
request -> ServerResponse.ok().body(Mono.just("See ya!"), String.class));
}1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
Hello World 示例对于初步了解新技术确实很不错。但是让我们再进一步,看看如何使用 Spring 的函数式 Web 编程模型来处理类似于真实场景的请求。
为了演示函数式编程模型在真实应用程序中的使用方式,让我们以函数式方式重新实现 TacoController 的功能。以下配置类是 TacoController 的函数式模拟版本:
Java
@Configuration
public class RouterFunctionConfig {
@Autowired
private TacoRepository tacoRepo;
@Bean
public RouterFunction<?> routerFunction() {
return RouterFunctions
.route(RequestPredicates.GET("/api/tacos")
.and(RequestPredicates.queryParam("recent", t -> t != null)),
this::recents)
.andRoute(RequestPredicates.POST("/api/tacos"), this::postTaco);
}
public Mono<ServerResponse> recents(ServerRequest request) {
return ServerResponse.ok().body(tacoRepo.findAll().take(12), Taco.class);
}
public Mono<ServerResponse> postTaco(ServerRequest request) {
return request.bodyToMono(Taco.class)
.flatMap(taco -> tacoRepo.save(taco))
.flatMap(savedTaco ->
ServerResponse.created(URI.create("http://localhost:8080/api/tacos/" + savedTaco.getId()))
.body(savedTaco, Taco.class));
}
}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
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
正如你所看到的,routerFunction() 方法声明了一个 RouterFunction<?> bean,就像 Hello World 示例一样。但它不同之处在于处理的请求类型和处理方式。在这种情况下,RouterFunction 被创建来处理 /api/tacos?recent 的 GET 请求以及 /api/tacos 的 POST 请求。
更引人注目的是,这些路由使用了方法引用来处理。当 RouterFunction 背后的行为相对简单而且简短时,lambda 表达式是很好的选择。然而,在许多情况下,最好将这种功能性提取到一个单独的方法中(甚至是一个单独类中的一个单独方法)以保持代码的可读性。
根据你的需求,/api/tacos?recent 的 GET 请求将由 recents() 方法处理。它使用注入的 TacoRepository 获取一个 Flux<Taco>,然后取出 12 个项目。然后它将 Flux<Taco> 包装在 Mono<ServerResponse> 中,以便我们可以通过在 ServerResponse 上调用 ok() 来确保响应具有 HTTP 200(OK)状态。重要的是要理解,虽然最多返回 12 个 taco,但只有一个服务器响应,这就是为什么它返回一个 Mono 而不是 Flux。在内部,Spring 仍然会将 Flux<Taco> 作为 Flux 流式传输到客户端。
与此同时,/api/tacos 的 POST 请求由 postTaco() 方法处理,该方法从传入的 ServerRequest 的主体中提取了一个 Mono<Taco>。然后,postTaco() 方法使用一系列的 flatMap() 操作将该 taco 保存到 TacoRepository 中,并创建一个带有 HTTP 201(CREATED)状态码和保存的 Taco 对象作为响应主体的 ServerResponse。
flatMap() 操作用于确保在流程的每一步,映射的结果都被包装在一个 Mono 中,从第一个 flatMap() 开始是一个 Mono<Taco>,最终以一个从 postTaco() 返回的 Mono<ServerResponse> 结束。
3. 测试响应式 Controller
在测试响应式控制器方面,Spring 并没有让我们措手不及。事实上,Spring 引入了 WebTestClient,这是一个新的测试工具,可以轻松地编写针对使用 Spring WebFlux 编写的响应式控制器的测试。为了看到如何使用 WebTestClient 编写测试,让我们首先使用它来测试你在编写响应式 Controller 小节中编写的 TacoController 中的 recentTacos() 方法。
3.1. 测试 GET 请求
关于 recentTacos() 方法,我们希望断言的一件事是,如果针对路径 /api/tacos?recent 发出了 HTTP GET 请求,那么响应将包含不超过 12 个 taco 的 JSON 负载。下面的测试类是一个很好的起点:
Java
public class TacoControllerTest {
@Test
public void shouldReturnRecentTacos() {
Taco[] tacos = {
testTaco(1L), testTaco(2L),
testTaco(3L), testTaco(4L),
testTaco(5L), testTaco(6L),
testTaco(7L), testTaco(8L),
testTaco(9L), testTaco(10L),
testTaco(11L), testTaco(12L),
testTaco(13L), testTaco(14L),
testTaco(15L), testTaco(16L)};
Flux<Taco> tacoFlux = Flux.just(tacos);
TacoRepository tacoRepo = Mockito.mock(TacoRepository.class);
Mockito.when(tacoRepo.findAll()).thenReturn(tacoFlux);
WebTestClient testClient = WebTestClient.bindToController(
new TacoController(tacoRepo))
.build();
testClient.get().uri("/api/tacos?recent")
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$").isArray()
.jsonPath("$").isNotEmpty()
.jsonPath("$[0].id").isEqualTo(tacos[0].getId().toString())
.jsonPath("$[0].name").isEqualTo("Taco 1")
.jsonPath("$[1].id").isEqualTo(tacos[1].getId().toString())
.jsonPath("$[1].name").isEqualTo("Taco 2")
...
.jsonPath("$[11].id").isEqualTo(tacos[11].getId().toString())
.jsonPath("$[11].name").isEqualTo("Taco 11")
.jsonPath("$[12]").doesNotExist();
}
...
}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
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
shouldReturnRecentTacos() 方法的第一步是设置测试数据,形式为 Flux<Taco>。然后,将这个 Flux 作为模拟 TacoRepository 的 findAll() 方法的返回值。
关于由 Flux 发布的 Taco 对象,它们是使用名为 testTaco() 的实用方法创建的。该方法接收一个数字作为参数,生成一个 Taco 对象,其 ID 和名称基于该数字。testTaco() 方法的实现如下:
Java
private Taco testTaco(Long number) {
Taco taco = new Taco();
taco.setId(number);
taco.setName("Taco " + number);
List<Ingredient> ingredients = new ArrayList<>();
ingredients.add(
new Ingredient("INGA", "Ingredient A", Ingredient.Type.WRAP));
ingredients.add(
new Ingredient("INGB", "Ingredient B", Ingredient.Type.PROTEIN));
taco.setIngredients(ingredients);
return taco;
}1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
为了简化起见,所有的测试 taco 将拥有相同的两种配料。但它们的 ID 和名称将根据给定的数字确定。
与此同时,在 shouldReturnRecentTacos() 方法中,您实例化了一个 TacoController,将模拟 TacoRepository 注入到构造函数中。然后将该控制器提供给 WebTestClient.bindToController(),以创建 WebTestClient 的一个实例。
完成所有设置后,您现在可以使用 WebTestClient 提交一个 GET 请求到 /api/tacos?recent,并验证响应是否符合您的期望。调用 get().uri("/api/tacos?recent") 描述了您要发出的请求。然后调用 exchange() 提交请求,该请求将由 WebTestClient 绑定到的控制器处理 —— TacoController。
最后,您可以确认响应是否符合预期。通过调用 expectStatus(),您断言响应具有 HTTP 200(OK)状态码。之后,您会看到几次调用 jsonPath(),它们断言响应体中的 JSON 具有应有的值。最后的断言检查第 12 个元素(基于零的数组)是否不存在,因为结果不应该有超过 12 个元素。
如果 JSON 返回的数据结构复杂,包含大量数据或高度嵌套的数据,使用 jsonPath() 可能会很繁琐。事实上,在上述测试代码中,我省略了许多对 jsonPath() 的调用,以节省空间。对于那些可能使用 jsonPath() 会很笨拙的情况,WebTestClient 提供了 json(),它接受一个包含要与响应进行比较的 JSON 的字符串参数。
例如,假设您已经创建了完整的响应 JSON,并将其命名为 recent-tacos.json,并将其放置在类路径下的路径 /tacos 中。然后,您可以重写 WebTestClient 的断言,使其如下所示:
Java
ClassPathResource recentsResource =
new ClassPathResource("/tacos/recent-tacos.json");
String recentsJson = StreamUtils.copyToString(
recentsResource.getInputStream(), Charset.defaultCharset());
testClient.get().uri("/api/tacos?recent")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isOk()
.expectBody()
.json(recentsJson);1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
因为 json() 方法接受一个字符串,所以您必须首先将类路径资源加载到一个字符串中。幸运的是,Spring 的 StreamUtils 通过 copyToString() 方法很容易实现这一点。copyToString() 返回的字符串将包含您期望在响应中看到的整个 JSON。将其传递给 json() 方法可以确保控制器生成了正确的输出。
WebTestClient 提供的另一个选项允许您将响应体与值列表进行比较。expectBodyList() 方法接受一个表示列表中元素类型的 Class 或 ParameterizedTypeReference,并返回一个 ListBodySpec 对象,用于进行断言。使用 expectBodyList(),您可以重写测试,使用与创建模拟 TacoRepository 时相同的一部分测试数据,如下所示:
Java
testClient.get().uri("/api/tacos?recent")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isOk()
.expectBodyList(Taco.class)
.contains(Arrays.copyOf(tacos, 12));1
2
3
4
5
6
2
3
4
5
6
在这里,您断言响应体包含一个列表,其中包含与您在测试方法开始时创建的原始 Taco 数组的前 12 个元素相同的元素。
3.2. 测试 POST 请求
WebTestClient 不仅可以用于对控制器进行 GET 请求的测试,还可以用于测试任何类型的 HTTP 方法。表 3.1 将 HTTP 方法与 WebTestClient 方法进行了对应:
| HTTP 方法 | WebTestClient 方法 |
|---|---|
| GET | .get() |
| POST | .post() |
| PUT | .put() |
| PATCH | .patch() |
| DELETE | .delete() |
| HEAD | .head() |
作为针对 Spring WebFlux 控制器测试另一个 HTTP 方法请求的示例,让我们再看一个针对 TacoController 的测试。这次,您将编写一个测试,通过向 /api/tacos 提交 POST 请求来测试 API 的 taco 创建端点,如下所示:
Java
@Test
public void shouldSaveATaco() {
TacoRepository tacoRepo = Mockito.mock(
TacoRepository.class);
WebTestClient testClient = WebTestClient.bindToController(
new TacoController(tacoRepo)).build();
Mono<Taco> unsavedTacoMono = Mono.just(testTaco(1L));
Taco savedTaco = testTaco(1L);
Flux<Taco> savedTacoMono = Flux.just(savedTaco);
Mockito.when(tacoRepo.saveAll(Mockito.any(Mono.class))).thenReturn(savedTacoMono);
testClient.post()
.uri("/api/tacos")
.contentType(MediaType.APPLICATION_JSON)
.body(unsavedTacoMono, Taco.class)
.exchange()
.expectStatus().isCreated()
.expectBody(Taco.class)
.isEqualTo(savedTaco);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
与前一个测试方法类似,shouldSaveATaco() 方法开始时会模拟 TacoRepository,构建一个绑定到控制器的 WebTestClient,并设置一些测试数据。然后,它使用 WebTestClient 提交一个 POST 请求到 /api/tacos,请求体的类型是 application/json,负载是未保存的 Mono 中 Taco 对象的 JSON 序列化形式。在执行 exchange() 后,测试断言响应具有 HTTP 201(CREATED)状态,并且响应体中的负载与保存的 Taco 对象相等。
3.3. 使用线上服务器进行测试
到目前为止,您编写的测试依赖于 Spring WebFlux 框架的模拟实现,因此不需要真实的服务器。但是,您可能需要在像 Netty 或 Tomcat 这样的服务器上测试一个 WebFlux 控制器,可能还需要使用仓库或其他依赖项。换句话说,您可能想要编写一个集成测试。
要编写一个 WebTestClient 集成测试,您首先需要像编写任何其他 Spring Boot 集成测试一样,为测试类添加 @ExtendWith 和 @SpringBootTest 注解,示例如下:
Java
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class TacoControllerWebTest {
@Autowired
private WebTestClient testClient;
}1
2
3
4
5
6
2
3
4
5
6
通过将 webEnvironment 属性设置为 WebEnvironment.RANDOM_PORT,您要求 Spring 启动一个运行中的服务器,并监听一个随机选择的端口。
您可能已经注意到,您还在测试类中自动装配了一个 WebTestClient。这不仅意味着您在测试方法中不再需要创建它,还意味着在发出请求时无需指定完整的 URL。这是因为 WebTestClient 将被配置为知道测试服务器运行在哪个端口上。现在,您可以将 shouldReturnRecentTacos() 重写为使用自动装配的 WebTestClient 的集成测试,如下所示:
Java
@Test
public void shouldReturnRecentTacos() throws IOException {
testClient.get().uri("/api/tacos?recent")
.accept(MediaType.APPLICATION_JSON).exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$").isArray()
.jsonPath("$.length()").isEqualTo(3)
.jsonPath("$[?(@.name == 'Carnivore')]").exists()
.jsonPath("$[?(@.name == 'Bovine Bounty')]").exists()
.jsonPath("$[?(@.name == 'Veg-Out')]").exists();
}1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
毫无疑问,您肯定已经注意到,这个新版本的 shouldReturnRecentTacos() 代码量大大减少了。您不再需要创建 WebTestClient,因为您将使用自动装配的实例。您也不必再模拟 TacoRepository,因为 Spring 将创建一个 TacoController 实例,并注入一个真实的 TacoRepository。在这个测试方法的新版本中,您使用 JSONPath 表达式来验证从数据库返回的值。
在测试过程中,当您需要调用 WebFlux 控制器暴露的 API 时,WebTestClient 是非常有用的。但是当您的应用程序本身需要调用其他 API 时呢?让我们将注意力转向 Spring 响应式 Web 的客户端部分,看看 WebClient 如何提供一个基于 Mono 和 Flux 等响应式类型的 REST 客户端。
4. 响应式消费 REST API
在《Spring In Action 6th:创建 REST 服务》中我们使用 RestTemplate 向 Taco Cloud API 发送客户端请求。RestTemplate 是一个老牌工具,在 Spring 3.0 版本中引入。在它的使用历史中,已经为许多应用程序发出了无数的请求。但是,RestTemplate 提供的所有方法都处理非响应式的领域类型和集合。这意味着,如果您希望以响应式方式处理响应的数据,您需要将其包装在 Flux 或 Mono 中。而如果您已经有了 Flux 或 Mono,并且希望将其作为 POST 或 PUT 请求发送,那么您需要在发送请求之前将数据提取到非响应式类型中。
如果有一种方法可以直接将 RestTemplate 与响应式类型一起使用就好了。别担心,Spring 提供了 WebClient 作为对 RestTemplate 的响应式替代方案。WebClient 允许您在向外部 API 发送请求时发送和接收响应式类型的数据。
使用 WebClient 与使用 RestTemplate 有很大的不同。WebClient 不是有多个方法来处理不同类型的请求,而是采用了一种流畅的构建器式接口,让您可以描述和发送请求。使用 WebClient 的一般模式如下:
- 创建一个
WebClient实例(或注入一个WebClientbean); - 指定要发送的请求的 HTTP 方法;
- 指定请求的
URI和任何应该在请求中的标头; - 提交请求;
- 处理响应;
让我们看几个使用 WebClient 的示例,首先看看如何使用 WebClient 发送 HTTP GET 请求。
4.1. 获取资源
作为使用 WebClient 的示例,假设您需要从 Taco Cloud API 根据其 ID 获取一个 Ingredient 对象。使用 RestTemplate,您可能会使用 getForObject() 方法。但是使用 WebClient,您会构建请求,获取响应,然后提取一个发布 Ingredient 对象的 Mono,如下所示:
Java
Mono<Ingredient> ingredient = WebClient.create()
.get()
.uri("http://localhost:8080/ingredients/{id}", ingredientId)
.retrieve()
.bodyToMono(Ingredient.class);
ingredient.subscribe(i -> { ... });1
2
3
4
5
6
2
3
4
5
6
在这里,您使用 create() 创建了一个新的 WebClient 实例。然后,您使用 get() 和 uri() 定义了一个 GET 请求,目标 URL 是 http://localhost:8080/ingredients/{id},其中的 {id} 将被替换为 ingredientId 的值。retrieve() 方法执行了请求。最后,调用 bodyToMono() 将响应的主体提取到一个 Mono<Ingredient> 中,您可以在其中继续应用额外的 Mono 操作。
要在从 bodyToMono() 返回的 Mono 上应用额外的操作,重要的是在发送请求之前订阅它。发送可能返回值集合的请求非常简单。例如,以下代码片段获取了所有的 Ingredient:
Java
Flux<Ingredient> ingredients = WebClient.create()
.get()
.uri("http://localhost:8080/ingredients")
.retrieve()
.bodyToFlux(Ingredient.class);
ingredients.subscribe(i -> { ... });1
2
3
4
5
6
2
3
4
5
6
在大多数情况下,获取多个项与获取单个项的请求基本相同。最大的区别在于,您不再使用 bodyToMono() 将响应的主体提取到 Mono 中,而是使用 bodyToFlux() 将其提取到 Flux 中。
与 bodyToMono() 一样,从 bodyToFlux() 返回的 Flux 也尚未被订阅。这允许在数据开始流动之前对 Flux 应用额外的操作(过滤、映射等)。因此,重要的是要订阅生成的 Flux,否则请求甚至都不会被发送。
4.1.1. 使用基础 URI 发送请求
您可能会发现自己需要为许多不同的请求使用相同的基础 URI。在这种情况下,创建一个带有基础 URI 的 WebClient bean 并在需要的任何地方注入它会很有用。这样的 bean 可以在任何 @Configuration 注解的类中声明,如下所示:
Java
@Bean
public WebClient webClient() {
return WebClient.create("http://localhost:8080");
}1
2
3
4
2
3
4
然后,在任何需要使用该基础 URI 进行请求的地方,可以注入并像这样使用 WebClient bean:
Java
@Autowired
WebClient webClient;
...
public Mono<Ingredient> getIngredientById(String ingredientId) {
Mono<Ingredient> ingredient = webClient
.get()
.uri("/ingredients/{id}", ingredientId)
.retrieve()
.bodyToMono(Ingredient.class);
ingredient.subscribe(i -> { ... });
}1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
因为 WebClient 已经被创建,您可以直接调用 get() 开始工作。至于 URI,在调用 uri() 时只需要指定相对于基础 URI 的路径。
4.1.2. 对于长时间运行的请求设置超时
有一点是可以肯定的,那就是网络并不总是可靠的,速度也不总是如你所期望的那样快。或者远程服务器在处理请求时可能会很慢。理想情况下,对远程服务的请求应该在合理的时间内返回。但如果没有,那么如果客户端在等待响应时不会被卡住就太好了。
为了避免您的客户端请求受到缓慢的网络或服务的影响,您可以使用 Flux 或 Mono 的 timeout() 方法来限制等待数据发布的时间。例如,考虑在获取食材数据时如何使用 timeout(),如下所示的代码示例:
Java
Flux<Ingredient> ingredients = webclient
.get()
.uri("/ingredients")
.retrieve()
.bodyToFlux(Ingredient.class);
ingredients
.timeout(Duration.ofSeconds(1))
.subscribe(
i -> { ... },
e -> {
// handle timeout error
});1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
正如您所看到的,在订阅 Flux 之前,您调用了 timeout(),指定了 1 秒的持续时间。如果请求在 1 秒内可以完成,那就没有问题。但如果请求花费的时间超过了 1 秒,它将会超时,并且作为 subscribe() 的第二个参数给出的错误处理程序会被调用。
4.2. 发送资源
使用 WebClient 发送数据并不比接收数据有多大不同。举个例子,假设您有一个 Mono<Ingredient>,并且想要将由该 Mono 发布的 Ingredient 发送到相对路径为 /ingredients 的 URI。您所需做的就是使用 post() 方法而不是 get(),并调用 body() 指定该 Mono 用于填充请求体,如下所示:
Java
Mono<Ingredient> ingredientMono = Mono.just(
new Ingredient("INGC", "Ingredient C", Ingredient.Type.VEGGIES));
Mono<Ingredient> result = webClient
.post()
.uri("/ingredients")
.body(ingredientMono, Ingredient.class)
.retrieve()
.bodyToMono(Ingredient.class);
result.subscribe(i -> { ... });1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
如果您没有 Mono 或 Flux 要发送,而是手头有原始的领域对象,您可以使用 bodyValue()。例如,假设您手头有一个 Ingredient 而不是 Mono<Ingredient>,您希望将其作为请求体发送,如下所示:
Java
Ingredient ingredient = ...;
Mono<Ingredient> result = webClient
.post()
.uri("/ingredients")
.bodyValue(ingredient)
.retrieve()
.bodyToMono(Ingredient.class);
result.subscribe(i -> { ... });1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
如果您想要使用 PUT 请求来更新一个 Ingredient,您可以调用 put() 而不是 post(),并相应地调整 URI 路径,如下所示:
Java
Mono<Void> result = webClient
.put()
.uri("/ingredients/{id}", ingredient.getId())
.bodyValue(ingredient)
.retrieve()
.bodyToMono(Void.class);
result.subscribe();1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
通常情况下,PUT 请求的响应主体是空的,因此您需要让 bodyToMono() 返回一个 Mono<Void>。订阅该 Mono 后,请求将被发送。
4.3. 删除资源
WebClient 还允许通过其 delete() 方法删除资源。例如,以下代码删除了给定 ID 的食材:
Java
Mono<Void> result = webClient
.delete()
.uri("/ingredients/{id}", ingredientId)
.retrieve()
.bodyToMono(Void.class);
result.subscribe();1
2
3
4
5
6
7
2
3
4
5
6
7
与 PUT 请求类似,DELETE 请求通常不包含有效载荷。再次返回并订阅 Mono<Void> 来发送请求。
4.4. 处理错误
到目前为止,所有的 WebClient 示例都假设一切顺利。没有返回 400 级或 500 级状态码的响应。如果返回了其中一种错误状态,WebClient 将记录失败并继续进行而不发生异常。
如果您需要处理这样的错误,那么可以使用 onStatus() 方法来指定如何处理各种 HTTP 状态码。onStatus() 接受两个函数:一个断言函数,用于匹配 HTTP 状态,以及一个函数,它给定一个 ClientResponse 对象,返回一个 Mono<Throwable>。
为了演示如何使用 onStatus() 创建自定义错误处理程序,考虑下面对 WebClient 的使用,它旨在根据其 ID 获取一个食材:
Java
Mono<Ingredient> ingredientMono = webClient
.get()
.uri("/ingredients/{id}", ingredientId)
.retrieve()
.bodyToMono(Ingredient.class);1
2
3
4
5
2
3
4
5
只要 ingredientId 的值匹配已知的食材资源,当订阅时,产生的 Mono 将发布 Ingredient 对象。但是,如果没有匹配的食材会发生什么呢?
当订阅可能以错误结束的 Mono 或 Flux 时,重要的是在调用 subscribe() 时注册错误消费者和数据消费者,如下所示:
Java
ingredientMono.subscribe(
ingredient -> {
// handle the ingredient data
...
},
error -> {
// deal with the error
...
});1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
如果找到了食材资源,那么将调用传递给 subscribe() 的第一个 lambda 函数(数据消费者),并传递匹配的 Ingredient 对象。但如果没有找到,那么请求将会以 HTTP 404(未找到)的状态码响应,这会导致默认情况下将第二个 lambda 函数(错误消费者)传递给 WebClientResponseException。
WebClientResponseException 最大的问题在于它对导致 Mono 失败的原因并不具体。它的名称表明在由 WebClient 发出的请求的响应中出现了错误,但您需要深入研究 WebClientResponseException 才能知道出了什么问题。而且无论如何,如果传递给错误消费者的异常更加与领域相关而不是特定于 WebClient,那将是很好的。
通过添加自定义错误处理程序,您可以提供将状态码转换为您自己选择的 Throwable 类型的代码。假设您希望对食材资源的失败请求导致 Mono 以 UnknownIngredientException 完成错误,您可以在调用 retrieve() 后添加以下对 onStatus() 的调用来实现这一点:
Java
Mono<Ingredient> ingredientMono = webClient
.get()
.uri("/ingredients/{id}", ingredientId)
.retrieve()
.onStatus(HttpStatus::is4xxClientError,
response -> Mono.just(new UnknownIngredientException()))
.bodyToMono(Ingredient.class);1
2
3
4
5
6
7
2
3
4
5
6
7
在 onStatus() 调用中,第一个参数是一个断言,它接收一个 HttpStatus 并在状态码是您想要处理的状态码时返回 true。如果状态码匹配,那么响应将被传递给第二个参数中的函数进行处理,最终返回一个 Mono<Throwable>。
在这个例子中,如果状态码是 400 级状态码(例如客户端错误),那么将返回一个带有 UnknownIngredientException 的 Mono。这将导致 ingredientMono 失败,并抛出该异常。
请注意,HttpStatus::is4xxClientError 是对 HttpStatus 的 is4xxClientError 方法的方法引用。这个方法将在给定的 HttpStatus 对象上被调用。如果需要,您可以使用 HttpStatus 上的另一个方法作为方法引用;或者您可以使用 lambda 或方法引用来提供自己的函数,该函数返回一个布尔值。
例如,您可以在错误处理中变得更加精确,通过修改 onStatus() 的调用,专门检查 HTTP 404(未找到)状态,如下所示:
Java
Mono<Ingredient> ingredientMono = webClient
.get()
.uri("/ingredients/{id}", ingredientId)
.retrieve()
.onStatus(status -> status == HttpStatus.NOT_FOUND,
response -> Mono.just(new UnknownIngredientException()))
.bodyToMono(Ingredient.class);1
2
3
4
5
6
7
2
3
4
5
6
7
值得注意的是,您可以根据需要对 onStatus() 进行多次调用,以处理可能在响应中返回的各种 HTTP 状态码。
4.5. 请求转换
到目前为止,在使用 WebClient 时,您已经使用了 retrieve() 方法来表示发送请求。在这些情况下,retrieve() 方法返回了一个 ResponseSpec 对象,通过它,您可以使用 onStatus()、bodyToFlux() 和 bodyToMono() 等方法处理响应。使用 ResponseSpec 处理简单情况是可以的,但在某些方面它有一些局限性。例如,如果您需要访问响应的头信息或 cookie 值,那么 ResponseSpec 将无法满足您的需求。
当 ResponseSpec 不能满足要求时,您可以尝试调用 exchangeToMono() 或 exchangeToFlux() 来替代 retrieve()。exchangeToMono() 方法返回一个 Mono 类型的 ClientResponse,您可以在其中应用响应式操作来检查和使用来自整个响应的数据,包括载荷、头信息和 cookie。exchangeToFlux() 方法工作方式类似,但返回一个 Flux 类型的 ClientResponse,用于处理响应中的多个数据项。
在我们探讨 exchangeToMono() 和 exchangeToFlux() 与 retrieve() 有何不同之前,让我们先看看它们的相似之处。下面的代码片段使用了 WebClient 和 exchangeToMono() 来根据配料的 ID 获取单个配料:
Java
Mono<Ingredient> ingredientMono = webClient
.get()
.uri("/ingredients/{id}", ingredientId)
.exchangeToMono(cr -> cr.bodyToMono(Ingredient.class));1
2
3
4
2
3
4
这大致相当于下一个使用 retrieve() 的示例:
Java
Mono<Ingredient> ingredientMono = webClient
.get()
.uri("/ingredients/{id}", ingredientId)
.retrieve()
.bodyToMono(Ingredient.class);1
2
3
4
5
2
3
4
5
在 exchangeToMono 中我们可以通过访问 ClientResponse 来提供更多控制。假设请求的响应包含一个名为 X_UNAVAILABLE 的标头,其值为 true,表示(由于某种原因)所请求的配料不可用。为了讨论的完整性,假设如果存在该标头,则你希望返回的 Mono 为空,即不返回任何内容。则请参考以下示例代码:
Java
Mono<Ingredient> ingredientMono = webClient
.get()
.uri("/ingredients/{id}", ingredientId)
.exchangeToMono(cr -> {
if (cr.headers().header("X_UNAVAILABLE").contains("true")) {
return Mono.empty();
} else {
return cr.bodyToMono(Ingredient.class);
}
});1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
Note
在调用完
exchangeToMono/exchangeToFlux之后ClientResponse会立即被 Release 掉。也就是说以下示例代码实际上永远也不会读取到响应体当中的内容,它总是得到一个MonoEmpty对象:JavaMono<Ingredient> ingredientMono = webClient .get() .uri("/ingredients/{id}", ingredientId) .exchangeToMono(Mono::just) .flatMap(cr -> cr.bodyToMono(Ingredient.class));1
2
3
4
5
5. 保护响应式 Web API
自从有了 Spring Security(甚至在那之前,当它被称为 Acegi Security 时),其 Web 安全模型一直建立在 Servlet 过滤器的基础上。毕竟,这是合理的。如果你需要拦截一个针对基于 Servlet 的 Web 框架的请求,以确保请求者具有适当的权限,Servlet 过滤器是一个显而易见的选择。但是 Spring WebFlux 对这种方法提出了异议。
在使用 Spring WebFlux 编写 Web 应用程序时,不能保证 Servlets 会被涉及。事实上,响应式 Web 应用程序更有可能构建在 Netty 或其他非 Servlet 服务器上。这是否意味着基于 Servlet 过滤器的 Spring Security 无法用于保护 Spring WebFlux 应用程序呢?
确实,在保护 Spring WebFlux 应用程序时,使用 Servlet 过滤器不是一个选择。但是 Spring Security 仍然能够完成任务。从 5.0.0 版本开始,你可以使用 Spring Security 来保护基于 Servlet 的 Spring MVC 和基于响应式 Spring WebFlux 的应用程序。它使用 Spring 的 WebFilter,这是 Servlet 过滤器的 Spring 特定模拟,不需要依赖于 Servlet API。
更令人印象深刻的是,响应式 Spring Security 的配置模型与《Spring In Action 6th:Spring 安全》中看到的并没有太大的不同。作为提醒,Spring Security 启动器的配置如下:
XML
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>1
2
3
4
2
3
4
话虽如此,Spring Security 的响应式和非响应式配置模型之间存在一些小差异。值得快速查看一下这两个配置模型的比较。
5.1. 配置响应式 Web 安全
提醒一下,配置 Spring Security 以保护 Spring MVC Web 应用程序通常涉及创建一个新的配置类,该类扩展 WebSecurityConfigurerAdapter 并用 @EnableWebSecurity 进行注解。这样的配置类将覆盖 configuration() 方法,以指定 Web 安全的具体内容,比如对于某些请求路径需要什么样的授权。以下是一个简单的 Spring Security 配置类,作为如何为非响应式的 Spring MVC 应用程序配置安全性的提醒:
Java
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/api/tacos", "/orders").hasAuthority("USER")
.antMatchers("/**").permitAll();
}
}1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
Warning:
WebSecurityConfigurerAdapter已不再推荐使用,建议的方法是注册一个SecurityFilterChainbean,详细说明可参见 Spring 官方说明文档。
现在让我们看看同样的配置对于响应式的 Spring WebFlux 应用程序可能会是什么样子。以下代码展示了一个响应式安全配置类,大致相当于之前的简单安全配置:
Java
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
@Bean
public SecurityWebFilterChain securityWebFilterChain(
ServerHttpSecurity http) {
return http
.authorizeExchange()
.pathMatchers("/api/tacos", "/orders").hasAuthority("USER")
.anyExchange().permitAll()
.and()
.build();
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
正如您所看到的,很多地方是熟悉的,但同时也有很多不同之处。与其使用 @EnableWebSecurity,这个新的配置类使用 @EnableWebFluxSecurity 进行注解。此外,配置类不再扩展 WebSecurityConfigurerAdapter 或任何其他基类。因此,它也不再覆盖任何 configure() 方法。
取而代之的是一个 securityWebFilterChain() 方法,它声明了一个 SecurityWebFilterChain 类型的 bean,而不是 configure() 方法。虽然 securityWebFilterChain() 的主体与之前配置中的 configure() 方法并没有太大不同,但确实存在一些微妙的变化。
主要的变化是使用给定的 ServerHttpSecurity 对象声明配置,而不是 HttpSecurity 对象。使用给定的 ServerHttpSecurity,你可以调用 authorizeExchange(),它大致相当于 authorizeRequests(),来声明请求级别的安全性。
Note:
ServerHttpSecurity是 Spring Security 5 的新特性,是对HttpSecurity的响应式模拟。
在匹配路径时,你仍然可以使用 Ant 风格的通配符路径,但是使用 pathMatchers() 方法,而不是 antMatchers()。而且作为一种便利,你不再需要指定一个通配符路径的 /**,因为 anyExchange() 返回你需要的通配符。
最后,由于你将 SecurityWebFilterChain 声明为一个 bean 而不是覆盖框架方法,你必须调用 build() 方法将所有安全规则组装成 SecurityWebFilterChain 并返回。
除了这些小的差异,为 Spring WebFlux 配置 Web 安全性与为 Spring MVC 配置并没有太大的不同。但用户详情呢?
5.2. 配置响应式用户信息服务
在扩展 WebSecurityConfigurerAdapter 时,你会覆盖一个 configure() 方法来声明 Web 安全规则,另一个 configure() 方法用于配置认证逻辑,通常通过定义一个 UserDetails 对象。作为对这个样子的提醒,考虑以下覆盖的 configure() 方法,它使用了一个注入的 UserRepository 对象,在 UserDetailsService 的匿名实现中通过用户名查找用户:
Java
@Autowired
UserRepository userRepo;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(new UserDetailsService() {
@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
User user = userRepo.findByUsername(username)
if (user == null) {
throw new UsernameNotFoundException(
username" + not found")
}
return user.toUserDetails();
}
});
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
在这个非响应式的配置中,你覆盖了 UserDetailsService 需要的唯一方法:loadUserByUsername()。在这个方法内部,你使用给定的 UserRepository 通过给定的用户名查找用户。如果找不到该名称,你会抛出 UsernameNotFoundException。但如果找到了,然后你调用一个辅助方法 toUserDetails() 来返回生成的 UserDetails 对象。
在响应式安全配置中,你不再覆盖 configure() 方法。相反,你声明了一个 ReactiveUserDetailsService bean。ReactiveUserDetailsService 是对 UserDetailsService 的响应式等效物。与 UserDetailsService 一样,ReactiveUserDetailsService 只需要实现一个方法。具体来说,findByUsername() 方法返回一个 Mono<UserDetails>,而不是原始的 UserDetails 对象。
在以下示例中,声明了一个 ReactiveUserDetailsService bean,该 bean 使用给定的 UserRepository,假定该仓库是一个响应式的 Spring Data 仓库:
Java
@Bean
public ReactiveUserDetailsService userDetailsService(
UserRepository userRepo) {
return new ReactiveUserDetailsService() {
@Override
public Mono<UserDetails> findByUsername(String username) {
return userRepo.findByUsername(username)
.map(user -> user.toUserDetails());
}
};
}1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
在这里,返回了一个 Mono<UserDetails>,但是 UserRepository.findByUsername() 方法返回一个 Mono<User>。因为它是一个 Mono,你可以在其上链接操作,比如使用 map() 操作将 Mono<User> 映射为 Mono<UserDetails>。
在这种情况下,map() 操作被应用于一个 lambda,该 lambda 在 Mono 发布的 User 对象上调用了辅助方法 toUserDetails()。这将 User 转换为 UserDetails。因此,.map() 操作返回一个 Mono<UserDetails>,这正是 ReactiveUserDetailsService.findByUsername() 所需的。如果 findByUsername() 找不到匹配的用户,那么返回的 Mono 将为空,表示没有匹配,导致身份验证失败。
6. 总结
Spring WebFlux 提供了一个响应式的 Web 框架,其编程模型与 Spring MVC 非常相似,甚至共享许多相同的注解;
Spring 还提供了一种函数式编程模型,作为 Spring WebFlux 基于注解的编程模型的替代方案;
可以使用
WebTestClient对响应式控制器进行测试;在客户端,Spring 提供了
WebClient,这是 Spring 的RestTemplate的响应式模拟;尽管 WebFlux 对于保护 Web 应用程序的基础机制有一些重要的影响,但 Spring Security 5 支持响应式安全,其编程模型与非响应式的 Spring MVC 应用程序并没有明显的区别;