Spring Aysnc 最佳實踐

1. @Async 的工作機制

首先爲方法添加 `Async` 註解。接着,Spring 會基於 `proxyTargetClass` 屬性,爲包含 `Async` 定義的對象創建代理(JDK Proxy/CGlib)。最後,Spring 會嘗試搜索與當前上下文相關的線程池,把該方法作爲獨立的執行路徑提交。確切地說,Spring 會搜索唯一的 `TaskExecutor` bean 或者名爲 `taskExecutor` 的 bean。如果找不到,則使用默認的 `SimpleAsyncTaskExecutor`。

要完成上面的過程,使用中需要注意幾個限制,否則會出現 `Async` 不起作用的情況。

2. @Async 的限制

1. 必須在標記 `@ComponentScan` 或 `@configuration` 的類中使用 `@Async`。

2.1 在類中使用 Async 註解

package com.example.ask2shamik.springAsync.demo;

import java.util.Map;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;

@Component
public class AsyncMailTrigger {
    @Async
    public void senMail(Map<String,String> properties) {
        System.out.println("Trigger mail in a New Thread :: "  + Thread.currentThread().getName());
        properties.forEach((K,V)->System.out.println("Key::" + K + " Value ::" + V));
    }
}

2.2 Caller 類

package com.example.ask2shamik.springAsync.demo;

import java.util.HashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Component
public class AsyncCaller {
    @Autowired
    AsyncMailTrigger asyncMailTriggerObject;

    public void rightWayToCall() {
        System.out.println("Calling From rightWayToCall Thread " + Thread.currentThread().getName());
        asyncMailTriggerObject.senMail(populateMap());
    }
    
    public void wrongWayToCall() {
        System.out.println("Calling From wrongWayToCall Thread " + Thread.currentThread().getName());
        AsyncMailTrigger asyncMailTriggerObject = new AsyncMailTrigger();
        asyncMailTriggerObject.senMail(populateMap());
    }

    private Map<String,String> populateMap(){
        Map<String,String> mailMap= new HashMap<String,String>();
        mailMap.put("body", "A Ask2Shamik Article");
        return mailMap;
    }
}

上面的例子中,使用了 `@Autowired` 的 `AsyncMailTrigger` 受 `@ComponentScan` 管理,因而會創建新線程執行。而 `WrongWayToCall` 方法中創建的局部對象,不受 `@ComponentScan` 管理,不會創建新線程。

 2.3 輸出

Calling From rightWayToCall Thread main
2019-03-09 14:08:28.893  INFO 8468 --- [           main] o.s.s.concurrent.ThreadPoolTaskExecutor  : Initializing ExecutorService 'applicationTaskExecutor'
Trigger mail in a New Thread :: task-1
Key::body Value ::A Ask2Shamik Article
++++++++++++++++
Calling From wrongWayToCall Thread main
Trigger mail in a New Thread :: main
Key::body Value ::A Ask2Shamik Article

2. 不要在 `private` 方法上使用 `@Async` 註解。由於在運行時不能創建代理,所以不起作用。

@Async
private void senMail() {
    System.out.println("A proxy on Private method "  + Thread.currentThread().getName());
}

3. 調用 `methodAsync` 的 `caller` 方法與 `@Async` 方法應該在不同的類中定義。否則,儘管創建了代理對象,但 `caller` 會繞過代理直接調用方法,不會創建新線程。

2.4 示例

package com.example.ask2shamik.springAsync.demo;

import java.util.HashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;

@Component
public class AsyncCaller {
    @Autowired
    AsyncMailTrigger asyncMailTriggerObject;

    public void rightWayToCall() {
        System.out.println("Calling From rightWayToCall Thread " + Thread.currentThread().getName());
        asyncMailTriggerObject.senMail(populateMap());
    }

    public void wrongWayToCall() {
        System.out.println("Calling From wrongWayToCall Thread " + Thread.currentThread().getName());
        this.senMail(populateMap());
    }

    private Map<String,String> populateMap(){
        Map<String,String> mailMap= new HashMap<String,String>();
        mailMap.put("body", "A Ask2Shamik Article");
        return mailMap;
    }

    @Async
    public void senMail(Map<String,String> properties) {
        System.out.println("Trigger mail in a New Thread :: "  + Thread.currentThread().getName());
        properties.forEach((K,V)->System.out.println("Key::" + K + " Value ::" + V));
    }
}

最後,在執行的時候應當使用 `@EnableAsync` 註解。它的作用是讓 Spring 在後臺線程池中提交 `@Async` 方法。要自定義 `Executor` 可自己實現 bean。在接下來的文章中會給出具體的示例。

package com.example.ask2shamik.springAsync;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableAsync;
import com.example.ask2shamik.springAsync.demo.AsyncCaller;

@SpringBootApplication
@EnableAsync
public class DemoApplication {
    @Autowired
    AsyncCaller caller;

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

    @Bean
    public CommandLineRunner commandLineRunner(ApplicationContext ctx) {
        return args -> {
        caller.rightWayToCall();
        Thread.sleep(1000);
        System.out.println("++++++++++++++++");
        Thread.sleep(1000);
        caller.wrongWayToCall();
        };
    }
}

 

ExceptionHandler

從主線程 fork 新線程時,有兩種情況:

1. "Fire-and-forget":fork 線程,然後爲這個線程分配任務,接下來什麼也不用管。不需要關心任務執行結果,其他業務邏輯的執行也不依賴該結果。通常任務的返回類型是 `void`。讓我們通過例子幫助理解:假設你在爲員工發薪水,需要給每個員工發送一份工資單郵件,你可以異步執行該任務。發郵件顯然不是核心業務邏輯,而是一個橫切關注點。然而,發郵件很好,而且在某些情況下是必須的。這時候需要制定失敗重試或者定時機制。

2. "Fire-with-callback":在主線程中 fork 一個線程,爲該線程分配任務並關聯 `Callback`。接下來,主線程會繼續執行其他任務並持續檢查 `Callback` 結果。主線程需要子線程 `Callback` 執行結果進行下一步工作。

假設你正在做一份員工報告,員工信息根據各自數據類型存儲在不同的後端。General Service 存儲員工通用數據,比如姓名、生日、性別、地址等;Financial Service 存儲薪資、稅金以及其他 PF 相關數據。因此,你會創建兩個並行線程,分別調用 General Service 與 Financial Service。這兩組數據最終都要在報告中體現,因此需要進行數據組合,在主線程中表現爲子線程 Callback 結果。一般會用 `CompletebleFuture` 實現。

在上面描述的場景中,如果一切順利是最理想的結果。但如果執行中發生異常,該如何進行異常處理?

第二種情況下,由於回調執行後能夠返回成功或失敗,因此處理異常非常容易。失敗的時候,異常會被封裝在 `CompltebleFuture` 裏,在主線程中可以檢查異常並處理。處理異常的 Java 代碼很簡單,這裏直接略過。

然而,第一種情況的異常處理非常棘手:創建的線程會執行業務邏輯,但如何確保業務執行成功?或者說,執行失敗該如何進行調試,如何追蹤是什麼地方出現了問題?

解決方案很簡單:注入自己的 exception handler。這樣,當 `Async` 方法執行過程中發生異常,會把程序控制轉交給 handler。你的 handler 知道接下來該如何處理。很簡單,不是嗎?

要做到這一點,需要執行以下步驟:

1. `AsyncConfigurer`:`AsyncConfigurere` 是一個 Spring 提供的接口,包含兩個方法。一個可以重載 `TaskExecutor`(線程池),另一個是 exception handler。Exception handler 支持注入用來捕捉 unCaught 異常,也可以自己定義 class 直接實現。這裏我不會直接實現,而是用 Spring 提供的 `AsyncConfigurerSupport` 類,通過 `@Configuration` 和 `@EnableAsync` 註解提供默認實現。

package com.example.ask2shamik.springAsync;


import java.lang.reflect.Method;
import java.util.concurrent.Executor;
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.SimpleAsyncTaskExecutor;
import org.springframework.lang.Nullable;
import org.springframework.scheduling.annotation.AsyncConfigurerSupport;
import org.springframework.scheduling.annotation.EnableAsync;


@Configuration
@EnableAsync
public class CustomConfiguration extends AsyncConfigurerSupport {
    @Override
    public Executor getAsyncExecutor() {
        return new SimpleAsyncTaskExecutor();
    }


    @Override
    @Nullable
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (throwable, method, obj) -> {
            System.out.println("Exception Caught in Thread - " + Thread.currentThread().getName());
            System.out.println("Exception message - " + throwable.getMessage());
            System.out.println("Method name - " + method.getName());
            for (Object param : obj) {
                System.out.println("Parameter value - " + param);
            }
        };
    }
}

請注意,因爲不想使用自定義 task executor,在 `getAsyncExecutor` 方法中,沒有創建任何新的 executor。因此,我將使用 Spring 默認的 `SimpleAsyncExecutor`。

但是,我需要自己定義 uncaught exception handler 處理 uncaught 異常。因此,我寫了一條繼承 `AsyncUncaughtExceptionHandler` 類的 lambda 表達式並覆蓋 `handleuncaughtexception` 方法。

這樣,會讓 Spring 加載與應用匹配的 `AsyncConfugurer(CustomConfiguration)` 並用 lambda 表達式進行異常處理。

新建一個 `@Async` 方法拋出異常:

package com.example.ask2shamik.springAsync;

import java.util.Map;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;


@Component
public class AsyncMailTrigger {
    @Async
    public void senMailwithException() throws Exception {
        throw new Exception("SMTP Server not found :: orginated from Thread :: " + Thread.currentThread().getName());
    }
}

現在,創建調用方法。

package com.example.ask2shamik.springAsync;


import java.util.HashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;


@Component
public class AsyncCaller {
    @Autowired
    AsyncMailTrigger asyncMailTriggerObject;


    public void rightWayToCall() throws Exception {
        System.out.println("Calling From rightWayToCall Thread " + Thread.currentThread().getName());
        asyncMailTriggerObject.senMailwithException();
    }
}

接下來讓我們啓動 Spring Boot 應用,看它如何捕捉 `sendMailwithException` 方法引發的異常。

package com.example.ask2shamik.springAsync;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableAsync;
import com.example.ask2shamik.springAsync.demo.AsyncCaller;


@SpringBootApplication
public class DemoApplication {
    @Autowired
    AsyncCaller caller;


    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }


    @Bean
    public CommandLineRunner commandLineRunner(ApplicationContext ctx) {
        return args -> {
            caller.rightWayToCall();
        };
    }
}

結果如下:

```shell
Calling From rightWayToCall Thread main
Exception Caught in Thread - SimpleAsyncTaskExecutor-1
Exception message - SMTP Server not found:: originated from Thread:: SimpleAsyncTaskExecutor-1
Method name - senMailwithException
```

Spring Async 如何在 Web 應用中工作

很高興能和大家分享關於 Spring Async 和 `HttpRequest` 的使用經驗。在最近參與的項目中遇到了一件有趣的事情,相信我的經歷可以爲你在將來節省一些寶貴的時間。

讓我試着描述一下當時的場景:

目標

需要把數據從 UI 傳給後端 Controller,接着 Controller 將執行一些操作,最終調用異步郵件服務發送郵件。

一位初級工程師編寫了這部分代碼。下面是我根據功能復現的代碼,你能找出中間的問題嗎?

Controller

Controller 通過接收 HTTP Servelet 請求從 UI 收集信息,接着執行一些操作,並將請求轉給異步郵件服務。

package com.example.demo;
import javax.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class GreetController {
    @Autowired
    private AsyncMailTrigger greeter;

    @RequestMapping(value = "/greet", method = RequestMethod.GET)
    public String greet(HttpServletRequest request) throws Exception {
        String name = request.getParameter("name");
        greeter.asyncGreet(request);
        System.out.println(Thread.currentThread() + " Says Name is " + name);
        System.out.println(Thread.currentThread().getName() + " Hashcode" + request.hashCode());
        return name;
    }
}

異步郵件服務 `AsyncMailTrigger` 類加上了 `@Component` 註解,你也可以改成 `@Service`。其中包含了 `asyncGreet` 方法,接受 `HttpRequest` 輸入,從中獲取信息併發送郵件(簡單起見,這一部分被略過)。**注意:** 這裏有一條 `Thread.sleep()` 語句,稍後我會討論它的作用。

package com.example.demo;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;

@Component
public class AsyncMailTrigger {
    @Async
    public void asyncGreet(HttpServletRequest request) throws Exception {
        System.out.println("Trigger mail in a New Thread :: "  + Thread.currentThread().getName());
        System.out.println(Thread.currentThread().getName() + " greets before sleep" + request.getParameter("name"));
        Thread.sleep(1000);
        System.out.println(Thread.currentThread().getName() + " greets" + request.getParameter("name"));
        System.out.println(Thread.currentThread().getName() + " Hashcode" + request.hashCode());
    }
}

下面是 main class:

package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;

@SpringBootApplication
@EnableAsync
public class SpringAsyncWebApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpringAsyncWebApplication.class, args);
    }
}

運行程序,輸出結果如下:

Thread[http-nio-8080-exec-1,5,main] Says Name is Shamik
http-nio-8080-exec-1 Hashcode 821691136
Trigger mail in a New Thread:: task-1
task-1 greets before sleep Shamik
task-1 greets null task-1 Hashcode 821691136

仔細查看輸出會發現:在 `sleep()` 調用前 `request` 信息正確,但調用 `sleep()` 後 `request` 信息就神奇地消失了。很奇怪,對吧?但從 hashcode 可以證明它們是同一個 request 對象。

到底發生了什麼?`request` 信息消失的原因是什麼?我們的初級工程師遇到了這樣的情況,收件人信息、收件人的姓名從 `request` 中消失了,郵件也沒有發送成功。

讓我們仔細調查這個問題

`request` 出現問題很正常。要理解這個問題,首先要了解 `request` 的生命週期。

在調用 Servlet 方法前,Servlet 容器會創建 `request` 對象。Spring 通過 Dispatcher Servlet 傳遞 `request` ,根據映射找到對應的 Controller 並調用相應的方法。當 `request` 得到響應時,Servlet 容器要麼刪除要麼重置 `request` 對象的狀態(完全取決於容器的實現,這裏實際上維護了一個 request pool)。然而,這裏不打算深入探討關於容器如何維護 `request` 對象這個話題。

"但是請記住:" 一旦`request` 得到響應時,容器就會刪除或者重置 `request` 對象。

現在,讓我們思考 Spring Async 代碼。Async 的工作是從線程池中分配一個線程讓它執行任務。上面的例子中,我們把 `request` 對象傳遞給異步線程,並在 `asyncGreet` 方法中,試圖直接從 `request` 對象提取信息。

然而,由於這裏的操作是異步的,主線程(即 Controller 部分)不會等待線程完成。它會直接執行 `print` 語句,返回 `response`,並刷新 `request` 對象的狀態。

這裏的問題在於,我們直接把 `request` 對象傳給了異步線程。爲了證明上面的推斷,這裏加上了一條 `sleep` 語句。當主線程在 `sleep` 結束前返回 `response`,就能復現之前問題中的現象。

從這個實驗中可以學到什麼?

使用 Async 時,**不要**直接傳 `request` 對象或任何與 `Request/Response` 相關的對象。因爲永遠不知道什麼時候會提交 `response` 並刷新狀態。如果這樣做,可能會遇到偶發性錯誤。

有什麼解決辦法?

如果需要傳遞 `request` 中的信息,可以創建一個 `value` 對象。爲對象設置信息後,把 `value` 對象傳給 Spring Async。通過這種方式,可以解決上面的問題:

RequestVO 對象

package com.example.demo;

public class RequestVO {
    String name;
    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

異步郵件服務

package com.example.demo;

import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;

@Component
public class AsyncMailTrigger {
    @Async
    public void asyncGreet(RequestVO reqVO) throws Exception {
        System.out.println("Trigger mail in a New Thread :: "  + Thread.currentThread().getName());
        System.out.println(Thread.currentThread().getName() + " greets before sleep" + reqVO.getName());
        Thread.sleep(1000);
        System.out.println(Thread.currentThread().getName() + " greets" + reqVO.getName());
    }
}

Greet Controller


package com.example.demo;

import javax.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class GreetController {
    @Autowired
    private AsyncMailTrigger greeter;

    @RequestMapping(value = "/greet", method = RequestMethod.GET)
    public String greet(HttpServletRequest request) throws Exception {
        String name = request.getParameter("name");
        RequestVO vo = new RequestVO();
        vo.setName(name);
        //greeter.asyncGreet(request);
        greeter.asyncGreet(vo);
        System.out.println(Thread.currentThread() + " Says Name is " + name);
        System.out.println(Thread.currentThread().getName() + " Hashcode" + request.hashCode());
        return name;
    }
}

輸出

Thread[http-nio-8080-exec-1,5,main] Says Name is Shamik
http-nio-8080-exec-1 Hashcode 1669579896
Trigger mail in a New Thread:: task-1
task-1 greets before sleep Shamik
task-1 greets Shamik

 

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