2018-11-09 11:10

       我们都看看,Spring WebFlux是随Spring 5推出的响应式Web框架。好了,我们今天开始进入Spring WebFlux.WebFlux是Spring5.0开始引入的.有别于SpringMVC的Servlet实现,它是完全支持异步和非阻塞的.在正式使用Spring WebFlux之前,我们首先得了解他和Servlet的区别,以及他们各自的优势,这样我们才能够给合适的场景选择合适的开发工具.

     首先我们要问几个问题,为什么要有异步?在异步之前,软件行业做过哪些努力,他们的优势是什么?基于这几个问题,我们今天分享以下三个知识点:

  1. 从Http1.X 到Http2.0

  2. 从Servlet2.x到Servlet3.x

  3. WebFlux的出场

1. 从Http1.x到Http2.0

异步和同步是无法分开的.他们对性能的理解和处理也是各有千秋.传统的web项目因为是基于阻塞I/O模型而建立的,所以他们只能通过对整个链路的优化来提升性能,而这里的性能就包括了伸缩性和响应速度.这里面比较重要的一个环节就是网络传输.相对而言,这也是距离我们的用户最近的一个环节,因此他们对并发的处理以及对响应速度的处理就比其他的会更直接地影响我们的用户.

1.1 Http/1.x

在http1.x中,我们都知道,http会先进行三次握手,握手成功之后,开始传递数据,服务器响应完毕,就进行四次挥手,最后关闭链接.刚开始应用这个概念的时候,是非常受欢迎的,因为在那时候传递的还是静态页面或者动态数据比较少的资源,因此无论是客户端还是服务器端,他都节省了更多的资源.但随着互联网的飞速发展,这种方式就遇到了问题.如果每次传递数据都需要三次握手四次挥手的话,那么随着数据访问量的增加,那么三次握手四次挥手带来的资源消耗就会成为影响系统的瓶颈.这就好像一根针重量可以忽略,但当我们聚集上亿根针的时候,那么他的重量和所占用的空间,就成了必须要考虑的问题了.

那能不能建立好一次链接之后,我多传递几次数据,然后在关闭呢?当然可以,这就是长链接,也就是大家常说的"Keep-Alive".而HTTP1.1则是默认就开启了Keep-Alive.Keep-Alive虽然暂时性的解决了建立链接所带来的开销,也一定程度的提高了响应速度,但后来又凸显了另外两个问题:

  1. 首先,因为http是串行文件传输.所以当客户端请求a文件时,b文件只能等待.等待a链接到服务器,服务器处理文件,服务器返回文件这三个步骤完成后,b才能接着处理.我们假设,链接服务器,服务器处理,服务器返回各需要1秒,那么b处理完的时候就需要6秒,以此类推.(当然,这里有个前提,服务器和浏览器都是单通道的.)这就是我们说的阻塞.

  2. 其次,链接数的问题.我们都知道服务器的链接数是有限的.并且浏览器也对链接数有限制.这样能接入进来的服务就是有个数限制的,当达到这个限制的时候,其他的就需要等待链接被断开,然后新的请求才能够进入.这个比较容易理解.

之所以http1.x会使用串行文件传输,是因为http传输的无论是request还是response都是基于文本的,所以接收端无法知道数据的顺序,因此必须按着顺序传输.这也就限制了只要请求就必须新建立一个链接,这也就导致了第二个问题的出现.

1.2 Http/2

为了从根本上行解决http1.x所遗留的这两个问题,http2引入了二进制数据帧和流的概念.其中帧的作用就是对数据进行顺序标识,这样的话,接收端就可以根据顺序标识来进行数据合并了.同时,因为数据有了顺序,服务器和客户端就可以并行的传输数据,而这就是流所作的事情.

这样,因为服务器和客户端可以借助流进行并行的传递数据,那么同一台客户端就可以使用一个链接来进行传输,此时服务器能处理的并发数就有了质的飞跃.

http/2的这个新特性,就是多路复用.我们可以看到,多路复用的本质就是并行传输.那web对请求的处理是否可以使用这个思路呢?

2.Servlet

现在我们来讨论Servlet与Netty.这两个一个主要是以同步阻塞的方式服务的,另一个是异步非阻塞的.这也就造成了他们适用的场景是不同的.

2.1 Servlet

做JavaWeb研发的几乎没有不知道Servlet的.在Servlet 3.0之前,Servlet采用Thread-Per-Request的方式处理请求,即每一次Http请求都由某一个线程从头到尾负责处理。如果一个请求需要进行IO操作,比如访问数据库、调用第三方服务接口等,那么其所对应的线程将同步地等待IO操作完成, 而IO操作是非常慢的,所以此时的线程并不能及时地释放回线程池以供后续使用,在并发量越来越大的情况下,这将带来严重的性能问题。为了解决这一的问题,Servlet3.0引入了异步处理.

在Servlet 3.0中,我们可以从HttpServletRequest对象中获得一个AsyncContext对象,该对象构成了异步处理的上下文,Request和Response对象都可从中获取。AsyncContext可以从当前线程传给另外的线程,并在新的线程中完成对请求的处理并返回结果给客户端,初始线程便可以还回给容器线程池以处理更多的请求。如此,通过将请求从一个线程传给另一个线程处理的过程便构成了Servlet 3.0中的异步处理。

这里举个例子,对于一个需要完成长时处理的Servlet来说,其实现通常为:

 

package top.lianmengtu.testjson.servlet;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

//@WebServlet("/syncHello"),因为使用的SpringBoot模拟,所以注释掉该注解
public class MyServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException,       IOException {
        super.doGet(req, resp);
        new LongRunningProcess().run();
        System.out.println("HelloWorld");
    }
}

LongRunningProcess实现如下:

package top.lianmengtu.testjson.servlet;

import java.util.concurrent.ThreadLocalRandom;

public class LongRunningProcess {
    public void run(){
        try {
            int millis = ThreadLocalRandom.current().nextInt(2000);
            String currentThread = Thread.currentThread().getName();
            System.out.println(currentThread + " sleep for " + millis + " milliseconds.");
            Thread.sleep(millis);

        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

我们现在将MyServlet注入到Spring容器中:

@Bean
public ServletRegistrationBean servletRegistrationBean(){
    return new ServletRegistrationBean(new MyServlet(),"/syncHello");
}

此时的SyncHelloServlet将顺序地先执行LongRunningProcess的run()方法,然后在控制台打印HelloWorld.而3.0则提供了对异步的支持,因此在Servlet3.0中我们可以这么写:

 

@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    AsyncContext asyncContext=req.startAsync();
    asyncContext.start(()->{
        new LongRunningProcess().run();
        try {
            asyncContext.getResponse().getWriter().print("HelloWorld");
        } catch (IOException e) {
            e.printStackTrace();
        }
        asyncContext.complete();
    });

}

此时,我们先通过request.startAsync()获取到该请求对应的AsyncContext,然后调用AsyncContext的start()方法进行异步处理,处理完毕后需要调用complete()方法告知Servlet容器。start()方法会向Servlet容器另外申请一个新的线程(可以是从Servlet容器中已有的主线程池获取,也可以另外维护一个线程池,不同容器实现可能不一样),然后在这个新的线程中继续处理请求,而原先的线程将被回收到主线程池中。事实上,这种方式对性能的改进不大,因为如果新的线程和初始线程共享同一个线程池的话,相当于闲置下了一个线程,但同时又占用了另一个线程。

Servlet 3.0对请求的处理虽然是异步的,但是对InputStream和OutputStream的IO操作却依然是阻塞的,对于数据量大的请求体或者返回体,阻塞IO也将导致不必要的等待。因此在Servlet 3.1中引入了非阻塞IO,通过在HttpServletRequest和HttpServletResponse中分别添加ReadListener和WriterListener方式,只有在IO数据满足一定条件时(比如数据准备好时),才进行后续的操作。

虽然Servlet3.1提供了异步的方式,并且做的也比Servlet3.0更彻底,但是如果我们使用了Servlet3.1提供的异步接口,像刚刚的代码演示的那样,那么我们在之后的处理中就没有办法再使用他原来的接口了.这就让我们处于了一种非此即彼的状况中.如果是这样,Servlet系列的技术,如SpringMVC也就是这样了.那怎么办呢?

3. WebFlux的出场

现在我们会从以下几个层面来探讨WebFlux

  1. 为什么要有WebFlux?

  2. Reactive定义与ReactiveAPI

  3. WebFlux中的性能问题

  4. WebFlux的并发模型

  5. WebFlux的适用性

3.1为什么要有WebFlux

首先,为什么要有webFlux?

在前面两部分,我们一直在探讨并发问题.为了解决并发,我们需要使用非阻塞的web技术栈.因为非阻塞的web栈使用的线程数更少,对硬件资源的要求更低.虽然Servlet3.1为非阻塞I/O提供了一些支持,但刚刚我们提到了,如果我们使用Servlet3.1里的非阻塞API,会导致我们无法再使用它原来的API.并且,自从非阻塞I/O以及异步概念出现之后,就诞生了一批专为异步和非阻塞I/O设计的服务器,比如Netty,这就催生了新的能服务于各种非阻塞I/O服务器的统一的API.

WebFlux诞生的另一个重要原因是函数式程序设计.随着脚本型语言(Nodejs,Angular等)的扩张,函数式程序设计以及后继式API也相继火起来.以至于Java也在Java8中引入了Lambda来对函数式程序设计进行支持,又引入了StreamAPI来对后继式程序进行支持.由此,对具备函数式编程和后继式程序设计的Web框架的需求也越来越大了。

3.2Reactive的定义与API

Reactive的定义

我们接触了"非阻塞"和"函数式",那reactive是什么意思呢?

 "reactive"这个术语指的是:围绕着对改变做出响应的程序设计模型---网络组件对IO事件做出响应,UIController对鼠标事件做出响应等等.在那种情况下,非阻塞取代了阻塞是响应式的,我们正处于响应模式中,当操作完成和数据变得可用的时候发起通知.

还有另一个重要的机制那就是我们在spring team里整合"reactive"以及非阻塞式背压机制.在同步里,命令式的代码,阻塞式地调用服务为普通的表单充当背压机制强迫调用者等待.在非阻塞式编程中,控制事件的频率就变得很重要防止快速的生产者不会压垮他的目的地.

Reactive Streams 是一个定义了使用背压机制的异步组件之间交互设计的小型说明书(在Java9中也采纳了).例如,一个数据仓库(可以看做Publisher)可以生产数据,然后HTTP Server(看做订阅者)可以写入到响应里.Reactive Streams的主要目的是让订阅者可以控制生产者产生数据的速度有多快或有多慢.

Reactive API

Reactive Streams 在互操作性上扮演了一个很重要的角色.类库和基础设施组件虽然有趣,但对于应用程序API来说却用处甚少,因为他们太底层了.应用程序需要一个更高级别更丰富的函数式API来编写异步逻辑---和Java8里的StreamAPI很类似,不过不仅仅是为集合做准备的.

Reactor 是为SpringWebFlux选择的一个reactive类库.它提供了Mono和Flux类型的API来处理0..1(Mono)和0..N(Flux)数据序列化通过一组丰富的操作集和ReactiveX vocabulary of operators对齐.Reactor 是一个Reactive Streams类库,所以他所有的操作都支持非阻塞背压机制.Reactor强烈地聚焦于Server端的Java.他在发展上和Spring有着紧密的协作.

WebFlux要求Reactor作为一个核心依赖,但凭借Reactive Streams也可以和其他的reactive libraries一起使用.一般来说,一个WebFlux API 接收一个Publisher作为输入,转换给一个内置的Reactor类型来使用,最后返回一个Flux或一个Mono作为输出.所以,你可以批准任何的Publisher作为输入,你可以应用操作在输出上,但你因为你使用了其他的reactive library所以你需要进行转换.只要可行(例如,注解controllers),WebFlux可以在使用RXJava和另一个reactive library之间透明的改变.看获取更多地细节.

3.3 性能

性能这个词有很多特征和含义.Reactive 和非阻塞通常不会使应用程序运行地更快.在某些场景下,他们也可以.(例如,在并行条件下使用WebClient来执行远程调用的话).整体来说,非阻塞方式可能需要做更多的工作并且他也会稍微增加请求处理的时间.

对reactive和非阻塞好处的预期关键在于使用小,固定的线程数和更少的内存来扩展的能力.这使应用程序在加载的时候更加有弹性,因为他们以一种更可以预测的方式扩展.然而为了看到这些好处,你需要一些延迟(包括比较慢的不可预知的网络I/O).那是响应式堆栈开始显示他力量的地方,并且这些不同是非常吸引人的.

3.4并发模型

Spring MVC和Spring WebFlux都支持注解Controllers,但他们在并发模型和对阻塞和线程的默认呈现(assumptions)上是非常不同的.在Spring MVC(和通用的servlet应用)中,都假设应用程序是阻塞当前线程的(例如,远程调用),并且出于这个原因,servlet容器处理请求的期间使用一个巨大的线程池来吸收潜在的阻塞.

在Spring WebFlux(和非阻塞服务器)中,假设应用程序是非阻塞的,所以,非阻塞服务器使用小的,固定代销的线程池(event loop workders)来处理请求.

 "弹性伸缩"和"小数量的线程"或许听起来矛盾,但是对于不会阻塞当前线程(用依赖回调来取代)意味着你不需要额外的线程,因为非阻塞调用给处理了.

调用一个阻塞API

      要是你需要使用阻塞库怎么办?Reactor和RxJava都提供了publishOn操作用一个不同的线程来继续处理.那意味着有一个简单的脱离舱口(一个可以离开非阻塞的出口).然而,请牢记,阻塞API对于并发模型来说不太合适.

易变的状态

        在Reactor和RxJava里,你通过操作符生命逻辑,在运行时在不同的阶段里,都会形成一个进行数据序列化处理的管道.这样做的一个主要好处就是把应用程序从不同的状态保护中解放了出来,因为管道中的应用代码是绝不会被同时调用的.

线程模型

在运行了一个使用Spring WebFlux的服务器上,你期望看到什么线程呢?

  • 在一个"vanilla"Spring WebFlux服务器上(例如,没有数据访问也没有其他可选的依赖),你能够看到一个服务器线程和几个其他的用来处理请求的线程(一般来说,线程的数目和CPU的核数是一样的).然而,Servlet容器在启动的时候就使用了更多的线程(例如,tomcat是10个),来支持servlet(阻塞)I/O和servlet3.1(非阻塞)I/O的用法.

  • 响应式的WebClient操作是用Event Loop方式.所以你可以看到少量的固定数量的线程和他关联.(例如,使用了Reactor Netty连接的reactor-http-nio).然而,如果Reactor Netty在客户端和服务端都被使用了,这两者之间的event loop资源默认是被共享的.

  • Reactor和RxJava提供了抽象化的线程池,调度器目的是结合publishOn操作符在不同的线程池之间切换操作.调度器有一个名字,建议这个名字是一个具体的并发策略--例如,"parallel"(因为CPU-bound使用有限的线程数来工作)或者"elastic"(因为I/O-bound使用大量的线程来工作).如果你看到这类的线程,这就意味着一些代码正在使用一个具体的使用了Scheduler策略的线程池.

  • 数据访问库和其他第三方库依赖也创建和使用了他们自己的线程.

    今天讲的内容,如有讲的欠缺地方,还希望大家多多支持。若文中有所错误之处,还望提出,谢谢.

    原文出处:melon-jj


评论