ソフトウェアは、多くの場合、長時間実行され、リソースを大量に消費するプロセスを開始します(多くの場合、ゴルーチンで)。これを引き起こしたアクションがキャンセルされるか、何らかの理由で失敗した場合は、アプリケーションを通じてこれらのプロセスを一貫した方法で停止する必要があります。
これを管理しないと、非常に誇りに思っているキレの良いGoアプリケーションは、パフォーマンスの問題のデバッグが困難になる可能性があります。
この章では、context
パッケージを使用して、実行時間の長いプロセスを管理します。
まず、ヒットしたときに長時間実行される可能性のあるプロセスを開始して、データをフェッチして応答で返すWebサーバーの古典的な例から始めます。
データを取得する前にユーザーがリクエストをキャンセルするシナリオを実行し、プロセスが中止されるように指示します。
私たちは幸せなパスにいくつかのコードを設定して始めました。 これがサーバーコードです。
func Server(store Store) http.HandlerFunc {return func(w http.ResponseWriter, r *http.Request) {fmt.Fprint(w, store.Fetch())}}
関数Server
はStore
を受け取り、http.HandlerFunc
を返します。
Store
は次のように定義されます
type Store interface {Fetch() string}
返された関数は、store
のFetch
メソッドを呼び出してデータを取得し、それを応答に書き込みます。
テストで使用する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)}}
幸せなパスができたので、ユーザーがリクエストをキャンセルする前にStore
がFetch
を完了できない、より現実的なシナリオを作成したいと思います。
私たちのハンドラーは、Store
に作業をキャンセルしてインターフェースを更新するように指示する方法が必要になります。
type Store interface {Fetch() stringCancel()}
スパイを調整する必要があるので、data
を返すには時間がかかり、キャンセルするように指示されたことを知る方法があります。呼び出し方法を確認しているので、名前をSpyStore
に変更します。
Store
インターフェースを実装するメソッドとしてCancel
を追加する必要があります。
type SpyStore struct {response stringcancelled 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")}})
コンテキストパッケージは、既存のコンテキスト値から新しいコンテキスト値を導出する関数を提供します。これらの値はツリーを形成します。コンテキストが取り消されると、それから派生したすべてのコンテキストも取り消されます。
キャンセルが特定のリクエストのコールスタック全体に伝播されるように、コンテキストを派生させることが重要です。
私たちがすることは、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つは、キャンセルを提供する一貫した方法であることです。
サーバーへの着信要求はコンテキストを作成し、サーバーへの発信呼び出しはコンテキストを受け入れる必要があります。それらの間の関数呼び出しのチェーンは、コンテキストを伝播する必要があり、オプションで、
WithCancel
、WithDeadline
、WithTimeout
、またはWithValue
を使用して作成された派生コンテキストに置き換えます。コンテキストがキャンセルされると、そのコンテキストから派生したすべてのコンテキストもキャンセルされます。
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 stringt *testing.T}func (s *SpyStore) Fetch(ctx context.Context) (string, error) {data := make(chan string, 1)go func() {var result stringfor _, c := range s.response {select {case <-ctx.Done():s.t.Log("spy store got cancelled")returndefault: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 = truereturn nil}func (s *SpyResponseWriter) Write([]byte) (int, error) {s.written = truereturn 0, errors.New("not implemented")}func (s *SpyResponseWriter) WriteHeader(statusCode int) {s.written = true}
テストで使用できるように、SpyResponseWriter
はhttp.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
の独自のスパイをロールする方法。
Michal Štrbaと私は同様の意見を持っています。
私の(non-existent)会社で
ctx.Value
を使用すると、解雇されます
一部のエンジニアは、「便利」と感じてcontext
を介して値を渡すことを提唱しています。
多くの場合、利便性が悪いコードの原因です。
context.Values
の問題は、型付けされていないマップであるため、タイプの安全性がなく、値を実際に含まないように処理する必要があることです。あるモジュールから別のモジュールへのマップキーの結合を作成する必要があり、誰かが何かを変更すると、何かが壊れ始めます。
要するに、関数がいくつかの値を必要とする場合、 context.Value
からそれらをフェッチしようとするのではなく、型付きパラメーターとしてそれらを置きます。これは静的にチェックされ、誰もが見ることができるように文書化されます。
一方、リクエストに直交する情報(トレースIDなど)をコンテキストに含めると役立つ場合があります。潜在的に、この情報はコールスタックのすべての関数で必要とされるわけではなく、関数シグネチャが非常に乱雑になります。
Jack Lindamoodによると、Context.Valueは制御ではなく通知
context.Value
のコンテンツは、ユーザーではなくメンテナー向けです。文書化された結果または期待される結果の入力が必要になることはありません。
MichalŠtrbaによるGo2のコンテキストはなくなるはずですを読んで本当に楽しんでいました。彼の主張は、どこでもcontext
を渡す必要があることは匂いであり、キャンセルに関する言語の欠陥を指摘しているということです。ライブラリレベルではなく、言語レベルで何らかの方法でこれを解決した方が良いと彼は言います。それが発生するまで、実行時間の長いプロセスを管理する場合は、context
が必要になります。