読者です 読者をやめる 読者になる 読者になる

Go Concurrency Patterns: Context[翻訳]

7月29日付けのgolang.orgブログエントリーで context というパッケージが紹介されました。

Go Concurrency Patterns: Context - The Go Blog

参考: Go言語のcontextパッケージについてのやりとり - ワザノバ | wazanova

今現在、業務でGo言語を使ったWebサービスを書いているのですが、contextはリクエストのキャンセルとタイムアウトとリクエストスコープの変数を扱う、Google社内で利用が標準化されているパッケージだという事なので、エントリーを翻訳しました。

以下和訳。

Introduction

Goサーバーでは、入ってくる各リクエストは専用のgoroutineで処理されます。リクエストハンドラは、データベースやRPCサーバーにアクセスするためにしばしば追加のゴルーチンを開始します。リクエスト上のゴルーチンは、一般的にエンドユーザーのアイデンティティ、認証トークン、リクエストのデッドラインなどのリクエスト特有の情報にアクセスする必要があります。リクエストがキャンセルされるかタイムアウトした場合、システムが資源を再利用できるようにするため、そのリクエスト上で動作していた全てのゴルーチンは即座に終了すべきです。

Googleで私たちは、リクエストスコープの値、キャンセレーションシグナル、そしてAPI境界をまたぐデッドラインを、リクエスト処理に関わる全てのゴルーチンに渡す事を簡単にするためのcontextパッケージを開発しました。パッケージは code.google.com/p/go.net/contextで公開されています。この記事では、パッケージの使い方と完全な動作例を紹介します。

Context

contextパッケージの核となるのはContext型です:

// A Context carries a deadline, cancelation signal, and request-scoped values
// across API boundaries. Its methods are safe for simultaneous use by multiple
// goroutines.
type Context interface {
    // Done returns a channel that is closed when this Context is canceled
    // or times out.
    Done() <-chan struct{}

    // Err indicates why this context was canceled, after the Done channel
    // is closed.
    Err() error

    // Deadline returns the time when this Context will be canceled, if any.
    Deadline() (deadline time.Time, ok bool)

    // Value returns the value associated with key or nil if none.
    Value(key interface{}) interface{}
}

(この記述は要約です。godocが信頼できます)

DoneメソッドはContextを実行中の関数へのキャンセレーションシグナルとして振る舞うチャンネルを返します:そのチャンネルがクローズされたら、関数は自分たちの仕事を放棄してreturnするべきです。ErrメソッドはそのContextがキャンセルされた理由を示すerrorを返します。Pipelines and CancelationでDoneチャンネルイディオムについてより詳しく議論しています。

Contextには、Doneチャンネルがreceive-onlyであることと同じ理由によりCancelメソッドがありません:キャンセレーションシグナルを受信している関数は、通常そのシグナルを送信する関数と同じではないからです。特に、親オペレーションがサブオペレーションのためにゴルーチンを開始したとき、それらのサブオペレーションは親をキャンセルできるべきではありません。そのかわりに、WithCancel関数(後述)は新しいContext値をキャンセルする方法を提供します。

Contextは複数ゴルーチンによる同時利用に対して安全です。コードは単一のContextをいくつのゴルーチンにでも渡せますし、それら全てにシグナルしてそのContextをキャンセルする事も出来ます。

Deadlineメソッドにより、関数は自分が仕事を開始するべきかどうか判断することができます;もし残り時間がほとんどなければ、全くの無駄になるかもしれません。コードはI/Oオペレーションのタイムアウトを設定するのに使う事も出来ます。

Valueにより、Contextでリクエストスコープなデータを運ぶ事が出来ます。そのデータは複数ゴルーチンによる同時利用に対して安全である必要があります。

派生コンテキスト

contextパッケージは既存のContextから新しいContextを派生するための関数を提供します。これらの値はツリーを形成します; あるContextがキャンセルされたとき、そこから派生している全てのContextもまたキャンセルされます。

Backgroundは全てのContextツリーのルートです;決してcancelされることはありません。

// Backgroundは空のContextを返します。これは決してキャンセルされず、デッドラインを持たず、値も持ちません。
// Backgroundは一般的にmain,init,そしてtests内で、やってくるリクエストのトップレベルコンテキストとして使われます。
func Background() Context

WithCancelとWithTimeoutは、親Contextがキャンセルされるよりも早くキャンセルできる派生Contextの値を返します。到着したリクエストに関連づけられたContextは、一般的にはリクエストハンドラがreturnした時にキャンセルされます。WithCancelは複数レプリカ使用時に冗長なリクエストをキャンセルするのにも便利です。WithTimeoutは、バックエンドサーバーへのリクエストにデッドラインを設定するのに便利です:

// WithCancel は、parent.Doneがクローズされるかcancelが呼び出されると
// Doneチャンネルがクローズされるようなparentのコピーを返します
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

// CancelFuncはContextをキャンセルします
type CancelFunc func()

// WithTimeoutはparent.Doneがクローズされるかcancelが呼び出されるかtimeoutが
// 経過するとDoneチャンネルがクローズされるようなparentのコピーを返します。
// 新しいContextのDeadlineはnow+timeoutと、もしあればparentのdeadlineのうちの近い方です。
// もしtimerがまだ動いていれば、cancel関数はそのリソースをリリースします。
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

WithValueはrequest-scopedな値をContextに関連づける方法を提供します

// WithValue はValueメソッドがkeyに対するvalを返すようなparentのコピーを返します。
func WithValue(parent Context, key interface{}, val interface{}) Context

contextパッケージの使い方を見る一番の方法は、動いているサンプルを見る事です。

Example: Google Web Search

この例は、/search?q=golang&timeout=1s のようなURLを、クエリ"golang"をGoogle Web Search APIにフォワードして結果を表示するという風にハンドルするHTTPサーバーです。timeoutパラメーターはサーバーにこの時間が経過した後にリクエストをキャンセルするように指示します。このコードは三つの部分に分かれています:

  • server はmain関数と/search のハンドラを提供します
  • userip はユーザーIPアドレスをリクエストから取得してContextに関連づける関数を提供します
  • google はクエリをGoogleに送信するSearch関数を提供します

The server program

serverプログラムは/search?q=golang のようなリクエストに対して最初のいくつかのgolangGoogle検索結果を返します。ハンドラはctxと呼ばれる初期Contextを作成し、ハンドラがreturnした時にキャンセルされるように準備します。リクエストがtimeout URLパラメータを含んでいたら、Contextはtimeout経過後に自動的にキャンセルされます:

func handleSearch(w http.ResponseWriter, req *http.Request) {
    // ctxはこのハンドラのContextです。cancleを呼ぶと、このハンドラ
    // が開始したリクエストへのキャンセレーションシグナルである
    // ctx.Doneチャンネルがクローズします
    var (
        ctx    context.Context
        cancel context.CancelFunc
    )
    timeout, err := time.ParseDuration(req.FormValue("timeout"))
    if err == nil {
        // リクエストはタイムアウトを持つので、timeoutがexpireしたら
        // 自動的にキャンセルされるcontextを作ります
        ctx, cancel = context.WithTimeout(context.Background(), timeout)
    } else {
        ctx, cancel = context.WithCancel(context.Background())
    }
    defer cancel() // handleSearchがリターンしたらctxをキャンセル

ハンドラはリクエストからクエリを抜き出し、useripパッケージを呼び出す事でクライアントのIPアドレスを取得します。クライアントのIPアドレスはバックエンドリクエストに必要なので、handleSearchはそれをctxにくっつけます:

    // Check the search query.
    query := req.FormValue("q")
    if query == "" {
        http.Error(w, "no query", http.StatusBadRequest)
        return
    }

    // Store the user IP in ctx for use by code in other packages.
    userIP, err := userip.FromRequest(req)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    ctx = userip.NewContext(ctx, userIP)

ハンドラはctxとqueryでgoogle.Searchを呼び出します:

    // Run the Google search and print the results.
    start := time.Now()
    results, err := google.Search(ctx, query)
    elapsed := time.Since(start)

検索が成功したら、ハンドラは結果を表示します:

    if err := resultsTemplate.Execute(w, struct {
        Results          google.Results
        Timeout, Elapsed time.Duration
    }{
        Results: results,
        Timeout: timeout,
        Elapsed: elapsed,
    }); err != nil {
        log.Print(err)
        return
    }

Package userip

useripパッケージはリクエストからユーザーIPアドレスを抜き出してContextに関連づける関数を提供します。Contextはkey-valueマッピングを提供しますが、そのkeyとvalueはどちらもinterface{}型です。Key型は等価をサポートし、valueは複数ゴルーチンからの同時利用に対して安全である必要があります。useripのようなパッケージはこのマッピングの詳細を隠蔽し、特定のContext値への強く型付けされたアクセスを提供します。

キー衝突を避けるために、useripはexportされない型のkeyを定義してcontext keyとしてこの型の値を使います。

// The key type is unexported to prevent collisions with context keys defined in
// other packages.
type key int

// userIPkey is the context key for the user IP address.  Its value of zero is
// arbitrary.  If this package defined other context keys, they would have
// different integer values.
const userIPKey key = 0

FromRequestはuserIP値をhttp.Requestから抜き出します

func FromRequest(req *http.Request) (net.IP, error) {
    s := strings.SplitN(req.RemoteAddr, ":", 2)
    userIP := net.ParseIP(s[0])
    if userIP == nil {
        return nil, fmt.Errorf("userip: %q is not IP:port", req.RemoteAddr)
    }

NewContextは与えられたuserIP値を運ぶ新しいContextを返します:

func NewContext(ctx context.Context, userIP net.IP) context.Context {
    return context.WithValue(ctx, userIPKey, userIP)
}

FromContextはuserIPをContextから抜き出します:

func FromContext(ctx context.Context) (net.IP, bool) {
    // ctx.Value returns nil if ctx has no value for the key;
    // the net.IP type assertion returns ok=false for nil.
    userIP, ok := ctx.Value(userIPKey).(net.IP)
    return userIP, ok
}

Package Google

google.Search関数はGoogle Web Search APIへのHTTPリクエストを作ってJSONエンコードされた結果をパースします。これはContextパラメータctxを受け入れ、もしリクエストを飛ばしている間にctx.Doneがクローズされたらすぐにリターンします。

Google Web Search APIリクエストはサーチクエリとユーザーIPをクエリパラメータに含みます:

func Search(ctx context.Context, query string) (Results, error) {
    // Prepare the Google Search API request.
    req, err := http.NewRequest("GET", "https://ajax.googleapis.com/ajax/services/search/web?v=1.0", nil)
    if err != nil {
        return nil, err
    }
    q := req.URL.Query()
    q.Set("q", query)

    // If ctx is carrying the user IP address, forward it to the server.
    // Google APIs use the user IP to distinguish server-initiated requests
    // from end-user requests.
    if userIP, ok := userip.FromContext(ctx); ok {
        q.Set("userip", userIP.String())
    }
    req.URL.RawQuery = q.Encode()

Searchはヘルパー関数httpDoを使ってHTTPリクエストを発行し、もしリクエストかレスポンスが処理中にctx.Doneがクローズされたらキャンセルします。SearchはHTTPレスポンスを処理するクロージャをhttpDoハンドルに渡します:

    var results Results
    err = httpDo(ctx, req, func(resp *http.Response, err error) error {
        if err != nil {
            return err
        }
        defer resp.Body.Close()

        // Parse the JSON search result.
        // https://developers.google.com/web-search/docs/#fonje
        var data struct {
            ResponseData struct {
                Results []struct {
                    TitleNoFormatting string
                    URL               string
                }
            }
        }
        if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
            return err
        }
        for _, res := range data.ResponseData.Results {
            results = append(results, Result{Title: res.TitleNoFormatting, URL: res.URL})
        }
        return nil
    })
    // httpDo waits for the closure we provided to return, so it's safe to
    // read results here.
    return results, err

httpDo関数は新しいゴルーチンの中でHTTPリクエストを実行してレスポンスを処理します。もしゴルーチンがexitする前にctx.Doneがクローズしたらキャンセルします。

func httpDo(ctx context.Context, req *http.Request, f func(*http.Response, error) error) error {
    // Run the HTTP request in a goroutine and pass the response to f.
    tr := &http.Transport{}
    client := &http.Client{Transport: tr}
    c := make(chan error, 1)
    go func() { c <- f(client.Do(req)) }()
    select {
    case <-ctx.Done():
        tr.CancelRequest(req)
        <-c // Wait for f to return.
        return ctx.Err()
    case err := <-c:
        return err
    }
}

コードをContextに適合する

多くのサーバーフレームワークがリクエストスコープな値を運ぶためのパッケージと型を提供しています。私たちは、既存のフレームワークとContextパラメータを期待するコードとの間をブリッジする新しいContextインターフェイスの実装を定義する事が出来ます。

例えば、Gorillaのgithub.com/gorilla/contextパッケージは、HTTPリクエストからkey-valueペアへのマップを提供する事でハンドラにデータをリクエストに関連づける事を許します。gorilla.goにおいて、私たちはGorillaパッケージ内で特定のHTTPリクエストに関連づけられた値を返すValueメソッドを持つContext実装を提供します。

他のパッケージは、Contextに似たキャンセレーションサポートをサポートしてきました。例えば、TombはDyingチャンネルをcloseすることでキャンセルをシグナルするKillメソッドを提供します。Tomはまたこれらのゴルーチンが終了するのを待つ、sync.WaitGroupに似たメソッドも提供します。tomb.goでは、私たちは親のContextがキャンセルされるか与えられたTombがkillされるかの場合にキャンセルされるContext実装を提供します。

結論

Googleでは、私たちはGoプログラマーにContextパラメーターをリクエストの到着から返却までの間に呼ばれる全ての関数の第一引数にContextパラメーターを渡す事を要求しています。これは、たくさんの異なるチームで開発されたGoのコードに良好な相互運用性をもたらします。タイムアウトとキャンセレーションのシンプルな制御を導入し、セキュリティクレデンシャルのようなクリティカルな値が適切にGoプログラム内を移動する事を確かなものにします。

Context上に構築したいサーバーフレームワークは、それらのパッケージとContextパラメータを期待するものの間をブリッジするContext実装を提供するべきです。そうすればそれらのクライアントライブラリは呼び出しコードからContextを受け入れることでしょう。リクエストスコープなデータとキャンセレーションのための共通インターフェイスを確立する事により、Contextはパッケージ開発者がスケーラブルなサービスを作るためのコードを共有するのを簡単にするのです。

By Sameer Ajmani