コンテキスト

Context (長期実行プロセスの管理に役立つパッケージ)

この章のすべてのコードはここにあります

ソフトウェアは、多くの場合、長時間実行され、リソースを大量に消費するプロセスを開始します(多くの場合、ゴルーチンで)。これを引き起こしたアクションがキャンセルされるか、何らかの理由で失敗した場合は、アプリケーションを通じてこれらのプロセスを一貫した方法で停止する必要があります。

これを管理しないと、非常に誇りに思っているキレの良いGoアプリケーションは、パフォーマンスの問題のデバッグが困難になる可能性があります。

この章では、contextパッケージを使用して、実行時間の長いプロセスを管理します。

まず、ヒットしたときに長時間実行される可能性のあるプロセスを開始して、データをフェッチして応答で返すWebサーバーの古典的な例から始めます。

データを取得する前にユーザーがリクエストをキャンセルするシナリオを実行し、プロセスが中止されるように指示します。

私たちは幸せなパスにいくつかのコードを設定して始めました。 これがサーバーコードです。

func Server(store Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, store.Fetch())
}
}

関数ServerStoreを受け取り、http.HandlerFuncを返します。

Storeは次のように定義されます

type Store interface {
Fetch() string
}

返された関数は、storeFetchメソッドを呼び出してデータを取得し、それを応答に書き込みます。

テストで使用するStoreに対応するスタブがあります。

type StubStore struct {
response string
}
func (s *StubStore) Fetch() string {
return s.response
}
func TestHandler(t *testing.T) {
data := "hello, world"
svr := Server(&StubStore{data})
request := httptest.NewRequest(http.MethodGet, "/", nil)
response := httptest.NewRecorder()
svr.ServeHTTP(response, request)
if response.Body.String() != data {
t.Errorf(`got "%s", want "%s"`, response.Body.String(), data)
}
}

幸せなパスができたので、ユーザーがリクエストをキャンセルする前にStoreFetchを完了できない、より現実的なシナリオを作成したいと思います。

最初にテストを書く

私たちのハンドラーは、Storeに作業をキャンセルしてインターフェースを更新するように指示する方法が必要になります。

type Store interface {
Fetch() string
Cancel()
}

スパイを調整する必要があるので、dataを返すには時間がかかり、キャンセルするように指示されたことを知る方法があります。呼び出し方法を確認しているので、名前をSpyStoreに変更します。

Storeインターフェースを実装するメソッドとしてCancelを追加する必要があります。

type SpyStore struct {
response string
cancelled bool
}
func (s *SpyStore) Fetch() string {
time.Sleep(100 * time.Millisecond)
return s.response
}
func (s *SpyStore) Cancel() {
s.cancelled = true
}

100ミリ秒前にリクエストをキャンセルする新しいテストを追加して、ストアがキャンセルされるかどうかを確認します。

t.Run("tells store to cancel work if request is cancelled", func(t *testing.T) {
store := &SpyStore{response: data}
svr := Server(store)
request := httptest.NewRequest(http.MethodGet, "/", nil)
cancellingCtx, cancel := context.WithCancel(request.Context())
time.AfterFunc(5 * time.Millisecond, cancel)
request = request.WithContext(cancellingCtx)
response := httptest.NewRecorder()
svr.ServeHTTP(response, request)
if !store.cancelled {
t.Errorf("store was not told to cancel")
}
})

Goブログ: コンテキストContext

コンテキストパッケージは、既存のコンテキスト値から新しいコンテキスト値を導出する関数を提供します。これらの値はツリーを形成します。コンテキストが取り消されると、それから派生したすべてのコンテキストも取り消されます。

キャンセルが特定のリクエストのコールスタック全体に伝播されるように、コンテキストを派生させることが重要です。

私たちがすることは、cancel関数を返すrequestから新しいcancellingCtxを派生させることです。次に、time.AfterFuncを使用して、その関数が5ミリ秒で呼び出されるようにスケジュールします。最後に、request.WithContextを呼び出して、この新しいコンテキストをリクエストで使用します。

テストを実行してみます

テストは予想通り失敗します。

--- FAIL: TestServer (0.00s)
--- FAIL: TestServer/tells_store_to_cancel_work_if_request_is_cancelled (0.00s)
context_test.go:62: store was not told to cancel

成功させるのに十分なコードを書く

TDDの訓練を受けることを忘れないでください。テストに合格するための minimal 量のコードを記述します。

func Server(store Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
store.Cancel()
fmt.Fprint(w, store.Fetch())
}
}

これはこのテストに合格しますが、気分が良くありません! あらゆるリクエストをフェッチする前に、Storeをキャンセルしてはいけません。

懲戒処分を受けることで、テストの欠陥が明らかになり、これは良いことです!

キャンセルされないことを確認するために、幸せなパステストを更新する必要があります。

t.Run("returns data from store", func(t *testing.T) {
store := &SpyStore{response: data}
svr := Server(store)
request := httptest.NewRequest(http.MethodGet, "/", nil)
response := httptest.NewRecorder()
svr.ServeHTTP(response, request)
if response.Body.String() != data {
t.Errorf(`got "%s", want "%s"`, response.Body.String(), data)
}
if store.cancelled {
t.Error("it should not have cancelled the store")
}
})

両方のテストを実行すると、ハッピーパステストが失敗し、より賢明な実装を実行する必要があります。

func Server(store Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
data := make(chan string, 1)
go func() {
data <- store.Fetch()
}()
select {
case d := <-data:
fmt.Fprint(w, d)
case <-ctx.Done():
store.Cancel()
}
}
}

ここで何をしましたか?

contextにはメソッドDone()があり、コンテキストが「完了」または「キャンセル」されたときに信号を送信するチャネルを返します。そのシグナルをリッスンし、それを取得した場合はstore.Cancelを呼び出しますが、Storeがその前にFetchを実行した場合は無視します。

これを管理するには、ゴルーチンでFetchを実行し、結果を新しいチャネルdataに書き込みます。次に、selectを使用して2つの非同期プロセスに効率的に競合し、応答またはCancelを書き込みます。

リファクタリング♪

スパイでアサーションメソッドを作成することで、テストコードを少しリファクタリングできます。

func (s *SpyStore) assertWasCancelled() {
s.t.Helper()
if !s.cancelled {
s.t.Errorf("store was not told to cancel")
}
}
func (s *SpyStore) assertWasNotCancelled() {
s.t.Helper()
if s.cancelled {
s.t.Errorf("store was told to cancel")
}
}

スパイを作成するときは、*testing.Tを渡すことを忘れないでください。

func TestServer(t *testing.T) {
data := "hello, world"
t.Run("returns data from store", func(t *testing.T) {
store := &SpyStore{response: data, t: t}
svr := Server(store)
request := httptest.NewRequest(http.MethodGet, "/", nil)
response := httptest.NewRecorder()
svr.ServeHTTP(response, request)
if response.Body.String() != data {
t.Errorf(`got "%s", want "%s"`, response.Body.String(), data)
}
store.assertWasNotCancelled()
})
t.Run("tells store to cancel work if request is cancelled", func(t *testing.T) {
store := &SpyStore{response: data, t: t}
svr := Server(store)
request := httptest.NewRequest(http.MethodGet, "/", nil)
cancellingCtx, cancel := context.WithCancel(request.Context())
time.AfterFunc(5*time.Millisecond, cancel)
request = request.WithContext(cancellingCtx)
response := httptest.NewRecorder()
svr.ServeHTTP(response, request)
store.assertWasCancelled()
})
}

このアプローチは大丈夫ですが、慣用的ですか?

私たちのウェブサーバーが手動でStoreをキャンセルすることに関心を持つことは理にかなっていますか?Storeが他の実行速度の遅いプロセスに依存している場合はどうなりますか? Store.Cancelがキャンセルを依存するすべてに正しくキャンセルすることを確認する必要があります。

contextの主なポイントの1つは、キャンセルを提供する一貫した方法であることです。

go doc から

サーバーへの着信要求はコンテキストを作成し、サーバーへの発信呼び出しはコンテキストを受け入れる必要があります。それらの間の関数呼び出しのチェーンは、コンテキストを伝播する必要があり、オプションで、WithCancelWithDeadlineWithTimeout、またはWithValueを使用して作成された派生コンテキストに置き換えます。コンテキストがキャンセルされると、そのコンテキストから派生したすべてのコンテキストもキャンセルされます。

再びGoブログ: コンテキストContextから

Googleでは、Goプログラマーが、最初の引数として、着信要求と発信要求の間の呼び出しパス上のすべての関数にContextパラメーターを渡す必要があります。これにより、多くの異なるチームが開発したGoコードを適切に相互運用できます。タイムアウトとキャンセルを簡単に制御し、セキュリティ認証情報などの重要な値がGoプログラムを適切に通過するようにします。

(少し間を置いて、コンテキストで送信する必要があるすべての機能の影響と、その人間工学について考えてください。)

少し不安ですか?大丈夫です。 そのアプローチを試してみましょう。代わりに、contextを介して私たちのStoreに渡し、責任を持たせましょう。そうすることで、contextをその依存関係に渡すこともでき、それらも依存を停止する責任があります。

最初にテストを書く

責任が変化するにつれて、既存のテストを変更する必要があります。ハンドラーが今担当する唯一のことは、コンテキストがダウンストリームのStoreに送信されることと、キャンセルされたときにStoreから発生するエラーを処理することです。

Storeインターフェースを更新して、新しい責任を示しましょう。

type Store interface {
Fetch(ctx context.Context) (string, error)
}

とりあえずハンドラー内のコードを削除してください

func Server(store Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
}
}

SpyStoreを更新します

type SpyStore struct {
response string
t *testing.T
}
func (s *SpyStore) Fetch(ctx context.Context) (string, error) {
data := make(chan string, 1)
go func() {
var result string
for _, c := range s.response {
select {
case <-ctx.Done():
s.t.Log("spy store got cancelled")
return
default:
time.Sleep(10 * time.Millisecond)
result += string(c)
}
}
data <- result
}()
select {
case <-ctx.Done():
return "", ctx.Err()
case res := <-data:
return res, nil
}
}

スパイをcontextで機能する実際の方法のように動作させる必要があります。

遅いプロセスをシミュレートしていて、ゴルーチンで文字ごとに文字列を追加して、結果をゆっくり構築しています。ゴルーチンが作業を終了すると、文字列をdataチャネルに書き込みます。ゴルーチンはctx.Doneをリッスンし、そのチャネルでシグナルが送信されると作業を停止します。

最後に、コードは別のselectを使用して、そのゴルーチンが作業を完了するか、キャンセルが発生するのを待ちます。

これは以前のアプローチに似ています。Goの同時実行プリミティブを使用して、2つの非同期プロセスが互いに競合して、何を返すかを決定します。

contextを受け入れる独自の関数とメソッドを記述する場合も同様のアプローチをとるので、何が起こっているのかを確実に理解してください。

最後に、テストを更新できます。幸せなパステストを最初に修正できるように、キャンセルのテストをコメント化します。

t.Run("returns data from store", func(t *testing.T) {
store := &SpyStore{response: data, t: t}
svr := Server(store)
request := httptest.NewRequest(http.MethodGet, "/", nil)
response := httptest.NewRecorder()
svr.ServeHTTP(response, request)
if response.Body.String() != data {
t.Errorf(`got "%s", want "%s"`, response.Body.String(), data)
}
})

テストを実行してみます

=== RUN TestServer/returns_data_from_store
--- FAIL: TestServer (0.00s)
--- FAIL: TestServer/returns_data_from_store (0.00s)
context_test.go:22: got "", want "hello, world"

成功させるのに十分なコードを書く

func Server(store Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data, _ := store.Fetch(r.Context())
fmt.Fprint(w, data)
}
}

私たちの幸せな道は...幸せでなければなりません。これで、他のテストを修正できます。

最初にテストを書く

エラーの場合には、いかなる種類の応答も書かないことをテストする必要があります。悲しいことに、httptest.ResponseRecorderにはこれを理解する方法がないため、これをテストするために私たち自身のスパイをロールする必要があります。

type SpyResponseWriter struct {
written bool
}
func (s *SpyResponseWriter) Header() http.Header {
s.written = true
return nil
}
func (s *SpyResponseWriter) Write([]byte) (int, error) {
s.written = true
return 0, errors.New("not implemented")
}
func (s *SpyResponseWriter) WriteHeader(statusCode int) {
s.written = true
}

テストで使用できるように、SpyResponseWriterhttp.ResponseWriterを実装します。

t.Run("tells store to cancel work if request is cancelled", func(t *testing.T) {
store := &SpyStore{response: data, t: t}
svr := Server(store)
request := httptest.NewRequest(http.MethodGet, "/", nil)
cancellingCtx, cancel := context.WithCancel(request.Context())
time.AfterFunc(5*time.Millisecond, cancel)
request = request.WithContext(cancellingCtx)
response := &SpyResponseWriter{}
svr.ServeHTTP(response, request)
if response.written {
t.Error("a response should not have been written")
}
})

テストを実行してみます

=== RUN TestServer
=== RUN TestServer/tells_store_to_cancel_work_if_request_is_cancelled
--- FAIL: TestServer (0.01s)
--- FAIL: TestServer/tells_store_to_cancel_work_if_request_is_cancelled (0.01s)
context_test.go:47: a response should not have been written

成功させるのに十分なコードを書く

func Server(store Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data, err := store.Fetch(r.Context())
if err != nil {
return // todo: log error however you like
}
fmt.Fprint(w, data)
}
}

この後、サーバーコードはキャンセルの明示的な責任がなくなり、単純化されていることがわかります。サーバーコードは単にcontextを通過し、発生する可能性のあるすべてのキャンセルを下流の関数に依存しています。

まとめ

カバーしたこと

  • リクエストがクライアントによってキャンセルされたHTTPハンドラをテストする方法。

  • キャンセルを管理するためのコンテキストの使用方法。

  • contextを受け入れ、それを使ってゴルーチン、select、およびチャネルを使用してそれ自体をキャンセルする関数の作成方法。

  • コールスタックを通じてリクエストスコープのコンテキストを伝播してキャンセルを管理する方法については、Googleのガイドラインに従ってください。

  • 必要に応じて、http.ResponseWriterの独自のスパイをロールする方法。

context.Valueはどうですか?

Michal Štrbaと私は同様の意見を持っています。

私の(non-existent)会社でctx.Valueを使用すると、解雇されます

一部のエンジニアは、「便利」と感じてcontextを介して値を渡すことを提唱しています。

多くの場合、利便性が悪いコードの原因です。

context.Valuesの問題は、型付けされていないマップであるため、タイプの安全性がなく、値を実際に含まないように処理する必要があることです。あるモジュールから別のモジュールへのマップキーの結合を作成する必要があり、誰かが何かを変更すると、何かが壊れ始めます。

要するに、関数がいくつかの値を必要とする場合、 context.Valueからそれらをフェッチしようとするのではなく、型付きパラメーターとしてそれらを置きます。これは静的にチェックされ、誰もが見ることができるように文書化されます。

しかし...

一方、リクエストに直交する情報(トレースIDなど)をコンテキストに含めると役立つ場合があります。潜在的に、この情報はコールスタックのすべての関数で必要とされるわけではなく、関数シグネチャが非常に乱雑になります。

Jack Lindamoodによると、Context.Valueは制御ではなく通知

context.Valueのコンテンツは、ユーザーではなくメンテナー向けです。文書化された結果または期待される結果の入力が必要になることはありません。

追加資料