當前位置: 妍妍網 > 碼農

動態更改 Spring 定時任務 Cron 運算式的優雅方案!

2024-02-28碼農

👉 歡迎 ,你將獲得: 專屬的計畫實戰 / Java 學習路線 / 一對一提問 / 學習打卡 / 贈書福利

全棧前後端分離部落格計畫 1.0 版本完結啦,2.0 正在更新中 ... , 演示連結 http://116.62.199.48/ ,全程手摸手,後端 + 前端全棧開發,從 0 到 1 講解每個功能點開發步驟,1v1 答疑,直到計畫上線。 目前已更新了219小節,累計34w+字,講解圖:1492張,還在持續爆肝中.. 後續還會上新更多計畫,目標是將Java領域典型的計畫都整一波,如秒殺系統, 線上商城, IM即時通訊,Spring Cloud Alibaba 等等,

在 SpringBoot 計畫中,我們可以透過 @EnableScheduling 註解開啟排程任務支持,並透過 @Scheduled 註解快速地建立一系列定時任務。

@Scheduled支持下面三種配置執行時間的方式:

  • cron(expression) :根據Cron運算式來執行。

  • fixedDelay(period) :固定間隔時間執行,無論任務執行長短,兩次任務執行的間隔總是相同的。

  • fixedRate(period) :固定頻率執行,從任務啟動之後,總是在固定的時刻執行,如果因為執行時間過長,造成錯過某個時刻的執行(晚點),則任務會被立刻執行。

  • 最常用的應該是第一種方式,基於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.get class().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(falsetrue)) {
    log.info("定時排程任務動態載入開始>>>>>>");
    try {
    schedulingConfiguration.refresh();
    } finally {
    initializing.set(false);
    }
    log.info("定時排程任務動態載入結束<<<<<<");
    }
    }
    @Override
    public void run(ApplicationArguments args) {
    if (appStarted.compareAndSet(falsetrue)) {
    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等額外的中介軟體,可以說是十分的輕量和優雅了。當然,如果各位看官有更好的方法,還請不吝賜教。

    👉 歡迎 ,你將獲得: 專屬的計畫實戰 / Java 學習路線 / 一對一提問 / 學習打卡 / 贈書福利

    全棧前後端分離部落格計畫 1.0 版本完結啦,2.0 正在更新中 ... , 演示連結 http://116.62.199.48/ ,全程手摸手,後端 + 前端全棧開發,從 0 到 1 講解每個功能點開發步驟,1v1 答疑,直到計畫上線。 目前已更新了219小節,累計34w+字,講解圖:1492張,還在持續爆肝中.. 後續還會上新更多計畫,目標是將Java領域典型的計畫都整一波,如秒殺系統, 線上商城, IM即時通訊,Spring Cloud Alibaba 等等,


    1. 

    2. 

    3. 

    4. 

    最近面試BAT,整理一份面試資料Java面試BATJ通關手冊,覆蓋了Java核心技術、JVM、Java並行、SSM、微服務、資料庫、數據結構等等。

    獲取方式:點「在看」,關註公眾號並回復 Java 領取,更多內容陸續奉上。

    PS:因公眾號平台更改了推播規則,如果不想錯過內容,記得讀完點一下在看,加個星標,這樣每次新文章推播才會第一時間出現在你的訂閱列表裏。

    「在看」支持小哈呀,謝謝啦