【C#】ASP.NETで定期実行ならCronosが便利らしい?

技術

CRONのような定期実行がしたい?

それならタスクスケジューラを使うのがやっぱり正攻法だと思うんです。

LINUXにcrontabがあるみたいに、Windowsにはタスクスケジューラがあって、素直にそれを使う方がいいと個人的には思います。

ただ、なぜかASP.NETから実行することにこだわる人がいます。
どうもリリースの時の手間が気に入らないらしいのですが、その人からCronosってライブラリを紹介されました。

「これ(Cronos)を使えば、ASP.NETでも定期実行できるらしいよ」と。

そんなわけで、Cronosってライブラリをちょっと触ってみました
https://www.nuget.org/packages/Cronos/

Cronosって定期実行のライブラリじゃない

Cronosはオープンソースで開発されていて、こちらでソースは確認できるんだけど、よく見るとCronosって実は定期実行の仕組みを持っていないらしい。
https://github.com/HangfireIO/Cronos

Cronos自体はCRON式を解析して次の実行タイミングをDateTimeで教えてくれるってライブラリみたいです。

で、教えてもらったタイミングで処理を実行するのは自分で実装しないといけないらしいのです。
(なんじゃそらって感じですが)

こちらでその辺が実装されているサンプルが公開されています。
https://github.com/dotnet-labs/ServiceWorkerCronJob

ただ、今回は定期実行する仕組みがほしいだけで「CRON式で定義したい」が要件ではないはず。
無理に使う必要はないと思うんだが・・・?

サンプル通り実装してみた

せっかく調べたので、サンプルを参考にASP.netで動くように実装してみました。
ホステッドサービスとして実装して、バックグラウンド タスクとして動かす仕組みらしいです。

まずはサンプルプロジェクトの作成。
環境はdocker上のlinux(Debian)で、フレームワークは.NET6にしました。

cd /usr/src/sample
dotnet new mvc

プロジェクトができたら、そのままCronosをNugetから参照追加します。

dotnet add package Cronos

ここまで準備できたら早速実装していきましょう。
まずはプロジェクトの直下にServicesディレクトリを作って、その下にCronJobServiceクラスを実装します。

using Cronos;

namespace sample.Services
{
    public class CronJobService : IHostedService, IDisposable
    {
        private System.Timers.Timer? _timer;
        private readonly CronExpression _expression;
        private readonly TimeZoneInfo _timeZoneInfo;

        public CronJobService(IScheduleConfig<CronJobService> config)
        {
            _expression = CronExpression.Parse(config.CronExpression);
            _timeZoneInfo = config.TimeZoneInfo;
        }

        public async Task StartAsync(CancellationToken cancellationToken)
        {
            await ScheduleJob(cancellationToken);
        }

        protected async Task ScheduleJob(CancellationToken cancellationToken)
        {
            var next = _expression.GetNextOccurrence(DateTimeOffset.Now, _timeZoneInfo);
            if (next.HasValue)
            {
                var delay = next.Value - DateTimeOffset.Now;
                if (delay.TotalMilliseconds <= 0)   // prevent non-positive values from being passed into Timer
                {
                    await ScheduleJob(cancellationToken);
                }
                _ = Task.Run(async() => {
                    await Task.Delay((int)delay.TotalMilliseconds, cancellationToken);
                    Console.WriteLine($"Do scheduled task.({DateTimeOffset.Now})");
                    await ScheduleJob(cancellationToken);
                });
            }
            await Task.CompletedTask;
        }

        public async Task StopAsync(CancellationToken cancellationToken)
        {
            _timer?.Stop();
            await Task.CompletedTask;
        }

        public void Dispose()
        {
            _timer?.Dispose();
            GC.SuppressFinalize(this);
        }
    }

    // ReSharper disable once UnusedTypeParameter
    public interface IScheduleConfig<T>
    {
        string CronExpression { get; set; }
        TimeZoneInfo TimeZoneInfo { get; set; }
    }

    public class ScheduleConfig<T> : IScheduleConfig<T>
    {
        public string CronExpression { get; set; } = string.Empty;
        public TimeZoneInfo TimeZoneInfo { get; set; } = TimeZoneInfo.Local;
    }

    public static class ScheduledServiceExtensions
    {
        public static IServiceCollection AddCronJob<T>(this IServiceCollection services, Action<IScheduleConfig<T>> options) where T : CronJobService
        {
            if (options == null)
            {
                throw new ArgumentNullException(nameof(options), @"Please provide Schedule Configurations.");
            }
            var config = new ScheduleConfig<T>();
            options.Invoke(config);
            if (string.IsNullOrWhiteSpace(config.CronExpression))
            {
                throw new ArgumentNullException(nameof(ScheduleConfig<T>.CronExpression), @"Empty Cron Expression is not allowed.");
            }

            services.AddSingleton<IScheduleConfig<T>>(config);
            services.AddHostedService<T>();
            return services;
        }
    }}

定期的に Console.WriteLine するだけの単純なものです。
サンプルでは Timer を使っていましたが、私は Task.Delay で実装してみました。

後は作った「CronJobService」をWebApplicationBuilderでサービスに追加すればいいだけです。
自動生成されている Program.cs をこのように修正します。

using sample.Services;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddControllersWithViews();

// Add cron service
builder.Services.AddCronJob<CronJobService>(c =>
{
    c.TimeZoneInfo = TimeZoneInfo.Local;
    c.CronExpression = @"*/1 * * * *";
});

var app = builder.Build();
 ・
 ・
(以下省略)
 ・
 ・

Program.cs の 8~13行目に追加したこのコードがCronJobServiceの追加になります。

// Add cron service
builder.Services.AddCronJob<CronJobService>(c =>
{
    c.TimeZoneInfo = TimeZoneInfo.Local;
    c.CronExpression = @"*/1 * * * *";
});

1分間隔で処理が実装されるようにCRONを定義しました。

この状態で起動すると1分間隔で

Do scheduled task.(02/23/2023 20:25:00 +00:00)
Do scheduled task.(02/23/2023 20:26:00 +00:00)
Do scheduled task.(02/23/2023 20:27:00 +00:00)

と、こんな感じで標準出力されるはずです。

IISにデプロイする時はちょっと注意

ところで、作成したCronJobServiceは Program.cs の中で サービスに追加したわけですが、この実装でIISにデプロイする時は注意が必要です。

というのも、何も考えずに単純にデプロイしただけだとこのサービスは初回アクセスされるまで一切動きません。

IISは最初のリクエストを受け取るまで何も実行しないのがデフォルトらしいです。
IISの開始後、最初にリクエストを受けた時にサービスは開始され、定期的にログが出力されるようになります。
そしてサービスが開始された後はそのサービスもIISのアイドルタイムアウトの影響を受けるとのことです。
そのあたりがStack overflowで議論されていました。

そんなわけで、何かしら対策する必要があるわけですが、それについてもStack overflowで教えてくれています。
詳しい手順は省略しますが、IISを下記の通り設定すればいいということでした。

  1. Application Initializationを有効化する
  2. アプリケーション プールの開始モードをAlwaysRunningにする

これで期待通りに動作してくれるようになりました。

ちなみに、IIS以外は試していないのでわかりません。
NginxやApacheのようなIIS以外のWebServerでは ASP.NET は直接ホストされずに、Kestrelサーバー上にホストされて、WebServerはロードバランサのようにフロントでリクエストを待ち受ける形になります。

なので、動作の仕組みがIISとは少し異なるので、もしかすると問題なく動くかもしれませんし、IISと同じような問題があるのかもしれません。

まとめ

繰り返しになりますが、Windowsで定期実行したいならあくまでも「タスクスケジューラを使う方がいい」と私は思っています。
そしてCronosは定期実行するライブラリではなく、あくまでCRON式を解析してくれるライブラリです。
だから、今回紹介した方法は正直誰かにオススメしていいかどうか、私にはわかりません。

ただ、Cronosというライブラリについていえば、定期実行できるライブラリと聞いて紹介されたので、ちょっと残念に感じてしまいましたが、CRON式を読んでくれるライブラリとしてみると、とても使いやすくいいライブラリだと思いました。

次は正しい使いどころで使えるといいなと思っています。

コメント

タイトルとURLをコピーしました