"Most of you are familiar with the virtues of a programmer. There are three, of course: laziness, impatience, and hubris." - Larry Wall
「程式設計師的美德:懶惰,不耐煩以及老子天下第一。」 —— 拉里·沃爾
在 SpringBoot 專案中,我們可以通過@EnableScheduling
註解開啟排程任務支援,並通過@Scheduled
註解快速地建立一系列定時任務。
@Scheduled
支援下面三種設定執行時間的方式:
Cron
表示式來執行。最常用的應該是第一種方式,基於Cron
表示式的執行模式,因其相對來說更加靈活。
預設情況下,@Scheduled
註解標記的定時任務方法在初始化之後,是不會再發生變化的。Spring 在初始化 bean 後,通過後處理器攔截所有帶有@Scheduled
註解的方法,並解析相應的的註解引數,放入相應的定時任務列表等待後續統一執行處理。到定時任務真正啟動之前,我們都有機會更改任務的執行週期等引數。換言之,我們既可以通過application.properties
組態檔配合@Value
註解的方式指定任務的Cron
表示式,亦可以通過CronTrigger
從資料庫或者其他任意儲存中介軟體中載入並註冊定時任務。這是 Spring 提供給我們的可變的部分。
但是我們往往要得更多。能否在定時任務已經在執行過的情況下,去動態更改Cron
表示式,甚至禁用某個定時任務呢?很遺憾,預設情況下,這是做不到的,任務一旦被註冊和執行,用於註冊的引數便被固定下來,這是不可變的部分。
既然創造之後不可變,那就毀滅之後再重建吧。於是乎,我們的思路便是,在註冊期間保留任務的關鍵資訊,並通過另一個定時任務檢查設定是否發生變化,如果有變化,就把「前任」幹掉,取而代之。如果沒有變化,就保持原樣。
先對任務做個簡單的抽象,方便統一的識別和管理:
public interface IPollableService {
/**
* 執行方法
*/
void poll();
/**
* 獲取週期表示式
*
* @return CronExpression
*/
default String getCronExpression() {
return null;
}
/**
* 獲取任務名稱
*
* @return 任務名稱
*/
default String getTaskName() {
return this.getClass().getSimpleName();
}
}
最重要的便是getCronExpression()
方法,每個定時服務實現可以自己控制自己的表示式,變與不變,自己說了算。至於從何處獲取,怎麼獲取,請諸君自行發揮了。接下來,就是實現任務的動態註冊:
@Configuration
@EnableAsync
@EnableScheduling
public class SchedulingConfiguration implements SchedulingConfigurer, ApplicationContextAware {
private static final Logger log = LoggerFactory.getLogger(SchedulingConfiguration.class);
private static ApplicationContext appCtx;
private final ConcurrentMap<String, ScheduledTask> scheduledTaskHolder = new ConcurrentHashMap<>(16);
private final ConcurrentMap<String, String> cronExpressionHolder = new ConcurrentHashMap<>(16);
private ScheduledTaskRegistrar taskRegistrar;
public static synchronized void setAppCtx(ApplicationContext appCtx) {
SchedulingConfiguration.appCtx = appCtx;
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
setAppCtx(applicationContext);
}
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
this.taskRegistrar = taskRegistrar;
}
/**
* 重新整理定時任務表示式
*/
public void refresh() {
Map<String, IPollableService> beanMap = appCtx.getBeansOfType(IPollableService.class);
if (beanMap.isEmpty() || taskRegistrar == null) {
return;
}
beanMap.forEach((beanName, task) -> {
String expression = task.getCronExpression();
String taskName = task.getTaskName();
if (null == expression) {
log.warn("定時任務[{}]的任務表示式未設定或設定錯誤,請檢查設定", taskName);
return;
}
// 如果策略執行時間發生了變化,則取消當前策略的任務,並重新註冊任務
boolean unmodified = scheduledTaskHolder.containsKey(beanName) && cronExpressionHolder.get(beanName).equals(expression);
if (unmodified) {
log.info("定時任務[{}]的任務表示式未發生變化,無需重新整理", taskName);
return;
}
Optional.ofNullable(scheduledTaskHolder.remove(beanName)).ifPresent(existTask -> {
existTask.cancel();
cronExpressionHolder.remove(beanName);
});
if (ScheduledTaskRegistrar.CRON_DISABLED.equals(expression)) {
log.warn("定時任務[{}]的任務表示式設定為禁用,將被不會被排程執行", taskName);
return;
}
CronTask cronTask = new CronTask(task::poll, expression);
ScheduledTask scheduledTask = taskRegistrar.scheduleCronTask(cronTask);
if (scheduledTask != null) {
log.info("定時任務[{}]已載入,當前任務表示式為[{}]", taskName, expression);
scheduledTaskHolder.put(beanName, scheduledTask);
cronExpressionHolder.put(beanName, expression);
}
});
}
}
重點是儲存ScheduledTask
物件的參照,它是控制任務啟停的關鍵。而表示式「-」則作為一個特殊的標記,用於禁用某個定時任務。當然,禁用後的任務通過重新賦予新的 Cron 表示式,是可以「復活」的。完成了上面這些,我們還需要一個定時任務來動態監控和重新整理定時任務設定:
@Component
public class CronTaskLoader implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(CronTaskLoader.class);
private final SchedulingConfiguration schedulingConfiguration;
private final AtomicBoolean appStarted = new AtomicBoolean(false);
private final AtomicBoolean initializing = new AtomicBoolean(false);
public CronTaskLoader(SchedulingConfiguration schedulingConfiguration) {
this.schedulingConfiguration = schedulingConfiguration;
}
/**
* 定時任務設定重新整理
*/
@Scheduled(fixedDelay = 5000)
public void cronTaskConfigRefresh() {
if (appStarted.get() && initializing.compareAndSet(false, true)) {
log.info("定時排程任務動態載入開始>>>>>>");
try {
schedulingConfiguration.refresh();
} finally {
initializing.set(false);
}
log.info("定時排程任務動態載入結束<<<<<<");
}
}
@Override
public void run(ApplicationArguments args) {
if (appStarted.compareAndSet(false, true)) {
cronTaskConfigRefresh();
}
}
}
當然,也可以把這部分程式碼直接整合到SchedulingConfiguration
中,但是為了方便擴充套件,這裡還是將執行與觸發分離了。畢竟除了通過定時任務觸發重新整理,還可以在介面上通過按鈕手動觸發重新整理,或者通過訊息機制回撥重新整理。這一部分就請大家根據實際業務情況來自由發揮了。
我們建立一個原型工程和三個簡單的定時任務來驗證下,第一個任務是執行週期固定的任務,假設它的Cron
表示式永遠不會發生變化,像這樣:
@Service
public class CronTaskBar implements IPollableService {
@Override
public void poll() {
System.out.println("Say Bar");
}
@Override
public String getCronExpression() {
return "0/1 * * * * ?";
}
}
第二個任務是一個經常更換執行週期的任務,我們用一個亂數發生器來模擬它的善變:
@Service
public class CronTaskFoo implements IPollableService {
private static final Random random = new SecureRandom();
@Override
public void poll() {
System.out.println("Say Foo");
}
@Override
public String getCronExpression() {
return "0/" + (random.nextInt(9) + 1) + " * * * * ?";
}
}
第三個任務就厲害了,它彷彿就像一個電燈的開關,在啟用和禁用中反覆橫跳:
@Service
public class CronTaskUnavailable implements IPollableService {
private String cronExpression = "-";
private static final Map<String, String> map = new HashMap<>();
static {
map.put("-", "0/1 * * * * ?");
map.put("0/1 * * * * ?", "-");
}
@Override
public void poll() {
System.out.println("Say Unavailable");
}
@Override
public String getCronExpression() {
return (cronExpression = map.get(cronExpression));
}
}
如果上面的步驟都做對了,紀錄檔裡應該能看到類似這樣的輸出:
定時排程任務動態載入開始>>>>>>
定時任務[CronTaskBar]的任務表示式未發生變化,無需重新整理
定時任務[CronTaskFoo]已載入,當前任務表示式為[0/6 * * * * ?]
定時任務[CronTaskUnavailable]的任務表示式設定為禁用,將被不會被排程執行
定時排程任務動態載入結束<<<<<<
Say Bar
Say Bar
Say Foo
Say Bar
Say Bar
Say Bar
定時排程任務動態載入開始>>>>>>
定時任務[CronTaskBar]的任務表示式未發生變化,無需重新整理
定時任務[CronTaskFoo]已載入,當前任務表示式為[0/3 * * * * ?]
定時任務[CronTaskUnavailable]已載入,當前任務表示式為[0/1 * * * * ?]
定時排程任務動態載入結束<<<<<<
Say Unavailable
Say Bar
Say Unavailable
Say Bar
Say Foo
Say Unavailable
Say Bar
Say Unavailable
Say Bar
Say Unavailable
Say Bar
我們在上文通過定時重新整理和重建任務的方式來實現了動態更改Cron
表示式的需求,能夠滿足大部分的專案場景,而且沒有引入quartzs
等額外的中介軟體,可以說是十分的輕量和優雅了。當然,如果各位看官有更好的方法,還請不吝賜教。