エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

Goで時刻のゼロ値が変になった話

エムスリーエンジニアリングG/BIRの遠藤です。

アドベントカレンダーは終わっちゃっいましたが、年納めに小ネタを。

BIRはビジネスインテリジェンス&リサーチの略で、そこでは医療従事者の会員向けアンケートをベースに、製薬会社へのマーケティング支援を提供する事業を行っています。

f:id:enkn:20201225180221p:plain

BIRでは多くのシステムをGoで実装しています。

業務で1年利用してだいぶ慣れてきましたが、 先日思わぬ時刻のオフセットの問題に引っかかったので紹介します。 最終的には言語関係ない問題だったので、もしかすると他の言語でも似たような問題があるのかもしれません。

IsZero()が正常に判定されない

Goではtime.Time{}を呼びだすとデフォルト値としてゼロ値である0001-01-01 00:00:00.000000000 UTC相当の時刻が返されます。そして、このゼロ値を容易に判定する方法としてIsZero()メソッドが用意されています。

このIsZero()を使って判定処理を書いたところ、ゼロ値を設定したはずの値でIsZero() = falseになってしまうという問題が発生しました。

ゼロ値を設定したはずのデータの値を出力してみると以下のような値になっていました。

0001-01-01T09:18:59+09:18

0001-01-01T09:18:59 とは?

JSTのオフセットは+09:00なので午前9時なのはわかるとして、 18分59秒って何でしょう?

と思って調べたら、2年前のエムスリーのアドベントカレンダーに辿り着きました。 テックブログすごい。

www.m3tech.blog

詳細な解説は上記の記事に譲りますが、 簡単にまとめると、

  • 日本で標準時が採用されたのは1888年
  • 標準時の施行前は、都市ごとの地方平均時(Local Mean Time:LMT)を利用していた
  • LMTにおける東京のオフセットが+09:18:59

ということで、1888年より前の日時は上記のオフセットが適用されるようtzdataに登録されているようです。

知らなんだ。

このため、Goでもゼロ値にAsia/Tokyoのタイムゾーンを適用すると、LMTのオフセットが適用されることになるわけですね。

tz, _ := time.LoadLocation("Asia/Tokyo")
fmt.Printf("%+v", time.Time{}.In(tz))

The Go Playground > 001-01-01 09:18:59 +0918 LMT

なぜIsZero判定できなくなったか?

Asia/Tokyoのタイムゾーンでゼロ値のオフセットがLMTになるのはわかったとして、IsZero()の処理がなぜ正しく判定できなくなったのでしょうか?

結論としては、RFC3339(ISO8601)形式への変換を挟んだためでした。

f:id:enkn:20201228100601p:plain
問題の概要

問題が発生したシステムでは、永続化層にDatastoreを利用し、バックエンドのtime.LocalにはAsia/TokyoのLocationを設定していました。

そして、GoのDatastoreクライアントはデータ取得の際に、このローカル時間を反映してくれます。これはゼロ値であっても例外ではありません。

- time.Time (stored with microsecond precision, retrieved as local time)

datastore · pkg.go.dev

一方で、バックエンド-フロントエンド間でやりとりする時刻形式にRFC3339形式を使用していました。

RFC3339形式ではオフセットが以下のように定義されています。

time-numoffset  = ("+" / "-") time-hour ":" time-minute

RFC 3339 - Date and Time on the Internet: Timestamps

上記の通り、RFC3339形式では、オフセットとして分単位までしか想定していないため、 LMTの秒単位のオフセットに対する十分な表現力がありません。

よって、RFC3339形式にシリアライズした際に、59秒の部分のオフセットが桁落ちしてしまいます。

tz, _ := time.LoadLocation("Asia/Tokyo")
fmt.Printf("%s", time.Time{}.In(tz).Format(time.RFC3339))

The Go Playground > 0001-01-01T09:18:59+09:18

これにより、これをtime.Timeに戻した際に、ゼロ値から59秒ずれた時刻で保存されてしまうということが発生していました。 こちらで確認できます。

この問題を回避するには

原因はわかったので、回避する方法を検討してみました。

1. UTCで運用

要件が許すのであれば、アプリケーションで扱う時刻をUTCにするのが確実です。

よく考えたら、私は今までシステム時刻がUTCのシステムにしか携わってこなかったので、この問題に遭遇しなかったのかもしれません。

2. Asia/Tokyoのオフセットを+09:00に固定

Asia/Tokyoのタイムゾーンのオフセットを一定に固定してしまう方法もありそうです。

jst := time.FixedZone("Asia/Tokyo", 9*60*60)
fmt.Printf("%s", time.Time{}.In(jst).Format(time.RFC3339))

The Go Playground > 0001-01-01T09:00:00+09:00

time.Local = time.FixedZone("Asia/Tokyo", 9*60*60)は、 タイムゾーンが取得できないシステムでの回避方法と理解していましたが、LMTで扱わないようにするという副次的効果もあるようです。

3. オフセット情報のない形式にシリアライズ

オフセットを考慮しない形式でシリアライズすればこの問題は回避できます。

時刻の可読性は落ちますが、Unix timeで扱うのが一番確実でしょうか。

また、RFC3339形式で扱うのであれば、 UTC()を呼んで、オフセットを取り除いてからシリアライズしてあげれば回避できます。 ただし、各値ごとに呼ばなければいけないので呼び忘れに注意が必要です。

4. LMTのオフセットを表現できる形式でシリアライズ

timeパッケージに定義済みのレイアウトでは秒単位のオフセットを表現することはできませんが、 秒単位のオフセットを表現する定義自体はあるので、 独自形式のレイアウトを定義すれば、桁落ちすること自体は防げます。

const OriginalLayout = "2006-01-02T15:04:05Z07:00:00"

func main() {
    tz, _ := time.LoadLocation("Asia/Tokyo")
    tStr := time.Time{}.In(tz).Format(OriginalLayout)
    t, _ := time.Parse(OriginalLayout, tStr)
    fmt.Printf("%v: IsZero()=%v", tStr, t.IsZero())
}

The Go Playground > 0001-01-01T09:18:59+09:18:59: IsZero()=true

ただ、時刻レイアウトを独自定義するのはあまり筋がよくなさそうですね。

5. ゼロ値をnil(null)で表現

(2021/01/04 追記)該当の値をtime.Timeではなく*time.Timeとして表現し、ゼロ値ではなくnilとして表現すればこの問題は発生しません。 すでに運用されていた場合はI/Fに影響が出ますが、オフセットのややこしい問題からは開放されます。

まとめ

オフセットにまつわる問題について紹介しました。 文字列にする際は極力オフセット表現は避けたほうが無難かな、という所感です。

それでは皆様、素敵な年末を!

(2021/01/04 追記)

弊社CTOより「そもそも Go のゼロ値の Time をシリアライズするべきなのか?」という指摘がありました。 確かに本問題は「オフセット + ゼロ値」において発生する問題ですので、 ゼロ値の代わりにnilを使って表現すればオフセット関係なく問題は発生しません。 この方が誤解のない実装が望めそうですね。

ということで、 「5. ゼロ値をnil(null)で表現」 も追記しました。

ちなみに、弊社CTOが日時の扱いのベストプラクティスをまとめた記事がこちらにまとまっています。チェケラ。

www.m3tech.blog

We're hiring!

エムスリーではGoで一緒に開発してくれるエンジニアを募集しています。 興味を持たれた方は下記よりお問い合わせください。

open.talentio.com

jobs.m3.com