emahiro/b.log

Drastically Repeat Yourself !!!!

time.IsZero()の挙動でハマった話

サマリ

  • goのtimeパッケージの IsZero() はUnixTime = 0ではない
  • GAEのdatasotreのdefaultの時刻で IsZero() を使ってもtrueを返さない

IsZero()メソッドについて

refs: time package - time - pkg.go.dev

IsZero reports whether t represents the zero time instant, January 1, year 1, 00:00:00 UTC. 

IsZero()は 01-01 00:00:00 UTC の時にtrueを返します。
ここで注意するべきはtrueを返す時刻はunixtimeのスタート時刻 1970-01-01 00:00:00 +0000 UTC を指し示すわけではないということでです。

実際の挙動を見てみます。

def := time.Time{}
fmt.Printf("%v\n", def)
fmt.Printf("%v\n", def.IsZero())
// output
// 0001-01-01 00:00:00 +0000 UTC
// true

time.Time{} は何もない時刻をinstance化する、すなわちtimeパッケージにおける標準時刻をinstance化することですが、これの結果は 0001-01-01 00:00:00 +0000 UTC という時刻がinstance化され、IsZero() はこの時刻のときのみtrueを返します。

udef, _ := time.Parse("2006-01-02 15:04:05 -0700", "1970-01-01 00:00:00 +0000")
fmt.Printf("%v\n", udef)
fmt.Printf("%v\n", udef.IsZero())
// output
// 1970-01-01 00:00:00 +0000 +0000
// false

一方でコンピューターにおける時刻ゼロとはunixtimeの1番最初だと想起できるので、unixtimeのスタートした時刻に対して IsZero() をcallすると、unixtimeのstartの時刻にもかかわらず false を返します。

timeパッケージの中身を見てみると

type Time struct {
    // sec gives the number of seconds elapsed since
    // January 1, year 1 00:00:00 UTC.
    sec int64

    // nsec specifies a non-negative nanosecond
    // offset within the second named by Seconds.
    // It must be in the range [0, 999999999].
    nsec int32

    // loc specifies the Location that should be used to
    // determine the minute, hour, month, day, and year
    // that correspond to this Time.
    // Only the zero Time has a nil Location.
    // In that case it is interpreted to mean UTC.
    loc *Location
}

とあり、そもそもの sec = 0 の時には January 1, year 1 00:00:00 UTC. が初期値設定されています。
IsZero() については

// IsZero reports whether t represents the zero time instant,
// January 1, year 1, 00:00:00 UTC.
func (t Time) IsZero() bool {
    return t.sec == 0 && t.nsec == 0
}

とあるので、そもそもunixtime=0を返さないのはgoのtimeパッケージの仕様のようです。

GAE上での挙動について

さて、ここで困ったのがGAEでDatastore上に time.Time 型で標準時刻をinstance化した時のことです。

以下のようなstructを考えてみます。

type App struct {
  ID         int       `datastore: "ID"         json: "id"`
  CreatedAt  time.Time `datastore: "createdAt"  json: "created_at"`
  UpdatedAt  time.Time `datastore: "updatedAt"  json: "updated_at"`
  ReleasedAt time.Time `datastore: "ReleasedAt" json: "released_at"`
}

この App Entityがcreateされた時に CreatedAtUpdatedAt はそれぞれcreateされた時刻が入りますが リリースされたわけではないので、 ReleasedAt には何も入りません。
つまり、 ReleasedAt のfieldには time.Time{} が入ってくることを期待してました。
しかし実際には 1970-01-01 00:00:00 +0900 JST という日本標準時のでのunixtime = 0の状態が入っていました。

つまり、 ReleasedAt に一度しか値を入れたくない、みたいな要件があったときに

if !app.ReleasedAt.UTC().IsZero() {
    // ReleasedAtにすでに値が入っている時
} else {
    // ReleasedAtに初回に値が入る    
}

上記のような条件分岐を考慮した場合、どんなときでも else 以下に入ってしまいます。
理由は上記で述べた通り、 unixtime のスタートはtimeパッケージで IsZero 判別するときには false を返してしまうからです。

ではどうすればいいかというと、実は unixtimeの最初の状態を作り出した time オブジェクトのunixtimeを取ると 0 になります。

udef, _ := time.Parse("2006-01-02 15:04:05 -0700", "1970-01-01 00:00:00 +0000")
fmt.Printf("%v\n", udef)
fmt.Printf("%v\n", udef.UTC().Unix())
// output
// 1970-01-01 00:00:00 +0000 +0000
// 0

これを利用して上記の条件分岐を以下のように書き換えます。

if app.ReleasedAt.UTC().Unix() != 0 {
    // ReleasedAtにすでに値が入っている時
} else {
    // ReleasedAtに初回に値が入る    
}

app.ReleasedAt.UTC().Unix() とすることで、すでに ReleasedAt に値が入ってきている場合は、 Unix() でunixtimeに変換した時に 0以外 が入ってくる事になります。

まとめ

timeパッケージにおける IsZero() の挙動とGAEのDatastoreでデフォルトの時刻を unixtime = 0 判定を同様に考えてきて、かなりハマりました。
IsZero() がunixtimeのstart時刻を示さないのはどうにも納得が行きませんが、timeパッケージ的にはどうしようもなさそうなので、注意しようと思いました。

追記

このエントリを書いてから 4年以上経って言及されるとは思ってなかったですが、 いい感じに答えたが書いてあって参考にしたいなと思いました。