Go time.Timeの扱いで学んだこと

今回は本業で開発しているGoのAPIで時刻の概念をドメインとして扱ったのでその時に学んだことを復習したいと思います。

TL;DR

要件はDateTimeのような日付ではなく、時刻のみを扱い比較した結果に応じてビジネスロジックを振り分けるようなイメージです。

以下の順でユースケースごとに振り返ります。

  • MySQLのTIME型を使いたい
  • stringをtime.Timeに変換したい
  • tzをどの環境でも同じにしたい
  • Before, After で時間を比較したい

MySQLのTIME型を使いたい

開発中のプロダクトでは gormとMySQLを使用しています。

時刻のみを扱いたかったのでMySQLのTIME型 (12:00:00 )にしました。

parseTime=True オプションを付ければDATETIME型をスキャンできるみたいなのですが、どうやらTIME型はスキャンできないみたいです。

そこでDBの型を変えるかAPIの型を変えるかの二択だと思うのでですが、DBに余計な情報を入れたくなかったので後者を選びGoの型をstringに変更しました。

Go(string) ✖︎ MySQL(TIME)では上手いことスキャンしてくれました。

stringをtime.Timeに変換したい

スキャンする時はstringにしているものの、時刻である以上time.Time型にしたほうが色々と便利メソッドを使えそうです。

DB TIME型 → API String → API time.Time

こちらもDateTimeのように日付と共に扱えばtimeパッケージでいい感じに変換してくれるのですが時刻のみが正しければよかったので以下のような関数を作りました。

func StrToTimeJST(clock string) time.Time {
    t := time.Now()
    splitted := strings.Split(clock, ":")
    hour, _ := strconv.Atoi(splitted[0])
    min, _ := strconv.Atoi(splitted[1])
    parsed := time.Date(t.Year(), t.Month(), t.Day(), hour, min, 0, 0, tz)
    return parsed
}

DBには必ず 12:00:00の形で保存されるので 日付は常にtime.Now()を取得するようにします。

この関数で作った値を比較するときに時刻以外はすべて同じになるということです。

tzをどの環境でも同じにしたい

ここまでの実装だと問題がありました。 Docker環境とPCのローカル環境でTimeZoneが違いテストに落ちていました。

TimeZoneを追加して対応しました。

func StrToTimeJST(clock string) time.Time {
    tz, _ := time.LoadLocation("Asia/Tokyo")
    t := time.Now().In(tz)
    splitted := strings.Split(clock, ":")
    hour, _ := strconv.Atoi(splitted[0])
    min, _ := strconv.Atoi(splitted[1])
    parsed := time.Date(t.Year(), t.Month(), t.Day(), hour, min, 0, 0, tz)
    return parsed
}

timeパッケージのtime.LoadLocation()メソッドでtime.Location型を作るのですがこの扱いで迷ったポイントがありました。

time.Time型には In()メソッドでTimeZoneを付与できます。

time.Date(t.Year(), t.Month(), t.Day(), hour, min, 0, 0,  time.Local).In(tz)

ただ、上記だと Date()メソッドのtime.Localが優先されるみたいで

time.Date(t.Year(), t.Month(), t.Day(), hour, min, 0, 0, tz)

この書き方が正しかったみたいです。

Before, After で時間を比較したい

ここではtime.Time型に変換した旨味が得られます。

timeパッケージのAfter(), Before()メソッドが使えました。

func InTimeSpan(start, end, check time.Time) bool {
    return !start.After(check) && check.Before(end)
}

上記は start <= now < endと同意です。

AfterやBeforeは比較対象が全く同じtime.Timeだとfalseを返すので 以上, 以下 の表現はエクスクラメーションを使います。

!start.After(check)

これで同時刻でもtrueを返せます。

今回は以上となります。

時刻の概念を扱う時は言語やDBによって癖があるので毎回ベストプラクティスがを探している気がします。

個人的には言語で1番人気のライブラリに型を合わせて扱いたいです。Goだとtimeパッケージ、JavaScriptだとMomentを使ったことがあります。(確かMomentは開発中止されたらしい) Goで CreateAtのようなよくある日付の概念は扱った事があったのですが、時刻を中心としたビジネス要件を初めて設計したのでハマりどころがたくさんありました。もっとtimeパッケージを使い倒したい。。

最後に、 このブログではweb開発について発信していくのでまたご覧頂けると嬉しいです。 最後までお読み頂きありがとうございました。

追記

この記事を書いた後にもっとtimeパッケージを知りたいと思って色々調べていたら

string型からtime.Time型に変換するにはこのほうが綺麗だなと思いました。

func StrToTimeJST(clock string) time.Time {
    const format = "15:04:00"
    jst, _ := time.LoadLocation("Asia/Tokyo")
    parsed, _ := time.Parse(format, clock)
    return parsed.In(jst)
}

このほうがGoっぽい。

しかしこれだとJSTではなくLMTになる問題があるみたいです。

www.m3tech.blog

結局時刻はUTCに合わせるのが1番扱いやすそう。