C# Tips | 日付・時間処理:Cron表現解析

C# C#
スポンサーリンク

はじめに:なぜ「Cron表現解析」が業務で効くのか

業務システムで「毎日3時に」「5分ごとに」「平日の9時だけ」といったスケジュールを柔軟に設定したいとき、
文字列1本でスケジュールを表現できるのが「Cron表現(Cron式)」です。

C# でこれを扱えるようになると、
「設定ファイルに書かれた Cron を読み取って、次の実行時刻を計算する」
「ユーザーが入力した Cron が正しいかチェックする」
といった“実務っぽい”処理を自前で組めるようになります。

ここでは、まず Cron の基本形をざっくり押さえたうえで、
「自作の超シンプル Cron パーサ」と「次の実行時刻を求めるロジック」を、
初心者向けにかみ砕いて説明していきます。


Cron表現の基本:5フィールドをざっくり理解する

標準的な5フィールド形式

よく使われる標準的な Cron は、スペース区切りの5フィールドです。

分 時 日 月 曜日
* * * * *

それぞれの意味は次のとおりです。

分:0〜59
時:0〜23
日:1〜31
月:1〜12(JAN〜DEC の略称を許す実装も多い)
曜日:0〜7(0と7が日曜、1が月曜…)

そして、各フィールドには「特定の数字」だけでなく、
*(全部)、1-5(範囲)、*/5(ステップ)、1,3,5(列挙)などが書けます。

ここでは、初心者向けにまず「数字」と「*」と「カンマ(,)」だけを扱うシンプル版から始めます。


最小限のCronパーサを作る(数字・*・カンマだけ)

方針:文字列 → 5つの「許可値セット」に分解する

「Cron表現解析」の一番のコアは、
文字列を「各フィールドごとの“許可される値の集合”」に変換することです。

例えば、

"0 9 * * 1,2,3,4,5"

なら、

分:{0}
時:{9}
日:{1〜31全部}
月:{1〜12全部}
曜日:{1,2,3,4,5}

というイメージです。

これを C# のクラスに落とし込んでみます。

public class SimpleCronExpression
{
    public HashSet<int> Minutes { get; }
    public HashSet<int> Hours { get; }
    public HashSet<int> Days { get; }
    public HashSet<int> Months { get; }
    public HashSet<int> DayOfWeeks { get; }

    private SimpleCronExpression(
        HashSet<int> minutes,
        HashSet<int> hours,
        HashSet<int> days,
        HashSet<int> months,
        HashSet<int> dayOfWeeks)
    {
        Minutes = minutes;
        Hours = hours;
        Days = days;
        Months = months;
        DayOfWeeks = dayOfWeeks;
    }

    public static SimpleCronExpression Parse(string cron)
    {
        if (cron == null) throw new ArgumentNullException(nameof(cron));

        var parts = cron.Split(' ', StringSplitOptions.RemoveEmptyEntries);
        if (parts.Length != 5)
            throw new FormatException("Cron は 5 フィールドである必要があります。");

        var minutes    = ParseField(parts[0], 0, 59);
        var hours      = ParseField(parts[1], 0, 23);
        var days       = ParseField(parts[2], 1, 31);
        var months     = ParseField(parts[3], 1, 12);
        var dayOfWeeks = ParseField(parts[4], 0, 7); // 0,7=日曜

        return new SimpleCronExpression(minutes, hours, days, months, dayOfWeeks);
    }

    private static HashSet<int> ParseField(string field, int min, int max)
    {
        var set = new HashSet<int>();

        if (field == "*")
        {
            for (int i = min; i <= max; i++)
            {
                set.Add(i);
            }
            return set;
        }

        var tokens = field.Split(',', StringSplitOptions.RemoveEmptyEntries);

        foreach (var token in tokens)
        {
            if (!int.TryParse(token, out int value))
                throw new FormatException($"数値に変換できません: {token}");

            if (value < min || value > max)
                throw new FormatException($"値が範囲外です: {value} (許容: {min}〜{max})");

            set.Add(value);
        }

        if (set.Count == 0)
            throw new FormatException("フィールドが空です。");

        return set;
    }
}
C#

ここでの重要ポイントは、

「Cron文字列を“そのまま解釈し続ける”のではなく、一度“構造化された形(許可値セット)”に変換する」

という発想です。

一度この形にしてしまえば、
「ある DateTime が Cron にマッチするか?」
「次にマッチする時刻は?」
といった判定が、かなり書きやすくなります。


DateTime が Cron にマッチするか判定する

IsMatch(DateTime) を実装する

先ほどの SimpleCronExpression に、
「この日時が Cron の条件を満たしているか?」を判定するメソッドを足してみます。

public bool IsMatch(DateTime dt)
{
    int minute = dt.Minute;
    int hour   = dt.Hour;
    int day    = dt.Day;
    int month  = dt.Month;

    int dow = (int)dt.DayOfWeek; // 0:日曜, 1:月曜, ...

    if (dow == 0)
    {
        DayOfWeeks.Add(7); // 0 と 7 を両方サポートしたい場合の工夫
    }

    return Minutes.Contains(minute)
        && Hours.Contains(hour)
        && Days.Contains(day)
        && Months.Contains(month)
        && DayOfWeeks.Contains(dow);
}
C#

使い方の例です。

var cron = SimpleCronExpression.Parse("0 9 * * 1,2,3,4,5"); // 平日9:00

DateTime dt1 = new DateTime(2026, 2, 18, 9, 0, 0); // 水曜
DateTime dt2 = new DateTime(2026, 2, 18, 10, 0, 0);
DateTime dt3 = new DateTime(2026, 2, 22, 9, 0, 0); // 日曜

Console.WriteLine(cron.IsMatch(dt1)); // true
Console.WriteLine(cron.IsMatch(dt2)); // false
Console.WriteLine(cron.IsMatch(dt3)); // false
C#

ここでの重要ポイントは、
「Cron の各フィールドと DateTime の各要素を、1対1で比較しているだけ」
というシンプルさです。

Cron の“魔法感”が薄れて、
「ただの条件集合なんだな」と分かってくるはずです。


次の実行時刻を求める:GetNextOccurrence

方針:今から1分ずつ進めて、最初にマッチした時刻を返す

実務で一番使いたくなるのは、
「この Cron に従うと、今から見て次に実行すべき時刻はいつか?」
という関数です。

まずは、分解能を「1分」と割り切って、
「今の時刻の1分後から順に、マッチするまで進める」という素直な実装をしてみます。

public DateTime GetNextOccurrence(DateTime from)
{
    DateTime current = from.AddMinutes(1).AddSeconds(-from.Second).AddMilliseconds(-from.Millisecond);

    for (int i = 0; i < 60 * 24 * 366; i++)
    {
        if (IsMatch(current))
        {
            return current;
        }

        current = current.AddMinutes(1);
    }

    throw new InvalidOperationException("1年以内にマッチする時刻が見つかりませんでした。");
}
C#

使い方の例です。

var cron = SimpleCronExpression.Parse("0 9 * * 1,2,3,4,5"); // 平日9:00

DateTime now = new DateTime(2026, 2, 18, 8, 30, 0); // 水曜 8:30

DateTime next = cron.GetNextOccurrence(now);

Console.WriteLine(next); // 2026/02/18 9:00:00
C#

この実装は決して効率的ではありませんが、
「Cron の条件に合う時刻を、時間軸上で“前から順に探しているだけ”」という構造が、
とても分かりやすいはずです。

実務で本気のスケジューラを作るなら、
フィールドごとに飛び先を計算してもっと効率化しますが、
まずはこの“総当たり1分刻み”版で、動くイメージを掴むのが良いです。


実務での現実的な選択肢:ライブラリを使う

NCrontab / Cronos などの利用

ここまで「自作パーサ」を書いてきましたが、
実務では、Cron の仕様は思った以上に奥が深く、
*/51-10/2JANMON-FRI? など、
実装ごとの拡張も含めるとかなり複雑になります。

そのため、実際のプロダクトでは

  • NCrontab
  • Cronos

といった既存ライブラリを使うのが現実的です。

ただし、「中で何をやっているか」の感覚を持たずにライブラリだけ使うと、
意図しないスケジュールを組んでしまったときに原因が追えません。

だからこそ、ここまでのような「超シンプル版」を一度自分で書いてみて、

Cron は「文字列 → 許可値セット → DateTime とのマッチ判定 → 次の時刻探索」
という流れで動いている

というイメージを持っておくと、ライブラリの挙動も理解しやすくなります。


まとめ:「Cron表現解析ユーティリティ」は“文字列のスケジュールを時間軸に落とす橋”

Cron 表現解析の本質は、
「コンパクトな文字列で書かれたスケジュール」を、
「具体的な日時(DateTime)」に落とし込む橋渡しです。

ここで押さえておきたいポイントを、言葉でまとめるとこうなります。

Cron は「分・時・日・月・曜日」の5フィールドで、各フィールドは“許可される値の集合”だと捉える。
文字列を一度「許可値セット」にパースしてしまえば、DateTime がマッチするかどうかの判定は単純な集合チェックになる。
「次の実行時刻」は、“今から未来に向かって時間を進め、最初にマッチした時刻”として定義できる。
まずは 1分刻みの総当たりで実装してみて、動くイメージを掴んでから効率化やライブラリ利用を考える。

ここまで理解できれば、
設定ファイルや管理画面に書かれた Cron を、
自分の C# コードの中で“ちゃんと解釈して動かせる”ところまで、
一気に近づけます。

C#C#
スポンサーリンク
シェアする
@lifehackerをフォローする
スポンサーリンク
タイトルとURLをコピーしました