本文要点
- Java 大力转向了异步和非阻塞并发。
- Spring 5 为 Web 应用程序引入了完全非阻塞的反应式技术栈。
- 反应式技术栈使用更少的资源处理更高的并发量,而且在客户端和服务器端的流式处理方面有突出的表现。
- Spring MVC 为现有的应用程序提供了一些反应式特性。
- Spring Boot 2 内置了反应式 Web 容器,默认使用的是 Netty,当然也可以选择 Tomcat、Jetty 或 Undertow。
Spring 5 提供了 Servlet 和反应式这两种 Web 技术栈,在应用层面充分向异步和非阻塞并发靠拢。这篇文章主要介绍了几种可选项以及如何在这些选项中做出选择。
文中我分别使用“Servlet 栈”和“反应式栈”来指代 Spring 5 所提供的两种技术栈,应用程序可以分别通过 Spring MVC(spring-webmvc 模块)和 Spring WebFlux(spring-webflux 模块)来使用这两个技术栈。
改变的动机
现如今,“反应式”俨然已经成为一个热门词汇。不过,它真的是大势所趋,异步 Web 开发的发展经历了从早期简单的 Servlet 容器到现如今的异步无处不在。我们为此做好准备了吗?
传统的 Java 使用线程池来并行执行阻塞式的 IO 操作(比如进行远程调用)。表面上看,这样做很简单,但实际上可能不是这么回事。首先,在进行同步或共享数据结构时,应用程序就会变得很复杂。其次,每个阻塞操作都会占用一个线程,以致于难以伸缩,而且这种等待所带来的延迟我们是无能为力的(这样会让客户端和服务器端都慢下来)。
因此,出现了各种解决方案(比如协程、actor 等),把并发的复杂性归到框架或编程语言当中。现在出现了一个有关 Java 轻量级线程模型的提议,叫作 Loom ,但要看到它被应用到生产环境可能还要等上好几年。而在当前,我们能够做点什么来更好地处理异步并发呢?
在 Java 开发者当中普遍存在这样的误解,他们认为伸缩需要更多的线程。或许,在使用命令式编程范式和阻塞式模型时,这样想是对的,但通常来说并非如此。如果一个应用程序是完全非阻塞的,那么它完全可以使用少量的线程来实现伸缩,Node.js 就是最好的例子。而在 Java 里,我们不一定要局限于只使用单个线程,我们可以启动足够的线程,把 CPU 的核数都用上。不过原则依旧:我们并不依赖更多的线程来实现高并发。
我们是如何让应用程序变成非阻塞式的?首先,我们必须放弃与命令式编程有关的串行逻辑,我们使用异步 API,对事件作出反应。当然,使用太多的回调函数很快就会让事情变得复杂起来。我们可以使用更好的模型,比如 Java 8 引入的 CompletableFuture,它提供了链式 API,处理事件的逻辑是按步骤串联在一起的,而不是按照嵌套回调的方式组织在一起。
CompletableFuture.supplyAsync(() -> "stage0") .thenApply(s -> s + "-stage1") .thenApplyAsync(s -> { // insert async call here... return s + "-stage2"; }) .thenApply(s -> s + "-stage3") .thenApplyAsync(s -> { // insert async call here... return s + "-stage4"; }) .thenAccept(System.out::println);
这种方式虽好,但只能返回单一值。如果要处理一个异步的序列,那该怎么办?Java 8 的流式 API 提供了针对流元素的函数式操作,不过它只支持集合(消费者从流中拉取数据),不适用于“实时”流(生产者向流中推送数据,中间还可能有延迟)。
于是乎,出现了一些反应式类库,如 RxJava、Reactor、Akka Streams 等。它们看起来很像 Java 8 Streams,只不过是为异步序列而设计的,并且加入了反应式流回压特性,让消费者能够控制生产者的速率。
在一开始,从命令式编程转换到函数式或声明式的编程风格可能会觉得不习惯,这需要点时间适应。这个与生活中的其他事情一样,比如学习骑自行车或学习一门新语言。不要放弃,它们会变得越来越容易,而且终究会给你带来好处。
从命令式到声明式的转变就好比使用Java 8 的Streams API 来重写循环代码。在使用Java 8 Streams API 时,你声明要“做什么”,而不是“如何做”,代码更具有可读性。使用反应式类库也是类似,只要声明要做什么,不需要处理与并发、线程和同步相关的问题,所以使用更少的硬件资源就可以实现更高效的伸缩。
最后,Java 8 的lambda 语法也是促成我们转向反应式编程的因素之一,它促进了函数式编程、声明式API 和反应式类库的使用,让我们对新的编程模型充满了遐想。就像可以使用注解来开发REST 端点一样,我们也可以使用Java 8 的lambda 语法来开发函数式的路由和请求处理器。
技术栈选择
Spring 并不是第一个提供异步非阻塞特性的开发框架,不过它在企业级应用层面提供了各个层次的选择。能够做出自由的选择是非常关键的一点,因为并非所有的应用程序都能随意更改,况且有些应用根本就不需要做出更改。随着微服务架构的发展,单个应用程序可以独立发生变更,那么选择性、一致性和持续性也就变得越发重要。
接下来让我们来看看我们都有哪些选择。
应用服务器
长久以来,Servlet API 是事实上的应用服务器标准。不过,随着时间推移,出现了一些替代方案,对于想尝试事件循环并发和非阻塞式 IO 的项目来说,它们早就把目光移到了 Servlet API 和 Servlet 容器之外。
确实,在过去几年,Tomcat 和 Jetty 发展得很不错。但这 20 年来一直没怎么发生改变的是,我们一直在使用阻塞式的 Servlet API。Servlet API 在 3.1 版本中引入了非阻塞式 API,不过还没有被实际采用,因为它要求应用服务器做出深度的修改,把原先围绕阻塞式 IO 而设计的核心框架和应用程序接口全部都改掉。所以实际情况是,开发者需要在 Servlet 的阻塞式 API 和不依赖 Servlet API 的第三方异步运行时(如 Netty)之间做出选择。
在 Spring 5 中,我们可以选择是使用阻塞式 API 还是反应式运行时。Spring WebFlux 应用程序可以运行在 Servlet 容器上。从 Spring Boot 2 开始,WebFlux 默认使用 Netty,不过也可以选用 Tomat 或 Jetty,只需要修改几行配置代码。
在控制器上使用注解
Spring MVC 的注解方式可以用在 Servlet 栈(Spring MVC)和反应式栈(Spring WebFlux)上。也就是说,我们可以在阻塞式和非阻塞式之间做出选择,同时保持编程模型不变。
反应式客户端
使用反应式客户端可以在不处理与线程相关代码的情况下,更有效地调度对远程服务的调用。这对于服务器端的并发性能来说无疑有巨大的好处。
使用反应式客户端不仅限于反应式栈。下面的代码也可以用在 Servlet 栈上,一个 Spring MVC 控制器也可以处理请求并生成反应式类型的响应:
@RestController public class CarController { private final WebClient carsClient = WebClient.create("http://localhost:8081"); private final WebClient bookClient = WebClient.create("http://localhost:8082"); @PostMapping("/booking") public Mono<ResponseEntity<Void>> book() { return carsClient.get().uri("/cars") .retrieve() .bodyToFlux(Car.class) .take(5) .flatMap(this::requestCar) .next(); } private Mono<ResponseEntity<Void>> requestCar(Car car) { return bookClient.post() .uri("/cars/{id}/booking", car.getId()) .exchange() .flatMap(response -> response.toEntity(Void.class)); } }
在上面这个例子中,控制器使用了反应式的非阻塞 WebClient 从远程获取车辆数据,然后尝试通过第二次调用远程服务预定车辆服务,最后,将响应返回给客户端。我们声明异步远程调用,然后并发执行它们(事件循环方式),不需要处理与线程和同步相关的代码,一切看起来多么简单。
反应式类库
注解编程模型的好处之一是可以灵活地选择方法签名。应用程序可以灵活地选择方法参数和返回值,这样就可以更方便地支持多个反应式类库。
不管是 Servlet 栈还是反应式栈,都可以在控制器的方法签名中使用 Reactor 或 RxJava 类型。而且这是可配置的,我们也可以使用其他反应式类库。
函数式 Web 端点
除了可以在控制器上使用注解,Spring WebFlux 还支持轻量级的函数式编程模型,也就是基于 lambda 表达式来路由和处理请求。
函数式端点与注解控制器非常不一样。在使用注解时,我们告诉框架应该要做什么,然后让框架尽可能为我们做更多的工作——还记得好莱坞的“不要打给我,我会打给你”法则吗?相反,函数式编程模型提供了一系列辅助类,用于从头到尾处理请求消息。下面是一个简短的代码示例:
RouterFunction<?> route = RouterFunctions.route(POST("/cars/{id}/booking"), request -> { Long carId = Long.valueOf(request.pathVariable("id")); Long id = ... ; // Create booking URI location = URI.create("/car/" + carId + "/booking/" + id); return ServerResponse.created(location).build(); });
下面使用 Netty 来运行它:
HttpHandler handler = RouterFunctions.toHttpHandler(route); ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(handler); HttpServer server = HttpServer.create("localhost", 8080); server.startAndAwait(adapter);
函数式端点可以与反应式类库共存,因为它们都是基于函数式和声明式风格的 API 而构建的。
Web 技术栈架构
现在让我们深入了解 Servlet 技术栈和反应式技术栈。
Servlet 栈由经典的 Servlet 容器和 Servlet API 组成,使用 Spring MVC 作为 Web 框架。最开始,Servlet API 采用了“每请求一个线程”的模型,也就是说,一个线程负责处理一个请求,让请求流经整个 filter-servlet 链条,在必要的时候需要阻塞线程。后来,Web 应用程序对 Servlet API 提出了更多的需求,于是加入了更多的特性:
1997 1.0 初始版本 … 2009 3.0 异步 Servlet 2013 3.1 Servlet 非阻塞 I/O3.0 版本引入的异步 Servlet 为开发者带来了异步处理响应消息的可能性,也就是说,即使是在请求消息已经流经了整个 filter-servlet 链条,仍然可以对响应消息做额外的处理。Spring MVC 充分利用了这一特性,这也就是为什么可以在带有注解的控制器上使用反应式返回类型。
可惜的是,3.1 版本引入的非阻塞 IO 无法与现有的那些使用了命令式和阻塞式语义的 Web 框架集成在一起。这也是 Spring MVC 不支持非阻塞 IO 的原因,不过新出现的 Spring WebFlux 却成为非阻塞 Web 框架的基石,它同时支持 Servlet API 和其他的应用服务器。
反应式栈可以运行在 Tomcat、Jetty、Servlet 3.1 容器、Netty 和 Undertow 上。这些应用服务器实现了 Reactive Streams API,用以处理 HTTP 请求。在这些容器之上是 WebHandler API 层,与 Servlet API 处于同一层,只不过它是异步非阻塞的。
下图是两种技术栈的对比:
尽管两种技术栈都使用了Tomcat 和Jetty,但用法不一样。在Servlet 栈中,通过阻塞式的Servlet API 来使用它们。而在反应式栈中,则通过Servlet 3.1 的非阻塞IO 来使用它们,甚至不会暴露出Servlet API——它们大部分是阻塞和同步的(比如处理请求参数、二进制数据请求等),主要留给应用程序使用。
反应式、非阻塞和回压
Servlet 栈和反应式栈都支持在控制器上使用注解,不过它们的并发模型是不一样的。
在 Servlet 栈里,允许应用程序发生阻塞,这也就是为什么 Servlet 容器需要使用一个很大的线程池来应对可能发生的阻塞。这个可以从 Filter 和 Servlet 接口看出来,它们是命令式的,而且返回的是 void。阻塞式的 InputStream 和 OutputStream 也是一样。
而在反应式栈里,应用程序不能发生阻塞。事件轮询只提供了少量的线程,如果应用程序发生阻塞,很快就会殃及整个服务器。这个可以从 WebFilter 和 WebHandler 看出来,它们返回的是 Mono
请求消息体可以通过 Flux
例如,我们可以从客户端上传一个 JSON 数据流:
// 每秒钟弹出一辆车 Flux<Car> body = Flux.interval(Duration.ofSeconds(1)).map(i -> new Car(...)); // 将数据流发送给服务器 WebClient.create().post() .uri("http://localhost:8081/cars") .contentType(MediaType.APPLICATION_STREAM_JSON) .body(body, Car.class) .retrieve() .bodyToMono(Void.class) .block();
在服务器端,使用一个 Spring WebFlux 控制器来摄入数据流,再使用一个 Spring Data 反应式 Repository 将数据插入到数据库:
// 在数据到达时将其插入数据库 @PostMapping(path="/cars", consumes = "application/stream+json") public Mono<Void> loadCars(@RequestBody Flux<Car> cars) { return repository.insert(cars).then(); }
流可以持续很长一段时间。对流的处理也是很高效的,不需要占用额外的线程或内存。Spring WebFlux 和 Spring Data 都支持反应式流,那么上述的代码可以扩展成一个处理管道,这个管道支持从数据库到 HTTP 运行时的反应式流回压。数据库端可以控制 HTTP 读取数据块并转成对象的速率:
假设我们将控制器摄入的汽车数据插入到MongoDB 的一个集合中,其他客户端向MongoDB 发起JSON 请求:
WebClient.create().get() .uri("http://localhost:8081/cars") .accept(MediaType.APPLICATION_STREAM_JSON) .retrieve() .bodyToFlux(Car.class) .doOnNext(car -> { logger.debug("Received " + car)); //... }) .block();
在服务器端,控制器是这样处理 JSON 数据流的:
@GetMapping(path = "/cars", produces = "application/stream+json") public Flux<Car> getCarStream() { return this.repository.findCars(); }
这一次,反应式流回压的方向与上次相反,是从 HTTP 运行时流向数据库。HTTP 运行时控制着数据对象从数据库中出来、序列化成 JSON,再写到 HTTP 响应消息里的速率:
上面演示了如何在反应式栈中通过使用Flux
我们仍然返回Flux,框架会自动检查媒体类型并做出相应处理。
下面的代码使用“application/json”来渲染一个JSON 数组:
@GetMapping(path = "/cars", produces = "application/json") public Flux<Car> getCars() { return this.repository.findAll(); }
“application/json”是非流式媒体类型,框架会假设 Flux 是一个有边界的集合,使用 Flux.collectionToList() 来获取所有元素,把它们收集到一个 List 当中,再把集合写入响应消息。
在这个章节里,我们介绍了反应式栈。Servlet 栈依赖阻塞式 IO,所以不支持非阻塞或流式的 @RequestBody 注解。不过,因为 Servlet 3.0 提供了异步请求特性,控制器的方法仍然能够进行一些异步处理,所以,Spring MVC 控制器可以调用反应式客户端并返回反应式类型。
在 Servlet 栈中处理“application/json”时,Spring MVC 也会将 Flux 中的元素收集到一个 List 中,并以 JSON 数组的形式写到响应消息里:
@GetMapping("/cars") public Flux<Car> getCars() { return this.repository.findAll(); }
而对于“application/stream+json”和其他流式媒体类型,Spring MVC 会用到反应式流回压:
@GetMapping(path = "/cars", produces = "application/stream+json") public Flux<Car> getCarStream() { return this.repository.findCars(); }
不过,与反应式栈不同的是,Servlet 栈的写入操作是阻塞式的,而且是在单独的线程中进行的:
做出选择
你应该选择哪个?Spring MVC 还是Spring WebFlux?
这是个很现实的问题,但却难以给出答案。这两个框架各有优势,也存在一些交集。或许,我们可以把它们放在一起,这样就可以看到更多的可能性。
Spring MVC 基于典型的 Servlet 栈提供了一个简单的命令式模型,可用于阻塞或非阻塞式的场景。Spring WebFlux 提供了事件循环式的并发模型和函数式的编程模型。我们可以在它们的交集中看到共性的东西。
如果你的应用程序没有伸缩方面的问题,或者横向伸缩的成本在预算之内,那么就没必要做出任何改变。命令式的代码是最容易编写的,而且我希望在不久的将来,大部分应用程序能够归到这个频谱的一边。
当然,命令式代码也会越变越复杂。对于现代应用程序来说,调用远程服务是件很常见的事。有时候,使用命令式风格进行同步调用是没有问题的,但如果要在有限的硬件资源上进行伸缩,那么就需要考虑并发和异步。反应式编程为此提供了有效的解决方案。我希望大部分应用程序能够在Spring MVC 中使用WebClient 或其他反应式类库(如Spring Data 反应式Repository),因为这样只需要做出少许的改动,却会带来很多好处。
反应式栈和Spring WebFlux 有多方面的好处。或许,有些场景所要求的高并发和低延迟是传统阻塞式IO 无法提供的,又或者横向或纵向伸缩的成本太高。对Spring Cloud Gateway 来说,Spring WebFlux 显然是个正确的选择,因为它有高并发方面的需求。
反应式栈的反应式处理管道非常适用于流式场景当中,而大部分应用程序都有这样的场景。它支持反应式流回压和非阻塞写入操作,可用于处理请求消息和响应消息。
有些人选择了反应式栈,只是为了能够使用函数式编程模型。函数式端点在Kotlin 应用程序中非常流行,WebFlux 提供了一个Kotlin DSL 用于处理路由请求消息。因为函数式编程模型的简单和清晰,或许十分合适用在微服务上。
这篇文章所用到的代码可以在 demo-reactive-spring 仓库中获取。
关于作者
Rossen Stoyanchev 是 Spring 的贡献者,经历了三代 Spring MVC 框架的开发和 Spring WebFlux 从诞生到发布的整个过程。
评论 2 条评论