📑

Goの日時のParseの分かりづらいところ: タイムゾーン名を含む場合

2023/09/16に公開2

本記事の概要

Zennで読むことのできるGoの日時の扱いについては、@hsaki さんの記事が大変分かりやすいですよね。
https://zenn.dev/hsaki/articles/go-time-cheatsheet

こちらでも解説されてはいるのですが、混乱しやすいポイントだけに焦点を当てた記事も需要があるかな?と思い、私の頭の整理もかねて記事を書きます。

本記事で着目するのは、「Parseする日時文字列にタイムゾーン名だけ(例:JST)がある時、得られるtime.Time型の時間オフセットはどうなるか?」という点です。同じようにハマったことがある人は多いかな、と思うので、「あーあるある」ぐらいで読んでいただければと思います。

本記事の対象読者

  • Goの基本的な日時の扱い方は知っている

本記事の結論

  • タイムゾーン名だけでは時間オフセットは適切に設定されない。実行環境のタイムゾーンへの依存がある。
  • 解決策は環境変数TZの設定かParseInLocationを使うことだが、実行環境に必要なタイムゾーン情報(tzdataなど)がないとエラーになるため、特にDocker等で使うような軽量なOSでは注意が必要。対処方法は本文参照。

Parse時のタイムゾーンの扱い

さっそくですが、以下のコードを見てみましょう。

package main

func main(){
    // なぞのフォーマット
    s := "2023-09-16 09:00:00 JST"
    format := "2006-01-02 15:04:05 MST"

    t, _ := time.Parse(format, s)
    fmt.Println(t.Format(time.RFC3339))
}

これを以下の環境で実行すると、どうなるでしょうか?

# 環境変数TZは未設定
# システムのタイムゾーンはAsia/Tokyo

go run main.go
# Output: 2023-09-16T09:00:00+09:00

はい、適切な日本時間として変換されましたね。では、環境変数TZUTCに設定してみましょう。

TZ=UTC go run main.go
# Output: 2023-09-16T09:00:00Z

時間オフセットがUTC相当になってしまいました。 これは、time.Parseのドキュメントにも書かれた挙動ですが、ちょっとピンとこないですね。

When parsing a time with a zone abbreviation like MST, if the zone abbreviation has a defined offset in the current location, then that offset is used. The zone abbreviation "UTC" is recognized as UTC regardless of location. If the zone abbreviation is unknown, Parse records the time as being in a fabricated location with the given zone abbreviation and a zero offset. This choice means that such a time can be parsed and reformatted with the same layout losslessly, but the exact instant used in the representation will differ by the actual zone offset. To avoid such problems, prefer time layouts that use a numeric zone offset, or use ParseInLocation.

ちなみに、先ほどはParse後のtime.TimeをRFC3339にフォーマットして表示しましたが、String()の結果をみてみるとさらに混乱は深まります。

    fmt.Println(t)
    // Output: 2023-09-16 09:00:00 +0000 JST

JSTなのに+0000…?
Goでは実行環境のタイムゾーンを、環境変数TZTZが未設定であればシステム設定から取得します。今回はTZを上書きしたため、時刻文字列に含まれていたJST=Asia/Tokyoとアンマッチになり、タイムゾーン名だけがJST、オフセットは+0000になってしまったのですね。

多様な実行環境を扱う現代では、TZによりタイムゾーンを明示するか、あるいは次に示すようにtime.ParseInLocationを使う必要がありそうですね。ただ、この場合にも1点注意が必要なので、次にその点を見ていきましょう。

ロケーションを明示してParseする場合の注意点

time.ParseInLocationを使うと、タイムゾーンを明示的に指定することができます。以下のコードを見てみましょう。

package main

func main() {
    loc, _ := time.LoadLocation("Asia/Tokyo")
    s := "2023-09-16 09:00:00 JST"
    format := "2006-01-02 15:04:05 MST"

    t, _ := time.ParseInLocation(format, s, loc)
    fmt.Println(t.Format(time.RFC3339))
}

これを以下のように実行すると、どうなるでしょうか?

TZ=UTC go run main.go
# Output: 2023-09-16T09:00:00+09:00

はい、実行環境のローカルタイムに関わらず、Asia/Tokyo相当の時間になりましたね。

では上記のコードをGOOS=linuxとしてビルドし、ビルドしたファイルをDockerイメージのalpine:latest上で実行してみましょう。

GOOS=linux go build main.go -o tzplay

# Dockerのもろもろは省略します

# alpine:latest上での実行結果:
# ./tzplay
# panic: unknown time zone Asia/Tokyo

time.LoadLocationでエラーとなりました。Go1.15より前を経験した方には馴染みがあるかもしれませんが、これは実行環境にタイムゾーンの取得に必要な情報がないためです。

こちらの解説が分かりやすいです。
https://speakerdeck.com/hiroakis/go-false-timezone-to-go-1-dot-15-false-tzdata-mai-meip-mi?slide=23

この課題はGo1.15で導入されたtime/tzdataパッケージにより解決されました。

https://pkg.go.dev/time/tzdata

time/tzdataをブランクインポートするか、ビルド時のオプションとして-tags timetzdataを指定することで、タイムゾーン情報を埋め込むことができます。その代償はビルドサイズで、上記ドキュメント内では450kB程度の増加が示唆されています。

ちなみに先ほどのエラーは、alpine:latest環境に、環境変数TZ=Asia/Tokyoを指定した上でtime.Parseを実行しても再現しますが、こちらもtime/tzdataを埋め込むことで解決できます。

おわりに

よいおさらいになりましたね!個人的にはこのあたりの挙動はちょっと不可解だなと思っていたので、改めて調べてみてよかったです。

GitHubで編集を提案

Discussion

tenkohtenkoh

時間オフセットを適切に見られるように、Formatの結果を表示するよう修正しました。それに合わせて一部文言を修正しました。