SpringMVC異步處理模式分析(DeferredResult/SseEmitter等)

1. 背景
Tomcat等應用服務器的連接線程池實際上是有限制的;每一個連接請求都會耗掉線程池的一個連接數;如果某些耗時很長的操作,如對大量數據的查詢操作、調用外部系統提供的服務以及一些IO密集型操作等,會佔用連接很長時間,這個時候這個連接就無法被釋放而被其它請求重用。如果連接佔用過多,服務器就很可能無法及時響應每個請求;極端情況下如果將線程池中的所有連接耗盡,服務器將長時間無法向外提供服務!

在常規場景中,客戶端需要等待服務器處理完畢後返回才能繼續進行其它操作,這個場景下每一步都是同步調用,如客戶端調用Servlet後需要等待其處理返回,Servlet調用具體的Controller後也需要等待其返回。這種情況是在服務器端開發中最常見的場景,適合於服務器端處理時間不是很長的情況;默認情況下Spring的Controller提供的就是這樣的服務。

當某項服務處理時間過長時,如郵件發送,需要調用到外部接口,處理時間不受調用方的控制,因此如果耗時過長會有兩個比較嚴重的後果:一是如上文所說的會長時間的佔用請求連接數,嚴重時有可能導致服務器失去響應; 二是客戶端等待時間過長,導致前端應用的用戶友好性下降,而且客戶很有可能因爲長時間得不到服務器響應而重複操作,從而加重服務器的負擔,使得應用崩潰的機率變大!
爲應對這種場景,一般會啓用一個後臺的線程池,處理請求的Controller會先提交一個耗時長操作如郵件發送到線程池中,然後立即返回到前臺。因此處理響應的主線程耗時變短,客戶感受到的就是在點擊某個發送按鈕後很快就得到服務器反饋結果,然後就放心的繼續處理其它工作。實際上郵件發送這種事情延遲幾秒對於客戶來說根本感受不到。當然應用需要保證提交到線程池中的任務執行成功,或者是執行失敗後在前端某個地方能夠看到失敗的具體情況。

這種場景在Spring中可使用TaskExecutor或者是Async來處理,關於它們的用法請參考:Spring基礎學習-任務執行(TaskExecutor及Async)

通過以上兩種場景,很容易就會想到,如果某個操作既耗時很長,客戶端又必須要等待其返回才能進一步處理時,應該通過什麼方式來處理?Servlet3.0中引入異步請求處理來處理這種場景,相應的,Spring在3.2版本中就引入相關機制來使用Servlet的該特性。

2. SpringMVC異步處理概述
爲滿足耗時任務佔用應用服務器連接數,而客戶端又必須等待這些耗時長任務返回才能處理下一步工作的場景,Spring引入了以下機制來處理:

使用Callable或者DeferredResult當成Controller的返回值,能夠處理異步返回單個結果的場景
使用ResponseBodyEmitter/SseEmitter或者StreamingResponseBody來流式處理多個返回值
在Controller中使用響應式客戶端調用服務並返回響應式的數據對象
2.1 Callable
Callable直接使用在Controller中被RequestMapping所註解的方法上,做爲其返回對象。
使用示例:

@RequestMapping("/testCallable")
public Callable<String> testCallable() {
    logger.info("Controller開始執行!");
    Callable<String> callable = () -> {
        Thread.sleep(5000);

        logger.info("實際工作執行完成!");

        return "succeed!";
    };
    logger.info("Controller執行結束!");
    return callable;
}

使用瀏覽器訪問http://localhost/test/testCallable, 結果如下:

2018-03-12 22:38:05.547  INFO 4980 --- [p-nio-80-exec-2] c.l.t.b.e.controllers.TestController     : Controller開始執行!
2018-03-12 22:38:05.553  INFO 4980 --- [p-nio-80-exec-2] c.l.t.b.e.controllers.TestController     : Controller執行結束!
2018-03-12 22:38:10.560  INFO 4980 --- [      MvcAsync1] c.l.t.b.e.controllers.TestController     : 實際工作執行完成!

可以看到以下結果:

  • 瀏覽器等待了大約5秒後返回結果
  • 打印日誌中,Controller在6ms就執行結束
  • 打印日誌中,實際的任務執行在一個名稱爲MvcAsync1的線程中執行,並且在Controller執行完5s後才執行結束

因此可以得到結論:

返回Callable對象時,實際工作線程會在後臺處理,Controller無需等待工作線程處理完成,但Spring會在工作線程處理完畢後才返回客戶端。
它的執行流程是這樣的:

客戶端請求服務
SpringMVC調用Controller,Controller返回一個Callback對象
SpringMVC調用ruquest.startAsync並且將Callback提交到TaskExecutor中去執行
DispatcherServlet以及Filters等從應用服務器線程中結束,但Response仍舊是打開狀態,也就是說暫時還不返回給客戶端
TaskExecutor調用Callback返回一個結果,SpringMVC將請求發送給應用服務器繼續處理
DispatcherServlet再次被調用並且繼續處理Callback返回的對象,最終將其返回給客戶端
2.2 DeferredResult
DeferredResult使用方式與Callable類似,但在返回結果上不一樣,它返回的時候實際結果可能沒有生成,實際的結果可能會在另外的線程裏面設置到DeferredResult中去。
該類包含以下日常使用相關的特性:

超時配置:通過構造函數可以傳入超時時間,單位爲毫秒;因爲需要等待設置結果後才能繼續處理並返回客戶端,如果一直等待會導致客戶端一直無響應,因此必須有相應的超時機制來避免這個問題;實際上就算不設置這個超時時間,應用服務器或者Spring也會有一些默認的超時機制來處理這個問題。
結果設置:它的結果存儲在一個名稱爲result的屬性中;可以通過調用setResult的方法來設置屬性;由於這個DeferredResult天生就是使用在多線程環境中的,因此對這個result屬性的讀寫是有加鎖的。
接下來將對DeferredResult的處理流程進行說明,並實現一個較爲簡單的示例。

2.2.1 DeferredResult處理流程
DeferredResult的處理過程與Callback類似,不一樣的地方在於它的結果不是DeferredResult直接返回的,而是由其它線程通過同步的方式設置到該對象中。它的執行過程如下所示:

客戶端請求服務
SpringMVC調用Controller,Controller返回一個DeferredResult對象
SpringMVC調用ruquest.startAsync
DispatcherServlet以及Filters等從應用服務器線程中結束,但Response仍舊是打開狀態,也就是說暫時還不返回給客戶端
某些其它線程將結果設置到DeferredResult中,SpringMVC將請求發送給應用服務器繼續處理
DispatcherServlet再次被調用並且繼續處理DeferredResult中的結果,最終將其返回給客戶端
2.2.2 DeferredResult使用示例
本示例將在一個Controller中添加兩個RequestMapping註解的方法。其中一個返回的是DeferredResult的對象,另外一個設置這個對象的值。

@RestController
@RequestMapping("/test")
public class TestController {
    private static final Logger logger = LoggerFactory.getLogger(TestController.class);

    @Autowired
    private AsyncService asyncService;

    private DeferredResult<String> deferredResult = new DeferredResult<>();

     /**
     * 返回DeferredResult對象
     *
     * @return
     */
    @RequestMapping("/testDeferredResult")
    public DeferredResult<String> testDeferredResult() {
        return deferredResult;
    }

    /**
     * 對DeferredResult的結果進行設置
     * @return
     */
    @RequestMapping("/setDeferredResult")
    public String setDeferredResult() {
        deferredResult.setResult("Test result!");
        return "succeed";
    }
}

第一步先訪問:http://localhost/test/testDeferredResult
此時客戶端將會一直等待,直到一定時長後會超時
第二步再新開頁面訪問:http://localhost/test/setDeferredResult
此時第一個頁面會返回結果。

2.3 SseEmitter
Callback和DeferredResult用於設置單個結果,如果有多個結果需要返回給客戶端時,可以使用SseEmitter以及ResponseBodyEmitter等;
下面直接看示例,與DeferredResult的示例類似:

@RestController
@RequestMapping("/test")
public class TestController {
    private static final Logger logger = LoggerFactory.getLogger(TestController.class);

    @Autowired
    private AsyncService asyncService;

    private DeferredResult<String> deferredResult = new DeferredResult<>();

    private SseEmitter sseEmitter = new SseEmitter();

    /**
     * 返回SseEmitter對象  
     * 
     * @return
     */
    @RequestMapping("/testSseEmitter")
    public SseEmitter testSseEmitter() {
        return sseEmitter;
    }

    /**
     * 向SseEmitter對象發送數據  
     * 
     * @return
     */
    @RequestMapping("/setSseEmitter")
    public String setSseEmitter() {
        try {
            sseEmitter.send(System.currentTimeMillis());
        } catch (IOException e) {
            logger.error("IOException!", e);
            return "error";  
        }

        return "Succeed!"; 
    }

     /**
     * 將SseEmitter對象設置成完成
     *
     * @return
     */
    @RequestMapping("/completeSseEmitter")
    public String completeSseEmitter() {
        sseEmitter.complete();

        return "Succeed!";
    }
}

第一步訪問:http://localhost/test/testSseEmitter
第二步連續訪問:http://localhost/test/setSseEmitter
第三步訪問:http://localhost/test/completeSseEmitter
可以看到結果,只有當第三步執行後,第一步的訪問纔算結束。

2.4 StreamingResponseBody
用於直接將結果寫出到Response的OutputStream中; 如文件下載等,示例:

@GetMapping("/download")
public StreamingResponseBody handle() {
    return new StreamingResponseBody() {
        @Override
        public void writeTo(OutputStream outputStream) throws IOException {
            // write...
        }
    };
}

3 異步處理攔截器
在進行異步處理時,可以使用CallableProcessingInterceptor來對Callback返回參數的情況進行攔截,也可以使用DeferredResultProcessingInterceptor來對DeferredResult的情況進行攔截。 也可以直接使用AsyncHandlerInterceptor 。
攔截器的使用與普通攔截器並無不一樣的,因此此處不再展開。具體可以參考: Spring Boot攔截器示例及源碼原理分析

參考資料

https://spring.io/blog/2012/05/07/spring-mvc-3-2-preview-introducing-servlet-3-async-support

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章