💭

Quartz.NETでジョブをスケジュールする

2023/12/26に公開

最近、Quartz.NETというライブラリを利用する機会がありました。
このライブラリは、Windowsのタスクスケジューラのように特定の曜日や時刻を指定してジョブを実行できちゃうすごいやつです。
日本語での利用例が少なかったので、簡単にですが使い方をまとめてみたいと思います。

Quartz.NET の使い方

Quartz.NET はオープンソースのライブラリです。
NuGetパッケージとして公開されているので、C#erなら特に苦も無くインストールして利用開始できると思います。

Quartz.NETによるジョブの実行の基本的な流れは以下のようになります。

using Quartz;
using Quartz.Impl;

// スケジューラを作成
var schedulerFactory = new StdSchedulerFactory();
var scheduler = await schedulerFactory.GetScheduler();

// スケジューラを開始
await scheduler.Start();

// ジョブ詳細を作成
var jobDetail = JobBuilder.Create<SampleJob>()
    .WithIdentity("sample-job")
    .Build();

// トリガーを作成
var trigger = TriggerBuilder.Create()
    .WithIdentity("sample-trigger")
    .StartNow()
    .WithSimpleSchedule(x => x
        .WithIntervalInSeconds(5)
        .RepeatForever())
    .Build();

// ジョブ詳細とトリガーをスケジューラーに登録
await scheduler.ScheduleJob(jobDetail, trigger);

// 実行を待機する
await Task.Delay(TimeSpan.FromMinutes(5));

// スケジューラを終了
await scheduler.Shutdown();

// ジョブ
public class SampleJob : IJob
{
    public async Task Execute(IJobExecutionContext context)
    {
        // ここに実行したいジョブの処理を記述
        Console.WriteLine("ジョブが実行されました - " + DateTime.Now);
        await Task.CompletedTask;
    }
}

それでは、コードについてひとつひとつ具体的に見ていきましょう。

スケジューラの作成

// スケジューラを作成
var schedulerFactory = new StdSchedulerFactory();
var scheduler = await schedulerFactory.GetScheduler();

// スケジューラを開始
await scheduler.Start();

なにはなくともスケジューラが存在しなければ始まらないので、まずはQuartzのスケジューラのインスタンスを生成する必要があります。
上記コードでは、ISchedulerFactoryを実装しているQuartz組み込みのクラスStdSchedulerFactoryを利用して、スケジューラを取得しています。
他にも、Fluent Scheduler Builder APIで生成できるようです。こちらはスケジューラの設定項目をコード上で指定するのに便利そうなインターフェースが用意されています。
なお、コードで明示的にStart()を呼んでいることから分かるように、後述する処理でジョブとトリガーを登録しても、スケジューラを開始しなければジョブは実行されません。

ジョブ詳細の作成

// ジョブ詳細を作成
var jobDetail = JobBuilder.Create<SampleJob>()
    .WithIdentity("sample-job")
    .Build();

ジョブ詳細はIJobDetailインターフェースを実装したクラスのインスタンスです。
この詳細情報を定期実行の対象としてスケジューラに登録した状態で実行タイミングが訪れると、Create<T>()Tに指定しているSampleJobクラスがインスタンス化され、ジョブが実行されます。

このインスタンスでは、ジョブ実行時に利用できるデータのコレクション(JobDataMap)を保持できます。
例えば、ジョブの実行時に使うデータとしてジョブの登録者の名前を渡したい場合は、以下のように指定できます。

var job = JobBuilder.Create<SampleJob>()
    .UsingJobData("OwnerName", "Alice") // or .UsingJobData(new JobDataMap(){ {"OwnerName", "Alice"} })
    .Build();

トリガーの作成

// トリガーを作成
var trigger = TriggerBuilder.Create()
    .WithIdentity("sample-trigger")
    .StartNow()
    .WithSimpleSchedule(x => x
        .WithIntervalInSeconds(5)
        .RepeatForever())
    .Build();

トリガーはジョブが実行されるタイミングを指定するためのクラスで、ITriggerインタフェースを実装します。
上記のコードは、5秒毎に発火し続けるトリガーを生成しています。

トリガーの発火タイミングを設定する方法として、以下の4種類があるようです。

  • WithCalendarIntervalSchedule
  • WithCronSchedule
  • WithDailyTimeIntervalSchedule
  • WithSimpleSchedule

特に、WithCronScheduleではcron式による指定ができます。
ただし、このcron式はUNIXのcron式とは互換性がなく、Quartz特有のcron式であることに注意が必要です。
Quartzのcron式はスペース区切りの7つのフィールドから構成されます(Yearフィールドは省略可能です)。

  • Seconds
  • Minutes
  • Hours
  • Day of month
  • Month
  • Day of week
  • Year

各フィールドはある単一の値の指定以外にも、Day of weekMON-FRI(=月曜日から金曜日)のような感じで範囲を指定したり、Minutes0/15(0分に開始して15分ごと)を指定して一定時間ごとに実行できます。
また、Day of monthDay of weekには特殊文字として?が用意されています。これはDay of monthDay of weekを同時に指定できないからで、例えばDay of monthに特定の値を指定した場合は、もう一方のDay of weekには?を指定する必要があります。
Quartzのcron式の詳細については、こちらのページをご覧ください。

スケジューラに登録する

// ジョブ詳細とトリガーをスケジューラーに登録
await scheduler.ScheduleJob(jobDetail, trigger);

ジョブ詳細とトリガーを作成したら、スケジューラに登録します。
これで、あとは指定した日時まで待機するだけです。
なお、Quartz.NETでは、ひとつのジョブ詳細に対して複数のトリガーを関連付けることができます。
また、一つもトリガーが存在しない状態のジョブ詳細も追加できるので、トリガーだけを後から動的に追加して実行させることができます。

// トリガーなしで、ジョブ詳細だけ追加する
// storeNonDurableWhileAwaitingSchedulingをtrueに設定することで、
// トリガーがなくてもジョブ詳細のインスタンスが破棄されないようにしている
await scheduler.AddJob(jobDetail, replace: true, storeNonDurableWhileAwaitingScheduling: true);

ジョブ

// ジョブ
public class SampleJob : IJob
{
    public async Task Execute(IJobExecutionContext context)
    {
        // ここに実行したいジョブの処理を記述
        Console.WriteLine("ジョブが実行されました - " + DateTime.Now);
        await Task.CompletedTask;
    }
}

トリガーの発火時に実行する処理はIJobインターフェースを実装した任意のクラスのExecuteメソッドで記述します。

contextには、ジョブの実行時に利用できる情報として以下のものが含まれています。

  • context.JobDetail
  • context.Trigger
  • context.Scheduler
  • context.FireTimeUtc
    • ジョブの実際の実行時刻。9:00にスケジュールされていても、マシンがビジー状態で9:03に実行されていた場合、9:03を返す。
  • context.ScheduledFireTimeUtc
    • ジョブのスケジュールされた実行時刻。マシンがビジー状態で9:03に実行されていた場合でも、9:00にスケジュールされていれば9:00を返す。
  • context.MergedJobDataMap
    • JobDataMapはジョブ詳細だけでなく、トリガーごとに保持することもできます。context.MergedJobDataMapは、ジョブ詳細が持つJobDataMapとトリガーが持つJobDataMapを集約したコレクションを返します。なお、同名のキーが存在した場合、トリガーが持つ値が優先されます。

スケジュールされたジョブのテスト

Quartz.NETでスケジュールされたジョブのテスト方法についてです。

まず、スケジュールされたジョブが意図した時間で実行されることを確認するには、スケジューラが参照する時間をコントロールできることが望ましいです。
Quartz.NETでは、それを実現するための手段としてSystemTimeという静的クラスが用意されています。
以下のようにSystemTimeクラスに値を設定することで、スケジューラが参照する時間を変更できます。

// 現在の日時として、2024/12/1 9:00:00 を指定
SystemTime.UtcNow = () =>
{
    return new DateTime(2024, 1, 1, 9, 0, 0);
};

上記のように指定すると、スケジューラは常に現在の日時が"2024/12/1 9:00:00"だと判断します。もし時間を進めたい場合は、自身でカウントアップする処理を書けば実現できるはずです。

日時については解決しましたが、他にも問題があります。
スケジューラにジョブを登録後、ジョブの実行を待機する必要がありますが、ジョブは別スレッドで実行されるため、正しくアサ―トを書くにはジョブの終了まで待機しなければなりません。Task.Delayなどで待ってもよいのですが、マシンの状態によってはテスト結果が不安定になってしまいます。
なので、ここではManualResetEventクラスを利用する方法を紹介します。
ManualResetEventクラスでは、あるメソッドが呼ばれるまで処理を停止して待機することができます。

var event =  new ManualResetEvent(false);

// thread 1
event.Wait(); // event.Set()が呼ばれるまでhoge()は実行されず、この行で待機する
hoge();

// thread 2
huga();
event.Set(); // この行を実行後、thread 1の待機が解除され、thread 1ではhoge()が実行される。

また、Quartz.NETのスケジューラでは、IJobListenerインターフェースによってジョブの実行が完了したタイミングに処理を注入できます。
具体的には、以下のようなテスト用のジョブリスナーを実装し、

// テスト用のジョブリスナー
public class TestJobListener : IJobListener
{
    // マニュアルリセットイベント
    private ManualResetEventSlim _manualResetEvent;

    // コンストラクタ
    public TestJobListener(ManualResetEventSlim manualResetEvent)
    {
        _manualResetEvent = manualResetEvent;
    }

    // <inheritdoc />
    public string Name => "TestJobListener";

    // <inheritdoc />
    public Task JobExecutionVetoed(IJobExecutionContext context, CancellationToken cancellationToken = default)
    {
        // ..
    }

    // <inheritdoc />
    public Task JobToBeExecuted(IJobExecutionContext context, CancellationToken cancellationToken = default)
    {
        // ..
    }

    // ManualResetEventのシグナルを送信する。
    public Task JobWasExecuted(IJobExecutionContext context, JobExecutionException? jobException, CancellationToken cancellationToken = default)
    {
        _manualResetEvent.Set();
        return Task.CompletedTask;
    }
}

テストコードを以下のように記載します。

// Arrange
// テスト用のジョブリスナーの登録
var manualResetEvent = new ManualResetEventSlim(false);
var listener = new TestJobListener(manualResetEvent);
var scheduler = await new StdSchedulerFactory().GetScheduler();
scheduler.ListenerManager.AddJobListener(listener, GroupMatcher<JobKey>.AnyGroup());

// Act
// スケジューラへ登録
await scheduler.ScheduleJob(jobDetail, trigger);

// ジョブの実行を待機する
manualResetEvent.Wait(timeout: TimeSpan.FromMilliseconds(100));

// Assert
// ..

テストコードのmanualResetEvent.Wait(timeout: TimeSpan.FromMilliseconds(100))でAssert前に実行を待機し、ジョブ実行が完了したらTestJobListener.JobWasExecuted_manualResetEvent.Set()が呼ばれるので、ジョブの実行完了を確実に待機してから、アサ―トが実行されます。

まとめ

Quartz.NETの簡単な利用例を紹介しました。
ここに記載した内容はQuartz.NETの機能の中でもほんの一部になりますが、使い始める方の参考になれば良いなと思っています。
また、ドキュメントをすべて確認しているわけではないので、何か問題が含まれていたらフィードバックいただけると嬉しいです!

参考資料

https://www.quartz-scheduler.net/documentation/quartz-3.x
https://outlawtrail.wordpress.com/2015/03/23/testing-retries-in-quartz-net/

Discussion