本文翻譯自Sameer Ajmani的Go Concurrency Patterns: Context。
目錄
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將代碼共享變得更加容易。