👻

.Net 系の Web アプリケーションでスケジュールしたタスクを実行する

2023/04/19に公開

この記事は

この記事は .Net 系の Web アプリケーション(.net framework または .Net Core)においてスケジュールしたタスクを実行する方法を紹介しています。

導入する前に検討すべきこと

一般的に Web アプリケーションは、アクセスがあった場合にレスポンスを返すようなシステムです。
スケジュールしたタスク実行を行いたい場合、まずは目的に適したサービスやツールで解決できないか検討してみましょう。Windows であればタスクスケジューラーや Windows サービス、Linux では cron などが定期実行に適したサービスです。

また、スケジュールしたタスクの実行を Web アプリケーションに統合することによってどんなメリット・デメリットがあるか検討すべきです。
例えば以下のメリット・デメリットが考えられます。

  • メリット
    • サービスを Web アプリに集約することによって、デプロイ・環境構築・メンテナンスが楽になる
      → テナントが沢山ある場合にデプロイ楽
  • デメリット
    • Web アプリケーションの肥大化
    • Web アプリケーションとスケジュールサービスの密結合化
      → Web アプリケーションが停止するとスケジュールも停止する

スケジュールしたタスクを実行する方法

.Net 系の Web アプリケーションでスケジュールしたタスクを実行する方法を2つご紹介します。

  1. ライブラリ Hangfire の利用
  2. キャッシュアイテム のタイムアウトコールバックを利用する方法

以降、詳しく説明します。

1. ライブラリ Hangfire の利用

ライブラリ Hangfire を利用する方法です。他にも Quartz という同じようなライブラリがありますが、Hangfire が一番メジャーなようです。ここでは Hangfire を取り上げます。

NuGet でプロジェクトにインストールして利用します。
注意点としてデータベースが必要で、DB に必要なテーブルが自動作成されることを留意してください。
DB を使用してタスクをスケジュールおよび管理します。これにより、Web アプリケーションの停止または再起動時にもタスクが失われないことが保証されます。

検証環境・検証内容

  • 検証環境
    • フレームワークは ASP.NET MVC の Web アプリケーション (C#)
    • DB は SQLServer を利用
  • 検証内容
    • 1分毎に "Hello Hangfire!" をデバッグ出力する

以降、コーディング例を示します。

Global.asax.cs
...

using Hangfire;
using Hangfire.SqlServer;

...

public class MvcApplication : HttpApplication
  {
    private BackgroundJobServer _backgroundJobServer;

    protected void Application_Start()
    {
      // DB接続文字列を Hangfire に設定する
      var connectionString = "Data Source=.\SQLEXPRESS;Initial Catalog=DBName;User ID=UserId;Password=P@$$w0rd"
      GlobalConfiguration.Configuration.UseSqlServerStorage(connectionString);

      // Hangfire server をスタートする
      _backgroundJobServer = new BackgroundJobServer();

      // 1分毎で定期実行する job 登録する
      RecurringJob.AddOrUpdate<HelloHangfireJob>("hello-hangfire-job", job => job.Run(), Cron.MinuteInterval(1));
    }

    protected void Application_End()
    {
      // Hangfire server を破棄
      _backgroundJobServer.Dispose();
    }

...
HelloHangfireJob.cs
using System.Diagnostics;

namespace HangfireMvcExample
{
    public class HelloHangfireJob
    {
        public void Run()
        {
            Debug.WriteLine("Hello Hangfire!");
        }
    }
}

※ Hangfire は OWIN というインターフェイス規格に即したアプリケーションで、IIS を必要としない独立したWeb アプリケーションとして稼働ができます。
今回は Application_Start 関数をトリガーとするので自動スタートを OFF にします。

Web.config
<configuration>
  ...
  <appSettings>
    <!--OWIN ホスティングの自動スタートを OFF にする-->
    <add key="owin:AutomaticAppStartup" value="false" />
  </appSettings>
  ...
</configuration>

2. キャッシュアイテム のタイムアウトコールバックを利用する方法

.net 系の Web アプリケーションではキャッシュアイテムというものを登録することができます。キャッシュアイテムは任意の時間でタイムアウトして除去されますが、タイムアウト時のコールバック関数をセットすることができます。このコールバック関数でキャッシュアイテムを登録する処理を行えば、再帰的にコールバック関数が呼び出され、一定の時間間隔で定期処理を行えるというわけです。時間間隔(タイムアウトする時間)はある程度任意に設定できますが、私が動作確認したところでは 「60秒以上かつ20秒刻み」という制限がありました。また、Webアプリケーションは一定時間アクセスが無いとスリープ状態になるので、スリープ状態になると定期処理は止まってしまうことを確認しました。(定期処理の開始はApplication_BeginRequest関数に書いてるので、またリクエストがあれば再開します)
以降のコーディング例ではスリープを防ぐためにダミーのApiを作成し、それをコールしていますが、それでは完全にスリープを防ぐことはできませんでした。IISの設定でできることは今回は調べていません。

検証環境・検証結果

  • 検証環境
    • フレームワークは ASP.NET MVC (.NET Framework 4.7.2) を利用
  • 検証結果
    • 一定の時間間隔で定期処理を行う。
    • 時間間隔は「60秒以上かつ20秒刻みで設定できる」という制限がある
    • Webアプリケーションがスリープ状態の間は定期処理は止まってしまう。
  • 以下の記事を参考にしました

以降、コーディング例を示します。

Global.asax.cs
using System.Threading.Tasks;

namespace MyApp
{
    public class MvcApplication : HttpApplication
    {
...
        protected void Application_BeginRequest(Object sender, EventArgs e)
        {
            //一度だけ行う周期的タスクのための初期化処理
            Task.Run(() => FirstRequestInitialization.Initialize(sender));
        }
...
FirstRequestInitialization.cs
using System.Threading.Tasks;

namespace MyApp
{
    public class FirstRequestInitialization
    {
        private static bool initializedAlready = false;
        private static Object _lock = new Object();
	
        /// <summary>初回リクエスト時に1回だけ行う</summary>
        public static void Initialize()
        {
            if (initializedAlready) return;
            lock (_lock)
            {
                PeriodicTask.BeginPeriodicTask();
            }
        }
    }
}
PeriodicTask.cs
namespace MyApp
{
    public class PeriodicTask
    {
        public static void BeginPeriodicTask()
        {
	    try
            {
                //キャッシュアイテムを登録する
                CacheItemInserter.InsertCacheItem();

                //メインの処理
                Task.Run(() => MainTask());
	    
	        //スリープ防ぐためダミーAPIをコールする。
		//しかし完全にスリープを防ぐことはできない...
                CallApi_Dummy();
            }
	    catch (Exception ex)
            {
                //エラー時の処理
            }
        }
	
	public static void MainTask()
	{
	    //ここに定期実行したいことを書く
	}
    }
}
CacheItemInserter.cs
using System;
using System.Web;
using System.Web.Caching;

namespace MyApp
{
    public class CacheItemInserter
    {
        public static void InsertCacheItem()
        {
            var timeSpanSec = 80;

            HttpRuntime.Cache.Add(
                "anyKey",
                "anyValue",
                null,
                DateTime.MaxValue,
                TimeSpan.FromSeconds(timeSpanSec),
                CacheItemPriority.Normal,
                CacheItemRemovedCallback);
        }
	
        private static void CacheItemRemovedCallback(string key, object value, CacheItemRemovedReason reason)
        {
            PeriodicTask.BeginPeriodicTask();
        }
    }
}

Discussion