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


https://unsplash.com/photos/LmyPLbbUWhA

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

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

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

一、範例專案介紹

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

(一)資料結構

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

public class LoginActivity {
private String name;
private Date loginTime;
private boolean notified = false;
public static LoginActivity of(String name, Date loginTime) {
var activity = new LoginActivity();
activity.name = name;
activity.loginTime = loginTime;
return activity;
}
// getter, setter...
}

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

@Repository
public class LoginActivityRepository {
private final List<LoginActivity> activities = new ArrayList<>();
public void insert(String name) {
var activity = LoginActivity.of(name, new Date());
activities.add(activity);
}
public List<LoginActivity> findByNotNotified() {
return activities.stream()
.filter(Predicate.not(LoginActivity::isNotified))
.collect(Collectors.toList());
}
}

(二)任務內容

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

@Component
public class NotifyUserLoginDemoTask {
private final Logger logger = LoggerFactory.getLogger(NotifyUserLoginDemoTask.class);
private final SimpleDateFormat sdf = new SimpleDateFormat("MM/dd hh:mm:ss");
private int count = 1;
@Autowired
private LoginActivityRepository repository;
public void createLoginData() {
var name = "User_" + count;
repository.insert(name);
logger.info("執行 createLoginData: {}", count);
count++;
}
public void notifyLoginUser() {
logger.info("開始發送登入通知");
var activities = repository.findByNotNotified();
activities.forEach(act -> {
var timeStr = sdf.format(act.getLoginTime());
logger.info("親愛的 {},您於 {} 登入本服務。", act.getName(), timeStr);
act.setNotified(true);
});
}
}

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

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

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

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


二、固定週期任務

(一)使用 Schedlued 標記

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

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

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

@Component
public class NotifyUserLoginDemoTask {
@Scheduled(initialDelay = 20000, fixedDelay = 5000)
public void createLoginData() {
// 其餘略過
}
// 其餘略過
}

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

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

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

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

@Component
public class NotifyUserLoginDemoTask {
// 其餘略過
@Scheduled(cron = "${task.notify_login_user.cron}")
public void notifyLoginUser() {
// 其餘略過
}
}

task.notify_login_user.cron=*/15 * * * * ?

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

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

(二)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 啟動時執行一次。

@Component
public class NotifyUserLoginDemoTask implements SchedulingConfigurer {
// 其餘略過
@Override
public void configureTasks(ScheduledTaskRegistrar registrar) {
Runnable runnable = this::notifyLoginUser;
// TODO
}
}

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

@Component
public class NotifyUserLoginDemoTask implements SchedulingConfigurer {
// 其餘略過
@Value("${task.notify_login_user.mode}")
private String mode;
@Value("${task.notify_login_user.cron_exp}")
private String cronExp;
@Value("${task.notify_login_user.interval}")
private long interval;
@Value("${task.notify_login_user.initial_delay}")
private long initialDelay;
// 移除 @Scheduled 標記
public void notifyLoginUser() {
// 其餘略過
}
@Override
public void configureTasks(ScheduledTaskRegistrar registrar) {
Runnable runnable = this::notifyLoginUser;
if (mode.equalsIgnoreCase("cron")) {
registrar.addCronTask(runnable, cronExp);
} else if (mode.equalsIgnoreCase("fixedDelay")) {
var task = new IntervalTask(runnable, interval, initialDelay);
registrar.addFixedDelayTask(task);
} else if (mode.equalsIgnoreCase("fixedRate")) {
var task = new IntervalTask(runnable, interval, initialDelay);
registrar.addFixedRateTask(task);
} else {
throw new RuntimeException("Please define parameters for scheduled task.");
}
}
}
task.notify_login_user.mode=cron
task.notify_login_user.cron_exp=*/15 * * * * ?
task.notify_login_user.interval=15000
task.notify_login_user.initial_delay=20000

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

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

四、改變任務的週期

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

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

(一)事前準備

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

@Component
public class NotifyUserLoginDemoTask implements SchedulingConfigurer {
private TaskCycleConfig config = TaskCycleConfig.ofDelay(15000);
public void setNotifyConfig(TaskCycleConfig config) {
this.config = config;
}
@Override
public void configureTasks(ScheduledTaskRegistrar registrar) {
// 其餘略過
}
// 其餘略過
}
public class TaskCycleConfig {
private Integer delay;
private Integer rate;
private String cron;
public static TaskCycleConfig ofDelay(int delay) {
var config = new TaskCycleConfig();
config.delay = delay;
return config;
}
// getter, setter...
}
@RestController
@RequestMapping(value = "/task", produces = MediaType.APPLICATION_JSON_VALUE)
public class TaskController {
@Autowired
private NotifyUserLoginDemoTask notifyUserLoginTask;
@PostMapping("/notifyUserLogin")
public ResponseEntity<Void> setNotifyUserLoginTaskConfig(@RequestBody TaskCycleConfig config) {
notifyUserLoginTask.setNotifyConfig(config);
return ResponseEntity.noContent().build();
}
}

(二)Trigger 的機制

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

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

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

@Component
public class NotifyUserLoginDemoTask implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar registrar) {
Trigger trigger = new Trigger() {
@Override
public Date nextExecutionTime(TriggerContext triggerContext) {
// TOOD
}
};
registrar.addTriggerTask(runnable, trigger);
}
// 其餘略過
}

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

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

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

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

public class PeriodicTrigger implements Trigger {
private final long period;
private volatile long initialDelay;
private volatile boolean fixedRate;
public Date nextExecutionTime(TriggerContext triggerContext) {
Date lastExecution = triggerContext.lastScheduledExecutionTime();
Date lastCompletion = triggerContext.lastCompletionTime();
if (lastExecution != null && lastCompletion != null) {
return this.fixedRate
? new Date(lastExecution.getTime() + this.period)
: new Date(lastCompletion.getTime() + this.period);
} else {
return new Date(triggerContext.getClock().millis() + this.initialDelay);
}
}
// 其餘略過
}

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

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

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

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

@Component
public class NotifyUserLoginDemoTask implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar registrar) {
Runnable runnable = this::notifyLoginUser;
Trigger trigger = new Trigger() {
@Override
public Date nextExecutionTime(TriggerContext triggerContext) {
if (config.getCron() != null) {
return new CronTrigger(config.getCron()).nextExecutionTime(triggerContext);
} else if (config.getDelay() != null) {
var t = new PeriodicTrigger(config.getDelay(), TimeUnit.MILLISECONDS);
t.setFixedRate(false);
return t.nextExecutionTime(triggerContext);
} else if (config.getRate() != null) {
var t = new PeriodicTrigger(config.getRate(), TimeUnit.MILLISECONDS);
t.setFixedRate(true);
return t.nextExecutionTime(triggerContext);
} else {
throw new RuntimeException("Please define parameters for scheduled task.");
}
}
};
registrar.addTriggerTask(runnable, trigger);
}
// 其餘略過
}

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

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

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

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

留言