NAVITIME の時刻表サービスを支える時間計算のあれこれ
こんにちは、けんにぃです。
ナビタイムジャパンで公共交通の時刻表を使ったサービス開発やリリースフローの改善を担当しています。
今回は弊社の時刻表サービスで使われる時間計算がどれだけ複雑であり、それに対してどのようなアプローチを取っているのかを話そうと思います。
今日の時刻表を見たい時
今日が 2020年10月23日(金)だったとして、今日の時刻表を見ようとした時、何時から何時までの便を表示すべきでしょうか?
一見、午前 00:00 から 午後 23:59 までの便でいいんじゃないの?と思うかも知れませんが、そうではありません。何故かを説明するために、ちょっと表参道における銀座線の時刻表を見てみましょう(深夜帯の時刻表だけ掲載しています)。
深夜帯の時刻表は 24:00 を超えて表示されています。24:00 台というのは暦の上では 10月24日(土)なのですが、終電までの時刻表を出す必要があるため、10月23日(金)の時刻表として表示されます。
つまり「暦上の日付」と「時刻表上の日付」は区別して扱う必要があります。
特定の日に運行する便
全国の列車やバスの中にはさまざまな運行形態をもっているものがあります。
・平日運行
・土曜・休日のみ運行
・第2・第4土曜のみ運行
などです。そのため日付が平日か?土曜日か?といった判定を行う必要があります。これぐらいならライブラリで何とかなりそうですが、第2・第4土曜か?という判定は面倒くさそうですよね。
また時刻表はアプリだと平日・土曜・休日 3 日分の時刻表が見られるため、現在の日付に対して直近の土曜日や日曜日の日付も計算しないといけません。
日付の正規化
前述の通り 24 時台という時刻を扱う必要があっても、曜日判定のためには暦上それが何日なのかを知る必要があります。
「10月23日(金) 25:15」は暦上「10月24日(土)01:15」
「2020年02月29日」は暦上でも「2020年02月29日」
「2021年02月29日」は暦上「2021年03月01日」
このように 25:15 を 01:15 と変換したり、02月29日 を 03月01日 と変換したりすることを日付の正規化と言います。
「暦上の日付」から「時刻表上の日付」の変換が容易にできないと不具合が発生しやすくなりますし、何より開発者が混乱します。
現在時刻の取得
今日の時刻表が見たいというとき、現在時刻を取得する必要がありますが、どうやって取得すればよいでしょうか?
サーバのシステム時間?
これだとサーバのタイムゾーンに依存してしまうため、正しい結果が得られない可能性があります。
ちゃんと日本時間に設定していれば良さそうにも見えますが、NAVITIME は海外の駅の時刻表も見られるようになっているため、日本時間にしていれば大丈夫というものではありません。
スマホの時間?
これもサーバのシステム時間と同じ問題をはらんでいるため違います。
正解は「時刻表を見たい駅における現在時刻」です。現地時間と言えば分かりやすいでしょうか。
表参道の時刻表を見たい場合は「表参道における現在時刻」、すなわち「日本時間における時刻」です。ニューヨークの時刻表を見たい場合は東部標準時における時刻になります。
夏時間がある国では、当然それも加味した時刻を取得する必要があります。
現在時刻に対する単体テスト
時間というものは、テストとの相性がとても悪いです。
現在時刻が 10月23日 の場合に期待する時刻表はこれだ!と思ったところで、テストを実行する時間が 10月23日 でなかったらこのテストは確認できません。
サーバの現在時刻をいじってしまえば良いのでは?と思ったかも知れませんが、前述で述べたとおりサーバの時刻を使うことは間違っているので、プログラムではシステム時間を取得していません。
C++ で開発するということ
NAVITIME の時刻表サービスの開発では C++ が使われています。おかげでかなり速いパフォーマンスで時刻表を取得することが出来ます。
しかし C++ で時間計算をするのはかなり苦労を強いられます。
C++ で時間計算をする場合 <chrono> を使うと思いますが、<chrono> にはタイムゾーンを扱う機能が実装されていません。
※ C++20 では追加されました
また <chrono> は学習コストが高くて、使いこなすまでにそれなりの労力が必要となります。
Pendulum C++
このような問題を解消するために C++ でも直感的に扱える時間計算ライブラリを開発しました。
それが Pendulum C++ です。
使い方
例として日本時間の 2020-10-23 15:04:05 という時間を扱うときの書き方について説明するとこんな感じです。
#include <iostream>
#include <pendulum/pendulum.h>
int main() {
const auto& dt = pendulum::datetime(2020, 10, 23, 15, 4, 5, "Asia/Tokyo");
std::cout << dt << std::endl; // 2020-10-23T15:04:05+09:00
dt.year(); // 2020(年)
dt.month(); // 10(月)
dt.day(); // 23(日)
dt.hour(); // 15(時)
dt.minute(); // 4(分)
dt.second(); // 5(秒)
dt.day_of_week(); // 4(週の何日目か)
dt.day_of_year(); // 297(年の何日目か)
dt.week_of_month(); // 4(月の何周目か)
dt.timestamp(); // 1603433045(UNIX 時間)
dt.timezone_name(); // "Asia/Tokyo"(タイムゾーン名)
dt.offset(); // 32400(UTC との時差(秒))
dt.offset_hours(); // 9(UTC との時差(時))
return 0;
}
時間の正規化
さきほどの銀座線の終電時間(24:35)を取得するには次のように書きます。
const auto& dt = pendulum::datetime(2020, 10, 23, 15, 4, 5, "Asia/Tokyo");
const auto& last_train = dt.at(24, 35, 0);
std::cout << last_train << std::endl; // 2020-10-24T00:35:00+09:00
at(hh, mm, ss) という関数を使って時刻の変更ができます。正規化もちゃんとやってくれます。
タイムゾーンの変換
タイムゾーンの変換も簡単に行なえます。
// 東部標準時に変換 (2020-10-23T01:04:05-05:00)
dt.in_timezone("EST");
// 時差で変換(2020-10-23T01:04:05-05:00)
dt.in_offset_hours(-5);
// タイムゾーンだけ上書き (2020-10-23T15:04:05-05:00)
dt.timezone("EST");
// 時差で上書き (2020-10-23T15:04:05-05:00)
dt.offset_hours(-5);
タイムゾーン名でも時差でも変換ができます。
特定の曜日を取得
次の土曜日を取得したいときは次のように書きます。
dt.next(pendulum::kSaturday); // 2020-10-24T00:00:00+09:00
週の始まりの日なども取得できます。
dt.start_of("week"); // 2020-10-19T00:00:00+09:00
2020年10月19日というのは月曜日になります。週の始まりは日曜日じゃないの?と思った方もいるかも知れません。そういう時のために週の始まりを日曜日にすることもできます。
pendulum::week_starts_at(pendulum::kSunday);
dt.start_of("week"); // 2020-10-18T00:00:00+09:00
文字列のパース
文字列のパースもお手の物です。
pendulum::parse("20201023"); // 2020-10-23T00:00:00+00:00
文字列のパースはフォーマットを推測してパースしてくれます。そのため下記のような文字列を渡してもちゃんとパースすることが出来ます。
pendulum::parse("2020-10-23"); // 2020-10-23T00:00:00+00:00
pendulum::parse("20201023", "Asia/Tokyo"); // 2020-10-23T00:00:00+09:00
pendulum::parse("now"); // (現在時刻)
推測できる文字列の書式は下記のとおりになります。
・2006-01-02T15:04:05-07:00
・2006-01-02T15:04:05-0700
・2006-01-02 15:04:05
・2006-01-02
・2006-01
・20060102
・2006
2020-1-1 のようにゼロパディングされていなくてもパースすることが出来ます。
parse() の第 2 引数にはタイムゾーンを渡すことが出来ます。
// タイムゾーンが未指定だと UTC にずらしてパース: 2006-01-02T22:04:05+00:00
const auto& dt = pendulum::parse("2006-01-02T15:04:05-07:00");
// タイムゾーン指定があると時差までずらしてパース: 2006-01-03T07:04:05+09:00
const auto& dt = pendulum::parse("2006-01-02T15:04:05-07:00", "Asia/Tokyo");
// 文字列内に時差がない場合は指定したタイムゾーンを使う: 2006-01-02T15:04:05+09:00
const auto& dt = pendulum::parse("2006-01-02 15:04:05", "Asia/Tokyo");
もし上記以外のフォーマットをパースしたい場合は from_format() が使用できます。第 3 引数にタイムゾーンも指定できます。
pendulum::from_format("2020/11/01", "%Y/%m/%d");
pendulum::from_format("2020/11/1", "%Y/%m/%d", "Asia/Tokyo");
pendulum::from_format("2020.11.01", "%Y.%m.%d");
現在時刻を扱うテスト
プログラム中で現在時刻を扱うようなコードを書くと、時間の値が一位に定まらないため、テストがとても書きづらくなります。
こういう時どうやってテストすれば良いんだろう?って思ったことがある人も少なくないと思います。Pendulum C++ なら現在時刻を固定させる機能があるので、それを使って簡単にテストが書けます。
// 現在時刻を「2000年1月2日 15時4分5秒」に固定
pendulum::set_test_now(pendulum::datetime(2000, 1, 2, 15, 4, 5, "Asia/Tokyo"));
pendulum::now(); // 2000-01-02T15:04:05+09:00
pendulum::now(); // 2000-01-02T15:04:05+09:00
pendulum::now(); // 2000-01-02T15:04:05+09:00
// もとに戻す
pendulum::set_test_now();
現在時刻を何回取得しても同じ時間が返ってきます(笑)。
特定のテストのときだけ現在時刻を固定したいということであれば次のようにも書けます。
// このブロックの中だけ現在時刻が固定される
pendulum::test(pendulum::datetime(2000, 1, 2, 15, 4, 5, "Asia/Tokyo"), [&]() {
pendulum::now(); // 2000-01-02T15:04:05+09:00
});
まとめ
以上、時間計算のお話でした。Pendulum C++ は使いやすさにこだわって作ったので、日本の複雑な時刻表や全世界の公共交通の時刻表が正しく扱えるようになっています。