Goで「一か月以上先」を表現したかった

Goのtime packageを使って、「一か月以上先」を表現したかったときにはまったポイントを振り返ったら、時刻処理の沼にはまったのでまとめます。

はじめに

プログラムを書いていると、「期間が1か月以上なら」のような条件を取り扱うことはよくあると思います。
割とわかりにくいミスをしたので、その備忘録もかねて記録を残しておきます。

前提

Goで年月を表現するとき、timepackageのtime.Time型を使うのが一般的だと思っています。
ただ、time.Time型としっかり向き合ったことは無い方も多いのではないでしょうか。私はその一人で、なんとなくで使っていました。
とくに、time.Durationとの違いを分かっていませんでした。

遭遇したバグ

今回遭遇したバグは以下の処理です。

// 1か月以上の場合は7日間に丸める
    // util.CreateXxxAtはtime.Time型の引数を受け取って、Timezoneに合わせた時刻にフォーマットしたcarbon.Carbon型のインスタンスを返す関数
    startAt := util.CreateStartAt(input.StartAt, 0, input.Timezone, input.UnitDay).ToStdTime()
    endAt := util.CreateEndAt(input.EndAt, 24, input.Timezone, input.UnitDay).ToStdTime()

    if endAt.Sub(startAt) > time.Hour*24*31{
        now := time.Now()
        targetStart := now.AddDate(0, 0, -7)
        startAt = util.CreateStartAt(targetStart, 0, input.Timezone, input.UnitDay).ToStdTime()
        endAt = util.CreateEndAt(now.AddDate(0, 0, -1), 24, input.Timezone, input.UnitDay).ToStdTime()
    }

ぱっと見動きそうですが、いくつか引っ掛かる点がありますね。

これを実装したときの気持ちになると、おそらくSubの返り値がtime.Durationだったため、比較にはtime.Hourを使って何とか1か月を表現したかったのかなと。
ただ、これだと正確に「1か月」は表現できていません。28日の月もあるし、30日の月もあります。
これは要件次第なとこはあると思っていて、厳密に1か月ではなく、「まあ31日以上のデータは重くなるからはじいとこ」くらいの気持ちでの実装にも見えます。

そんな感じで、ふわっと動きそうに見えますね。
見えますが、動きませんでした。見事に31日の月まで7日に丸められていました。

なんでや・・・って感じですが、原因究明のためにtimepackageを深ぼっていきたいと思います。

time.Timeとはそもそも何?

ふわっと使っている状況から脱却するには、ドキュメントを読むべし、ということでGoのドキュメントに目を通していきます。
パッケージの概要は、

Package time provides functionality for measuring and displaying time.

とあるように、時間の計測と表示のためのパッケージのようです。

よく使う関数としては、 time.Now() time.Since() time.Sleep() あたりでしょうか。処理時間の計測やサンプルコードでよく見るイメージがありますね。

time.Now()が返すのは、time.Time型です。
中身を見ていくと、以下のような構造体になっています。

type Time struct {
	wall uint64
	ext  int64
	loc *Location
}

locはまだわかります。タイムゾーンっぽいですね。ただ、wallextは何者?って顔になります。

コメントをみると、以下のように書いてあります。

wall and ext encode the wall time seconds, wall time nanoseconds, and optional monotonic clock reading in nanoseconds.

直訳すると、

wallとextは、秒単位のウォールタイム、ナノ秒単位のウォールタイム、そしてオプションのナノ秒単位の読み取り用モノトニッククロックをエンコードする。

といった感じでしょうか。ちんぷんかんぷんです。

そもそもウォールクロックとはって話ですが、その名の通りウォールクロックを表す時間、つまり我々の普段使う時間そのもののことです。
文中でウォールクロックと対比されている概念がモノトニッククロックですが、こちらは”モノトニック”が表すように、単調増加の時間を表しています。ストップウォッチでよくたとえられますね。

時間を表すために2つの概念が出てきました。なぜそんなことになっているのでしょうか?
理由は単純、ウォールクロックはコンピューターによって補正されうる値で、時が巻き戻る可能性があります。以下の記載がある通り、モノトニッククロックであれば、たとえウォールクロックが巻き戻ろうと常に同じ値を算出することができます。

The general rule is that the wall clock is for telling time and the monotonic clock is for measuring time.

注意点としては、モノトニッククロックをもつTime型の構造体にAddDate(y, m, d)などのメソッドを使用すると、モノトニッククロックはストリップされます。
t.Subなどの時間を比較するメソッドに関しては、モノトニッククロックで比較を試みますが、もし存在しない場合はウォールクロックでの比較になります。
また、コンピューターがスリープに入ったときにモノトニッククロックが止まるようなシステムでは、t.Subなどは正確な結果を返せない可能性があるみたいです。そのようなケースではモノトニッククロックをストリップするようにとのことですが、もし実務で遭遇したら迷宮入りしそうなにおいがしますね・・・

また、Time型のインスタンスを比較する際に==を使うと、モノトニッククロックやLocationの比較も行われてしまい意図した結果にならないケースもあります。

直感的な時刻比較(ロケーションも無視したウォールクロックどうし)を行いたい場合、t.Equalを使うというのはこういう理由があるみたいです。とても面白いですね。

ここまではtime.Timeについて深ぼりました。 そのうえで、原因を探っていきましょう。

原因はなんだったのか

前のセクションでtime.Timeにはモノトニッククロックとウォールクロックが含まれていることが分かりました。
今回のケースでは、util.CreateXxxAtcarbon.Carbonを返却し、その値をtime.Time型にパースする際にモノトニッククロックが欠落していそうです。

そのため、endAt.Sub(startAt)はウォールクロックでの比較になる・・・あれ、これだと特に問題は起きない気がします。内部処理的にはナノ秒まで一致しているので、この比較そのものに処理的な問題は起きません。

調べてみると、内部処理的にはナノ秒まで一致しているのでという認識が間違っていました。
実装内で、carbonインスタンスを生成する際、ナノ秒のリセットを行っておらず。time.Hour*24*31で比較するとナノ秒ぶん超過して意図しない動作になっている、というのが原因でした。

原因は究明できたものの、月の比較にtime.Durationを使うのも違和感が残ります。
なので、time.Durationを深ぼりつつ、月の比較時の処理について見直してみます。

time.Durationとは

ドキュメントによると、

A Duration represents the elapsed time between two instants as an int64 nanosecond count.

time.Timeインスタンス間の経過時間をナノ秒で表した型のようです。最大で290年。一般的な使用では290年以上が必要になることはなさそうですが、超える場合には注意したいですね。

経過時間を表す型なので、t.Subを使用したときの返り値の型に使われます。
time.Durationには月を表す方法がないので、今回のように比較対象である「1か月」という概念をtime.Durationに寄せる必要があり、time.Hourというtime.Duration型の定数を使用して比較を行っていました。

time.goのコメントにある通り、time.Durationは日・月・年の計算で使うことは想定されていなさそうです。そのため、定数もtime.Hourまでしか用意されていません。

ここまでかくと、そもそも経過時間を表現するための型で、「1か月より長いか」という時間という概念とは少し差のある比較を行おうとしていたところがズレていそうです。

結局、月を比較するときにはどうすればよかったのかを考えてみたいと思います。

Goで「1か月以上」を表現するにはどうすればよかったのか

ここまで調べて、time.Timetime.Durationの解像度が少し上がりました。 結局どうすればスマートに「一か月以上先」を表現できたのでしょうか。

time.Durationを経由せずに、月を比較する必要がありそうです。なので、t.Subではなく、t.Afterまたはt.Beforeを使用しましょう。

// やりたいこと:startAtとendAtの差が一か月以上か確認したい

// startAtに1か月を足した日付がEndAtより過去だったらそれは「1か月以上」である
if startAt.AddDate(0, 1, 0).Before(endAt)

// Afterでも表現可能
if endAt.After(startAt.AddDate(0, 1, 0))

直感的なのは前者でしょうか。年月日の処理には、AddDateAfter, Beforeといった関数を使う、と覚えておけばとりあえずは大丈夫そうです。
ただ、AddDateは月末の処理に注意が必要です。そもそも「1か月以上」の定義がはっきり(カレンダー上での1か月なのか、31日以上なのか 等)していないと、また思わぬ落とし穴にはまる可能性があります。

また、AddDateAddの違いについても今回調べたことで少し輪郭がはっきりしたと思います。

timeはGoでwebアプリケーションを作る以上、必ずと言っていいほど使用するパッケージだと思います。
今回のように、深ぼることを通して解像度を上げていきたいものですね。

それでは。

ブログ一覧へ戻る