[譯]Go併發模式:context

原文地址: Go Concurrency Patterns: Context

Introduction

在Go server中,新的請求通常都會起一個新的goroutine處理,這個goroutine又通常會起一些額外的goroutines來訪問後端,例如database,RPC服務等。這一系列處理一個請求的goroutines通常需要訪問請求相關的數據,例如最終用戶的身份,Authorization tokens,請求截止時間等。當一個請求退出或者超時,所有爲這個請求工作的goroutines都必須立即退出,然後系統才能回收他們佔用的資源。

Google開發並開源了一個context包可以很容易的通過API邊界向所有處理同一個請求的goroutines傳遞請求範圍的數據,退出信號,截止時間等(goroutines之間的全局變量共享, 以及同步退出控制)。這篇文章描述瞭如何使用這個包,以及提供一個完整的可工作的示例。

Context

context包的核心就是定義的Context interface。

// 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{}
}

Done方法返回一個作爲退出信號的channel到運行在Context中的函數(通俗的講就是接受Context變量的Goroutines通過Done方法獲取退出信號)。如果channel被關閉(close),該函數(goroutines)應當立即停止工作並退出。Pipelines and Cancelation這篇文章更詳細的討論了Done channel。

Error方法返回一個error用於指示Context退出的理由。

Context沒有一個Cancel方法,原因同Done返回的channel是一個只能用於從channel讀取數據的單向channel一樣:就是接收退出信號的函數通常不是信號的發送方。通常情況下,當一個父親起了一些goroutines處理一些子任務,這些子goroutines是不能終止parent並讓其退出的。作爲替代下面將提到的WithCancel方法將提供一個方法用於退出一個新的Context值。

Context對被多個goroutines同時使用是安全的。代碼可以將同一個Context傳給任何數量的goroutines使用,並像它們發送信號指示退出這個context。

Deadline方法允許函數決定是否啓動工作;如果距離timeout只有很少的時間,可能就沒有必要啓動執行了。代碼還可以使用Deadline爲IO操作設置超時。

Value方法允許Context攜帶一些請求範圍的數據。這些數據必須是對多個goroutines同時訪問安全的。

Derived context

context包提供了方法從一個已有的Context派生新的Context。所有這些Context組成了一棵樹:如果一個Context退出,則從它派生出的所有Context都將退出。

Background是所有Context樹的根節點,它永遠不會退出。

// Background returns an empty Context. It is never canceled, has no deadline,
// and has no values. Background is typically used in main, init, and tests,
// and as the top-level Context for incoming requests.
func Background() Context

WithCancelWithTimeout返回一個可以比parent Context更早被退出的派生Context。典型情況下,與請求相關的Context通常在請求Handler退出時退出。WithCancel對於使用多個副本時用於退出冗餘請求也很有用。WithTimeout對設置請求的後臺服務的超時也很有用。

// WithCancel returns a copy of parent whose Done channel is closed as soon as
// parent.Done is closed or cancel is called.
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

// A CancelFunc cancels a Context.
type CancelFunc func()

// WithTimeout returns a copy of parent whose Done channel is closed as soon as
// parent.Done is closed, cancel is called, or timeout elapses. The new
// Context's Deadline is the sooner of now+timeout and the parent's deadline, if
// any. If the timer is still running, the cancel function releases its
// resources.
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

WithValue提供了一種將請求範圍的數據同Context關聯的方法。

// WithValue returns a copy of parent whose Value method returns val for key.
func WithValue(parent Context, key interface{}, val interface{}) Context

我們的示例是一個HTTP server,它處理像/search?q=golang&timeout=1s一樣的URLs,轉發查詢golang的請求到Google Web Search API並翻譯結果。timeout告訴服務器多長時間後退出請求。

代碼被分成了三個部分:

  • server提供了main函數以及/search的handler;
  • userip提供了從請求中解析用戶IP並將它關聯的Context的功能;
  • google提供了Search函數,用於向Google發送查詢請求。

server部分代碼

server代碼通過爲golang提供Google的前幾個搜索結果來處理像/search?q=golang一樣的請求。它爲註冊了一個handleSearch來處理/searchAPI。這個handler創建了一個叫做ctx的初始Context,並在handler返回時安排ctx退出。如果請求中還包含了叫timeout的URL參數,ctx將在超時時自動退出。

handler解析請求中的查詢條件,還會通過調用userip包來解析請求中的用戶IP。用戶IP信息在後端服務中被需要,所以handleSearch將它存入ctx中。

// The server program issues Google search requests and demonstrates the use of
// the go.net Context API. It serves on port 8080.
//
// The /search endpoint accepts these query params:
//   q=the Google search query
//   timeout=a timeout for the request, in time.Duration format
//
// For example, http://localhost:8080/search?q=golang&timeout=1s serves the
// first few Google search results for "golang" or a "deadline exceeded" error
// if the timeout expires.
package main

import (
    "html/template"
    "log"
    "net/http"
    "time"

    "golang.org/x/blog/content/context/google"
    "golang.org/x/blog/content/context/userip"
    "golang.org/x/net/context"
)

func main() {
    http.HandleFunc("/search", handleSearch)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

// handleSearch handles URLs like /search?q=golang&timeout=1s by forwarding the
// query to google.Search. If the query param includes timeout, the search is
// canceled after that duration elapses.
func handleSearch(w http.ResponseWriter, req *http.Request) {
    // ctx is the Context for this handler. Calling cancel closes the
    // ctx.Done channel, which is the cancellation signal for requests
    // started by this handler.
    var (
        ctx    context.Context
        cancel context.CancelFunc
    )
    timeout, err := time.ParseDuration(req.FormValue("timeout"))
    if err == nil {
        // The request has a timeout, so create a context that is
        // canceled automatically when the timeout expires.
        ctx, cancel = context.WithTimeout(context.Background(), timeout)
    } else {
        ctx, cancel = context.WithCancel(context.Background())
    }
    defer cancel() // Cancel ctx as soon as handleSearch returns.

    // 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)

    // Run the Google search and print the results.
    start := time.Now()
    results, err := google.Search(ctx, query)
    elapsed := time.Since(start)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    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
    }
}

var resultsTemplate = template.Must(template.New("results").Parse(`
<html>
<head/>
<body>
  <ol>
  {{range .Results}}
    <li>{{.Title}} - <a href="{{.URL}}">{{.URL}}</a></li>
  {{end}}
  </ol>
  <p>{{len .Results}} results in {{.Elapsed}}; timeout {{.Timeout}}</p>
</body>
</html>
`))

Package userip

userip包提供了從用戶請求中解析用戶IP,並將它關聯到Context的功能。一個Context提供了一個key-value mapping,其中key和value的類型都是interface{}。key類型必須支持等(equal)判斷,並且value必須對多個goroutines同時訪問安全。像userip這樣的軟件包應該隱藏所有的映射細節,並提供對指定Context value的強類型訪問。

爲了避免key衝突,userip定義了一個不對外暴露的key類型,並使用這個類型的數據作爲Context的key。

// Package userip provides functions for extracting a user IP address from a
// request and associating it with a Context.
//
// This package is an example to accompany https://blog.golang.org/context.
// It is not intended for use by others.
package userip

import (
    "fmt"
    "net"
    "net/http"

    "golang.org/x/net/context"
)

// FromRequest extracts the user IP address from req, if present.
func FromRequest(req *http.Request) (net.IP, error) {
    ip, _, err := net.SplitHostPort(req.RemoteAddr)
    if err != nil {
        return nil, fmt.Errorf("userip: %q is not IP:port", req.RemoteAddr)
    }

    userIP := net.ParseIP(ip)
    if userIP == nil {
        return nil, fmt.Errorf("userip: %q is not IP:port", req.RemoteAddr)
    }
    return userIP, nil
}

// 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

// NewContext returns a new Context carrying userIP.
func NewContext(ctx context.Context, userIP net.IP) context.Context {
    return context.WithValue(ctx, userIPKey, userIP)
}

// FromContext extracts the user IP address from ctx, if present.
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

goole.Search函數會向Google Web Search API發送一個HTTP請求,並解析返回的JSON編碼的結果。它接收一個Context類型的參數ctx並且在request還沒有執行完成,但是ctx.Done被關閉的時候立即返回。

Google Web Search API要求提供查詢條件和用戶IP做爲參數。

Search使用了一個幫助函數httpDo,由httpDo發起HTTP請求。如果在request或response仍然在處理時,ctx.Done被關閉時,httpDo也將被退出。Search傳遞了一個閉包給httpDo來處理HTTP response。

// Package google provides a function to do Google searches using the Google Web
// Search API. See https://developers.google.com/web-search/docs/
//
// This package is an example to accompany https://blog.golang.org/context.
// It is not intended for use by others.
//
// Google has since disabled its search API,
// and so this package is no longer useful.
package google

import (
    "encoding/json"
    "net/http"

    "golang.org/x/blog/content/context/userip"
    "golang.org/x/net/context"
)

// Results is an ordered list of search results.
type Results []Result

// A Result contains the title and URL of a search result.
type Result struct {
    Title, URL string
}

// Search sends query to Google search and returns the results.
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()

    // Issue the HTTP request and handle the response. The httpDo function
    // cancels the request if ctx.Done is closed.
    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 issues the HTTP request and calls f with the response. If ctx.Done is
// closed while the request or f is running, httpDo cancels the request, waits
// for f to exit, and returns ctx.Err. Otherwise, httpDo returns f's error.
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
    }
}

Adapting code for Contexts

有些服務框架提供包或類型用來攜帶請求範圍的參數。我們可以定義一個新的Context interface實現來作爲使用框架的代碼和希望使用Context作爲參數的代碼之間的橋樑。

例如,Gorilla的github.com/gorilla/context包允許handler通過提供將HTTP請求到key-value的映射來關聯請求和對應的數據。在下面的gorilla.go代碼中,我們提供了一個Context實現,它的Value方法返回了Gorilla包中指定HTTP request的數據。

// +build OMIT

// Package gorilla provides a go.net/context.Context implementation whose Value
// method returns the values associated with a specific HTTP request in the
// github.com/gorilla/context package.
package gorilla

import (
    "net/http"

    gcontext "github.com/gorilla/context"
    "golang.org/x/net/context"
)

// NewContext returns a Context whose Value method returns values associated
// with req using the Gorilla context package:
// http://www.gorillatoolkit.org/pkg/context
func NewContext(parent context.Context, req *http.Request) context.Context {
    return &wrapper{parent, req}
}

type wrapper struct {
    context.Context
    req *http.Request
}

type key int

const reqKey key = 0

// Value returns Gorilla's context package's value for this Context's request
// and key. It delegates to the parent Context if there is no such value.
func (ctx *wrapper) Value(key interface{}) interface{} {
    if key == reqKey {
        return ctx.req
    }
    if val, ok := gcontext.GetOk(ctx.req, key); ok {
        return val
    }
    return ctx.Context.Value(key)
}

// HTTPRequest returns the *http.Request associated with ctx using NewContext,
// if any.
func HTTPRequest(ctx context.Context) (*http.Request, bool) {
    // We cannot use ctx.(*wrapper).req to get the request because ctx may
    // be a Context derived from a *wrapper. Instead, we use Value to
    // access the request if it is anywhere up the Context tree.
    req, ok := ctx.Value(reqKey).(*http.Request)
    return req, ok
}

其他的一些包可能提供了類似Context的退出機制。例如Tomb提供了一個Kill方法來發起通過關閉一個Dying channel來退出。Tomb還提供了方法等待所有的goroutines退出,類似於sync.WaitGroup。在下面的tomb.go中我們實現了一個Context,它將在父Context退出,或提供的Tomb被kill時退出。

// +build OMIT

// Package tomb provides a Context implementation that is canceled when either
// its parent Context is canceled or a provided Tomb is killed.
package tomb

import (
    "golang.org/x/net/context"
    tomb "gopkg.in/tomb.v2"
)

// NewContext returns a Context that is canceled either when parent is canceled
// or when t is Killed.
func NewContext(parent context.Context, t *tomb.Tomb) context.Context {
    ctx, cancel := context.WithCancel(parent)
    go func() {
        select {
        case <-t.Dying():
            cancel()
        case <-ctx.Done():
        }
    }()
    return ctx
}

結論

在Google所有的Go程序員都被要求將Context變量作爲傳入和傳出請求之間所有調用路徑上的函數的第一個參數。這允許不同團隊之間開發的Go代碼能夠良好的交互。它提供了簡單的超時和退出機制,並確保諸如安全證書之類的數據能夠在Go程序間正確的傳遞。

如果想基於Context使用一些服務框架,則需要提供一個Context實現作爲包和期望的Context參數之間的橋樑。這樣它們的client就可以通過這個橋樑來接收一個Context。通過爲請求範圍的數據和退出機制建立通用的接口,Context使得包開發者能夠爲創建可擴展的服務更容易共享代碼。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章