Goで時刻を扱うチートシート

2022/04/10に公開
6

この記事について

上のチートシートは、Goで時刻を扱う際に出てくる表現法と、それらを互いに変換するためにはどうしたらいいのかを一枚の図にまとめたものです。
この記事では、このチートシートに出てくる処理の詳細について掘り下げて説明しています。

使用する環境・バージョン

  • OS: macOS Catalina 10.15.7
  • go version go1.18 darwin/amd64
  • OSのタイムゾーン: JST(日本標準時、UTC+0900)

想定読者

この記事では、以下のような方を想定しています。

  • Goの基本的な文法は分かっている人
  • 異なる時刻の表現法を、Goではどのように変換することになるかを知りたい人

逆に、以下のような方は読んでも物足りないか、ここからは得たい情報が得られないかもしれません。

  • 一般的にサーバーサイドで、どうすればタイムゾーンを正しく扱うことができるかを知りたい人
  • タイムゾーン実装のベストプラクティスが知りたい人

時刻を表すための方式

一言で「時刻」といっても、それをどういう形で扱うのかは様々です。

(例)2022年4月1日 午前9時0分0秒 日本標準時JSTの場合

  • time.Time型 : time.Date(2022, 4, 1, 0, 0, 0, 0, time.Local)
  • UNIX時間: 1648771200
  • 文字列: "2022-04-01T09:00:00+09:00"
  • JSON文字列: {"timestamp":"2022-04-01T09:00:00+09:00"}

それぞれについて説明します。

time.Time

Goの標準パッケージtimeに用意されている構造体です。
time.Date関数に日付、時間等を渡してやることで生成することができます。

func Date(year int, month Month, day, hour, min, sec, nsec int, loc *Location) Time

出典:pkg.go.dev - time.Date

Goのプログラムの中で、人がわかりやすい形で時刻を扱うのなら真っ先に選択肢に入ってくるのがこの構造体でしょう。

UNIX時間

1970年1月1日午前0時0分0秒から何秒経過したかの値で時刻を表す方式です。

(例)

  • 1970年1月1日午前0時0分0秒 : UNIX時間だと0
  • 1970年1月1日午前0時0分1秒 : UNIX時間だと1
  • 1970年1月1日午前1時0分0秒 : UNIX時間だと3600

文字列 / JSON文字列

"2022-04-01T00:00:00Z"のように、時刻の情報が文字列の形で与えられるということもあります。
これは、そのデータ構造の中で時刻を表すための特別な型が存在しないときに起こるパターンです。

代表的な例がJSONです。
RFC8259では、JSONの値として使えるのは以下の型だと規定されています。

  • オブジェクト
  • 配列
  • 数値
  • 文字列
  • 真偽値(true/false)
  • null

時刻を表すための特別な型はJSONにはなく、そのためJSONの中では時刻を文字列として扱わざるを得ません。

Goにおける時刻型の変換

それではここからは、上に挙げた時刻表現をどう互いに変換するのかについて見ていきます。

time.Time型からの変換

time.Time型 -> UNIX時間

time.Time型にはUnixメソッドが用意されており、それを用いることで簡単にUNIX時間を得ることができます。

func (t Time) Unix() int64

出典:pkg.go.dev - time.Time.Unix

func time2unix(t time.Time) int64 {
	// レシーバーtのUNIX時間を返す
	return t.Unix()
}

func main() {
	var timeTime = time.Date(2022, 4, 1, 9, 0, 0, 0, time.Local)
	fmt.Println(time2unix(timeTime)) // 1648771200
}

time.Time型 -> 文字列

time.Time型にはFormatメソッドというものが用意されています。

func (t Time) Format(layout string) string

出典:pkg.go.dev - time.Time.Format

このFormatメソッドの引数layoutにて、変換後の文字列のフォーマットを指定します。

func time2str(t time.Time) string {
	// レシーバーtを、"YYYY-MM-DDTHH-MM-SSZZZZ"という形の文字列に変換する
	return t.Format("2006-01-02T15:04:05Z07:00")
}

func main() {
	var timeTime = time.Date(2022, 4, 1, 9, 0, 0, 0, time.Local)
	fmt.Println(time2str(timeTime))
	// 2022-04-01T09:00:00+09:00
}

time.Time型 -> JSON文字列

JSONのようなkey-valueの形を作るためには、独自構造体を定義してそれをJSONエンコードする必要があります。

func time2json(t time.Time) string {
	// 独自構造体myStructを定義して、
	// そのTimestampフィールドをJSONキー"timestamp"に対応付けする
	type myStruct struct {
		Timestamp time.Time `json:"timestamp"`
	}

	// myStruct構造体をJSONエンコードして返す
	b, _ := json.Marshal(myStruct{t})
	return string(b)
}

func main() {
	var timeTime = time.Date(2022, 4, 1, 9, 0, 0, 0, time.Local)
	fmt.Println(time2json(timeTime))
	// {"timestamp":"2022-04-01T09:00:00+09:00"}
}

time.Time型からの変換まとめ

ここまでtime.Time型からUNIX時間・文字列・JSONに変換する方法について紹介しました。
これらが全て正しく動作することはユニットテストでも確認することができます。

var (
	// この4つは全て同じ時刻を表している
	timeTime time.Time = time.Date(2022, 4, 1, 9, 0, 0, 0, time.Local)
	unixTime int64     = 1648771200
	strTime  string    = "2022-04-01T09:00:00+09:00"
	jsonTime string    = `{"timestamp":"2022-04-01T09:00:00+09:00"}`
)

// time.Time型 -> UNIX時間
func time2unix(t time.Time) int64 {
	return t.Unix()
}

// time.Time型 -> 文字列
func time2str(t time.Time) string {
	return t.Format("2006-01-02T15:04:05Z07:00")
}

// time.Time型 -> JSON文字列
func time2json(t time.Time) string {
	type myStruct struct {
		Timestamp time.Time `json:"timestamp"`
	}
	b, _ := json.Marshal(myStruct{timeTime})

	return string(b)
}

// 4つの表現方法が等しいかどうか確認
func TestConvertTime(t *testing.T) {
	if got := time2unix(timeTime); got != unixTime {
		t.Errorf("time2unix: got %d but want %d\n", got, unixTime)
	}
	if got := time2str(timeTime); got != strTime {
		t.Errorf("time2str: got %s but want %s\n", got, strTime)
	}
	if got := time2json(timeTime); got != jsonTime {
		t.Errorf("time2json: got %s but want %s\n", got, jsonTime)
	}
}

// === RUN   TestConvertTime
// --- PASS: TestConvertTime (0.00s)
// PASS

UNIX時間からの変換

UNIX時間 -> time.Time

UNIX時間からtime.Time型に変換するためには、timeパッケージに用意されたUnix関数を用います。

func Unix(sec int64, nsec int64) Time

出典:pkg.go.dev - time.Unix

func unix2time(t int64) time.Time {
	// 秒単位のUNIX時間がt, ナノ秒が0の時刻を持つtime.Time型を返す
	return time.Unix(t, 0)
}

func main() {
	var unixTime int64 = 1648771200
	fmt.Println(unix2time(unixTime))
	// 2022-04-01 09:00:00 +0900 JST
}

UNIX時間 -> 文字列

UNIX時間から直接文字列を生成する方法は存在しません。
一旦time.Time型を経由して、「UNIX時間 -> time.Time型 -> 文字列」とする必要があります。

func unix2str(t int64) string {
	// UNIX時間 -> time.Time型の関数
	unix2time := func(t int64) time.Time {
		return time.Unix(t, 0)
	}
	// time.Time型 -> 文字列の関数
	time2str := func(t time.Time) string {
		return t.Format("2006-01-02T15:04:05Z07:00")
	}
	return time2str(unix2time(t))
}

func main() {
	var unixTime int64 = 1648771200
	fmt.Println(unix2str(unixTime))
	// 2022-04-01T09:00:00+09:00
}

UNIX時間 -> JSON文字列

UNIX時間からJSON文字列を生成する際も、一旦time.Time型を経由させるしかありません。

func unix2json(t int64) string {
	// UNIX時間 -> time.Time型の関数
	unix2time := func(t int64) time.Time {
		return time.Unix(t, 0)
	}
	// time.Time型 -> JSON文字列の関数
	time2json := func(t time.Time) string {
		type myStruct struct {
			Timestamp time.Time `json:"timestamp"`
		}
		b, _ := json.Marshal(myStruct{t})

		return string(b)
	}
	return time2json(unix2time(t))
}

func main() {
	var unixTime int64 = 1648771200
	fmt.Println(unix2json(unixTime))
	// {"timestamp":"2022-04-01T09:00:00+09:00"}
}

UNIX時間からの変換まとめ

紹介した変換方式が正しく動作するのか、ユニットテストで検証しましょう。

var (
	// この4つは全て同じ時刻を表している
	timeTime time.Time = time.Date(2022, 4, 1, 9, 0, 0, 0, time.Local)
	unixTime int64     = 1648771200
	strTime  string    = "2022-04-01T09:00:00+09:00"
	jsonTime string    = `{"timestamp":"2022-04-01T09:00:00+09:00"}`
)

func unix2time(t int64) time.Time {
	return time.Unix(t, 0)
}

func unix2str(t int64) string {
	time2str := func(t time.Time) string {
		return t.Format("2006-01-02T15:04:05Z07:00")
	}
	return time2str(unix2time(t))
}

func unix2json(t int64) string {
	time2json := func(t time.Time) string {
		type myStruct struct {
			Timestamp time.Time `json:"timestamp"`
		}
		b, _ := json.Marshal(myStruct{t})

		return string(b)
	}
	return time2json(unix2time(t))
}

func TestConvertUnix(t *testing.T) {
	if got := unix2time(unixTime); !got.Equal(timeTime) {
		t.Errorf("unix2time: got %s but want %s\n", got, timeTime)
	}
	if got := unix2str(unixTime); got != strTime {
		t.Errorf("unix2str: got %s but want %s\n", got, strTime)
	}
	if got := unix2json(unixTime); got != jsonTime {
		t.Errorf("unix2json: got %s but want %s\n", got, jsonTime)
	}
}

// === RUN   TestConvertUnix
// --- PASS: TestConvertUnix (0.00s)
// PASS

文字列からの変換

文字列 -> time.Time

文字列からtime.Time型に変換するには、timeパッケージ内にあるParse関数を使います。

func Parse(layout, value string) (Time, error)

出典:pkg.go.dev - time.Parse

引数layoutに、変換対象となる文字列がどのような表現形式になっているのかを指定して変換を行います。
このlayout引数も、t.Formatメソッドと同様に「2006年1月2日15時4分5秒 アメリカ山地標準時MST(GMT-0700)」の時刻文字列を使用することになっています。

func str2time(t string) time.Time {
	// YYYY-MM-DDTHH:MM:SSZZZZの形式で渡される文字列tをtime.Time型に変換して返す
	parsedTime, _ := time.Parse("2006-01-02T15:04:05Z07:00", t)
	return parsedTime
}

func main() {
	var strTime string = "2022-04-01T09:00:00+09:00"
	fmt.Println(str2time(strTime))
	// 2022-04-01 09:00:00 +0900 JST
}

文字列 -> UNIX時間

UNIX時間から文字列に直接変換する術がなかったのと同様に、その逆変換である文字列 -> UNIX時間も一度time.Time型を経由する必要があります。

func str2unix(t string) int64 {
	// 文字列 -> time.Time型の関数
	str2time := func(t string) time.Time {
		parsedTime, _ := time.Parse("2006-01-02T15:04:05Z07:00", t)
		return parsedTime
	}
	// time.Time型 -> UNIX時間の関数
	time2unix := func(t time.Time) int64 {
		return t.Unix()
	}
	return time2unix(str2time(t))
}

func main() {
	var strTime string = "2022-04-01T09:00:00+09:00"
	fmt.Println(str2unix(strTime))
	// 1648771200
}

文字列 -> JSON文字列

この変換を行いたいというユースケースは、おそらくあまりないのではないでしょうか……。というわけで割愛します。

文字列からの変換まとめ

ここまで紹介した変換が正しく動作するかを検証するユニットテストはこちらです。

var (
	// この3つは全て同じ時刻を表している
	timeTime time.Time = time.Date(2022, 4, 1, 9, 0, 0, 0, time.Local)
	unixTime int64     = 1648771200
	strTime  string    = "2022-04-01T09:00:00+09:00"
)

func str2time(t string) time.Time {
	parsedTime, _ := time.Parse("2006-01-02T15:04:05Z07:00", t)
	return parsedTime
}

func str2unix(t string) int64 {
	time2unix := func(t time.Time) int64 {
		return t.Unix()
	}
	return time2unix(str2time(t))
}

func TestConvertStr(t *testing.T) {
	if got := str2time(strTime); !got.Equal(timeTime) {
		t.Errorf("str2time: got %s but want %s\n", got, timeTime)
	}
	if got := str2unix(strTime); got != unixTime {
		t.Errorf("str2unix: got %d but want %d\n", got, unixTime)
	}
}

// === RUN   TestConvertStr
// --- PASS: TestConvertStr (0.00s)
// PASS

JSON文字列からの変換

JSON文字列 -> time.Time

JSONから何らかの値を読み込むためには、独自構造体を定義してそこに向かってJSONでコードを行う必要があります。

func json2time(t string) time.Time {
	// 独自構造体myStructを定義して
	// そのTimestampフィールドにJSONキー"timestamp"を対応付け
	type myStruct struct {
		Timestamp time.Time `json:"timestamp"`
	}

	// JSONをmyStruct構造体にデコードして、そのTimestampフィールドを取り出して返す
	var myStc myStruct
	json.Unmarshal([]byte(t), &myStc)
	return myStc.Timestamp
}

func main() {
	var jsonTime string = `{"timestamp":"2022-04-01T09:00:00+09:00"}`
	fmt.Println(json2time(jsonTime))
	// 2022-04-01 09:00:00 +0900 JST
}

JSON文字列 -> UNIX時間

UNIX時間からJSON文字列に直接変換できなかったように、その逆もtime.Time型を経由させる必要があります。

func json2unix(t string) int64 {
	// JSON文字列 -> time.Time型の関数
	json2time := func(t string) time.Time {
		type myStruct struct {
			Timestamp time.Time `json:"timestamp"`
		}
		var myStc myStruct
		json.Unmarshal([]byte(t), &myStc)
		return myStc.Timestamp
	}
	// time.Time型 -> UNIX時間の関数
	time2unix := func(t time.Time) int64 {
		return t.Unix()
	}
	return time2unix(json2time(t))
}

func main() {
	var jsonTime string = `{"timestamp":"2022-04-01T09:00:00+09:00"}`
	fmt.Println(json2unix(jsonTime))
	// 1648771200
}

JSON文字列 -> 文字列

「文字列 -> JSON」同様に、これもユースケースが見えないので割愛します。

JSON文字列からの変換まとめ

紹介した変換方法の動作を検証するユニットテストです。

var (
	timeTime time.Time = time.Date(2022, 4, 1, 9, 0, 0, 0, time.Local)
	unixTime int64     = 1648771200
	jsonTime string    = `{"timestamp":"2022-04-01T09:00:00+09:00"}`
)

func json2time(t string) time.Time {
	type myStruct struct {
		Timestamp time.Time `json:"timestamp"`
	}
	var myStc myStruct
	json.Unmarshal([]byte(t), &myStc)
	return myStc.Timestamp
}

func json2unix(t string) int64 {
	time2unix := func(t time.Time) int64 {
		return t.Unix()
	}
	return time2unix(json2time(t))
}

func TestConvertJSON(t *testing.T) {
	if got := json2time(jsonTime); !got.Equal(timeTime) {
		t.Errorf("json2time: got %s but want %s\n", got, timeTime)
	}
	if got := json2unix(jsonTime); got != unixTime {
		t.Errorf("json2unix: got %d but want %d\n", got, unixTime)
	}
}

// === RUN   TestConvertJSON
// --- PASS: TestConvertJSON (0.00s)
// PASS

まとめ

ここまで紹介した変換方法をまとめた図が以下です。

扱う時刻のタイムゾーンと実行環境のタイムゾーンが異なる場合

さて、今まで私たちはJST(日本標準時)を扱ってきました。

// JST(日本標準時)での表記
var (
	timeTime time.Time = time.Date(2022, 4, 1, 9, 0, 0, 0, time.Local)
	unixTime int64     = 1648771200
	strTime  string    = "2022-04-01T09:00:00+09:00"
	jsonTime string    = `{"timestamp":"2022-04-01T09:00:00+09:00"}`
)

しかし、「実行環境のタイムゾーンとは異なる時刻」を扱う際には少々注意が必要です。
ここからは、それを別のタイムゾーンの時刻にして同様の変換処理を行なっていきたいと思います。

別のタイムゾーンでの検証

UTC(協定標準時)の場合

以下のように、扱う時刻をUTC(協定標準時)のものに変えて、これまで用意したユニットテストを実行してみます。

var (
	timeTime time.Time = time.Date(2022, 4, 1, 0, 0, 0, 0, time.UTC)
	unixTime int64     = 1648771200
	strTime  string    = "2022-04-01T00:00:00Z"
	jsonTime string    = `{"timestamp":"2022-04-01T00:00:00Z"}`
)
$ go test
--- FAIL: TestConvertUnix (0.00s)
    unix_test.go:34: unix2time: got 2022-04-01 09:00:00 +0900 JST but want 2022-04-01 00:00:00 +0000 UTC
    unix_test.go:37: unix2str: got 2022-04-01T09:00:00+09:00 but want 2022-04-01T00:00:00Z
    unix_test.go:40: unix2json: got {"timestamp":"2022-04-01T09:00:00+09:00"} but want {"timestamp":"2022-04-01T00:00:00Z"}
FAIL
exit status 1

以下3つの関数でおかしな挙動をしていることが確認できます。

  • unix2time: UNIX時間からtime.Time型への変換
  • unix2str: UNIX時間から文字列への変換
  • unix2json: UNIX時間からJSON文字列への変換

unix2strunix2jsonは、内部でunix2timeを挟んでいることを考えると、実質的には「UNIX時間からtime.Time型への変換」がうまくいってないのが根本原因と考えていいでしょう。

タイムゾーンAmerica/New_Yorkの場合

UTC(協定標準時)のようなメジャーな時間ではなく、今度はニューヨーク時間(通常UTC-0500、サマータイムUTC-0400)で検証してみましょう。

var (
	newYork  *time.Location
	timeTime time.Time
	unixTime int64  = 1648771200
	strTime  string = "2022-03-31T20:00:00-04:00"
	jsonTime string = `{"timestamp":"2022-03-31T20:00:00-04:00"}`
)

func TestMain(m *testing.M) {
	location, _ := time.LoadLocation("America/New_York")
	newYork = location

	timeTime = time.Date(2022, 3, 31, 20, 0, 0, 0, newYork)

	m.Run()
}
$ go test
--- FAIL: TestConvertJSON (0.00s)
    json_test.go:27: json2time: got 2022-03-31 20:00:00 -0400 -0400 but want 2022-03-31 20:00:00 -0400 EDT
--- FAIL: TestConvertStr (0.00s)
    str_test.go:22: str2time: got 2022-03-31 20:00:00 -0400 -0400 but want 2022-03-31 20:00:00 -0400 EDT
--- FAIL: TestConvertUnix (0.00s)
    unix_test.go:34: unix2time: got 2022-04-01 09:00:00 +0900 JST but want 2022-03-31 20:00:00 -0400 EDT
    unix_test.go:37: unix2str: got 2022-04-01T09:00:00+09:00 but want 2022-03-31T20:00:00-04:00
    unix_test.go:40: unix2json: got {"timestamp":"2022-04-01T09:00:00+09:00"} but want {"timestamp":"2022-03-31T20:00:00-04:00"}
FAIL
exit status 1

UNIX時間からの変換である以下3つがおかしな挙動をしているのは、UTC(協定標準時)の時と同様です。

  • unix2time: UNIX時間からtime.Time型への変換
  • unix2str: UNIX時間から文字列への変換
  • unix2json: UNIX時間からJSON文字列への変換

それに加えて、新たに2つの関数でも想定外の挙動をしています。

  • str2time: 文字列からtime.Time型への変換
  • json2time: JSON文字列からtime.Time型への変換

実行環境のタイムゾーンに挙動が依存する関数・メソッド

ここまでの話をまとめると、扱うタイムゾーンによって挙動が変わるのは以下の操作です。

  • UNIX時間からtime.Time型への変換(UTC、ニューヨーク時間の場合)
  • 文字列からtime.Time型への変換(ニューヨーク時間の場合)
  • JSON文字列からtime.Time型への変換(ニューヨーク時間の場合)

そして実際、これら3つの処理の中で使っているtimeパッケージの関数・メソッドがシステムタイムゾーン依存の挙動をするのです。

UNIX時間からtime.Time型への変換 - time.Unix関数

UNIX時間からtime.Time型への変換する際に使用しているtime.Unix関数は、「返り値のtime.Time型は、そのプログラムを実行しているシステムタイムゾーンのものにする」という仕様になっています。

Unix returns the local Time corresponding to the given Unix time
(訳)Unix関数は、与えられたUNIX時間に対応するローカル時間を返却します。
出典:pkg.go.dev - time.Unix

そのため、今回のローカルタイムゾーンであるJST(日本標準時)以外のtime.Timeを返却したいのであれば、time.Time型に用意されたInメソッドを使用してタイムゾーンを明示的に指定してやる必要があります。

func (t Time) In(loc *Location) Time

出典:pkg.go.dev - time.In

このInメソッドは、レシーバーのtime.Timeからタイムゾーンだけを変えたtime.Timeを返してくれます。
つまり、Inメソッドの使用前と使用後でUNIX時間は変わりません。

(例)

  • 2022年4月1日 9時0分0秒 JST(日本標準時) -(Inメソッド)-> 2022年4月1日 0時0分0秒 UTC(協定標準時)

このInメソッドを用いて、UNIX時間からtime.Time型への変換関数unix2timeを修正すると以下のようになります。

// UTC(協定標準時)の場合
func unix2time(t int64) time.Time {
	return time.Unix(t, 0).In(time.UTC)
}

// ニューヨーク時間の場合
func unix2time(t int64) time.Time {
	return time.Unix(t, 0).In(newYork)
}

文字列からtime.Time型への変換 - time.Parse関数

文字列からtime.Time型への変換に使用しているtime.Parse関数では、タイムゾーンを以下のように扱っています。

  • 入力として与えられた文字列にタイムゾーン・オフセットの情報がなかったら、それはUTC(協定標準時)の時刻とみなしてパースする。
  • 入力として与えられた文字列に、オフセットの情報(例:+0900)のみがあり、タイムゾーンの情報(例:JST)がなかった場合
    • 実行環境のタイムゾーンのオフセット(今回だとJST=+0900)と一致していた場合、実行環境が使用しているタイムゾーン(今回だとJST)の時刻とみなしてパースする
    • 実行環境のタイムゾーンのオフセット(今回だとJST=+0900)と一致していない場合、タイムゾーンが確定できないので仮のタイムゾーン名でパースする
      • (例)JST(+0900)の実行環境で、-0400のオフセットを受け取った場合 -> オフセット-0400、仮タイムゾーン名-0400でパースする
  • 入力として与えられた文字列に、タイムゾーンの情報(例:JST)のみがあり、オフセットの情報(例:+0900)がなかった場合
    • 実行環境のタイムゾーン(今回だとJST=+0900)と一致していた場合、そのタイムゾーンのオフセット(今回だと+0900)を補完してパースする
    • 与えられたタイムゾーンがUTCだった場合には、UTC+0000としてパースする
    • それ以外の場合、与えられたタイムゾーンで、仮オフセット+0000としてパースする
      • (例)JST(+0900)の実行環境で、EDTのタイムゾーンの時刻を受け取った場合 -> タイムゾーン名EDT、仮オフセット+0000でパースする

今回関係あるのが「実行環境のタイムゾーンのオフセット(今回だとJST=+0900)と一致していない場合」という箇所です。
つまり、JSTの実行環境で、ニューヨーク時間(夏)のオフセットである-0400を与えられたとしても、time.Time型生成時にタイムゾーンEDTを補完してくれないのです。

// テストのFAILメッセージからも、タイムゾーンが補完されずに-0400という仮のものになっているのが確認できます。
--- FAIL: TestConvertStr (0.00s)
    str_test.go:22: str2time: got 2022-03-31 20:00:00 -0400 -0400 but want 2022-03-31 20:00:00 -0400 EDT

タイムゾーン・オフセットを指定して文字列をパースするためには、time.Parse関数ではなくtime.ParseInLocation関数を用いる必要があります。

func ParseInLocation(layout, value string, loc *Location) (Time, error)

出典:pkg.go.dev - time.ParseInLocation

これを用いて、文字列からtime.Time型への変換関数str2timeを直すと以下のようになります。

// ニューヨーク時間の場合
func str2time(t string) time.Time {
	parsedTime, _ := time.ParseInLocation("2006-01-02T15:04:05Z07:00", t, newYork)
	return parsedTime
}

JSON文字列からtime.Time型への変換

JSON文字列からtime.Time型への変換する際には、encoding/jsonパッケージのjson.Unmarshal関数を利用しています。
これは内部的にはtime.Time型のUnmarshalJSONメソッド→time.Time型のParseメソッドを利用しています。

つまり、「文字列->time.Time型」のときと同様に「実行環境のタイムゾーンのオフセット(今回だとJST=+0900)と一致していない場合」にはタイムゾーンの補完がなされないということです。

これを直すためには、デコード結果にInメソッドを用いて明示的にタイムゾーンを変換してやるという方法が一つあります。

func json2time(t string) time.Time {
	type myStruct struct {
		Timestamp time.Time `json:"timestamp"`
	}
	var myStc myStruct
	json.Unmarshal([]byte(t), &myStc)
	return myStc.Timestamp.In(newYork)
}

実行環境のタイムゾーンとは異なる時刻を扱うときのまとめ

応用編 - RCF3339以外の時刻フォーマットでJSONエンコード/でコードを行う

さて、ここからは応用編ということで、JSONに含まれている時刻文字列を自己流にいじることを考えていきたいと思います。

var (
	timeTime time.Time = time.Date(2022, 4, 1, 9, 0, 0, 0, time.Local)
	
	// RFC3339のフォーマット
	// strTime  string  = "2022-04-01T09:00:00+09:00"
	// jsonTime string  = `{"timestamp":"2022-04-01T09:00:00+09:00"}`

	// 独自の時刻文字列フォーマット
	strTime  string = "2022/04/01 09:00:00.000 +0900"
	jsonTime string = `{"timestamp":"2022/04/01 09:00:00.000 +0900"}`
)

ここまでは、以下2つのencoding/jsonパッケージ内の関数を何気なく使っていたかと思います。

  • json.Marshal関数 : time.Time型 -> JSON文字列への変換
  • json.Unmarshal関数 : JSON文字列 -> time.Time型への変換

しかしこれらは、以下のような仕様が存在するのです。

  • json.Marshal関数 : time.Time型はRFC3339で定義されたフォーマット(YYYY-MM-DDTHH:MM:SSZZZZ)に変換する
  • json.Unmarshal関数 : RFC3339で定義されたフォーマット(YYYY-MM-DDTHH:MM:SSZZZZ)の文字列をtime.Time型に変換する
// time.Time型 -> JSON文字列への変換
func time2json(t time.Time) string {
	// (一部抜粋)
	b, _ := json.Marshal(myStruct{t})
}

// JSON文字列 -> time.Time型への変換
func json2time(t string) time.Time {
	// (一部抜粋)
	json.Unmarshal([]byte(t), &myStc)
}

func main() {
	var timeTime = time.Date(2022, 4, 1, 9, 0, 0, 0, time.Local)
	fmt.Println(time2json(timeTime))
	// {"timestamp":"2022-04-01T09:00:00+09:00"}
	// RFC3339のフォーマットがJSONに使われる

	var jsonTime string = `{"timestamp":"2022-04-01T09:00:00+09:00"}`
	fmt.Println(json2time(jsonTime))
	// デコード対象のJSONの中でRFC3339のフォーマットを使う必要がある
	// 2022-04-01 09:00:00 +0900 JST
}

これを、

  • time.Time型 -> JSON文字列への変換時に、独自の文字列フォーマットで出力されるようにしたい
  • JSON文字列 -> time.Time型への変換時に、独自の文字列フォーマットが時刻文字列として認識されるようにしたい

というように変えたい場合には、json.Marshal関数/json.Unmarshal関数の挙動を変更してやる必要があるのです。

独自構造体の定義

json.Marshal関数がtime.Time型をJSONエンコードする際には、内部でtime.Time型のMarshalJSONメソッドを利用しています。

func (t Time) MarshalJSON() ([]byte, error)

出典:pkg.go.dev - time.MarshalJSON

また、json.Unmarshal関数が時刻文字列をtime.Time型にデコードする際には、内部でtime.Time型のUnmarshalJSONメソッドを利用しています。

func (t *Time) UnmarshalJSON(data []byte) error

出典:pkg.go.dev - time.UnmarshalJSON

time.Time型をエンコード/デコードしようとする限り、RFC3339フォーマットを利用するようになっているこれらのメソッドが使われることになってしまいます。
そのため、time.Time型をそのまま使うのをやめて、独自の時刻構造体を定義してしまいます。

type MyDate struct {
	Timestamp time.Time
}

独自構造体のエンコード/デコード挙動をカスタムする

独自構造体MyDate型ができたところで、このMyDate型がJSONエンコード・デコードされる際にはどのような処理・挙動をするのかというところを作っていきましょう。
そのためには、MyDate型のMarshalJSONメソッド・UnmarshalJSONメソッドを作っていけばOKです。

JST(日本標準時)を扱う場合

MarshalJSONメソッドは、JSONエンコード時に呼ばれるメソッドです。
そのため、ここでは「MyDate構造体のTimestampフィールドを、エンコード時に使いたいフォーマットに文字列変換して返す」ように作ります。

func (d MyDate) MarshalJSON() ([]byte, error) {
	return []byte(fmt.Sprintf(`"%s"`, d.Timestamp.Format("2006/01/02 15:04:05.000 -0700"))), nil
}

UnmarshalJSONメソッドは、JSONデコード時に呼ばれるメソッドです。
そのため、ここでは「受け取った入力を、使いたいフォーマットを利用してパースして、MyDate構造体のTimestampフィールドに収める」ように作ります。

func (d *MyDate) UnmarshalJSON(data []byte) error {
	t, err := time.Parse(`"2006/01/02 15:04:05.000 -0700"`, string(data))
	if err != nil {
		return err
	}
	d.Timestamp = t
	return nil
}

UTC(協定標準時)の場合

実行環境のタイムゾーンとは異なる時刻を扱う場合も同様に考えてみましょう。

var (
	timeTime time.Time = time.Date(2022, 4, 1, 0, 0, 0, 0, time.UTC)

	// RFC3339のフォーマット
	// strTime  string    = "2022-04-01T09:00:00+09:00"
	// jsonTime string    = `{"timestamp":"2022-04-01T09:00:00+09:00"}`

	// 独自の時刻文字列フォーマット
	strTime  string = "2022/04/01 00:00:00.000 +0000"
	jsonTime string = `{"timestamp":"2022/04/01 00:00:00.000 +0000"}`
)

MarshalJSONメソッド内で使用しているtime.Time.Formatメソッドは、特に実行環境のタイムゾーンに依存した挙動をすることはないため問題ありません。
しかし、UnmarshalJSONメソッド内で使用しているtime.Parse関数は実行環境のタイムゾーンによって挙動が変わります。

そのため、MyDate型のUnmarshalJSONメソッドで使用する関数を、time.Parse関数からtime.ParseInLocation関数に変えましょう。

func (d *MyDate) UnmarshalJSON(data []byte) error {
	t, err := time.ParseInLocation(`"2006/01/02 15:04:05.000 -0700"`, string(data), time.UTC)
	if err != nil {
		return err
	}
	d.Timestamp = t
	return nil
}

time.Time型 <-> JSON文字列の変換関数を修正

これで、独自フォーマットでのエンコード・デコードに対応したMyDate構造体の準備ができました。
ここからは、JSONエンコード・デコード時にこのMyDate型を使うように、変換関数を修正します。

// time.Time型 -> JSON文字列への変換関数
func time2json(t time.Time) string {
	type MyStruct struct {
		// ここをtime.Time型からMyDate型に変更
		Timestamp MyDate `json:"timestamp"`
	}
	b, _ := json.Marshal(MyStruct{MyDate{t}})
	return string(b)
}

// JSON文字列 -> time.Time型への変換関数
func json2time(t string) time.Time {
	type MyStruct struct {
		// ここをtime.Time型からMyDate型に変更
		Timestamp MyDate `json:"timestamp"`
	}
	var myStc MyStruct
	json.Unmarshal([]byte(t), &myStc)
	// 返り値にはMyStruct構造体のTimestampフィールドを採用する
	return myStc.Timestamp.Timestamp
}

これで、独自フォーマットを利用したJSONエンコード・デコードの実装は完了です。

func main() {
	var timeTime = time.Date(2022, 4, 1, 9, 0, 0, 0, time.Local)
	fmt.Println(time2json(timeTime))
	// {"timestamp":"2022/04/01 09:00:00.000 +0900"}
	// 独自フォーマットがJSONに使われる

	var jsonTime string = `{"timestamp":"2022/04/01 09:00:00.000 +0900"}`
	fmt.Println(json2time(jsonTime))
	// 独自フォーマットの時刻文字列をデコードできている
	// 2022-04-01 09:00:00 +0900 JST
}

まとめ

というわけで、Goでの時刻表現と、それらを変換するための処理方法一覧を紹介してきました。

タイムスタンプや時刻というのは、タイムゾーンや時差、サマータイムや秒数の単位はミリなのかナノなのか等、考えることが多くなかなか悩まされることが多い概念です。
この記事とチートシートで、処理の本質部分ではない変換部分はさくっと終わらせて、開発者が本来頭を使うべき上記の非機能要件に集中できるようになれれば幸いです。

Discussion

budougumi0617budougumi0617

好みの問題かもしれませんが、UnmarshalJSON をカスタマイズするときはDefined typeでも十分かなと思います。
https://go.dev/play/p/gVsVA8ZWg4O

// Defined type
type MyDate time.Time

func (d *MyDate) UnmarshalJSON(data []byte) error {
	t, err := time.ParseInLocation(`"2006/01/02 15:04:05.000"`, string(data), newyork)
	if err != nil {
		return err
	}
	*d = MyDate(t)
	return nil
}

あと、本筋とは関係ないですが *time.Location 変数を参照するたびに無名関数経由で毎回time.LoadLocation関数を呼び出すと毎回OSにシステムコールしてパフォーマンスに影響があるような気がしてます。

newYork *time.Location = func() *time.Location {
		location, _ := time.LoadLocation("America/New_York")
		return location
	}()
さき(H.Saki)さき(H.Saki)

コメントありがとうございます!

好みの問題かもしれませんが、UnmarshalJSONをカスタマイズするときはDefined typeでも十分かなと思います。

time.Time型をラップするときにわざわざ構造体を選んだのは、メソッドの委譲をしたかったからです。
MarshalJSONメソッドの方でtime.Time.Formatメソッドをガッツリ使っており、defined typeですとこれができなくなるのを嫌った形になります。

type MyDate struct {
	Timestamp time.Time
}

func (d MyDate) MarshalJSON() ([]byte, error) {
	return []byte(fmt.Sprintf(`"%s"`, d.Timestamp.Format("2006/01/02 15:04:05.000 -0700"))), nil
}

Unmarshalだけであれば、おっしゃる通りdefined typeで十分だと思います。

*time.Location変数を参照するたびに無名関数経由で毎回time.LoadLocation関数を呼び出すと毎回OSにシステムコールしてパフォーマンスに影響があるような気がしてます。

これは完全に私の考慮抜けでした、指摘ありがたいです。
これはinit関数でpackage呼び出し時に初期化するのが一番いいのでしょうかね…?

var (
	newYork  *time.Location
	timeTime time.Time
	unixTime int64  = 1648771200
	strTime  string = "2022-03-31T20:00:00-04:00"
	jsonTime string = `{"timestamp":"2022-03-31T20:00:00-04:00"}`
)

func init() {
	location, _ := time.LoadLocation("America/New_York")
	newYork = location

	timeTime = time.Date(2022, 3, 31, 20, 0, 0, 0, newYork)
}
budougumi0617budougumi0617

MarshalJSONメソッドの方でtime.Time.Formatメソッドをガッツリ使っており、defined typeですとこれができなくなるのを嫌った形になります。

defined typeですとこう書けますね。 time2json 関数みたいなところで使うときに、 MyStruct{MyDate{t}}MyDate(t) になってスッキリするかなーって。

https://go.dev/play/p/MjoQnnyoRU3

// Defined type
type MyDate time.Time

func (d MyDate) MarshalJSON() ([]byte, error) {
	return []byte(fmt.Sprintf(`"%s"`, time.Time(d).Format("2006/01/02 15:04:05.000 -0700"))), nil
}

func time2json(t time.Time) string {
	b, _ := json.Marshal(MyDate(t))
	return string(b)
}
budougumi0617budougumi0617

これはinit関数でpackage呼び出し時に初期化するのが一番いいのでしょうかね…?

  • init 関数を乱用すると初見殺しのコードになるので避けがち(init関数使っているとエラー出す静的解析もある)
  • webアプリケーションならば起動時に取得すればよい

…って感じなので私は main 関数らへん(リクエストを受け付ける前の起動時の処理)で初期化することが多いです。


テストコード上ならばtime.FixedZone 関数使って初期化するのがおすすめです。この関数は素朴に構造体を初期化するだけなので。
https://pkg.go.dev/time#FixedZone

SpiegelSpiegel

私も struct でラップする派ですね。ちょっと前まではよく見かけたコードなんですけど,最近は違うのでしょうか。
日付だけを使いたいときとかによくやります。

var (
    jstoffset = int64((9 * time.Hour).Seconds())
    JST       = time.FixedZone("JST", int(jstoffset)) // Japan standard Time
)

//DateJp is wrapper class of time.Time
type DateJp struct {
    time.Time
}

//NewDate returns DateJp instance
func NewDate(tm time.Time) DateJp {
    if tm.IsZero() {
        return DateJp{tm}
    }
    ut := tm.Unix()
    _, offset := tm.Zone()
    return DateJp{time.Unix(((ut+int64(offset))/86400)*86400-jstoffset, 0).In(JST)}
}

(via koyomi/date.go at master · goark/koyomi)

こんな感じのコードを作っておいてコピペで使いまわしてますw

budougumi0617budougumi0617

たしかに、昨夜は UnmarshalJSON/MarshalJSON しか考えてませんでしたが、他のもろもろの操作するならばEmbeddedして time.Time のメソッドが使えたほうが良いですね mm