動態更改Spring定時任務Cron表達式的優雅方案

来源:https://www.cnblogs.com/mylibs/archive/2022/12/21/dynamic-change-of-cron-expression.html
-Advertisement-
Play Games

"Most of you are familiar with the virtues of a programmer. There are three, of course: laziness, impatience, and hubris." - Larry Wall “程式員的美德:懶惰,不耐煩 ...


"Most of you are familiar with the virtues of a programmer. There are three, of course: laziness, impatience, and hubris." - Larry Wall

“程式員的美德:懶惰,不耐煩以及老子天下第一。” —— 拉里·沃爾

0x00 大綱

目錄

0x01 前言

在 SpringBoot 項目中,我們可以通過@EnableScheduling註解開啟調度任務支持,並通過@Scheduled註解快速地建立一系列定時任務。

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

  • cron(expression):根據Cron表達式來執行。
  • fixedDelay(period):固定間隔時間執行,無論任務執行長短,兩次任務執行的間隔總是相同的。
  • fixedRate(period):固定頻率執行,從任務啟動之後,總是在固定的時刻執行,如果因為執行時間過長,造成錯過某個時刻的執行(晚點),則任務會被立刻執行。

最常用的應該是第一種方式,基於Cron表達式的執行模式,因其相對來說更加靈活。

0x02 可變與不可變

預設情況下,@Scheduled註解標記的定時任務方法在初始化之後,是不會再發生變化的。Spring 在初始化 bean 後,通過後處理器攔截所有帶有@Scheduled註解的方法,並解析相應的的註解參數,放入相應的定時任務列表等待後續統一執行處理。到定時任務真正啟動之前,我們都有機會更改任務的執行周期等參數。換言之,我們既可以通過application.properties配置文件配合@Value註解的方式指定任務的Cron表達式,亦可以通過CronTrigger從資料庫或者其他任意存儲中間件中載入並註冊定時任務。這是 Spring 提供給我們的可變的部分。

但是我們往往要得更多。能否在定時任務已經在執行過的情況下,去動態更改Cron表達式,甚至禁用某個定時任務呢?很遺憾,預設情況下,這是做不到的,任務一旦被註冊和執行,用於註冊的參數便被固定下來,這是不可變的部分。

0x03 創造與毀滅

既然創造之後不可變,那就毀滅之後再重建吧。於是乎,我們的思路便是,在註冊期間保留任務的關鍵信息,並通過另一個定時任務檢查配置是否發生變化,如果有變化,就把“前任”幹掉,取而代之。如果沒有變化,就保持原樣。

先對任務做個簡單的抽象,方便統一的識別和管理:

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中,但是為了方便擴展,這裡還是將執行與觸發分離了。畢竟除了通過定時任務觸發刷新,還可以在界面上通過按鈕手動觸發刷新,或者通過消息機制回調刷新。這一部分就請大家根據實際業務情況來自由發揮了。

0x04 驗證

我們創建一個原型工程和三個簡單的定時任務來驗證下,第一個任務是執行周期固定的任務,假設它的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

0x05 小結

我們在上文通過定時刷新和重建任務的方式來實現了動態更改Cron表達式的需求,能夠滿足大部分的項目場景,而且沒有引入quartzs等額外的中間件,可以說是十分的輕量和優雅了。當然,如果各位看官有更好的方法,還請不吝賜教。


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 上官網看執行計劃文檔釋義,移步 此部分在MySQL官方文檔中的結構屬於優化(Optimization)-理解查詢執行計劃(Understanding the Query Execution Plan)。 此部分一共包括5個部分內容: 1,使用explain優化查詢 2,explain輸出內容釋義 3 ...
  • this指向 this定義 this用於指定對當前對象的引用。 this的兩種綁定方式 為什麼說是兩種?在《你不知道的JavaScript(上捲)》一書中共提到了四種綁定方式。如下: 預設綁定 隱式綁定 顯式綁定 new綁定 實際上這四種綁定方式有兩種方式重覆了(隱式綁定和new綁定)。我們在學習過 ...
  • 小程式開發整理 使用uni-app跨端開發框架,代碼寫法與vue2一致。 一、與web開發的區別 1. 運行方式不同 npm run dev:mp-weixin後,用微信開發者工具打開dist中工程。 2. 標簽與web開發不同 標簽的對應關係 | 小程式中使用 | web中使用 | | | | | ...
  • 華麗炫酷的動畫特效總能夠讓人心曠神怡,不能自已。艷羡之餘,如果還能夠探究其華麗外表下的實現邏輯,那就是百尺竿頭,更上一步了。本次我們使用圖片、SCSS樣式以及SVG圖片動畫來實現“點贊”按鈕的動畫特效,並比較不同之處。 圖片實現 最簡單,也最容易理解的實現方式就是使用圖片。曾幾何時,幾乎所有前端特效 ...
  • 高併發解決的核心問題是在同一時間上有大量的請求過來,然後我們的系統要怎麼抗住這些請求帶來的壓力。本文從基礎設施層、服務端架構層、服務應用層分別做了一個簡單的梳理,在每一層通過什麼的方式去抗併發,給大家提供一個思路。 ...
  • PowerDotNet個人項目中功能全面而強大的一個系統是支付平臺。我對PowerDotNet的自信很大程度上來自於經過PowerDotNet重寫後的支付、財務、結算、CRM等業務型公共服務系統的穩定運行。 使用PowerDotNet和PowerDotNetCore特別開發的業務邏輯型公共服務既有極 ...
  • 家居網購項目實現06 以下皆為部分代碼,詳見 https://github.com/liyuelian/furniture_mall.git 14.功能13-首頁分頁 14.1需求分析/圖解 顧客進入首頁頁面 分頁顯示家居 正確顯示分頁導航條 14.2思路分析 14.3代碼實現 14.3.1web層 ...
  • 用戶頁面wxml <!--pages/home/home.wxml--> <view class="container"> <view class="top-view"> <view class="user"> <view class="row"> <image class="avatar" wx: ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...