1. 程式人生 > >.net core 實現基於 cron 表示式的任務排程

.net core 實現基於 cron 表示式的任務排程

.net core 實現基於 cron 表示式的任務排程

Intro

上次我們實現了一個簡單的基於 Timer 的定時任務,詳細資訊可以看這篇文章。

但是使用過程中慢慢發現這種方式可能並不太合適,有些任務可能只希望在某個時間段內執行,只使用 timer 就顯得不是那麼靈活了,希望可以像 quartz 那樣指定一個 cron 表示式來指定任務的執行時間。

cron 表示式介紹

cron 常見於Unix和類Unix的作業系統之中,用於設定週期性被執行的指令。該命令從標準輸入裝置讀取指令,並將其存放於“crontab”檔案中,以供之後讀取和執行。該詞來源於希臘語 chronos(χρόνος),原意是時間。

通常,crontab儲存的指令被守護程序啟用,crond 常常在後臺執行,每一分鐘檢查是否有預定的作業需要執行。這類作業一般稱為cron jobs。

cron 可以比較準確的描述週期性執行任務的執行時間,標準的 cron 表示式是五位:

30 4 * * ? 五個位置上的值分別對應 分鐘/小時/日期/月份/周(day of week)

現在有一些擴充套件,有6位的,也有7位的,6位的表示式第一個對應的是秒,7個的第一個對應是秒,最後一個對應的是年份

0 0 12 * * ? 每天中午12點
0 15 10 ? * * 每天 10:15
0 15 10 * * ? 每天 10:15
30 15 10 * * ? * 每天 10:15:30

0 15 10 * * ? 2005 2005年每天 10:15

詳細資訊可以參考:http://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html

.NET Core CRON service

CRON 解析庫 使用的是 https://github.com/HangfireIO/Cronos
,支援五位/六位,暫不支援年份的解析(7位)

基於 BackgroundService 的 CRON 定時服務,實現如下:

public abstract class CronScheduleServiceBase : BackgroundService
{
        /// <summary>
        /// job cron trigger expression
        /// refer to: http://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html
        /// </summary>
        public abstract string CronExpression { get; }

        protected abstract bool ConcurrentAllowed { get; }

        protected readonly ILogger Logger;

        private readonly string JobClientsCache = "JobClientsHash";

        protected CronScheduleServiceBase(ILogger logger)
        {
            Logger = logger;
        }

        protected abstract Task ProcessAsync(CancellationToken cancellationToken);

        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            {
                var next = CronHelper.GetNextOccurrence(CronExpression);
                while (!stoppingToken.IsCancellationRequested && next.HasValue)
                {
                    var now = DateTimeOffset.UtcNow;

                    if (now >= next)
                    {
                        if (ConcurrentAllowed)
                        {
                            _ = ProcessAsync(stoppingToken);
                            next = CronHelper.GetNextOccurrence(CronExpression);
                            if (next.HasValue)
                            {
                                Logger.LogInformation("Next at {next}", next);
                            }
                        }
                        else
                        {
                            var machineName = RedisManager.HashClient.GetOrSet(JobClientsCache, GetType().FullName, () => Environment.MachineName); // try get job master
                            if (machineName == Environment.MachineName) // IsMaster
                            {
                                using (var locker = RedisManager.GetRedLockClient($"{GetType().FullName}_cronService"))
                                {
                                    // redis 互斥鎖
                                    if (await locker.TryLockAsync())
                                    {
                                        // 執行 job
                                        await ProcessAsync(stoppingToken);

                                        next = CronHelper.GetNextOccurrence(CronExpression);
                                        if (next.HasValue)
                                        {
                                            Logger.LogInformation("Next at {next}", next);
                                            await Task.Delay(next.Value - DateTimeOffset.UtcNow, stoppingToken);
                                        }
                                    }
                                    else
                                    {
                                        Logger.LogInformation($"failed to acquire lock");
                                    }
                                }
                            }
                        }
                    }
                    else
                    {
                        // needed for graceful shutdown for some reason.
                        // 1000ms so it doesn't affect calculating the next
                        // cron occurence (lowest possible: every second)
                        await Task.Delay(1000, stoppingToken);
                    }
                }
            }
        }

        public override Task StopAsync(CancellationToken cancellationToken)
        {
            RedisManager.HashClient.Remove(JobClientsCache, GetType().FullName); // unregister from jobClients
            return base.StopAsync(cancellationToken);
        }
    }

因為網站部署在多臺機器上,所以為了防止併發執行,使用 redis 做了一些事情,Job執行的時候嘗試獲取 redis 中 job 對應的 master 的 hostname,沒有的話就設定為當前機器的 hostname,在 job 停止的時候也就是應用停止的時候,刪除 redis 中當前 job 對應的 master,job執行的時候判斷是否是 master 節點,是 master 才執行job,不是 master 則不執行。完整實現程式碼:https://github.com/WeihanLi/ActivityReservation/blob/dev/ActivityReservation.Helper/Services/CronScheduleServiceBase.cs#L11

定時 Job 示例:

public class RemoveOverdueReservationService : CronScheduleServiceBase
{
    private readonly IServiceProvider _serviceProvider;
    private readonly IConfiguration _configuration;

    public RemoveOverdueReservationService(ILogger<RemoveOverdueReservationService> logger,
        IServiceProvider serviceProvider, IConfiguration configuration) : base(logger)
    {
        _serviceProvider = serviceProvider;
        _configuration = configuration;
    }

    public override string CronExpression => _configuration.GetAppSetting("RemoveOverdueReservationCron") ?? "0 0 18 * * ?";

    protected override bool ConcurrentAllowed => false;

    protected override async Task ProcessAsync(CancellationToken cancellationToken)
    {
        using (var scope = _serviceProvider.CreateScope())
        {
            var reservationRepo = scope.ServiceProvider.GetRequiredService<IEFRepository<ReservationDbContext, Reservation>>();
            await reservationRepo.DeleteAsync(reservation => reservation.ReservationStatus == 0 && (reservation.ReservationForDate < DateTime.Today.AddDays(-3)));
        }
    }
}

完整實現程式碼:https://github.com/WeihanLi/ActivityReservation/blob/dev/ActivityReservation.Helper/Services/RemoveOverdueReservationService.cs

Memo

使用 redis 這種方式來決定 master 並不是特別可靠,正常結束的沒有什麼問題,最好還是用比較成熟的服務註冊發現框架比較好

Reference

  • http://crontab.org/
  • https://en.wikipedia.org/wiki/Cron
  • http://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html
  • https://github.com/WeihanLi/ActivityReservation