グローバルサービスでのタイムゾーンとの向き合い方

Web developer の大庭 (@ohbarye) です。

今回はタイムゾーンにまつわるお話をしたいと思います。

タイムゾーンは私が Quipper に入社したばかりの頃に最も頭を悩ませたことの一つです。入社以前にはタイムゾーンを跨ぐようなグローバルなアプリケーションの開発を全くしてこなかったので、まさにゼロから学び、考え、そしてハマりました。今でも気を抜くとハマりそうです。

入社からおよそ1年。この間に得た経験と知識を活かし、タイムゾーンと向き合うテクニックをまとめてみたいと思います。

目次

はじめに

まずはじめに、私は「タイムゾーンを扱いつつ堅牢なコードを書くのは難しい」と考えています。

何をもって難しいと感じたかは後述しますが、難しいがゆえに私はタイムゾーン絡みの問題に幾つかハマりましたし、web 上でも同じような問題に悩む人を時折見かけます。問題を避ける or 解決するためには経験もさることながら、「タイムゾーンはマルチスレッドプログラミングのようにそれ自体が難しさを抱えている」という前提に立つ必要があると私は考えています。

この記事が私と同じような悩みに直面している方、これからその難しさに直面する方のお役に立てば幸いです。

(余談ですが、ある時「タイムゾーンは辛いね」「辛いよね」などと開発者間で話していた折、私以上にタイムゾーンと長く向き合ってきた CTO からこんな励ましの言葉をもらいました。おかげで「辛いが、それでもグローバルなサービスの開発者として向き合っていくぞ!」という気持ちを新たにしました)

f:id:quipper-ja:20161202193825p:plain

前提 - Quipper のご紹介

我々 Quipper がどういったサービスを開発しているかをご認識いただいた方が向き合っている課題のイメージが湧きやすいと思いますので、簡単にご紹介します。

これらのアプリケーションは同じコードベースなので、本記事で述べる話はどちらのアプリケーションでも考えなければいけないものです。

また、本稿はこれらの web 版である Rails アプリケーションにまつわる話です。コードも RubyRails の例が中心ですが基本的な考え方は言語・フレームワークを問いません。

難しさ

まず、日時・時刻とタイムゾーンは不可分であり、「タイムゾーンは難しい」と言う時にはそれぞれの難しさが混在していることを認識しましょう。

代表的な問題を見ていきます。

現在時刻を扱う問題

現在時刻というのはプログラマが制御出来ない外部からの入力です。そのため、プログラムのいろいろなところで自由に入力を受け入れると外部環境への依存度が上がってしまい、自由に動かしたりテストしたりするのが難しくなります。

クックパッド社による『「現在時刻」を外部入力とする設計と、その実装のこと』より)

これはタイムゾーンを抜きにしても語れる問題ですが、タイムゾーンを扱おうとするとこの問題にほぼ間違いなく直面します。

言語、フレームワークの実装

Rails アプリケーションでは日時を扱うクラスが複数あります。

これらのクラスとそれぞれのメソッドが非常にややこしいです。同じクラスのメソッドでもどのタイムゾーンに依存するかが異なったりもします。 本稿ではこれらの区別には触れませんが、以下の問に答えられないようであれば API リファレンス等を一読してみましょう。

  • Time, Date, DateTime, TimeWithZone の違いは?
  • Date.todayDate.currentTime.nowTime.zone.now それぞれの違いは?

また、時刻やタイムゾーンの扱いがフレームワークの内部に隠蔽されることで外部環境への依存が明示的に見えなくなり、さらに問題を難しくすることがあります。

認知の問題

人間はおおよそ単一のタイムゾーンに生きています。同じ時間に複数の場所に存在することができないので、タイムゾーンを行ったり来たりする思考だけでも存外疲れます。

海外の拠点とやり取りするなどして複数のタイムゾーンを意識することはあれど、時間の流れの中で自分の存在を前後させることはありません。異なるタイムゾーンに移っても、移動先のタイムゾーンが自分の標準になるだけです。

タイムゾーンを扱う時に人はこうした認知に抗っているので、以下のような過激な意見が出て来ることもあります。

f:id:quipper-ja:20161202193902p:plain

そう、

にんげんはねぇ
タイムゾーンを考えるために
この世に生まれてきたのではないんだよ
にんげんがさき タイムゾーンがあと

なのです。

タイムゾーンを考慮した設計の問題

機能や実装方式の設計時にタイムゾーンへの配慮が足りないと、例えば以下のような問題が起きえます。

  • 無料キャンペーンの適用期間が人によって異なってしまう
  • 宿題の提出期限に関するチート*1ができてしまう
  • 決済の入金や請求といったクリティカルな処理のタイミングがずれてしまう

これを避けるためには実装時ももちろん、それ以前の設計段階から以下のようなポイントに気をつけなければいけません。

解法

ここからは Quipper で実践していること、開発時に意識していることをまとめていきます。

基本的な考え方

これはおそらく既にベストプラクティスとして知られていることと思いますが、グローバルなアプリケーションでは以下の3点を必ず守るようにします。

ちなみに Quipper では完全国内向けのサイト、例えばスタディサプリの決済サイトについても、以下の理由からこのルールを適用しています。

  • 同サイトが依存する他のアプリケーションのデフォルトが UTC である
  • 複数のアプリケーションの間でデフォルトタイムゾーンが異なると、実装・レビュー時に脳のスイッチングコストが増える
  • 時間への統一的なコードが書けないので事故が多く発生する

既にグローバルなアプリケーション開発に最適化されたチームなので、その標準からわざわざ外れる方がリスクが高いと判断しました。

デフォルトタイムゾーンを設定する

何はともあれ上述の通りデフォルトタイムゾーンUTC に設定します。

完全国内向けのアプリであれば JST でも構わないですが、異なるタイムゾーンを考慮するタイミングが一度でも入ってくると破綻する危険があります。

PostgreSQL

alter database my_database set timezone to 'UTC';

または

# postgresql.conf
timezone = 'UTC'

Rails

# config/application.rb
config.time_zone ='UTC'
config.active_record.default_timezone = :utc

タイムゾーンを意識した設計

コードを書き始める前に「あ、これはタイムゾーンを意識しなければならないな」と気付くキーワードがあります。時間、時刻、締切、期限、開始日時、終了日時、定時… これらと遭遇した時には気を引き締めます。

タイムゾーンを意識せねばならない時、どの単位で意識しなければいけないのかを考えます。

"タイムラインに表示される日時"はクライアントマシンのタイムゾーンに合わせて時刻表示は切り替わってよいものですが、"キャンペーン期間の終了日時"はユーザーのタイムゾーンに関わらず全世界で統一されたタイミングで終わるべきです。

また、ユーザーが日時を入力する際には要注意です。「どのタイムゾーンで扱うべきか」、言い換えれば「どのタイムゾーンを前提にして入力された値か」を入念に意識する必要があります。

タイムゾーン自体を1つの入力項目として持たせることもあり得ます。「あなたのタイムゾーンはどこですか?」はユーザーフレンドリーではないですが「どこの国/地域にお住まいですか?」なら良いでしょう。ちなみに Quipper では社内向けのキャンペーン入稿機能などでは各国のスタッフにタイムゾーンを入力してもらっています。

タイムゾーンを意識したプログラミング

アプリケーション全体を通してシステムタイムゾーンへの依存を取り去ります。 タイムゾーンの問題に限らず外部要因や実行環境に依存した時点でそれは不安定な実装です。

入力

現在時刻を取得する際にはシステムタイムゾーンに依存しないメソッドを使います。Time.zone.now を使いTime.now は使わない、Date.current を使い Date.today は使わない、といった具合です。*2

特定の処理だけを特定のタイムゾーンで行いたい場合 Time.use_zone がそれを可能にします。コードの意図も明示的になるので必要な箇所では積極的に活用しましょう。

Time.zone = 'UTC'
Time.zone.now
# => Thu, 04 Feb 2016 10:00:00 UTC +00:00

Time.use_zone('Asia/Tokyo') { Time.zone.now }
# => Thu, 04 Feb 2016 19:00:00 JST +09:00

レビューの度にタイムゾーン警察と化すのもまた大変なので、以下のようなモンキーパッチを利用することもあります。

class Date
  def self.today
    raise 'Use Date.current instead of Date.today'
  end

  def self.tomorrow
    raise 'Use Date.current + 1.day instead of Date.tomorrow'
  end
end

永続化

時刻を持つカラムは Date 型ではなく Time 型にすることを推奨します。

Rails では Date 型のカラムに Time 型を渡すと Time インスタンスが持っている時刻ではなく DB のデフォルトタイムゾーンに変換された値の日次部分のみが保存されて日時がずれることがあります。

この例については弊社長永 (@kyanny) による『Rails と時刻 - @kyanny's blog』もご参照ください。

出力

先述の通り、基本的には UTC で保持していると認識した上で、必要なところで必要な変換をかけてやります。以下のような helper method を view のために用意すると効率的です。

def localize(time, zone)
  I18n.l time.in_time_zone(zone)
end

time = Time.zone.now
localize(time, 'UTC')        # => "Tue, 28 Nov 2016 15:14:05 +0000"
localize(time, 'Asia/Tokyo') # => "Tue, 29 Nov 2016 00:14:05 +0900"

また、モバイルアプリや Single Page Application でクライアントに依存したタイムゾーンで日時を表示したい時は API エンドポイントから日付の文字列表現を直接返さず、UNIX タイムスタンプを渡しています。フィールド名もタイムスタンプだとわかるようにしてやると各クライアントサイドとのコミュニケーションが円滑になります。

render json: { created_ts: user.created_at.to_i }

タイムゾーンを意識したテスト

ユニットテスト

現在時刻に関わるユニットテストについては和田卓人さんによるテスト容易性設計の話が大変参考になります。

同記事では幾つかのアプローチが紹介されていますが、このうち「現在時刻を引数で渡す」やり方についてはクックパッド社の『「現在時刻」を外部入力とする設計と、その実装のこと』が改めて参考になります。同社は Trice のようなライブラリで現在時刻の取得の局所化を実現しているとのことで、おかげでユニットテストも比較的容易に書けているのではと推察します。

私達はこのライブラリを使用していませんが、このような取り組みもテストの容易性を上げる参考になります。

さて Quipper のテストではどうかというと、「時刻ライブラリに介入」するアプローチを主に採用しています。Time.now 自体をスタブするようなテストダブルを使ったアプローチです。テストの対象となるプロダクトコードでは Time.zone.now を各所で呼んでおり、そのコードのテストでは Timecop を都度利用しています。

この方法のメリットはプロダクトコード側がテストを考慮していなくともテストが書ける、という点です。万一テストがないメソッドを見つけたとしても、プロダクトコード側の変更なくテストを追加する事が可能です。

このアプローチは言語の柔軟性が高く、かつ Timecop のような信頼されたライブラリもある Ruby だからこそ出来るものなので、もし Quipper が JavaPHP など他の言語を採用していれば他のアプローチを選択する可能性が高いと思います。いずれの方法も長短があるのでその時々に応じたアプローチを取るべきと考えます。

インテグレーションテスト

永続化まで含めたインテグレーションテストでは、保存された日時がタイムゾーン込で正しいかどうかまで検証します。

テストの失敗

日時関連のテストが不安定な時はテストコードがゾーンを考慮していないか、テストされるコードがゾーンを考慮していない時です。 こうしたケースは別拠点の開発者が気付くことが多い*3ので、 GitHub issues や Slack で伝えて直してもらったりします。

また、開発者と遠いタイムゾーン (Time.zone = 'EST' (UTC-5)) をあえて spec_helper.rb でセットする工夫もしています*4。これによりゾーンを考慮していないコードやテストが失敗し、比較的早い段階で間違いに気付くことができます。

スタブ

テストで日時をスタブする際は Timecop のようなライブラリを利用します。Rails 4.1 以上であれば ActiveSupportヘルパーが追加されているのでそれらを使うのも良いでしょう。

余談ですが、Timecop 自体が副作用をもたらさないように safe_mode で使用しています。

# spec/support/timecop.rb
Timecop.safe_mode = true

# spec/your/spec.rb
around do |example|
  Timecop.freeze(target_time) { example.run }
end

タイムゾーンを意識したチーム

タイムゾーンに限った話ではないですが、誰かが一度ハマった問題・知識・テクニックを開発者間で共有することはとても大事です。 以下の記事はその一環としてフィリピンの Joy という開発者が書いてくれた記事です。

How to Tame Time Zones in Ruby on Rails

こうした積み重ねがタイムゾーンに関するゆるいコンセンサスが取れたチームを作っていき、新しく join した開発者が同じ問題についてハマっても必ず誰かがフォローできるようになっています。

終わりに

タイムゾーン問題について語る時「アジア圏の開発者は割り食う分タイムゾーン文字コードに強いよね」などと冗談めいて話したりします。しかし誰しも初めからそういうわけではなく、必要に応じてキャッチアップしてそうなっていくものだと思います。

そうした必要性に直面できるのはマーケットを広く持ち、かつ複数の国の開発者が協働する Quipper ならではの醍醐味ですし、グローバルなサービスに関心のある開発者にとっては刺激的な環境だと強く思っています。

告知

最後に告知ですが、 Quipper とリクルートマーケティングパートナーズ (RMP) 合同の MeetUp イベント第四弾を開催します!

私と RMP の金谷 (@soplana) が登壇し、"エンジニアの生存戦略"をテーマに、エンジニアのキャリアパスや組織として重宝されるスキルについて対談します。

Quipper、RMP 各社やグローバルなサービスの開発に興味がある方は是非ご参加いただけると嬉しいです!

参考

本記事を書くにあたり参考にさせていただいた記事を以下に記します。


★Quipper日本オフィスでは仲間を募集しています。是非お気軽にご応募ください。★

*1:"制作者の意図しない動作をさせる不正行為"を指します。

*2:これらのクラスとメソッドの違いについては API リファレンスやRubyとRailsにおけるTime, Date, DateTime, TimeWithZoneの違い - Qiitaなどの記事から学びました。

*3:ロンドンの日中に review / merge された機能が翌朝の日本で fail するとか。

*4:この工夫は「ロンドンの開発者は UTC なのでローカルでも CircleCI でもテストが通ってしまうが、同じコードを非ロンドンの開発者がローカルで流すと落ちる (CircleCI では通る) 」という問題が発端でした。