【Spring Boot】第25課-定時任務排程


https://unsplash.com/photos/LmyPLbbUWhA

讀者在生活中也許有這樣的經驗,像是每個月月初會收到銀行寄來的電子對帳單,或是在平台的付費會員資格到期後,被自動續約、扣款。

為了讓這些機制能夠自動化,我們需要讓系統週期性地執行任務。本文將介紹定時任務,讓後端能夠每隔一段時間,執行指定的程式。後面也會提到如何在不重啟系統的條件下改變週期。

本文的練習用專案:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch25-start

一、範例專案介紹

本文的範例專案,是以「發送登入通知」為情境。模擬出系統儲存了會員的登入紀錄,並批次取出它們,用以通知何時曾經登入過。

(一)資料結構

專案中的 LoginActivity 類別,描述了登入紀錄,包含會員的名字、登入時間,和是否已發送通知。

而 LoginActivityRepository 是用來儲存資料,以及取得未通知的登入紀錄。

(二)任務內容

以下建立了叫做 NotifyUserLoginDemoTask 的元件類別。

裡面定義的兩個方法,是筆者想自動執行的任務。

  • createLoginData:用來產生登入紀錄的測試資料,並儲存起來。印出 log 是為了觀察該方法有被執行。
  • notifyLoginUser:找出未通知過的紀錄,取出其欄位值後印出 log。

為了讓任務排程生效,請記得在 Spring Boot 的啟動類別冠上 @EnableScheduling 標記。


二、固定週期任務

(一)使用 Schedlued 標記

為了安排定時任務,請在想自動執行的方法冠上 @Scheduled 標記,並傳入需要的參數。

  • initialDelay:後端程式啟動完成後,先延遲一段時間才開始第一次執行。
  • fixDelay:上一次任務結束後,間隔一段時間才執行下一次。
  • fixRate:每隔一段時間就開始一次新的任務。若當前的任務執行時間大於此間隔,下次的任務需等待前一次結束才隨即開始。
  • cron:用 cron 表示式定義執行週期。

前三項的參數皆以毫秒為單位。此處的範例,我們讓 createLoginData 方法在系統啟動完成,過 20 秒後執行。並於每次結束後,過 5 秒再執行下一次。

完成程式碼後,讀者可啟動專案確認結果。

定時任務每隔 5 秒就印出一次內容
定時任務每隔 5 秒就印出一次內容

關於 @Scheduled 標記的參數,尚有 fixedDelayString、fixedRateString 與 initialDelayString 可用,只是型態變成 String 罷了。好處是我們能用來讀取 properties 檔的參數值,這點和 cron 參數相同。

以下例子是安排 notifyLoginUser 方法,每 15 秒執行一次。而這項參數是從 properties 檔讀取 cron 表示式。

完成程式碼後,讀者可重新啟動專案確認結果。

兩個定時任務一起印出內容
兩個定時任務一起印出內容

(二)fixDelay 與 fixRate 的差別

用於 @Schedlued 標記中的 fixDelay 與 fixRate 參數,效果很相似,都能達到兩次任務要間隔一段時間。現在我們來推演一下,讓讀者更清楚它們的差異。

首先以 fixDelay 為例,參數值設為 5 秒。假設第一次任務耗時 3 秒,第二次 9 秒。

經過時間(秒)0381722
狀態第一次開始第一次結束第二次開始第二次結束第三次開始

可以看出,第一次結束到第二次開始之間,以及第二次結束到第三次開始之間,都是間隔 5 秒。不論任務執行多久,fixDelay 會確保兩次之間有固定的間隔。

接著示範 fixRate,參數值也設為 5 秒。假設第一次任務耗時 3 秒,第二次 6 秒,第三次 11 秒。

經過時間(秒)0351011152022
狀態第一次開始第一次結束第二次開始第三次任務阻塞第二次結束
第三次開始
第四次任務
阻塞
第五次任務
阻塞
第三次結束
第四次開始

若任務都能像第一次那樣在 5 秒內完成,則系統便可規律地每 5 秒就開始一次新任務。

然而當執行時間太久,則下一次任務就會被阻塞。例如原本第 10 秒要執行的第三次任務,因為第二次尚未完成,於是需等待其結束,隨後才立刻執行新任務。連帶導致後續的第四、五次任務也無法在預定時間開始。

三、以程式方式決定週期

前一節所介紹的 @Scheduled 標記,只能從 fixDelay、fixRate 與 cron 中選擇其中一種模式。如果想要更有彈性,例如從 properties 檔或資料庫中取得週期的定義,我們可改用程式化(programmatically)的方式來安排任務。

請實作 SchedulingConfigurer 介面,並完成 configureTasks 方法。該方法會在 Spring 啟動時執行一次。

透過該方法參數中的 ScheduledTaskRegistrar 物件,可以註冊任務。以下的範例程式,是從透過 properties 檔讀取模式、週期相關的參數。

若任務的週期想用 cron 表示式來定義,可呼叫 ScheduledTaskRegistrar 的 addCronTask 方法,將表示式傳入。至於 fixDelay 或 fixRate 模式的任務,可建立 IntervalTask 物件,再分別傳入 addFixedDelayTask 或 addFixedRateTask 方法,來完成安排。

這些建構子或方法亦支援多載(overloading),讀者可自行探索還有哪些傳入參數的組合。

四、改變任務的週期

前面介紹的定時任務,都是在 Spring 啟動時就將模式與週期給固定下來。若想更改,則必須寫好新的參數,再重新啟動伺服器。

本節會用筆者自己的做法,調整實作方式,以達到在 Spring 運行期間更改週期的目的。

(一)事前準備

筆者在原本的 NotifyUserLoginDemoTask 類別,宣告了一個全域變數 config。其類別 TaskCycleConfig 是自行設計的,它紀錄了任務執行週期的最新定義,並且可以透過從外部呼叫 REST API 來更新裡面的值。

(二)Trigger 的機制

透過前面介紹的 ScheduledTaskRegistrar 物件,此處請選擇呼叫 addTriggerTask 方法。其接收的參數除了 Runnable(想執行的任務),還有一個 Trigger 介面的物件。

Trigger 介面中只有一個 nextExecutionTime 方法,它會在 Spring 啟動完成後,以及每次任務結束後被呼叫,並回傳下次任務的開始時間給 Spring。

我們可利用這點,在此方法中取得 TaskCycleConfig 所記載的值,來「重新計算」下次任務的開始時間。首先下面建立一個空的 Trigger 物件。

(三)計算任務的開始時間

讀者可以看到,nextExecutionTime 方法的參數,是一個名為 TriggerContext 的介面(它會由 Spring 傳入)。查看原始碼後,發現它有三個抽象方法。

  • lastScheduledExecutionTime:上次任務預計開始的時間
  • lastActualExecutionTime:上次任務實際開始的時間
  • lastCompletionExecutionTime:上次任務完成的時間

這代表手邊有這三種資料,能用來計算開始的時間。那麼,實際上要怎麼去計算呢?Spring 內建 PeriodicTrigger 與 CronTrigger 這兩個類別,可以幫忙做這件事。讓我們閱讀 PeriodicTrigger 的原始碼,來理解其原理。

PeriodicTrigger 的 fixRate 欄位,代表了任務的週期模式為 fixRate 或 fixDelay。而從 nextExecutionTime 方法中可看出這兩種模式的計算方式。

  • fixRate:上次任務預計開始的時間(lastExecution)+ 時間間隔(period)
  • fixDelay:上次任務的完成時間(lastCompletion)+ 時間間隔(period)

若 Spring 剛啟動,尚未執行第一次任務,則開始時間為:現在時間 + initialDelay

了解原理後,最後讓我們完成 NotifyUserLoginDemoTask 的 configureTasks 方法。

每次任務結束後,都從 TaskCycleConfig 全域變數取得週期的定義,並用內建的 Trigger 實作類別幫助計算下次任務的開始時間。

本文的完成專案:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch25-fin

上一篇:【Spring Boot】第24課-使用 Controller Advice 處理例外與 query string

下一篇:【Spring Boot】第26課-使用快取並整合 Redis 來優化效能

留言