コンテキスト
Context (長期実行プロセスの管理に役立つパッケージ)
ソフトウェアは、多くの場合、長時間実行され、リソースを大量に消費するプロセスを開始します(多くの場合、ゴルーチンで)。これを引き起こしたアクションがキャンセルされるか、何らかの理由で失敗した場合は、アプリケーションを通じてこれらのプロセスを一貫した方法で停止する必要があります。
これを管理しないと、非常に誇りに思っているキレの良い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() 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")
}
})
コンテキストパッケージは、既存のコンテキスト値から新しいコンテキスト値を導出する関数を提供します。これらの値はツリーを形成します。コンテキストが取り消されると、それから派生したすべてのコンテキストも取り消されます。
キャンセルが特定のリクエストのコールスタック全体に伝播されるように、コンテキストを派生させることが重要です。
私たちがすることは、
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 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
を受け入れる独自の関数とメソッドを記述する場合も同様のアプローチをとるので、何が起こっているのかを確実に理解してください。