技術メモ

技術メモ

ラフなメモ

GoでJSTのタイムゾーンを扱う方法

本記事はGoでJSTタイムゾーンを指定する方法を紹介します。

現在時刻を取得するには time.Now() を使うことになります。time.Now() はデフォルトではローカルな時刻が取得できます。例えばAWS Lambda上ではUTCの時刻が取得できます。日本のロケーションで動作するアプリケーションを前提にすると、アプリケーションによっては時刻をJSTで統一したほうがシンプルで扱いやすい、といったケースもあるでしょう。本記事ではGoのアプリケーションでJSTの時刻を取得する方法を紹介します。

*time.Location の取得方法

まずタイムゾーン (time.Location) について説明します。Goではグローバル変数として *time.Local 変数があります。アプリケーションで更新することはアンチパターンとされているため、変数を上書きすることはないでしょう。

// Local represents the system's local time zone.
// On Unix systems, Local consults the TZ environment
// variable to find the time zone to use. No TZ means
// use the system default /etc/localtime.
// TZ="" means use UTC.
// TZ="foo" means use file foo in the system timezone directory.
var Local *Location = &localLoc

*time.Location を取得するには主に以下のAPIを使います。LoadLocationFromTZData を使用する機会は多くはないでしょう。

  • time.FixedZone
  • time.LoadLocation
  • LoadLocationFromTZData

time.FixedZone を用いて *time.Location を生成することができます。

   myJST := time.FixedZone("Asia/Tokyo", 9*60*60)

または time.LoadLocation でロケーション名を指定して *time.Location を取得します。

   jst, err := time.LoadLocation("Asia/Tokyo")
    if err != nil {
        panic(err)
    }

time.LoadLocation は外部依存があるため scratch などの軽量コンテナ上では動作しません。ソースはランタイムごとに異なりますが、例えばUNIX系の場合は zoneinfo_unix.go から確認できます。

以下のようにして確認することができます。

  • Dockerfile
FROM golang:1.15 as builder

WORKDIR /go/src/app

COPY go.mod go.sum ./
RUN go mod download

COPY ./main.go  ./

ARG CGO_ENABLED=0
ARG GOOS=linux
ARG GOARCH=amd64
RUN go build -o /go/bin/main -ldflags '-s -w' -trimpath


FROM scratch as runner

COPY --from=builder /go/bin/main /app/main

ENTRYPOINT ["/app/main"]
  • main.go
package main

import "time"

func main() {
    _, err := time.LoadLocation("Asia/Tokyo")
    if err != nil {
        panic(err)
    }
}
  • コンテナのビルド&実行
$ docker build -t jst-time-test1 -f Dockerfile .
$ docker run --rm jst-time-test1
  • 実行結果
panic: unknown time zone Asia/Tokyo

goroutine 1 [running]:
main.main()
        tzsample/light/main.go:8 +0x65

time.LoadLocation を使ってタイムゾーンを取得する場合は、アプリケーションが動作するランタイム上で動作するか確認する必要があります。ランタイムにタイムゾーンの情報が含まれない場合は

  • time.FixedZone を使ってタイムゾーンのオフセットを決め打ちする
  • ビルド時に -tags timetzdata を付与 1 してビルドする

などといった回避方法があります。

タイムゾーンを指定して時刻を取得する方法

続いて現在時刻を取得する方法です。time.Now() で取得する時刻にロケーションを指定する方法は以下の2種類の方法があります。

"Asia/Tokyo"タイムゾーンを指定する文字列は環境変数から値を設定することももちろん可能です。

方法1.時刻をパースする際に In でロケーションを指定する

もっともポピュラーな変換方法です。

   jst, err := time.LoadLocation("Asia/Tokyo")
    if err != nil {
        panic(err)
    }
    nowJST := time.Now().In(jst)
    fmt.Printf("nowJST: %v\n", nowJST.Format(time.RFC3339))
    // nowJST: 2009-11-11T08:00:00+09:00

https://play.golang.org/p/BRrCCx4qpcP

方法2. time パッケージが保持しているグローバル変数を更新する

time パッケージが保持しているグローバル変数 time.LocalJSTで更新する方法です。グローバル変数を更新することで間接的に time.Now() で取得できるタイムゾーンを差し替えることができます。

func init() {
    jst, err := time.LoadLocation("Asia/Tokyo")
    if err != nil {
        panic(err)
    }
    time.Local = jst
}

func main() {
    now := time.Now()
    fmt.Printf("now: %v\n", now.Format(time.RFC3339))
    // now: 2009-11-11T08:00:00+09:00
}

https://play.golang.org/p/UkMP0dxlOyE

方法1と方法2を比較すると、方法1では時刻を取得するすべての実装箇所から In を用いてタイムゾーンの変換をする必要があります。変換漏れがあると時刻がローカルな時刻とJSTと混在することになるため、一見方法2が一律指定できて、不具合が少ないと考えるかも知れません。

しかし方法2は筋がいい実装と言えません。もちろんミュータブルなグローバル変数を更新することで実現できますが、意図せずいろいろなところに影響が及ぶ可能性があります。実際にRuss Coxも次のようにほとんどの場合は間違いである、と述べています。

I've updated the title. What you're asking for is

``` package time

// SetLocal changes the current meaning of time.Local to match loc. func SetLocal(loc *Location) I don't believe this is a good idea. In general but especially in Go, coordination via global mutable state is almost always a mistake and difficult to correct once you realize it. ```

If there are higher-level APIs that force the use of time.Local, we should probably address those instead. For example, if package log is what you are concerned about, it would probably be okay to file a separate issue for adding log.SetLocation and log.(*Logger).SetLocation.

https://github.com/golang/go/issues/10701#issuecomment-148777945

実際、以下のように init 内で初期化した time.Local の変数とテスト中に time.Local を読み込むと DATA RACE が発生する、という事象もあります。私も再現しました。

方法1'.ロケーション付で時刻が取得できる関数を生成

方法1の拡張版です。In で変換するが、漏れのないように時刻を取得する関数を新たに生成して、その関数経由で時刻を取得する方法です。時刻を取得するときにアプリケーション側で宣言した nowFunc を使う必要がありますが、方法2と比較すると安全な方法です。

var nowFunc func() time.Time

func init() {
    jst, err := time.LoadLocation("Asia/Tokyo")
    if err != nil {
        panic(err)
    }

    nowFunc = func() time.Time {
        return time.Now().In(jst)
    }
}

func main() {
    now := nowFunc()
    fmt.Printf("now: %v\n", now.Format(time.RFC3339))
    // now: 2009-11-11T08:00:00+09:00
}

https://play.golang.org/p/uy6zILsixiZ

Songmu/flextime などの時刻を差し替えることができるライブラリなどを用いれば、上記同様に時刻のタイムゾーン変換ができます。以下は Songmu/flextimeNowFunc を用いて時刻を差し替えています。

func init() {
    tz, err := time.LoadLocation("Asia/Tokyo")
    if err != nil {
        panic(err)
    }

    flextime.NowFunc(func() time.Time {
        return time.Now().In(tz)
    })
}

func main() {
    now := flextime.Now()
    fmt.Printf("now: %v\n", now.Format(time.RFC3339))
    // now: 2009-11-11T08:00:00+09:00
}

https://play.golang.org/p/srJJReRbun_M

文字列から time.Time に変換する方法

日付の文字列 2021-01-01 から time.Time 型に変換するには以下のAPIを使います。

  • time.Parse
  • time.ParseInLocation

time.Parseタイムゾーンが文字列に含まれていない場合、デフォルトでUTCに変換します。

func main() {
    d := "2021-01-30"
    nowDate, err := time.Parse("2006-01-02", d)
    if err != nil {
        panic(err)
    }
    fmt.Printf("nowDate: %v", nowDate.Format(time.RFC3339))
    // nowDate: 2021-01-30T00:00:00Z
}

変換時にタイムゾーンが重要な場合は time.ParseInLocation で明示的にタイムゾーンを指定して文字列から変換しましょう。

func main() {
    d := "2021-01-30"
    tz, err := time.LoadLocation("Asia/Tokyo")
    if err != nil {
        panic(err)
    }
    nowDateJST, err := time.ParseInLocation("2006-01-02", d, tz)
    if err != nil {
        panic(err)
    }
    fmt.Printf("nowDateJST: %v", nowDateJST.Format(time.RFC3339))
    // nowDateJST: 2021-01-30T00:00:00+09:00
}

まとめ

*time.Locationグローバル変数を更新してタイムゾーンを変換する方法は危険なのでやめましょう。現在時刻をJSTで取得する場合は In を使って変換する方法が良いです。ラッパーをかますと、安全にかつ漏れが少なく変換することができます。


  1. Go1.15からビルド時に -tags timetzdata を付与してビルドすることで、タイムゾーン情報を埋め込むことができるようになりました。詳細は https://golang.org/pkg/time/tzdata/ に記載されています。