【PHP】【JavaScript】例外的な祝日変更にも耐えうる祝日カレンダーの作り方

 日本の祝日は法律で何年何月何日が祝日となるか定められています。
国民の祝日について – 内閣府#国民の祝日に関する法律(昭和23年法律第178号)
 しかしながらやんごとなき理由によってこの法律で定められた日とは異なる日が特例として祝日になりえます。2021年のオリンピックによる通常とは異なる祝日は記憶に新しいです。
2021年の祝日移動について | 首相官邸ホームページ
 この様な祝日の変更を考慮した祝日判定はオフラインで閉じているプログラムでは対応しきれません。何かしらで自身の持つ祝日の情報を更新するか、都度最新の祝日情報を取りに行くかする必要があります。この方法を 3 つほど紹介します。
 
 一つ目対応方法がライブラリを更新する方法です。生きているライブラリは概ね祝日が必要なタイミングまでに更新されるためそのライブラリのバージョンを毎日更新するなどして対応できます。これは例えば次の様に npm を介してライブラリをアップデート、ビルドコマンドを実行、といった具合にできます。

# cron
0 2 * * * cd /path-to-your-project && sh ./update_npm.sh >> /dev/null 2>&1
# 午前二時に npm を更新&ビルド
# update_npm.sh
#!/bin/sh
/usr/local/bin/npm update japanese-holidays # 日本の祝日ライブラリである https://github.com/osamutake/japanese-holidays-js を更新
/usr/local/bin/npm run production # 製品用JavaScriptビルド。ビルドが複雑ならここから更にコマンドを連鎖させるなどして対応

 必ず更新に成功し、正常動作し続ける前提ならばこれだけで十分です。実際には更新後のビルドした JavaScript が正常であるかテストする機能、テスト漏れがあっても被害を抑えるための監視機能、以前のビルド結果に差し戻す機能、あたりも安定した運用には欲しくなります。
 例では JavaScript ですが他言語のライブラリでも同様にできます。

 二つ目の方法が都度 API を実行する方法です。こちらはブラウザ上から祝日を公開している情報源にあたり、そこから情報を得て祝日に関連する処理をします。この方法でおすすめできる候補に次の二つがあります。
 まずは内閣府が公開している祝日です。
国民の祝日について – 内閣府
昭和30年(1955年)から令和4年(2022年)国民の祝日(csv形式:19KB) ←の名前ですが2017年後半あたりから同じURL、同じフォーマットで都度更新されています
昭和30年(1955年)から令和6年(2024年)国民の祝日(csv形式:20KB) ←の名前ですが2023年2月1日にリンク先が変わりました
昭和30年(1955年)から令和6年(2024年)国民の祝日(csv形式:20KB) ←の名前ですが2023年2月3日にリンク先が戻りました

 ここでは CSV フォーマットで祝日が更新されています。これを都度取得し、パースし、…と処理を重ねることで最新の祝日で処理ができます。
 また、国立天文台が Google Calendar を利用して祝日の公開をしてくれています。
今月のこよみ powered by Google Calendar – 国立天文台暦計算室
 これも内閣府同様に都度アクセス、パース、処理とすることで祝日を扱えます。
 この方法での注意点として外部APIの形式が変わる、提供が止まるといった事態に備えた機構を作っておくことです。そのためには前述のライブラリを併用して API の結果がダメだったらデフォルトとしてそちらを使うなどするのがよいでしょう。

 三つ目は一つ目と二つ目のハイブリッドです。都度外部 API を叩くのは外部 API に本来なかったはずのアクセス負荷を与える行為でよろしくありません。また処理速度の面でも毎回毎回無用な情報も得たり、パースを嚙ましたりするのはよろしくありません。そこで、毎日1度外部APIにアクセスして手持ちの祝日カレンダーを更新、API等を用いて手持ちの祝日カレンダーを必要な時に必要なだけ呼び出す、という方法が考えられます。この場合、外部APIの形式や提供が変わった時には変わる以前の正常に使えていた最後のカレンダーを使いまわすことになるでしょう。図にすると次です。

 この最新の祝日の保存処理は例えば次のコードでできます。

<?php

/**
 * 最新の祝日を取得。返り値は二重配列
 * <pre>
 * array() {
 *   ...
 *   [936]=>
 *   array(2) {
 *     ["holiday"]=>
 *     string(19) "2020-07-23 00:00:00"
 *     ["name"]=>
 *     string(9) "海の日"
 *   }
 *   ...
 * }
 * </pre>
 * @return array{holiday:string, name:string}[]
 */
function getLatestHolidayDefines(): array
{
    // 祝日程度ならば来年以降増え続けてもメモリへの負荷も大丈夫だろう、という想定で全てまとめて取得
    $csv = file_get_contents('https://www8.cao.go.jp/chosei/shukujitsu/shukujitsu.csv');
    // Excel で使いやすくするためか SJIS 形式なのでプログラム内で使いやすい UTF-8 に文字コードを変換
    $csv = mb_convert_encoding($csv, 'UTF-8', 'SJIS-win');

    // 月日と祝日名称なら一マスの中に改行が入ることはないだろう、という想定で explode 関数による行分割
    $csvLines = explode("\n", $csv);

    /** @var array 返り値用の配列。祝日の定義を格納する */
    $holidays = [];
    foreach($csvLines as $l) {
        if(empty($l)) {
            continue;// 空行はスキップ
        }
        // CSV として行をセルに分割
        $cells      = str_getcsv($l);
        // 扱いやすい様に名前を付けて返却用の配列に追加
        $holidays[] = [
            // MySQL の日時形式に変換。
            // 名前に date を使わないのは、この後もここの名前を使いまわし、その時に何かの予約語とぶつかって面倒になりがちなため
            'holiday' => date("Y-m-d H:i:s", strtotime($cells[0])),
            'name' => $cells[1],
        ];
    }

    return $holidays;
}

// Laravel のトランザクションとモデルを使った DELETE, INSERT 例
try {
    \DB::beginTransaction();
    $holidays = getLatestHolidayDefines();
    // 簡易な検証のために過ぎ去った祝日定義の数を用意。
    $oldPassedHolidayCount = \App\Models\Eloquents\Holiday::query()
        ->whereDate('holiday','<=' , now()->subDay())
        ->count();

    // 大した量があるわけではないので全削除、全挿入で更新。
    // 主キーの採番は 1 度の実行で2021年度は 975 程度増える。2022年度では恐らくは 990 程度増える。
    // MySQL の UnsignedBigInteger の上限は 18446744073709551615 なので全削除、全挿入でも採番限界には多分至らない。
    // TRUNCATE でなく DELETE なのはロールバックに備えたいため。TRUNCATE でもロールバックできる SQL なら TRUNCATE の方が高速でよさげ
    \App\Models\Eloquents\Holiday::query()->delete();
    // カラムに holiday と name を持つテーブルに全部まとめて INSERT
    \App\Models\Eloquents\Holiday::query()->insert($holidays);

    // 簡易な検証のために過ぎ去った祝日定義の数を用意。
    $newPassedHolidayCount = \App\Models\Eloquents\Holiday::query()
        ->whereDate('holiday','<=' , now()->subDay())
        ->count();

    // 祝日の数が変化したら何かしらのエラーが起きていると仮定して処理を中断
    // 最初の一回が考慮されていない処理なので最初はこれ抜きのコードを実行とかそんな感じで対応
    if($oldPassedHolidayCount !== $newPassedHolidayCount){
        throw new \RuntimeException('昨日までの祝日の数が変化しました。おそらくAPIのエラーです。');
    }

    \DB::commit();
}catch(\Throwable $e){
    \DB::rollBack();
    throw $e;
}

 仮定だらけの雑コードですがこれを cron なり Laravel のスケジューラーを嚙ませるなりして定期実行することによって最新の祝日カレンダーを保持できるようになります。

>株式会社シーポイントラボ

株式会社シーポイントラボ

TEL:053-543-9889
営業時間:9:00~18:00(月〜金)
住所:〒432-8003
   静岡県浜松市中央区和地山3-1-7
   浜松イノベーションキューブ 315
※ご来社の際はインターホンで「316」をお呼びください

CTR IMG