1. 程式人生 > >Spring啟動過程中Application事件的監聽與處理.md

Spring啟動過程中Application事件的監聽與處理.md

這篇部落格是解決一個實際問題,在解決過程中梳理SpringApplicationEvent的執行機制和使用方法。這個問題是,微服務架構下,需要依次啟動多個服務,服務之間存在執行時的依賴關係,必須保證多個服務的啟動順序。所以決定從Spring的Application事件入手。

1.Application Events and Listeners

我查了一些資料,通用的解決方案是:建立一個監聽類,實現org.springframework.context.ApplicationListener,並實現它的onApplicationEvent方法。
SpringApplicationEvent 有6種事件:

  • ApplicationStartingEvent:除了基礎的註冊監聽和初始化之外,在開始執行時做任何處理動作之前傳送
  • ApplicationEnvironmentPreparedEvent 在上下文中使用的環境已知,但是Context尚未建立之前傳送
  • ApplicationPreparedEvent 在Spring重新整理Context開始之前,而僅當載入bean定義之後傳送
  • ApplicationStartedEvent 在重新整理上下文之後,但在呼叫任何應用程式(ApplicationRunner)和命令列執行程式(CommandLineRunner)之前傳送
  • ApplicationReadyEvent 在呼叫應用程式和命令列執行程式後傳送。 它表示應用程式已準備好為請求提供服務。
  • ApplicationFailedEvent 在啟動過程中出現異常

前5種事件,是Spring按照啟動前後順序,依次生成的。我們想要監聽啟動之前的事件和啟動完成的事件,只需關注ApplicationStartingEvent和ApplicationReadyEvent
這是我的程式碼實現:

@Component
public class ApplicationEventListener implements ApplicationListener<SpringApplicationEvent >{
    
    @Value("${spring.application.name}")
    private String appName;
    private Logger log = Logger.getLogger(this.getClass());
    @Override
    public void onApplicationEvent(SpringApplicationEvent event) {
     
        if(event instanceof ApplicationStartingEvent) {//啟動之前
   
            
        }else if(event instanceof ApplicationReadyEvent ){//啟動成功之後
       
        }
    }

然而除錯之後,發現監聽時間並沒有生效,於是去看了一下官方文件Application Events and Listeners

Some events are actually triggered before the ApplicationContext is created, so you cannot register a listener on those as a @Bean. You can register them with the SpringApplication.addListeners(…) method or the SpringApplicationBuilder.listeners(…) method.
If you want those listeners to be registered automatically, regardless of the way the application is created, you can add a META-INF/spring.factories file to your project and reference your listener(s) by using the org.springframework.context.ApplicationListener key, as shown in the following example:
org.springframework.context.ApplicationListener=com.example.project.MyListener

官方提供了2種方案:

1.1.在啟動器中新增監聽器,然後啟動。SpringApplication.addListeners(…)或者SpringApplicationBuilder.listeners(…)。

public static void main(String[] args) {
        new SpringApplicationBuilder(DiscoveryServiceApplication.class)
            .listeners(new ApplicationEventListener())
            .run(args);
}
    public static void main(String[] args) {
        SpringApplication app = new SpringApplication(DiscoveryServiceApplication.class);
        app.addListeners(new ApplicationEventListener());
        app.run(args);
    }

1.2.從配置檔案配置監聽類

在META-INF/spring.factories中新增下面配置
org.springframework.context.ApplicationListener=com.xxx.listener.ApplicationEventListener

測試兩種方式均有效,可以執行到監聽方法。如此,我們的探索工作已經基本完成了。但是,作為研發人員不是應該有庖丁解牛的精神嗎?
下面我們進行下知識的延伸。

2.實現CommandLineRunner和ApplicationRunner在Spring啟動後執行

實現這兩個介面同樣能完成,在Spring容器啟動後做一些操作的需求。他們的執行順序是在
ApplicationStartedEvent 之後, ApplicationReadyEvent 之前執行。

@Component
public class MyApplicationRunner implements ApplicationRunner {
    @Override
    public void run(ApplicationArguments args) throws Exception {
    }

@Component
public class MyCommandLineRunner implements CommandLineRunner {
 
    @Override
    public void run(String... args) throws Exception {
    }

這兩個介面傳遞的引數不同,可有不同的用法。
如果有多個實現類,而你需要他們按一定順序執行的話,可以在實現類上加上@Order註解。@Order(value=整數值)。SpringBoot會按照@Order中的value值從小到大依次執行。

3.SpringApplicationEvent與ApplicationRunner的使用區別

你可能要問了,既然標題1提供了6中SpringApplicationEvent的統一處理方式,那麼為什麼還需要ApplicationRunner 和CommandLineRunner 這兩個介面呢?

我總結了兩個原因:

1.生命週期不同,執行時可使用的資源不同

在SpringApplicationEvent的例子中,我使用 @Value(" s p r i n g . a p p l i c a t i o n . n a m e &quot; ) A p p l i c a t i o n S t a r t i n g E v e n t S p r i n g B e a n a p p N a m e n u l l B e a n A p p l i c a t i o n S t a r t e d E v e n t A p p l i c a t i o n C o n t e x t @ V a l u e ( &quot; {spring.application.name}&quot;)註解。當ApplicationStartingEvent來的時候,Spring並沒有定義和初始化Bean,也沒有掃描包。這時的appName為null,如果要依賴注入其他Bean時,當然也為空。 直到執行ApplicationStartedEvent事件時,ApplicationContext初始化完成,重新整理之後,@Value(&quot; {spring.application.name}")才生效,這時才能使用上下文所提供的各種資源。所以如果ApplicationStartedEvent以前的幾個事件,我們需要操作引數,只能從啟動引數裡面傳遞。(參考下面的程式碼)。

ApplicationRunner 和CommandLineRunner這兩個介面的實現方法會在ApplicationContext重新整理之後,這時各種配置資源、Bean都初始化完成,可以使用上下文所提供的各種資源,比如依賴某一個Bean等等。
因此不同事件處理時所能獲取到的資源是不同的,依次來決定使用哪種方式。當然如果是“啟動完成之後的需求”,兩種方式是效果一樣的。
注意:測試時ApplicationReadyEvent 和它之前的5個事件可能會執行兩次,為什麼會執行兩次,參考Spring事件——onApplicationEvent執行兩次

2.可傳遞的引數不同

CommandLineRunner 和SpringApplicationEvent一樣,傳遞String陣列,而ApplicationRunner傳遞的是ApplicationArguments。他們處理的都是啟動引數,如----spring.profiles.active=discovery。其實ApplicationArguments和String陣列傳遞的值沒有什麼本質區別,只不過String數組裡面傳遞的是空格隔開的原始字串,如“spring.profiles.active=discovery”,而ApplicationArguments將原始字串拆成了鍵值對,如“spring.profiles.active”和“discovery”。因此這個區別可忽略不計。

4.解決現實問題的實現

如果我們需要啟動多個Spring專案,其中一個專案可能要依賴其他專案的介面,必須等依賴的專案啟動完成。我的思路是:
專案啟動的時,寫一個鎖檔案作為標誌,啟動完成之後,將改標誌檔案刪除。我們必須等待所有的基礎專案啟動完成之後,再啟動其他專案。迴圈檢測如果正在啟動的專案,有鎖檔案,則指令碼程序先休眠幾秒,如此迴圈。直到沒有鎖檔案,表明啟動成功,再啟動下一個。

@Component
public class ApplicationEventListener implements ApplicationListener<SpringApplicationEvent >{
    private Logger log = Logger.getLogger(this.getClass());
    @Value("${spring.application.name}")
    private String appName;
    
    public String tempPath = "../tmp";
    @Override
    public void onApplicationEvent(SpringApplicationEvent event) {
        String[] args = event.getArgs();
        if(args.length ==0 ) {//MVC容器發出的事件不關注
            return ;
        }else if(appName == null) {
            String[] str = args[0].split("=");
            appName = str[1];
        }
        if(event instanceof ApplicationStartingEvent) {//正在啟動
            File appFile = getAppTmpDir();
            if(appFile!=null) {
                File lock = new File(appFile,appName+".lock");
                if(!lock.exists()) {
                    try {
                        lock.createNewFile();
                        log.info("[+] ========= create lock path:" + lock.getAbsolutePath());
                    } catch (IOException e) {
                        log.error("[-] 啟動時建立lock檔案失敗",e);
                    }
                }
            }
            
        }else if(event instanceof ApplicationReadyEvent){
            File appFile = getAppTmpDir();
            if(appFile!=null) {
                File lock = new File(appFile,appName+".lock");
                if(lock.exists()) {
                    log.info("[+] ========= create lock path:" + lock.getAbsolutePath());
                    lock.delete();
                }
            }
        }
        
    }
    
    private File getAppTmpDir() {
        try {
            File tempDir = new File(tempPath);
            if(!tempDir.exists()) {
                tempDir.mkdir();
            }
            log.info("[+] ========= temp path:" + tempDir.getAbsolutePath());
            File appFile = new File(tempDir,appName);
            if(!appFile.exists()) {
                appFile.mkdirs();
            }
            log.info("[+] ========= app path:" + appFile.getAbsolutePath());
            return appFile;
        }catch( Exception e) {
            log.error("[-] 啟動時建立temp檔案失敗",e);
        }
        return null;
    }

下面是多個專案的啟動指令碼:
比如下面3個專案,我們需要依次啟動

APPS=(app1 app2 app3)
for app in ${APPS[@]}
do
    echo Starting ${app}.........
    ${BINDIR}/start_service.sh ${app} >/dev/null 2>&1 &
    echo Start ${app} Successfully
    waiting=0
    while [ ! -e ${TMPDIR}/${app}/${app}.lock ] && [ ${waiting} -lt 300 ]
    do
        echo "Waiting ${app} ..."
        let "waiting += 3"
        sleep 3s
    done
done

打完收工。