1. 程式人生 > >熵增學院-Anders-SpringBoot中的Http應用---WebFlux

熵增學院-Anders-SpringBoot中的Http應用---WebFlux

我們今天開始進入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之間透明的改變.看Reactive Libraries獲取更多地細節.

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策略的執行緒池.

  • 資料訪問庫和其他第三方庫依賴也建立和使用了他們自己的執行緒.

下次我們來分享Spring WebFlux的使用.

本文相關視訊