うるう年対策は万全ですか?
このポストは、2 月 2 日に投稿された Is your code ready for the leap year? の翻訳です。
2016 年も 2 月に入りました。そして今年はうるう年です。多くの人にとっては仕事や遊ぶ日が 1 日増えたイレギュラーな年なだけかも知れませんが、ソフトウェア開発者にとってはこれがたいへん大きな問題になる可能性があります。
もしも今、うるう年のバグが潜んでいるコードがあるかもと気になっている方は、すぐにチェックしてみてください。もしかしたら、既に影響が出始めているのに気付いていないだけかもしれません。では、どんなバグがコードに潜んでいることが考えられるのでしょうか?
- 日付が 1 日ずれる問題。特に、日付の範囲を指定するクエリを使用したデータ フィルタリング処理など
- ユーザー インターフェイスが予期しない動作をする、または望ましくない動作をする
- エッジ ケースの発生により、例外、クラッシュ、ハングアップが起きる
ここまで言っても、「まさか。私のコードは問題ないですよ。ちゃんと単体テストをしてますから」と、無関心な方もいるでしょう。
そのような方に、私はいつも次のような質問をします。「そうですか。ではテストではきちんと時計をモックしていますか? 2 月 29 日や 12 月 31 日を含めたエッジ ケースをテストしていますか? C++ の低レベル コードやシステムの残りの部分もテストしましたか? そもそも、うるう年のバグがどのようなものか、本当にご存じですか?」
すると、多くの人は一様にぽかんとした顔を見せます。
この問題が Azure に影響する理由
うるう年は開発者が書くほとんどのコードに関係する問題であり、多くの処理が Azure クラウドでの実行に影響します。Azure ではうるう年だった 2012 年にサービス停止 (英語) を経験したため、今度こそは何事も起きないよう十分に取り組んできました。そして、このとき私たちが行った調査や経験から学んだことを、ぜひ皆様にもお役立ていただきたいと考えています。
開発者が知っておくべきこと
いろいろありますが、一番重要なことからお伝えしていきましょう。
- うるう年で問題になるのは 2 月 29 日だけではありません。実は 12 月 31 日も重要です。この日はうるう年だと 366 日目にあたりますが、多くのアプリケーションでは 1 年は 365 日とハードコーディングされています。しかしこれは誤りです。
- 特にクラウドでは、クラウド アプリケーションのほとんどが UTC (協定世界時) を使用しています (というか使用すべきです)。つまり、2 月 29 日の午前 0 時はローカルのタイム ゾーンでは違う時刻になる可能性があります。たとえば米国の太平洋のタイム ゾーンでは、UTC でうるう日が始まるのは 2 月 28 日午後 4 時です (暦の上では前日)。他のタイム ゾーンの時刻 (英語) もチェックしてみてください。
- うるう日の問題に関してシステムをリアルタイムで監視する場合は、2 月 28 日から 3 月 1 日の 3 日間にわたって行うようにしてください。同様に、12 月 30 日から 1 月 1 日にかけて 366 日目の問題が発生しないか警戒が必要です。
- うるう年のバグはどこにでもどの言語でも潜んでいる可能性がありますが、最も危険なのが C と C++ のコードで、アプリケーションのクラッシュやバッファー オーバーフロー (セキュリティ リスク) が発生するおそれがあります。
- 過去に大きな影響を及ぼした有名なうるう年のバグには、次のようなものがあります。
- 2012 年、Microsoft Azure のサービス停止 (英語): 証明書の有効期限日の計算ミスにより、最長 12 時間にわたってサービスが中断
- 2012 年、TomTom GPS ナビゲーションのバグ (英語): 多くのモデルで現在の位置情報が識別不可能に
- 2010 年、Sony PlayStation Network のサービス停止 (英語): 2010 年をうるう年と誤認識したことが原因
- 2008 年、Microsoft Zune が動作停止 (英語): 12 月 31 日に関する論理エラーが原因
- 2008 年、Microsoft Exchange Management のバグ (英語): 管理者が 2 月 29 日にほとんど何も操作できなくなった
- Lotus 1-2-3 の 1900 年の計算ミス (英語): 30 年以上にわたって未だに Microsoft Excel に影響を及ぼしている
これらはメディアで大きく取り上げられたケースですが、影響や認知度の差こそあれ、ほかにも多くの問題が発生したに違いありません。たとえば、あまり知られてはいませんが 1996 年のうるう年のバグ (英語) の影響で、ニュージーランドやオーストラリアのタスマニア州のアルミニウム製錬工場のプロセス制御システムが 12 月 31 日 (366 日目) に突然シャットダウンするという事態が発生しました。
これにより溶融金属の温度管理ができなくなり、数百万ドル相当の設備被害が発生しました。こうした過去の事例は、ソフトウェアの不具合が現実にもたらすリスクを再認識させてくれます。最近の IoT の普及や IoT とクラウド コンピューティングの融合を考えると、開発者のだれもが十分注意すべき問題なのだということがわかります。
特に危険な 2 つのうるう年バグ
1. C/C++ での年数の加算または減算
Win32 API を使用する C/C++ コードでは、SYSTEMTIME (英語) 構造体で常用時を扱うのが一般的です。日付の要素ごとにフィールドが用意されており、年、月、日の値 (およびその他の値) が分離されています。よく次のように使用されます。
SYSTEMTIME st; // SYSTEMTIME 変数を宣言
GetSystemTime(&st); // 現在の日時を設定
st.wYear++; // 1 年分増やす
このコードではエラーは発生しませんが、コードが 2 月 29 日に呼び出されると、うるう年でない年の 2 月 29 日が返されます。たとえば、2016-02-29 + 1 年 = 2017-02-29 は存在しない日付です。
この値がいたるところの処理に渡されると、いずれは SystemTimeToFileTime (英語) などの関数のパラメーターに指定され、関数が正しく実行されずに戻り値 0 が返されることになります。戻り値をチェックせずにこのメソッドを使用しているコードは非常に多く、FILETIME の値が初期化されていない状態のままだと予期しない結果が生じる可能性があります。
- Win32 関数、特に SystemTimeToFileTime 関数のステータスは必ずチェックしてください。
- SYSTEMTIME に対して正しく 1 年を加算し、結果をチェックして必要に応じて調整してください。
SYSTEMTIME st; // SYSTEMTIME 変数を宣言
GetSystemTime(&st); // 現在の日時を設定
st.wYear++; // 1 年分増やす
// うるう年かどうかチェック
bool leap = st.wYear % 4 == 0 && (st.wYear % 100 != 0 || st.wYear % 400 == 0);
// 2 月 29 日だがうるう年でない場合、2 月 28 日にする
st.wDay = st.wMonth == 2 && st.wDay == 29 && !leap ? 28 : st.wDay;
類似のバグが標準的な C++ (非 Windows) コードでも発生する可能性があることにご注意ください。SYSTEMTIME の代わりに tm 構造体を使用できますが、動作が若干異なります。各月は 1 ~ 12 ではなく 0 ~ 11 で表されるため、2 月は 1 となります。SystemTimeToFileTime の代わりに _mkgmtime を呼び出して time_t 構造体を生成してもよいでしょう。重要な相違点としては、うるう年ではない年の 2 月 29 日を渡すと、実行に失敗せず 3 月 1 日を示す値が生成されます。2 月 28 日という戻り値を想定している場合は調整が必要です。
2. 1 年の 1 日ごとに値を格納する配列の宣言
int items[365];
items[dayOfYear - 1] = x;
上記の C コードは C# や別の言語で書き直してある可能性もあります。あるいは、整数の代わりに文字列やその他のデータ型を使用しているかもしれません。重要なのは、固定サイズの配列を宣言し、1 年の 1 日ごとにデータを格納するようになっている点です。うるう年の 366 日目の 12 月 31 日を格納する場所が用意されていないことが問題なのです。
この影響は言語により大きく異なります。C# では IndexOutOfRangeException が発生します。C では、コンパイラの境界チェック オプションが有効になっていないと、バッファー オーバーフローが発生します。その影響は無視できる場合もあれば、重大なものになることもあります。ただし、JavaScript では 366 番目の要素が自動的に追加されるので、この点はそれほど心配はないと考えられます。
データのフィルタリングに関する問題
うるう年のバグは、前年の 2 月 28 日から翌年の 3 月 1 日の間のデータに影響を及ぼす可能性があります。影響を受けるのはデータのフィルタリングで、1 年の日数が常に 365 日または 2 月の日数が常に 28 日だと想定していて、1 日増える場合に対応していないケースです。次の SQL 文を見てください。
SELECT AVG(Total) as AverageOrder, SUM(Total) as GrandTotal
FROM Orders WHERE OrderDate >= @startdate AND OrderDate < @enddate
もし @enddate が今日に設定され、@startdate が今日から 365 日前に設定されたとしたらどうでしょう。指定範囲内にうるう日の 2 月 29 日が含まれていると、1 年間を指定したことにはならなくなります。開始日が 1 年前の日付の 1 日後になってしまうため、1 年分のデータを表示することが目的の場合は値が不正確になります。
このようなバグを評価する際は、バグの影響度を考える必要があります。このケースで言えば、「値はどこに表示されるのか」ということです。たとえば、ダッシュボードで毎日更新される平均発注額のグラフであれば、米国 SEC 提出書類などの企業財務報告書に記載される年間総売上ほど重要ではないかもしれません。こうした評価を行うには当然、アプリケーションの機能やその用途をよく知っている人物の助けが必要です。画一的な対応策はありません。
こうした問題は、次のような手法で解決したくなるものです。
TimeSpan oneYear = TimeSpan.FromDays(isLeapYear(endDate.Year) ? 366 : 365);
DateTime startDate = endDate - oneYear;
しかし、この手法には欠陥があります。その年だけを見て追加する日数を判定することはできません。endDate が 2016-01-01 の場合 2016 年はうるう年ですが、2015-01-01 を得るには、引くのは 365 日です。このような手法ではなく、指定の範囲にうるう日の 2 月 29 日が含まれるかどうかを考えます。単年だけでなく複数年にまたがるケースに手作業で対応しようとするとかなり複雑なコードになるでしょう。
つまるところ、.NET (および他の言語の類似の型) の TimeSpan は絶対時間を表し、「年」も「月」も常用時の単位であるという点が重要となります。1 年または 1 か月の絶対時間は、どの年、または月かによって変化するのです。同じことが夏時間の「日」についてもあてはまりますが、本題から逸れるため今回は取り上げません。
.NET での正しい解決策は次のとおりです。
DateTime startDate = endDate.AddYears(-1);
AddYears メソッドは、何日先に進めるか、負の値の場合に何日前に戻すかを判定するのに必要なロジックをすべて正しく実装しています。
JavaScript で 1 年を加算する場合
JavaScript の開発者は、この場合 moment.js (英語) を使用すべきです。次のように非常にシンプルに処理できます。
var m = moment();
m.add(1, 'years');
未だに従来の面倒な方法で対処することを好む人もいるようです。よく次のようなコードを目にします。
var d = new Date();
d.setFullYear(d.getFullYear() + 1);
これは先に指摘したのと同じ問題です。今日がうるう年の 2 月 29 日の場合、値は 3 月 1 日となります。これで良しとするか悪いとするかは使う側しだいです。その他の日付については、元の値と同じ月の日付が返されます。月初ではなく月末の日付を取得したい場合についてもよく検討してください。
フル ライブラリを使用せずに JavaScript で正しく年数を加算する関数は次の通りです。
function addYears(d, n) {
var m = d.getMonth();
d.setFullYear(d.getFullYear() + n);
if (d.getMonth() !== m)
d.setDate(d.getDate() - 1);
}
// 使用例
var d = new Date();
addYears(d, 1);
上記のコードでは年数を追加した後、3 月にずらされたどうかを確認し、3 月にずらされた場合は修正しています。自分のやろうとしていることが完全に理解できていないなら、追加する正確な日数を割り出そうとするのは控えてください。
その他のよくあるミス
開発者がうるう年について誤解している点はほかにもたくさんあります。
- うるう年の計算方法に対する誤解。うるう年は 4 年に一度で、かつ 100 で割り切れないが 400 で割り切れる年です。1900 年はうるう年ではありません。2000 年はうるう年です。2100 年もうるう年ではありません。
- 月ごとに配列を用意し、2 月は 28 日としている。このような配列を使用するなら、うるう年の 29 日にも対応しなければなりません。うるう年は平年とは別の配列を使用するとよいでしょう。また、自力で計算するのではなく、API があればそれを使用する方が賢明です。
- うるう年用コードを作成したがすべてのコード パスをテストしていない。たとえば、Zune のバグ (英語) のコードには IsleapYear(year) のブランチが頂点にあり、明らかにテストが行われていませんでした。
- 年、月、日の値をチェックせず使用している。たとえば、年、月、日を個別に選択可能なドロップダウン コントロールを使用している場合、ある月内でその日が有効なことを検証するだけでは十分ではなく、年についても検証が必要です。
- 日付関連の計算で 1 年の平均日数として 365.25 日や 365.2425 日を使用している。これらの数字は科学的には正確ですが、常用時の操作には適していません。少なくとも、正確な値が必要な場合には使えません。概算でよければ構いませんが、時刻にずれが生じる可能性が高いです。
うるう年のバグを見つけるには
- コードを入念に精査する。時間に関するコードを隅から隅まで調べます。
- たくさんの単体テストを実施し、「時計をモックする」方法を習得する (次のセクションで説明します)。
- うるう年が来る直前だけでなく、年間を通してテストを行う。
- 設定値など、すべての入力値を検証する。
- 結果を検証してシナリオを完成させる。障害発生時の戦略を策定する。
このほかに次の 2 つのアプローチについてよく質問を受けます。
静的コード分析
コードをチェックしてうるう年のバグを指摘してくれるツールがあれば便利なのですが、残念ながらそのようなツールの存在は聞いたことがありません。シンプルな文字列検索や正規表現検索も、それ以上のことはできません。
.NET に必要なのは、うるう年、タイム ゾーン、夏時間、構文解析など日時に関する一般的なバグを特定できる包括的な Roslyn アナライザー一式なのですが、私には自作する時間的余裕がありません。将来いつか着手するかもしれませんが、現時点でこうしたツールは存在していません。
C++、JavaScript、他の言語でも類似のツールがあればと便利ですが、耳にしたことはありません。
タイム ワープ
問題を特定するために、時計の針を進めてみるのはどうでしょうか? 一部のシステムには効果的かもしれませんが、このアイデアには次のような問題があります。
- 単体テストで漏れたエラーがまだ存在するかもしれない。アプリケーションのすべての画面やレポートを目視で確認しないと、データのフィルタリングに関するエラーは発見できません。確実にエラーが潜んでいるはずです。
- 対応は万全で安全だと思い込んでしまうかもしれない。そうすると、2 月 29 日や 3 月 1 日に苦情の電話がかかってきて初めて、自分の判断が間違っていたことに気付くことになります。
- 多くのシステムがドメイン サーバーとの認証を行ったり、時間に依存したその他の認証スキームを使用したりしている。Kerberos プロトコル (英語) の時間同期の要件は厳しく、誤差は既定で 5 分となっています。また、SSL 証明書、コード署名証明書など、時間に依存しているセキュリティ関連の要素についても考慮してください。誤った時刻を通知すると処理が失敗します。
上記の理由から、一般にはこのアプローチはお勧めしません。少なくとも誤検知や、コード パスがテストから漏れる原因となる可能性がある外部リソースへの依存関係を必ず考慮してください。
時計をモックする
日付によって動作が異なるコードをテストするには時計をモックします。
これは多くの信頼性の高いシステムでよく採用されているパターンです (「Virtual Clock」と呼ばれることもあります)。重要なのは、現在時刻を知らせるシステム時計は行き当たりばったりに使用すべきではないということです。アプリケーション ロジックで DateTime.Now、DateTime.UtcNow、new Date()、GetSystemTime、または使用する言語の同等のコードを直接呼び出して現在の日時を取得しないでください。
代わりに時計を「サービス」として扱います (ドメイン駆動設計の意味で)。他のサービスと同様、「モック」できるからです。
たとえば、.NET では直接 DateTimeOffset.UtcNow (または類似の API) をアプリケーション ロジックから直接呼び出す代わりに、次を行います。
- インターフェイス IClock を作成し、GetCurrentTime メソッドを追加する。このメソッドは DateTimeOffset を返します。
- SystemClock クラスを作成し、IClock を実装する。GetCurrentTime が DateTimeOffset.UtcNow を呼び出します。
- FakeClock クラスを作成し、IClock を実装する。コンストラクターのパラメーターとして固定値を指定でき、GetCurrentTime は固定値を返します。
- アプリケーション ロジックでは IClock インターフェイスにのみ依存する。通常、これはコンストラクター インジェクションです。
- テストには FakeClock を使用し、実行時には SystemClock を使用する。
手間がかかりそうに思えますが、実際に動かしてみると利点がわかります。現在の日時が依存関係にある場合にすべてのコードを確実にテストするにはこの方法しかありません。
他の言語でもパターンは同じはずなので、あえてコードは紹介しませんでした。この優れた実装が Noda Time (英語) に組み込まれています。IClock と SystemClock がメイン アセンブリに、FakeClock が NodaTime.Testing アセンブリにあります。Noda Time は日付関連のテストの点やその他の目的に活用することができます。
JavaScript 開発者は Sinon.JS (英語) や MockDate (英語) などのライブラリや、moment.js のビルトイン モッキング サポート (英語) も検討することをお勧めします。
他の言語のライブラリにも同様の機能があるかもしれません。自力で実装しようとする前に調査してみてください。
まとめ
今年はついにうるう年です。2000 年問題でも 2038 年問題でもありませんが、定期的に対応しなければならない重要な問題です。皆様はこの 4 年の間にどのくらいのコードを作成しましたか? すべてが順調でしたでしょうか? ぜひこの機会にご自身のコードをチェックして、テストを行ってみてください。今まで見逃していた何かが見えてくるかもしれません。
ご不明な点はコメント欄までお寄せいただければ、喜んで回答させていただきます。
この記事の内容の一部は codeofmatt.com (英語) で公開されたものであり、了承のもと再掲載しています。