見出し画像

Go で入門する時間停止の世界

この記事は、NAVITIME JAPAN Advent Calendar 2024の 9 日目の記事です🎄


こんにちは、革靴です。
ナビタイムジャパンで、電鉄・バス事業者向けサービスの開発・運用を担当しています。

当社は社名のとおり、時間に関するサービスを多く提供しており、サービス内でも時間を扱う場面が頻繁にあります。今回は、プログラミングにおける「現在時刻」の計算について、表面上は簡単そうに見えて意外と奥が深いこのテーマを、Go 言語を用いた実装の観点から考えてみたいと思います。


現在時刻の取得

Go で現在時刻を取得する場合、一般的に標準の time パッケージの Now 関数を利用すると思います。
Now 関数はシステムローカルの現在時刻を取得するので、In メソッドと併用し、タイムゾーンを明示的に指定することを推奨します。

now := time.Now()

loc, _ := time.LoadLocation("Asia/Tokyo")
nowInJPN := time.Now().In(loc)

 紹介のために、タイムゾーンを取得する際に発生するエラーを省略していますが、実際に利用する際は、エラーハンドリングが必要です。

現在時刻に依存した実装

当社のいくつかのサービスには、「バス接近情報」と呼ばれる機能が存在し、バスのリアルタイムデータから、バス停留所にあとどのくらいで到着するのかという情報(以後「残り時間」と呼称)を案内しています。

残り時間は、バスのリアルタイムデータから、現在時刻を差し引いて、実装することができます。

Go での実装例

以下は、引数に渡されたリアルタイムデータから、現在時刻を差し引いて、残り時間を算出する関数です。

func RemainingTime(realtime time.Time) time.Duration {
	loc, _ := time.LoadLocation("Asia/Tokyo")
	now := time.Now().In(loc)

	return realtime.Sub(now)
}

Sub メソッドによって、time パッケージの Duration 型となり、ただの数値より、時間の単位を扱いやすくなるところが Go の利点です。

テストコード

バスのリアルタイムデータは常に変化していき、動作検証が難しくなりやすいポイントなので、RemainingTime 関数は簡単な実装ですが、テストコードを書いて、テストしましょう。

func TestRemainingTime(t *testing.T) {
    // 2024-12-09T10:00:00+09:00
    realtime := time.Date(2024, time.December, 9, 10, 0, 0, 0, time.FixedZone("Asia/Tokyo", 9*60*60))

	// want := ??

	got := RemainingTime(realtime)
	if got != want {
		t.Errorf("there is a difference between got and want. got=%d, want=%d", got, want)
	}
}

ところが、テスト対象の関数が現在時刻に依存しており、テストを実行する度に、期待値が変わってしまうので、テストすることが難しいことに気付きます。

現在時刻に依存するテストは「ユニットテストは Repeatable であるべし」という原則に反しているのです。

現在時刻が関わるユニットテストから、テスト容易性設計を学ぶ

テストを Repeatable にするアプローチ

前述した RemainingTime 関数に対するテストを Repeatable にするためには、何らかの方法で、現在時刻を固定する必要があります。

今回は、Go ならではの方法として、コンテキストを利用したアプローチを紹介したいと思います。

Go の関数・メソッドでは、第 1 引数に context パッケージの Context 型の値を取ることが慣習的に多いです。
RemainingTime 関数の入力をコンテキストを受け取るように変更するだけで済み、コンテキストは現在時刻以外の目的にも利用できるので、このアプローチを選定しました。

関数の引数にコンテキストを追加

RemainingTime 関数の第 1 引数にコンテキストを追加するように修正します。

func RemainingTime(ctx context.Context, realtime time.Time) time.Duration {
	// TODO: ctx から現在時刻を取得できるようにする
	return realtime.Sub(now)
}

あとは、コンテキストから現在時刻を取得できるようにするだけです。

コンテキストから現在時刻を取得

自前で実装してもいいですが、synchro というサードパーティパッケージを紹介したいと思います。

synchro は、標準の time パッケージには存在しない以下の機能が提供されています。

  1. タイムゾーンまで含めて、時刻を型で表現

  2. ISO 8601 の完全サポート

  3. 便利なユーティリティ

3 の側面において、以下 2 つの関数が存在し、コンテキストから現在時刻を取得する機能が提供されています。

  • NowWithContext:引数に渡されたコンテキストの値に、同じく引数に渡された現在時刻を設定

  • NowContext:引数に渡されたコンテキストの値から、設定された現在時刻を取得

こちらを利用して、RemainingTime 関数をさらに修正します。

import (
	"context"
	"time"

	"github.com/Code-Hex/synchro"
	"github.com/Code-Hex/synchro/tz"
)

func RemainingTime(ctx context.Context, realtime time.Time) time.Duration {
	// ctx に現在時刻が設定されている前提
	now := synchro.NowContext[tz.AsiaTokyo](ctx).StdTime()
	return realtime.Sub(now)
}

NowContext 関数で取得した現在時刻は、synchro パッケージの Time 型であり、StdTime メソッドを利用して、標準の time パッケージの Time 型に変換できます。

引数 realtime も synchro パッケージの Time 型に変更すれば、変換は不要になりますが、今回は紹介のために、標準の time パッケージの Time 型のままにしています。

実装コードは修正完了したので、テストコードも修正します。
テストコードでは、NowWithContext 関数を利用して、現在時刻を固定します。

import (
	"context"
	"testing"
	"time"

	"github.com/Code-Hex/synchro"
	"github.com/Code-Hex/synchro/tz"
)

func TestRemainingTime(t *testing.T) {
	ctx := context.Background()

	// 2024-12-09T09:55:00+09:00
	now := time.Date(2024, time.December, 9, 9, 55, 0, 0, time.FixedZone("Asia/Tokyo", 9*60*60))
	ctx = synchro.NowWithContext(ctx, synchro.In[tz.AsiaTokyo](now))

	// 2024-12-09T10:00:00+09:00
	realtime := time.Date(2024, time.December, 9, 10, 0, 0, 0, time.FixedZone("Asia/Tokyo", 9*60*60))

	// 5分
	want := time.Duration(5 * time.Minute)

	got := RemainingTime(ctx, realtime)
	if got != want {
		t.Errorf("there is a difference between got and want. got=%d, want=%d", got, want)
	}
}

引数 realtime が標準の time パッケージの Time 型です。型を統一した方が見読みやすくなるので、今回の実装では現在時刻も標準の time パッケージの Time 型で定義しています。その後、synchro パッケージの In 関数を利用して、synchro パッケージの Time 型に変換しています。

今度は、期待値を定義することができ、Repeatable なテストになりました。

テストコードを Table Driven Tests に変更すれば、様々なパターンの現在時刻で、テストすることも可能です。

import (
	"context"
	"testing"
	"time"

	"github.com/Code-Hex/synchro"
	"github.com/Code-Hex/synchro/tz"
)

func TestRemainingTime(t *testing.T) {
	tests := map[string]struct {
		now      time.Time
		realtime time.Time
		want     time.Duration
	}{
		"realtime>now": {
			now:      time.Date(2024, time.December, 9, 9, 55, 0, 0, time.FixedZone("Asia/Tokyo", 9*60*60)),
			realtime: time.Date(2024, time.December, 9, 10, 0, 0, 0, time.FixedZone("Asia/Tokyo", 9*60*60)),
			want:     time.Duration(5 * time.Minute),
		},
		"realtime=now": {
			now:      time.Date(2024, time.December, 9, 10, 0, 0, 0, time.FixedZone("Asia/Tokyo", 9*60*60)),
			realtime: time.Date(2024, time.December, 9, 10, 0, 0, 0, time.FixedZone("Asia/Tokyo", 9*60*60)),
			want:     time.Duration(0),
		},
		"realtime<now": {
			now:      time.Date(2024, time.December, 9, 10, 5, 0, 0, time.FixedZone("Asia/Tokyo", 9*60*60)),
			realtime: time.Date(2024, time.December, 9, 10, 0, 0, 0, time.FixedZone("Asia/Tokyo", 9*60*60)),
			want:     -time.Duration(5 * time.Minute),
		},
	}

	for name, tt := range tests {
		t.Run(name, func(t *testing.T) {
			ctx := context.Background()
			ctx = synchro.NowWithContext(ctx, synchro.In[tz.AsiaTokyo](tt.now))

			got := RemainingTime(ctx, tt.realtime)
			if got != tt.want {
				t.Errorf("there is a difference between got and want. got=%d, want=%d", got, tt.want)
			}
		})
	}
}

現在時刻はどこで固定するのか?

テストコードで NowWithContext 関数を利用しましたが、実装コードの場合、どこで呼び出せばいいのでしょうか?

可能な限り、エントリーポイントに近いところで、呼び出した方がいいと私は思います。

  • CLI の場合、main 関数内

  • 標準の net/http パッケージやサードパーティの Web フレームワークを利用して、Web サーバーを実装している場合、ミドルウェア層

以下は、標準の net/http パッケージにおいて、ミドルウェア層で現在時刻を固定する実装例です。

import (
	"net/http"

	"github.com/Code-Hex/synchro"
	"github.com/Code-Hex/synchro/tz"
)

func Now(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()

		loc, _ := time.LoadLocation("Asia/Tokyo")
		now := time.Now().In(loc)

		ctx = synchro.NowWithContext(ctx, synchro.In[tz.AsiaTokyo](now))

		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

ミドルウェア層で現在時刻を固定できると、後続にデータストアへの書き込みが存在している場合、リクエストのタイムスタンプと、書き込みのタイムスタンプを同じにできる利点があります。

さらに、パラメータから現在時刻を渡して、外部から現在時刻を固定することにも応用できます。

最後に

現在時刻を固定する方法は、Go 以外の言語でも当然利用可能です。たとえば、C++ の場合については、こちらの記事で詳しく解説していますので、ぜひご覧ください。

さらに、synchro パッケージについては、作者の方が以下の資料で詳しく紹介しています。この資料には、今回取り上げられなかった便利な機能も数多く掲載されていますので、ぜひチェックしてみてください。私自身も非常に参考にさせていただいています。
日時処理の新スタンダード - synchro によるタイムゾーン安全な開発

プログラミングの世界ではありますが、時間を停止する力を得ることができました。
この力を有益な 時間 が生まれるサービスの開発に、役立てていきたいと思います。