概述
为什么创建了Spring WebFlux?
部分答案在于需要一个非阻塞式Web栈来处理少量线程的并发问题,并且能够在较少的硬件资源下实现扩展。Servlet的非阻塞I/O特性与其他Servlet API(如同步的Filter、Servlet方法,或阻塞的getParameter、getPart方法)有所不同。因此,人们有了开发新的通用API的动机,该API可以作为任何非阻塞运行时的基础。这一点尤为重要,因为像Netty这样的服务器在异步、非阻塞技术领域已经有着成熟的实现和广泛的应用。
答案的另一部分是函数式编程。正如Java 5中加入注解(如带注解的REST控制器或单元测试)创造了新的可能性一样,Java 8中引入的lambda表达式也为Java中的函数式API带来了机会。这对于非阻塞应用程序以及采用延续式设计(continuation-style)的API来说是一大福音(这类API以CompletableFuture和ReactiveX为代表),它们允许以声明式的方式组合异步逻辑。在编程模型层面,Java 8使得Spring WebFlux能够提供函数式的Web端点,与带注解的控制器并存。
定义“Reactive”
我们提到了“非阻塞(non-blocking)”和“函数式(functional)”,但“反应式(reactive)”到底是什么意思呢?
“反应式(reactive)”这一术语指的是围绕对变化的响应来构建的编程模型——网络组件对I/O事件作出响应,用户界面控制器对鼠标事件作出响应,等等。从这个意义上说,非阻塞(non-blocking)也是反应式的,因为我们现在不是处于等待状态,而是能够在操作完成或数据可用时立即作出响应的模式。
在Spring团队看来,还有另一种与“响应式”(reactive)相关的重要机制,那就是非阻塞式的背压(non-blocking back pressure)。在同步的、命令式的代码中,阻塞调用(blocking calls)本身就是一种自然的背压形式,它会迫使调用者等待。而在非阻塞代码中,控制事件的发送速率就变得非常重要,这样才能防止快速产生的事件(fast producer)压垮其接收端(destination)。
常见问题:如果发布者无法减速怎么办?
Reactive Streams的用途仅仅是建立机制和设定边界。如果发布者无法减速,那么就必须决定是进行缓冲、丢弃数据还是直接失败。
反应式API
Reactive Streams在互操作性方面发挥着重要作用。对于库和基础设施组件来说,它很有用,但作为应用程序API时则不太实用,因为它属于太底层的工具。应用程序需要一个更高层次、更丰富的函数式API来组合异步逻辑——类似于Java 8的Stream API,但不仅仅适用于集合操作。而这正是反应式编程库所扮演的角色。
WebFlux 需要 Reactor 作为核心依赖,但它可以通过 Reactive Streams 与其他反应式库进行互操作。一般来说,WebFlux API 接受一个普通的 Publisher 作为输入,在内部将其适配为 Reactor 类型,然后使用该类型,并返回一个 Flux 或 Mono 作为输出。因此,你可以传递任何 Publisher 作为输入,并对输出应用操作,但你需要将输出适配以便与其他反应式库一起使用。在可行的情况下(例如,带有注解的控制器),WebFlux 会透明地适配 RxJava 或其他反应式库的使用方式。有关更多详细信息,请参阅 Reactive Libraries。
除了Reactive APIs之外,WebFlux还可以与Kotlin中的协程(Coroutines) API一起使用,后者提供了一种更为命令式的编程风格。以下的Kotlin代码示例将结合协程API进行展示。
编程模型
spring-web 模块包含了 Spring WebFlux 的基础框架,包括 HTTP 抽象、针对支持服务器的 Reactive Streams 适配器、编解码器,以及一个核心 WebHandler API。该 API 与 Servlet API 相似,但采用了非阻塞契约。
在这个基础上,Spring WebFlux提供了两种编程模型的选择:
适用性
Spring MVC还是WebFlux?
这是一个自然会提出的问题,但这种提问方式建立了一个不合理的二分法。实际上,两者是相互配合来扩大可用选项范围的。这两者是为了彼此之间的连贯性和一致性而设计的,可以并行使用,而且每一方的反馈都会对另一方有所帮助。下图展示了两者之间的关系、它们的共同点以及各自所独有的支持功能:

我们建议您考虑以下具体要点:
-
如果你的Spring MVC应用程序运行良好,那么就没有必要进行更改。命令式编程是编写、理解和调试代码最简单的方式。你在库的选择上有着最大的自由度,因为从历史角度来看,大多数库都是阻塞式的。
-
如果你已经在寻找非阻塞的Web框架栈,那么Spring WebFlux提供了与该领域其他框架相同的执行模型优势,同时还提供了服务器选择(Netty、Tomcat、Jetty和Servlet容器)、编程模型选择(带注解的控制器和函数式Web端点),以及反应式库选择(Reactor、RxJava或其他)。
-
如果你对使用Java 8的Lambda表达式或Kotlin的轻量级、函数式Web框架感兴趣,你可以使用Spring WebFlux的函数式Web端点。对于需求不那么复杂的小型应用程序或微服务来说,这也是一个不错的选择,因为它们可以从更高的透明度和控制性中受益。
-
在微服务架构中,你可以同时拥有使用Spring MVC或Spring WebFlux控制器,或者使用Spring WebFlux函数式端点的应用程序。由于两种框架都支持相同的基于注解的编程模型,因此可以更容易地重用知识,同时也能为每项任务选择合适的工具。
-
评估一个应用程序的一个简单方法是检查其依赖关系。如果你需要使用阻塞式的持久化API(JPA、JDBC)或网络API,那么至少对于常见的架构来说,Spring MVC是最佳选择。从技术上讲,Reactor和RxJava也可以在单独的线程上执行阻塞调用,但这样你就无法充分利用非阻塞Web框架栈的优势了。
-
如果你的Spring MVC应用程序有对远程服务的调用,可以尝试使用反应式的
WebClient。你可以直接从Spring MVC控制器方法中返回反应式类型(Reactor、RxJava或其他)。每次调用的延迟越大,或者调用之间的相互依赖性越强,其带来的好处就越明显。Spring MVC控制器也可以调用其他反应式组件。 -
如果你的团队规模较大,请记住转向非阻塞、函数式和声明式编程的学习曲线较为陡峭。一个实际的做法是从使用反应式的
WebClient开始,而不是一下子完全切换。除此之外,从小处着手,逐步衡量其带来的好处。我们认为,对于大多数应用程序来说,这种转变并非必要。如果你不确定应该关注哪些好处,可以先了解非阻塞I/O的工作原理(例如,在单线程的Node.js上的并发)及其效果。
服务器
Spring WebFlux没有内置的支持来启动或停止服务器的功能。然而,从Spring配置和WebFlux基础设施中组装一个应用程序,并用几行代码运行它是相当简单的。
Spring Boot有一个WebFlux启动器,可以自动完成这些步骤。默认情况下,该启动器使用Netty,但通过更改Maven或Gradle的依赖项,可以很容易地切换到Tomcat或Jetty。Spring Boot默认使用Netty,是因为Netty在异步、非阻塞领域应用更为广泛,并且可以让客户端和服务器共享资源。
Tomcat和Jetty都可以与Spring MVC和WebFlux一起使用。然而,需要注意的是,它们的使用方式有很大的不同。Spring MVC依赖于Servlet阻塞I/O,如果需要的话,应用程序可以直接使用Servlet API。而Spring WebFlux则依赖于Servlet非阻塞I/O,并在底层适配器的基础上使用Servlet API,该API并不直接暴露给应用程序使用。
强烈建议不要在 WebFlux 应用程序的上下文中映射 Servlet 过滤器或直接操作 Servlet API。出于上述原因,在同一上下文中混合阻塞式 I/O 和非阻塞式 I/O 会导致运行时问题。
性能
性能有很多特性和含义。响应式(reactive)和非阻塞(non-blocking)通常并不会让应用程序运行得更快。在某些情况下,它们确实可以——例如,当使用WebClient并行执行远程调用时。然而,采用非阻塞方式来实现功能需要更多的工作量,这可能会稍微增加所需的处理时间。
反应式和非阻塞编程方式的主要预期好处在于,它们能够使用少量固定数量的线程来扩展,并且消耗较少的内存。这使得应用程序在负载下更具弹性,因为它们的扩展方式更加可预测。然而,为了观察到这些好处,系统中需要存在一定的延迟(包括慢速且不可预测的网络I/O操作)。正是在这种环境下,反应式编程栈的优势开始显现出来,而且其优势往往非常显著。
并发模型
Spring MVC和Spring WebFlux都支持带注解的控制器,但在并发模型以及对阻塞和线程的默认假设方面存在一个关键差异。
在Spring MVC中(以及一般的servlet应用程序中),假设应用程序可以阻塞当前线程(例如,进行远程调用时)。因此,servlet容器使用一个较大的线程池来吸收请求处理过程中可能出现的阻塞情况。
在Spring WebFlux(以及一般的非阻塞服务器)中,假设应用程序不会进行阻塞。因此,非阻塞服务器使用一个小型、固定大小的线程池(事件循环工作者)来处理请求。
“可扩展性”(to scale)和“少量的线程”(small number of threads)听起来似乎相互矛盾,但实际上,如果永远不阻塞当前线程(而是依赖回调函数),那就意味着你不需要额外的线程了,因为不存在需要处理的阻塞调用。
调用阻塞型API
如果你确实需要使用阻塞型库呢?Reactor和RxJava都提供了publishOn操作符,可以在不同的线程上继续处理。这意味着有一个简单的解决办法。然而,请记住,阻塞型API并不适合这种并发模型。
可变状态
在Reactor和RxJava中,你通过操作符来声明逻辑。在运行时,会形成一个反应式管道(reactive pipeline),数据在这个管道中按顺序、分不同的阶段进行处理。这样做的一个关键好处是,它让应用程序无需再担心保护可变状态(mutable state)的问题,因为该管道内的应用程序代码永远不会被并发调用。
线程模型
在运行着Spring WebFlux的服务器上,你可能会看到哪些线程?
-
在一个“基础”的Spring WebFlux服务器上(例如,不包含数据访问或其他可选依赖),你可以预期服务器只有一个线程,而用于请求处理的线程则有几个(通常与CPU核心的数量相同)。然而,Servlet容器可能会启动更多的线程(例如,在Tomcat中可能有10个),以支持Servlet(阻塞式)I/O和Servlet 3.1(非阻塞式)I/O的使用。
-
反应式的
WebClient采用事件循环的方式运行。因此,你会看到与之相关的处理线程数量较少且是固定的(例如,使用Reactor Netty连接器的reactor-http-nio-)。但是,如果客户端和服务器都使用Reactor Netty,那么它们会默认共享事件循环资源。 -
Reactor和RxJava提供了线程池抽象,称为调度器(schedulers),可以与
publishOn操作符一起使用,该操作符用于将处理任务切换到不同的线程池。这些调度器的名称暗示了特定的并发策略——例如,“parallel”(适用于需要大量线程的CPU密集型任务)或“elastic”(适用于需要大量线程的I/O密集型任务)。如果你看到这样的线程,那就意味着某些代码正在使用特定的线程池调度策略。 -
数据访问库和其他第三方依赖也可能创建并使用自己的线程。