Go語言併發模式:Context包

本文翻譯自Sameer Ajmani的Go Concurrency Patterns: Context

目錄

Go語言Context包的講解和使用

介紹

context

派生的contexts

例子:谷歌網頁搜索

服務器程序

程序包userip

程序包google

調整代碼適應Contexts

結論


 

Go語言Context包的講解和使用

介紹

在Go服務器中,每個到達的請求都被各自對應的goroutine處理。請求的處理程序增加新的goroutine來訪問後端,比如數據庫和RPC服務。共同爲某個請求服務的goroutine集合需要獲取特定請求信息,比如終端用戶的身份、認證標識和請求者的截止時間。在一個請求被取消或者過期的時候,所有相關的goroutine都需要快速退出,以便及時釋放系統資源。

在谷歌,我們開發了名爲context的包,它可以傳遞特定請求範圍內的值、取消信號和截止時間到所有處理該請求的goroutine。這個包公開發布爲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.
// 一個Context封裝了截止時間、取消信號和特定請求信息,並可以通過API使用。它的方法可以安全地被
// 多個goroutine同時使用。
type Context interface {
    // Done returns a channel that is closed when this Context is canceled
    // or times out.
    // 當該Context被取消或者過時了,Done 返回一個關閉了的頻道channel。
    Done() <-chan struct{}

    // Err indicates why this context was canceled, after the Done channel
    // is closed.
    // 當Done的channel被關閉後,Err 表明爲什麼這個context被取消。
    Err() error

    // Deadline returns the time when this Context will be canceled, if any.
    // 如果存在截止時間,Deadline 返回該Context被撤銷的時間。
    Deadline() (deadline time.Time, ok bool)

    // Value returns the value associated with key or nil if none.
    // Value 返回與key綁定的數據。如果沒有相應數據則返回nil。
    Value(key interface{}) interface{}
}

Done方法返回一個頻道channel,作爲一個取消信號提供給與Context相關的函數:當頻道關閉,這些函數需要放棄工作並返回。

Err方法返回一個錯誤,說明爲什麼context被取消。文章管線和取消細致討論頻道習語Done。

Context沒有取消方法,因爲同樣的原因Done返回的頻道是隻能用於接收:接收取消信號的函數一般不是發送這個信號的函數。具體來說,一個父操作給子操作開啓goroutine之後,子操作沒有能力取消父操作。WithCancel提供了取消Context的一種途徑。

Context能夠安全地被多個goroutine同時使用。我們可以把context發送給任意多個goroutine,然後通過取消這個context來通知所有的goroutine。

Deadline方法可以幫助函數確定是否開展工作;如果剩餘的時間太少,可能就不值得開始工作。代碼可以使用deadline來爲I/O操作設定時限。

Value允許一個Context裝載特定請求的數據。這些數據必須能夠安全地被多個goroutine同時使用。

派生的contexts

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.
// Background返回一個空的context。它無法被取消,沒有截止時間,沒有數據。background一般在
// main、init和tests中使用,作爲接收到的requests的頂級context。
func Background() Context

WithCancel和WithTimeout派生出新的context值,它們可以比先輩的context更早的取消。與某個請求相關的context往往在請求處理函數返回後取消。在使用多個副本時,WithCancel可以用於取消冗餘的請求。WithTimeout可以在發送給後端服務器的請求上設定截止時間。

// WithCancel returns a copy of parent whose Done channel is closed as soon as
// parent.Done is closed or cancel is called.
// WithCancel返回父輩的複製品,它的Done頻道隨着父輩Done的關閉而關閉。
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

// A CancelFunc cancels a Context.
// 一個CancelFunc取消一個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.
// WithTimeout返回一個父輩的複製品,它的Done頻道隨着父輩Done的關閉而關閉或取消而取消。
// 如果存在截止時間,Context的截止時間是now+timeout和父輩截止時間兩者距當前更近一個值。
// 如果計時器仍在運行,取消函數會釋放它的資源。
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

WithValue提供一種途徑,把特定請求的數據和context連接在一起:

// WithValue returns a copy of parent whose Value method returns val for key.
// WithValue返回父輩的一個拷貝,子輩的Value方法可以根據key返回val。
func WithValue(parent Context, key interface{}, val interface{}) Context

瞭解context包工作機理最好的方式就是通過一個實際例子。

例子:谷歌網頁搜索

我們的例子是一個HTTP服務器,處理類似於/search?q=golang&timeout=1s的URL,先把查詢請求推送給谷歌網頁搜索接口,然後渲染查詢結果。timeout參數告訴服務器查詢截止時間。

代碼劃分爲三個包:

  • server 提供main函數和/search的處理函數。
  • userip 提供從請求中提取用戶IP地址並將其與Context綁定的函數。
  • google 提供將請求發送給Google的查詢函數。

服務器程序

服務器程序處理請求,返回最靠前的幾個谷歌搜索結果,比如對於/search?q=golang ,它返回golang的查詢結果。它註冊handleSearch去處理/search終端。處理函數創建了一個名爲ctx的初始Context,然後設定它在處理函數返回後取消。如果請求包含timeout URL參數,Context時間一到就會自動取消。

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.

處理函數從請求中提取查詢任務,調用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
    }

程序包userip

userip程序包提供從請求提取用戶IP地址並將其與Context綁定的函數。Context提供了key-value映射,這些key和value的類型都是interface{}。key的實際類型必須支持相等比較,value實際類型必須能夠安全地被多個goroutine同時使用。userip這樣的程序包隱藏了這個映射的細節,提供了特定Context值的強類型式訪問。

爲了避免鍵衝突,userip定義了外部無法訪問的類型key,使用該類型的值作爲context的鍵。

// 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從一個http.Request提取了一個userIP值:

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

NewContext返回了一個包含剛得到的userIP值的Context:

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

FromContext從Context中提取userIP:

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
}

程序包google

google.Search函數發送了一個HTTP請求給谷歌網頁搜索接口,然後解析JSON格式的結果。它接收一個Context類型參數ctx。如果ctx.Done關閉了,它就立刻返回,就算請求還在執行。

谷歌網頁搜索接口請求使用搜索查詢和用戶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被關閉了,它就會取消httpDo,就算請求或響應仍然在執行中。Search傳遞一個閉包給httpDo來處理HTTP響應:

    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請求,新開一個goroutine來處理響應。如果ctx.Done被取消了,在goroutine退出前,它就會取消請求。

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.
    c := make(chan error, 1)
    req = req.WithContext(ctx)
    go func() { c <- f(http.DefaultClient.Do(req)) }()
    select {
    case <-ctx.Done():
        <-c // Wait for f to return.
        return ctx.Err()
    case err := <-c:
        return err
    }
}

調整代碼適應Contexts

許多服務器框架提供代碼包和類型,用於傳遞特定請求數據。我們可以定義符合Context接口的新代碼實現來溝通已有框架和需要Context作爲參數的代碼。

例如,Gorilla的 github.com/gorilla/context 程序包提供了從HTTP請求到鍵-值對的映射,讓處理函數將數據和收到的請求聯繫在一起。在gorilla.go,我們提供了Context的代碼實現,它的Value方法返回與Gorilla包中特定HTTP請求相對應的數據。

其他程序包也支持類似於Context的取消操作。例如,Tomb 提供了一個kill方法,通過關閉頻道來傳遞取消信息。Tomb也提供等待那些goroutine退出的方法,類似於sync.WaitGroup。在 tomb.go中,我們提供了Context接口的另外一個實現,在先輩Context被取消或者特定Tomb被殺死後,該Context也被取消了。

結論

在谷歌,我們要求Go程序員在接收和發送請求的調用路徑上,把Context參數作爲每個函數的第一個參數。這讓不同團隊開發的Go代碼能夠很好地兼容。它提供了簡單的過時和取消控制,確保關鍵信息在Go程序間準確傳送,比如安全證書。

服務器框架想要適應Context,需要提供Context的實現來溝通它們的程序包和需要Context作爲參數的代碼。他們的客戶端代碼庫需要能夠從調用代碼那裏接收Context。在建立可伸縮服務的領域,通過建立一個特定請求數據和取消機制的通用接口,Context將代碼共享變得更加容易。

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