遅れてやってきた令和バグ あるいはiOSアプリでの日付の扱い方

こんばんは、id:kouki_dan です。突然ですが、現在は2021年ですね。あるいは令和3年です。今年が有効期限の免許証には平成33年と書かれているかもしれません。また、神武天皇即位の年を元年と定めた皇紀では2681年になります。

同じ年を表しているはずなのですが、暦によって何年なのかは違います。実はiOSは複数の暦に対応していて、日本で使われている和暦にも対応しています*1。令和元年5月にリリースされたiOS 12.3のリリースノートには、令和に対応したことが示されています。

暦を選択するのはiOSを使っているユーザーなので、iOSアプリでは端末によって複数の暦が存在しています。この記事では暦の取り扱いが不適切で実際に起きたバグとその原因、また、一般的にどのように日付を扱うべきかについて説明します。

突然、APIレスポンスのパースに失敗する

令和元年の秋頃、そろそろ新しい元号に慣れてきた頃に、ごく一部のユーザーからアプリの一部機能が使えなくなっているという報告が届き始めました。使えなくなっている機能はしばらく変更しておらず、全く原因がわからないまま調査を進めます。発生するユーザーから条件を聞き出しても、これといった共通点はなく、調査は難航していました。

エラーメッセージやログなどから、どうやらAPIのレスポンスをSwiftの構造体にパースする部分でエラーになっているということがわかりました。このエラーは主にAPIレスポンスとSwiftの構造体の型に不一致が発生した場合に発生します。アプリ側が必須だと思っていたキーがnullになっていたり、文字列を期待していたところに数値がくるなどが主な発生理由です。

ただ、API側でもアプリ側でもこの部分の変更はしばらく行われていませんでした。確認したレスポンスも問題なく、Androidのユーザーからの報告は上がってきていません。ごく一部のユーザーにだけ発生するという状況もさらに難しくさせます。

原因判明! APIのレスポンスが閏日だった

原因になりうる要素を探っていくと、APIのレスポンスに日付があることに気づきました。この日付のフィールドは有効期限を示すフィールドで、最長で半年後の月末を指します。そして、ちょうど半年後の月末は 2020年2月29日、閏日を指していました。

そして、iOS端末の暦設定を和暦に変更し、問題のある画面を開いたところ、見事にエラーが再現しました。

問題の日付のパース処理はこのようになっていました。

func parse(str: String) -> Date {
    let formatter = DateFormatter()
    formatter.dateFormat = "yyyy-MM-dd"
    return formatter.date(from: str)
}

DateFormatterはデフォルトで端末にあるカレンダーの暦を使用します。そのため、和暦を使用しているユーザーがこのparse関数で2020-02-29という文字列をパースすると、西暦2020年2月29日ではなく、令和2020年2月29日としてパースを試みることになります。

令和2020年は西暦4038年です。西暦4038年は4で割り切れないため、閏年ではありません。そのため2020-02-29は存在しない日付だと扱われてしまい、APIのパースが失敗するといった結果になっていました。

令和と閏年 ─ もし平成が続いていたなら

ここまで読んでくれた方は、令和ではなく暦関係のバグであるということに気づいた方もいらっしゃると思います。これは余談ですが、このバグに気づいた時に、もし改元が行われなかったらどうなるんだろうと考えてみました。

平成元年は1989年です。平成4年は1992年であり、閏年です。平成と西暦の差が4の倍数のため、どちらも同じタイミングで閏年がやってきます。もし改元が行われなかったら、このバグが次に再現するのは西暦2100年=平成112年でした*2

改元が行われなければ起きなかったとはいえ、爆弾を抱えたような状態なので対策を行っていきましょう。

対策 ─ ひとまず西暦を指定

APIからの入力に対して、端末設定による暦を使っていることが問題でした。APIからのレスポンスは西暦の値なので、グレゴリオ暦を明示的に使用することで、暫定的な対応ができます。

func parse(str: String) -> Date {
    let formatter = DateFormatter()
    formatter.calendar = Calendar(identifier: .gregorian) // ←追加
    formatter.dateFormat = "yyyy-MM-dd"
    return formatter.date(from: str)
}

さらに踏み込むなら、返ってくる日付は日本時間を指しているので、日本のタイムゾーンを指定することや、そもそも "yyyy-MM-dd" という独自の文字列を使うのをやめ、日付のやりとりは全て ISO8601DateFormatter でやりとりできるような形式に修正してしまうことも考えられます*3

iOSアプリにおける日付の取り扱い

日付の取り扱いは複雑です。iOSアプリにおいて日付を取り扱うDateFormatterでも、Calendar、TimeZone、Localeをそれぞれ指定する必要があります。特にiOSに特有な事情として、ユーザーが暦を選択できることにあります。DateFormatterではデフォルトでユーザーが設定した情報が使われるので、これを念頭に入れずに実装をしてしまうと、今回のように思いもよらないバグを引き起こしてしまいます。

このバグに遭遇してから、暦やタイムゾーンなどに関係する実装をより一層気にかけるようになり、自分のiPhoneも和暦にして使っています。また、普段からこのようなことを意識することで、国際化について考えることにもつながります。

まとめ

今回のエントリではiOSアプリ開発を行う上で遭遇したバグについて、発生から解決、対策までどのように行っていったかをまとめました。

株式会社はてなでは日付を完璧に扱うアプリを作りたい方や、そうではなくとも スマートフォンアプリエンジニアを募集中です!

もちろん、スマートフォンアプリエンジニア以外のエンジニアも積極採用中なので、ぜひお気軽にお声かけください!

はてなでは、技術に対する向上心を持つ仲間を募集しています

*1:皇紀には対応していませんでした

*2:100の倍数は平年となっています。その中でも400の倍数は閏年として扱うので、もしこのシステムが2000年に存在していてもバグは顕在化しませんでした

*3:ISO8601DateFormatterはformatOptionsで形式を変更できます。デフォルトではRFC 3339の形式が適用されます。