🏗️

AzureのWEBサイトでバックグラウンド処理と定期処理を実行する方法の紹介

2021/09/03に公開

概要

WEBサービスを運用していると1時間に1回定期的に実行したい処理などがあったりすると思います。例えば
・店舗別の1日の売上集計データの作成処理(夜中の2時に前日のを集計)
・今日締切のタスクのメール送信(朝7時)
・サイトが落ちていないか監視(毎分実行)

定期処理はAzure Functionを使えば良いのですがAzure Functionはたまに処理が実行されなかったりします。絶対に確実に実行したい場合はちょっと困りものです。そのようなときにWEBサイトのバックグラウンドスレッドを使う方法があります。この方法だとより確実に処理を実行することが可能です。C#で定期実行するクラスの作り方の一例をこの記事では紹介します。

バックグラウンド処理のクラス

定期実行のクラスを作る前にまずは汎用のバックグラウンド用のクラスを作ります。理由は後述します。

ソースはここにあります。
https://github.com/higty/higlabo/tree/master/Net5/HigLabo.Service

クラスは2つ。
・BackgroundService
・ServiceCommand
の二つです。BackgroundServiceはスレッドを立ち上げ、別スレッドからコマンドが登録されるまでAutoResetEventを使って待機します。

BackgroundService.cs

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;

namespace HigLabo.Service
{
    public enum BackgroundCommandServiceState
    {
        Ready,
        Executing,
        Suspend,
    }
    public class BackgroundService
    {
        public class ServiceCommandExecuteResult
        {
            public String Name { get; set; }
            public DateTimeOffset StartTime { get; set; }
            public TimeSpan Duration { get; set; }

            public ServiceCommandExecuteResult(ServiceCommand command)
            {
                this.Name = command.GetType().Name;
                this.StartTime = command.CommandStartTime.Value;
                this.Duration = command.CommandEndTime.Value - command.CommandStartTime.Value;
            }
            public override string ToString()
            {
                return String.Format("{0} (StartTime={1}, Duration={2}ms)"
                    , this.Name, this.StartTime.ToString("MM/dd HH:mm:ss.fffffff")
                    , this.Duration.TotalMilliseconds);
            }
        }

        public event EventHandler<ServiceCommandEventArgs> Executed;
        public event EventHandler<ServiceCommandEventArgs> Error;

        private Thread _Thread = null;
        private AutoResetEvent _AutoResetEvent = new AutoResetEvent(true);
        private ConcurrentQueue<ServiceCommand> _CommandList = new ConcurrentQueue<ServiceCommand>();
        private ServiceCommand _CurrentCommand = null;
        private List<ServiceCommand> _PreviousCommandList = new List<ServiceCommand>();
        private DateTimeOffset _PreviousResetTime = DateTimeOffset.Now;
        private Int64 _ExecutedCommandCount = 0;
        private Int64 _ExecutedSeconds = 0;
        private Boolean _IsStarted = false;
        private Int64 _IsSuspend = 0;

        public String Name { get; private set; }
        public BackgroundCommandServiceState State
        {
            get
            {
                if (_IsStarted)
                {
                    if (_IsSuspend == 1) { return BackgroundCommandServiceState.Suspend; }
                    else { return BackgroundCommandServiceState.Executing; }
                }
                else
                {
                    return BackgroundCommandServiceState.Ready;
                }
            }
        }
        public Int32 ThreadSleepSecondsPerCommand { get; set; }
        public Int64 ExecutedCommandCount
        {
            get { return Interlocked.Read(ref _ExecutedCommandCount); }
        }
        public Int64 ExecutedSeconds
        {
            get { return Interlocked.Read(ref _ExecutedSeconds); }
        }
        public Int32 CommandCount
        {
            get { return _CommandList.Count; }
        }

        public BackgroundService(String name, Int32 threadSleepSecondsPerCommand)
        {
            this.Name = name;
            this.ThreadSleepSecondsPerCommand = threadSleepSecondsPerCommand;
        }
        public void StartThread()
        {
            this.StartThread(thd => { });
        }
        public void StartThread(ThreadPriority priority)
        {
            this.StartThread(thd => thd.Priority = priority);
        }
        public void StartThread(Action<Thread> setPropertyFunc)
        {
            if (_Thread != null) { throw new InvalidOperationException("You can't call StartThread method twice."); }

            _Thread = new Thread(() => this.Start());
            _Thread.Name = String.Format("{0}({1})", nameof(BackgroundService), this.Name);
            _Thread.IsBackground = true;
            _Thread.Priority = ThreadPriority.BelowNormal;

            if (setPropertyFunc != null)
            {
                setPropertyFunc(_Thread);
            }
            _Thread.Start();
            _IsStarted = true;
        }
        private void Start()
        {
            while (true)
            {
                if (_IsSuspend == 1)
                {
                    _AutoResetEvent.WaitOne();
                    continue;
                }
                var l = new List<ServiceCommand>();
                while (_CommandList.TryDequeue(out var cm))
                {
                    if (cm == null) { continue; }
                    l.Add(cm);
                }

                var now = DateTimeOffset.Now;
                DateTimeOffset? minNextStartTime = null;
                foreach (var cm in l)
                {
                    //Not execute command until schedule time will come.
                    if (cm.ScheduleTime > now)
                    {
                        _CommandList.Enqueue(cm);
                        if (minNextStartTime == null || minNextStartTime > cm.ScheduleTime)
                        {
                            minNextStartTime = cm.ScheduleTime;
                        }
                        continue;
                    }
                    try
                    {
                        var sw = Stopwatch.StartNew();
                        cm.CommandStartTime = DateTimeOffset.Now;
                        _CurrentCommand = cm;
                        cm.Execute();
                        cm.CommandEndTime = DateTimeOffset.Now;
                        sw.Stop();

                        Interlocked.Increment(ref _ExecutedCommandCount);
                        Interlocked.Add(ref _ExecutedSeconds, sw.ElapsedTicks / TimeSpan.TicksPerSecond);
                        if (_PreviousResetTime.Day != now.Day)
                        {
                            Interlocked.Exchange(ref _ExecutedCommandCount, 0);
                            Interlocked.Exchange(ref _ExecutedSeconds, 0);
                        }
                        _PreviousResetTime = now;
                        this.Executed?.Invoke(this, new ServiceCommandEventArgs(cm, null));
                    }
                    catch (Exception ex)
                    {
                        cm.CommandEndTime = DateTimeOffset.Now;
                        try
                        {
                            this.Error?.Invoke(this, new ServiceCommandEventArgs(cm, ex));
                        }
                        catch { }
                    }
                    finally
                    {
                        _CurrentCommand = null;
                    }
                    if (this.ThreadSleepSecondsPerCommand > 0)
                    {
                        Thread.Sleep(this.ThreadSleepSecondsPerCommand);
                    }
                }
                _PreviousCommandList = l;

                if (minNextStartTime.HasValue)
                {
                    var ts = minNextStartTime.Value - DateTimeOffset.Now;
                    if (ts.TotalMilliseconds > 0)
                    {
                        _AutoResetEvent.WaitOne((Int32)ts.TotalMilliseconds);
                    }
                    else
                    {
                        continue;
                    }
                }
                else
                {
                    _AutoResetEvent.WaitOne();
                }
            }
        }
        public void Suspend()
        {
            Interlocked.Exchange(ref _IsSuspend, 1);
        }
        public void Resume()
        {
            Interlocked.Exchange(ref _IsSuspend, 0);
            _AutoResetEvent.Set();
        }
        public void AddCommand(ServiceCommand command)
        {
            _CommandList.Enqueue(command);
            _AutoResetEvent.Set();
        }
        public void AddCommand(IEnumerable<ServiceCommand> commandList)
        {
            foreach (var command in commandList)
            {
                _CommandList.Enqueue(command);
            }
            _AutoResetEvent.Set();
        }
        public (String Name, DateTimeOffset? StartTime) GetCurrentCommandData()
        {
            var cm = _CurrentCommand;
            if (cm == null) { return ("", null); }
            return (cm.GetType().Name, cm.CommandStartTime);
        }
        public List<String> GetCommandNameList()
        {
            var l = new List<String>();
            foreach (var item in _CommandList)
            {
                l.Add(item.GetType().Name);
            }
            return l;
        }
        public List<ServiceCommandExecuteResult> GetPreviousCommandList()
        {
            var l = new List<ServiceCommandExecuteResult>();
            var commandList = _PreviousCommandList;
            foreach (var cm in commandList)
            {
                l.Add(new ServiceCommandExecuteResult(cm));
            }
            return l;
        }
    }
}

コマンドはServiceCommandクラスを継承して作ります。

public abstract class ServiceCommand
{
    public DateTimeOffset? ScheduleTime { get; set; } 
    public DateTimeOffset? CommandStartTime { get; set; }
    public DateTimeOffset? CommandEndTime { get; set; }
    public abstract void Execute();
}

public class AddLogToBigqueryCommand : ServiceCommand
{
    public void Execute()
    {
        //Bigqueryへログを記録。Bigqueryへのログ書き込みは1秒以上かかる。
    }
}

WebサイトへのアクセスをログをBigqueryに保存するとします。BigqueryへのInsert処理は1秒くらい処理に時間がかかったりします。コントローラーにそのままログの記録処理を書いてしまうとユーザーへのレスポンスが1秒以上かかってしまいUIの表示が遅くなりUXが低下します。

それを避けるためにBackgroundServiceをStartup.csで起動しておいて、Controllerでのログの記録をバックグラウンドへ退避することでUXの低下を防ぐことができます。

MyBackgroundService.cs

public class MyBackgroundService
{
     public static BackgroundService LogService = new BackgroundService("Log", 0);
}

Startup.cs

public void ConfigureServices(IServiceCollection services)
{
    MyBackgroundService.LogService.StartThread();
}

SaleController.cs

[HttpPost("/Sale/Add")]
public IActionResult Sale_Add()
{
    //売上の登録処理
    SaleManager.Add(...);
    var cm = new AddLogToBigqueryCommand();
    MyBackgroundService.LogService.AddCommand(cm);//コマンドの登録なので0.001秒くらいで処理は完了。

    //レスポンスがすぐ帰るのでUXの低下を避けられる。
    return new { Message = "売上を追加しました!" };
}

MyBackgroundService.LogService.AddCommandメソッドを実行したタイミングでAutoResetEventのシグナルがセットされ眠っていたスレッドが起動しコマンドのExecuteメソッドが実行されます。

定期処理用のクラス

クラスは2つ。
・PeriodicCommandService
・PeriodicCommand

PeriodicCommandServiceは1分おきに起動され登録済みのコマンドを実行します。PeriodicCommandはプロパティにBackgroundServiceを持っています。PeriodicCommandの実行はPeriodicCommandServiceのスレッドとは別のスレッドで実行されます。これは何故かというと例えば毎分実行するコマンドが70個あって、それぞれが1秒くらい処理に時間がかかるとすると2時00分の定期処理が完了する前に2時01分の処理の開始時間が来てしまいます。
PeriodicCommandServiceはあくまでコマンドの起動のみに責務を持ち、実際の処理は別のBackgroundServiceに投げることで上記の問題を回避します。

PeriodicCommandService.cs

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace HigLabo.Service
{
    public class PeriodicCommandService
    {
        private Thread _Thread = null;

        private Object _LockObject = new Object();
        private List<PeriodicCommand> _CommandList = new List<PeriodicCommand>();

        public String Name { get; set; }
        public Boolean IsStarted { get; set; } = false;
        public Boolean Available { get; set; } = true;

        public PeriodicCommandService(String name)
        {
            this.Name = name;
        }
        public void StartThread()
        {
            this.StartThread(thd => { });
        }
        public void StartThread(ThreadPriority priority)
        {
            this.StartThread(thd => thd.Priority = priority);
        }
        public void StartThread(Action<Thread> setPropertyFunc)
        {
            _Thread = new Thread(() => this.Start());
            _Thread.Name = String.Format("{0}({1})", nameof(PeriodicCommandService), this.Name);
            _Thread.Priority = ThreadPriority.BelowNormal;
            _Thread.IsBackground = true;
            if (setPropertyFunc != null)
            {
                setPropertyFunc(_Thread);
            }
            _Thread.Start();
            this.IsStarted = true;
        }
        private void Start()
        {
            while (true)
            {
                try
                {
                    if (this.Available == false)
                    {
                        Thread.Sleep(this.GetNextExecuteTimeSpan());
                        continue;
                    }
                    var now = DateTime.UtcNow;
                    Trace.WriteLine(String.Format("{0} {1} started.", now.ToString("yyyy/MM/dd HH:mm:ss"), this.Name));
                    var scheduleTime = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0, DateTimeKind.Utc);

                    var l = new List<PeriodicCommand>();
                    lock (this._LockObject)
                    {
                        foreach (var item in _CommandList)
                        {
                            l.Add(item);
                        }
                    }
                    foreach (var cm in l)
                    {
                        try
                        {
                            if (cm.IsExecute(scheduleTime))
                            {
                                if (cm.Service != null)
                                {
                                    cm.Service.AddCommand(cm);
                                }
                            }
                        }
                        catch { }
                    }
                    Thread.Sleep(this.GetNextExecuteTimeSpan());
                }
                catch (Exception ex)
                {
                    Trace.WriteLine(ex.ToString());
                    Thread.Sleep(this.GetNextExecuteTimeSpan());
                }
            }
        }
        private TimeSpan GetNextExecuteTimeSpan()
        {
            var now = DateTime.UtcNow;
            var scheduleTime = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0).AddMinutes(1);
            return scheduleTime - DateTime.UtcNow;
        }

        public void AddCommand(PeriodicCommand command)
        {
            lock (this._LockObject)
            {
                _CommandList.Add(command);
            }
        }
        public void AddCommand(IEnumerable<PeriodicCommand> commandList)
        {
            lock (this._LockObject)
            {
                foreach (var command in commandList)
                {
                    _CommandList.Add(command);
                }
            }
        }
    }
}

PeriodicCommandクラス

public abstract class PeriodicCommand : ServiceCommand
{
    public BackgroundService Service { get; init; }

    public PeriodicCommand(BackgroundService service)
    {
        this.Service = service;
    }
    public abstract Boolean IsExecute(DateTime utcNow);
}

CreateDailySaleSummaryCommandクラス

public class CreateDailySaleSummaryCommand
{
    public CreateDailySaleSummaryCommand(BackgroundService service)
        : base(service)
    {
    }
    public override bool IsExecute(DateTime utcNow)
    {
        //日本時間の午前1時00分に実行
        return utcNow.Hour == 16 && utcNow.Minute == 0;
    }
    public override void Execute()
    {
        //売上の集計データの作成処理。10秒くらいかかるとかそんな感じ。
    }
}

二つサービスを宣言しスレッドを開始します。
MyBackgroundServiceクラス

public class MyBackgroundService
{
     public static BackgroundService DataService = new BackgroundService("DataService", 0);
     public static PeriodicCommandService PeriodicCommandService = new PeriodicCommandService("PeriodicCommandService");
}

Startup.cs

public void ConfigureServices(IServiceCollection services)
{
    MyBackgroundService.DataService.StartThread();
    
    var cm = new CreateDailySaleSummaryCommand(MyBackgroundService.DataService);
    MyBackgroundService.PeriodicCommandService.AddCommand(cm);
    MyBackgroundService.PeriodicCommandService.StartThread();
}

これでWEBサイトのバックグラウンドスレッドで毎日午前1時00分に実行されるようになります。

コンソールアプリやWPFで定期処理

上記のクラスはほぼそのままWPFやコンソールアプリでも利用できます。

コンソールアプリ

class Program
{
     public static BackgroundService DataService = new BackgroundService("DataService", 0);
     public static PeriodicCommandService PeriodicCommandService = new PeriodicCommandService("PeriodicCommandService");
     
    static void Main(string[] args)
    {
        Program.DataService.StartThread();
    
        var cm = new CreateDailySaleSummaryCommand(Program.DataService);
        Program.PeriodicCommandService.AddCommand(cm);
        Program.PeriodicCommandService.StartThread();
	
	Console.WriteLine("Press enter to exit...");
	Console.ReadLine();
    }
}

WPF

MyBackgroundServiceクラス

class MyBackgroundService
{
     public static BackgroundService DataService = new BackgroundService("DataService", 0);
     public static PeriodicCommandService PeriodicCommandService = new PeriodicCommandService("PeriodicCommandService");
}

MainWindow.xaml.cs

public void StartServiceButton_Click(sender object, RoutedEventArgs e)
{
    MyBackgroundService.DataService.StartThread();
    
    var cm = new CreateDailySaleSummaryCommand(MyBackgroundService.DataService);
    MyBackgroundService.PeriodicCommandService.AddCommand(cm);
    MyBackgroundService.PeriodicCommandService.StartThread();
}

WEBサイトだとデバッグがしにくかったりするのでそういう時はコンソールアプリを作ってデバッグすると良いでしょう。

まとめ

自分が実行している環境(2年以上稼働中)だと今まで実行されなかったことはありませんが保証はできません。Azureが自動で行うWEBサイトの再起動時に運が悪いと実行されないこともあるかもしれません。例えばWEBサイトの再起動が1分以上かかる場合、実行されない場合もありえると思います。

より高信頼性を実現するには毎分の実行をログに記録して歯抜けの時間帯が無いか別サービスでチェックし、実行されていない時間帯があればその処理を実行するようにするなどしてさらに信頼性を担保する必要があるかもしれません。

ということで100%の保証はできませんがまあまあいい感じに動作すると思います。ご利用は自由にどうぞ。

Discussion