Goの日時のParseの分かりづらいところ: タイムゾーン名を含む場合
本記事の概要
Zennで読むことのできるGoの日時の扱いについては、@hsaki さんの記事が大変分かりやすいですよね。
こちらでも解説されてはいるのですが、混乱しやすいポイントだけに焦点を当てた記事も需要があるかな?と思い、私の頭の整理もかねて記事を書きます。
本記事で着目するのは、「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
はい、適切な日本時間として変換されましたね。では、環境変数TZ
をUTC
に設定してみましょう。
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では実行環境のタイムゾーンを、環境変数TZ
、TZ
が未設定であればシステム設定から取得します。今回は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より前を経験した方には馴染みがあるかもしれませんが、これは実行環境にタイムゾーンの取得に必要な情報がないためです。
こちらの解説が分かりやすいです。
この課題はGo1.15で導入されたtime/tzdata
パッケージにより解決されました。
time/tzdata
をブランクインポートするか、ビルド時のオプションとして-tags timetzdata
を指定することで、タイムゾーン情報を埋め込むことができます。その代償はビルドサイズで、上記ドキュメント内では450kB程度の増加が示唆されています。
ちなみに先ほどのエラーは、alpine:latest
環境に、環境変数TZ=Asia/Tokyo
を指定した上でtime.Parse
を実行しても再現しますが、こちらもtime/tzdata
を埋め込むことで解決できます。
おわりに
よいおさらいになりましたね!個人的にはこのあたりの挙動はちょっと不可解だなと思っていたので、改めて調べてみてよかったです。
Discussion
分かりづらいので、当然このようなIssueもある。
時間オフセットを適切に見られるように、
Format
の結果を表示するよう修正しました。それに合わせて一部文言を修正しました。