日時の取り扱いの注意点 - 「タクシー助成券」でどう対処したか

こんにちは。エンジニアの宮下です。

本年1月17日から約1ヶ月間、タクシー助成券ミニアプリの実証実験を周防大島町にて実施[1] 、筆者は実証実験の開発を担当しました。高齢者の移動促進をねらいとしてタクシー利用料金の助成を行いたい自治体をターゲットとしており、ポケットサインアプリから同意を得て取得した生年月日から年齢を算出・判定して助成券を交付をする機能があります。

自治体向けのサービスとして公正に運用するには、年齢や交付受付期間、利用日時といった時刻の計算・取り扱いには正確性が重要視されます。 本記事では、本アプリの日時の取り扱い方について、特に気をつけた箇所を3点書いていきます。

インフラ : UTCとローカルタイムゾーンの扱い方

年齢計算では現在時刻から日付を導出することになりますが、導出に関わる要素としてタイムゾーンがあります。本サービスでの年齢計算はGoogle Cloud Run上のサーバーアプリケーションで行っており、一般的なクラウドと同様、協定世界時(UTC)をデフォルトのタイムゾーンとして採用しています。当然ながら日付にも影響し、設定を怠るとJSTで午前9時にならないとUTC上で日付が変わらず年齢が上がらない、なんてことになり得ます。

では、タイムゾーンの設定はどこでやるのが適切でしょうか?これはシステムの要件によります。多くの場合、UTCで永続化・比較・計算する方針で問題ないでしょう。弊社のアプリの多くは日本国内での使用を想定しており、そういったケースだとOSレベルでJSTとすると計算時の設定漏れを減らすためにも適切な可能性はあります。グローバルなアプリケーションであっても、タイムゾーンの設定をユーザー設定に委ねるか現在位置をもとに算出するか等で分かれたり、選択肢は多様です。

本アプリでは、Protocol Buffers(Connect RPC)やPostgreSQLを用いており、これらのタイムスタンプ表現ではタイムゾーンそのものを保持しません[2] [3] 。ビジネスロジックの判定ならびにフロントエンドでの表示でのみJSTを基準とした算出を行い、ドメインモデル上やDB内・APIではUTCでやり取りをしています。加えて、生年月日についてはPostgreSQLではdate型で表現することで、時刻についての情報を扱わないことを明示します。

タクシー助成券の各層で取り扱うタイムゾーン。システム上変更できない箇所以外は開発者に任せられる

type User struct {
    UserID    UserID
    BirthDate time.Time // 時刻はUTCの0時とする。Postgres上ではdate型で保持する
    ...
}

func (u User) Age(now time.Time) int32 {
    baseDate := now.In(time.FixedZone("Asia/Tokyo", 9*60*60)).AddDate(0, 0, 1)

    age := int32(baseDate.Year() - u.BirthDate.Year())
    if baseDate.Month() < u.BirthDate.Month() {
        age -= 1
        return age
    }
    if baseDate.Month() == u.BirthDate.Month() && baseDate.Day() < u.BirthDate.Day() {
        age -= 1
        return age
    }
    return age
}

Domain層より、年齢計算の箇所を抜粋。UTCからJSTへの変換はここで行っている

さらに、テストや動作確認をするうえでも、本番環境に近い環境で行ったり、ローカル環境で実行するときにタイムゾーンをUTCで固定したりすることで(環境変数として TZ=Etc/UTC を設定する等)、考慮漏れの検出や修正頻度の向上に繋がりました。

バックエンド : 年齢計算のビジネスロジック

年齢、といえばその計算方法そのものにも目を向ける必要があります。

日本でよく知られている法律として「年齢計算ニ関スル法律」[4] が存在します。これは「出生日を起算日として、年齢が加算されるのは起算日に応当する日の前日が満了するときである」といったことを定めています。すなわち年齢が上がるのは誕生日当日の0時ではなく前日の24時、というわけです。さらに、日を単位とした計算では時刻が切り捨てられ、効力の開始は誕生日前日の0時となります。前日の0時加算か24時加算かは制度によっても異なります。

前日の0時を年齢の加算タイミングとする実装は先に示したコードにあります。これに加えて、開発メンバーと認識を揃えるためにも、テストにてその挙動を明文化しています。ふるまいについての形式を包含したRSpec的な記述を筆者は好んでおり、「正常に動く」などの挙動を明かさない書き方は避けています。こうすることでエンジニアでなくても挙動の把握が楽になります。テストの意図や説明が間違っている場合もレビューしやすくなり、最終的な手戻りのリスクも下がります。

import (
    "time"

    . "github.com/onsi/ginkgo/v2"
    . "github.com/onsi/gomega"
    ...
)

var tzTokyo = time.FixedZone("Asia/Tokyo", 9*60*60)

var _ = Describe("User", func() {
    Describe("Age", func() {
        createUser := func(birthDate time.Time) model.User {
            return model.User{
                BirthDate: birthDate,
                ...
            }
        }

        DescribeTable(
            "与えた現在時刻から「年齢計算ニ関スル法律」をもとに時刻を考慮しない年齢を算出する",
            func(subject model.User, now time.Time, expected int32) {
                Expect(subject.Age(now)).To(Equal(expected))
            },
            Entry("74歳の利用者について、生まれた月日の前日を迎える直前は74歳である",
                createUser(time.Date(1949, 12, 10, 0, 0, 0, 0, time.UTC)),
                time.Date(2024, 12, 8, 23, 59, 59, 0, tzTokyo),
                int32(74),
            ),
            Entry("74歳の利用者について、生まれた月日の前日を迎えた直後は75歳である",
                createUser(time.Date(1949, 12, 10, 0, 0, 0, 0, time.UTC)),
                time.Date(2024, 12, 9, 0, 0, 0, 0, tzTokyo),
                int32(75),
            ),
            Entry("うるう日に生まれた76歳の利用者について、うるう年でない2/27には76歳である", ...),
            Entry("うるう日に生まれた76歳の利用者について、うるう年でない2/28には77歳である", ...),
            ...
        )
    })
})

前述の年齢計算コードのテストより

ビジネスロジックのレアケースとしては他にも、数え年や別の暦年法などを用いる可能性もあります。「いい感じに」と言う前に確認してみるのがよいでしょう。

フロントエンド : ユーザーによる日時の読み書き

年齢と並行して生年月日を表示する場面では、日時のフォーマットについても一筋縄にはいかない箇所があります。

本アプリをはじめとして、CSVへのデータ出力機能を備えているプロダクトは多いかと思われます。ここで、情報量を落とさないためにも、タイムゾーンを含めたISO 8601形式( 2006-01-02T15:04:05-07:00 )を採用したくなるところですが、残念ながらExcelではこれを日時として認識しません。数式やマクロを駆使して変換するか、追加実装となるかもしれません。

Google Spreadsheetsでの書式変換結果。タイムゾーンの入った形式だと右のような置換処理を施さないと集計で使えない

また、本アプリでは現状取り扱っていませんが、西暦ではなく和暦で扱いたい、といったケースは行政ではあり得ます。[5] 特に和暦についてはJavaScriptのIntl APIで表示はできますが、和暦から西暦への変換は自前で行わないといけないため、入力の実装が加わると工数が大きく膨れるでしょう。

これらは要件定義の時点で確認をしておきたい箇所で、その時に特に筆者がよく聞いているのは「誰が何ができるのが理想?どういった利益が得られる?」です。この3つについてはアジャイルソフトウェア開発の「ユーザーストーリー」の3要素そのものであり、顧客要求を部分的に評価するときにもこの考え方は有用です。例えば「自治体がCSVを出力できるようにしたい。Excelで読み取って集計ができるから」、という旨が確認できれば、機能の実装完了条件や動作確認の考慮にも入れやすくなるはずですし、「自治体が和暦で入力できるようにしたい。資料との照合がつきやすいため」であれば、代替手法として西暦での入力に伴って和暦を併記する機能などを提案することだってできます。

まとめ

本記事で日時の扱いについてインフラ・バックエンド・フロントエンドで気をつけることを挙げました。書いている最中、要件定義からテストまでの間でふわっと「日時」や「期間」などが出てきたら、境界や機能について掘り下げていこうとより強く思いました。

本記事が日時を扱うアプリの品質向上の一助になりましたら幸いです。

脚注

[1] プレスリリース公開当時のプロダクト名は「タクシー利用券」でした

[2] https://protobuf.dev/reference/protobuf/google.protobuf/#timestamp

[3] https://www.postgresql.org/docs/17/datatype-datetime.html#DATATYPE-DATETIME-INPUT-TIME-STAMPS

[4] https://laws.e-gov.go.jp/law/135AC1000000050/

[5] 公文書などでの年表記について法律は存在しませんが、慣行として和暦表記が行われています