視頻信息
How to correctly use package context
by Jack Lindamood
at Golang UK Conf. 2017
視頻:
https://www.youtube.com/watch?v=-_B5uQ4UGi0
博文:
https://medium.com/@cep21/how-to-correctly-use-context-context-in-go-1-7-8f2c0fafdf39
爲什麼需要 Context
每一個長請求都應該有個超時限制
需要在調用中傳遞這個超時
比如開始處理請求的時候我們說是 3 秒鐘超時
那麼在函數調用中間,這個超時還剩多少時間了?
需要在什麼地方存儲這個信息,這樣請求處理中間可以停止
如果進一步考慮。
如上圖這樣的 RPC 調用,開始調用 RPC 1 後,裏面分別調用了 RPC 2, RPC 3, RPC 4,等所有 RPC 用成功後,返回結果。
這是正常的方式,但是如果 RPC 2 調用失敗了會發生什麼?
RPC 2 失敗後,如果沒有 Context 的存在,那麼我們可能依舊會等所有的 RPC 執行完畢,但是由於 RPC 2 敗了,所以其實其它的 RPC 結果意義不大了,我們依舊需要給用戶返回錯誤。因此我們白白的浪費了 10ms,完全沒必要去等待其它 RPC 執行完畢。
那如果我們在 RPC 2 失敗後,就直接給用戶返回失敗呢?
用戶是在 30ms 的位置收到了錯誤消息,可是 RPC 3 和 RPC 4 依然在沒意義的運行,還在浪費計算和IO資源。
所以理想狀態應該是如上圖,當 RPC 2 出錯後,除了返回用戶錯誤信息外,我們也應該有某種方式可以通知 RPC 3 和 RPC 4,讓他們也停止運行,不再浪費資源。
所以解決方案就是:
用信號的方式來通知請求該停了
包含一些關於什麼時間請求可能會結束的提示(超時)
用 channel 來通知請求結束了
那乾脆讓我們把變量也扔那吧。?
在 Go 中沒有線程/go routine 變量
其實挺合理的,因爲這樣就會讓 goroutine 互相產生依賴
非常容易被濫用
Context 實現細節
context.Context:
是不可變的(immutable)樹節點
Cancel 一個節點,會連帶 Cancel 其所有子節點 (從上到下)
Context values 是一個節點
Value 查找是回溯樹的方式 (從下到上)
示例 Context 鏈
完整代碼:https://play.golang.org/p/ddpofBV1QS
1 | package main |
如果這樣構成的 Context 鏈,其形如下圖:
那麼當 3 秒超時到了時候:
可以看到 ctx4 超時退出了。
當 5秒鐘 超時到達時:
可以看到,不僅僅 ctx3 退出了,其所有子節點,比如 ctx5 和 ctx6 也都退出了。
context.Context API
基本上是兩類操作:
3個函數用於限定什麼時候你的子節點退出;
1個函數用於設置請求範疇的變量
1
2
3
4
5
6
7
8type Context interface {
// 啥時候退出
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
// 設置變量
Value(key interface{}) interface{}
}
什麼時候應該使用 Context?
每一個 RPC 調用都應該有超時退出的能力,這是比較合理的 API 設計
不僅僅 是超時,你還需要有能力去結束那些不再需要操作的行爲
context.Context 是 Go 標準的解決方案
任何函數可能被阻塞,或者需要很長時間來完成的,都應該有個 context.Context
如何創建 Context?
在 RPC 開始的時候,使用 context.Background()
有些人把在 main() 裏記錄一個 context.Background(),然後把這個放到服務器的某個變量裏,然後請求來了後從這個變量裏繼承 context。這麼做是不對的。直接每個請求,源自自己的 context.Background() 即可。
如果你沒有 context,卻需要調用一個 context 的函數的話,用 context.TODO()
如果某步操作需要自己的超時設置的話,給它一個獨立的 sub-context(如前面的例子)
如何集成到 API 裏?
如果有 Context,將其作爲第一個變量。
如 func (d* Dialer) DialContext(ctx context.Context, network, address string) (Conn, error)
有些人把 context 放到中間的某個變量裏去,這很不合習慣,不要那麼做,放到第一個去。
將其作爲可選的方式,用 request 結構體方式。
如:func (r *Request) WithContext(ctx context.Context) *Request
Context 的變量名請用 ctx(不要起一些詭異的名字?)
Context 放哪?
把 Context 想象爲一條河流流過你的程序(另一個意思就是說不要喝河裏的水……?)
理想情況下,Context 存在於調用棧(Call Stack) 中
不要把 Context 存儲到一個 struct 裏
除非你使用的是像 http.Request 中的 request 結構體的方式
request 結構體應該以 Request 結束爲生命終止
當 RPC 請求處理結束後,應該去掉對 Context 變量的引用(Unreference)
Request 結束,Context 就應該結束。(這倆是一對兒,不求同年同月同日生,但求同年同月同日死……?)
Context 包的注意事項
要養成關閉 Context 的習慣
特別是 超時的 Contexts
如果一個 context 被 GC 而不是 cancel 了,那一般是你做錯了
1
2ctx, cancel := context.WithTimeout(parentCtx, time.Second * 2)
defer cancel()使用 Timeout 會導致內部使用 time.AfterFunc,從而會導致 context 在計時器到時之前都不會被垃圾回收。
在建立之後,立即 defer cancel() 是一個好習慣。
終止請求 (Request Cancellation)
當你不再關心接下來獲取的結果的時候,有可能會 Cancel 一個 Context?
以 golang.org/x/sync/errgroup 爲例,errgroup 使用 Context 來提供 RPC 的終止行爲。
1 | type Group struct { |
創建一個 group 和 context:
1 | func WithContext(ctx context.Context) (*Group, context.Context) { |
這樣就返回了一個可以被提前 cancel 的 group。
而調用的時候,並不是直接調用 go func(),而是調用 Go(),將函數作爲參數傳進去,用高階函數的形式來調用,其內部纔是 go func() 開啓 goroutine。
1 | func (g *Group) Go(f func() error) { |
當給入函數 f 返回錯誤,則使用 sync.Once 來 cancel context,而錯誤被保存於 g.err 之中,在隨後的 Wait() 函數中返回。
1 | func (g *Group) Wait() error { |
注意:這裏在 Wait() 結束後,調用了一次 cancel()。
1 | package main |
在這個例子中,同時發起了兩個 RPC 調用,當任何一個調用超時或者出錯後,會終止另一個 RPC 調用。這裏就是利用前面講到的 errgroup 來實現的,應對有很多並非請求,並需要集中處理超時、出錯終止其它併發任務的時候,這個 pattern 使用起來很方便。
Context.Value - Request 範疇的值
context.Value API 的萬金油(duct tape)
膠帶(duct tape) 幾乎可以修任何東西,從破箱子,到人的傷口,到汽車引擎,甚至到NASA登月任務中的阿波羅13號飛船(Yeah! True Story)。所以在西方文化裏,膠帶是個“萬能”的東西。在中文裏,恐怕萬金油是更合適的對應詞彙,從頭疼、腦熱,感冒發燒,到跌打損傷幾乎無所不治。
當然,治標不治本,這點東西方文化中的潛臺詞都是一樣的。這裏提及的 context.Value 對於 API 而言,就是這類性質的東西,啥都可以幹,但是治標不治本。
value 節點是 Context 鏈中的一個節點
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15package context
type valueCtx struct {
Context
key, val interface{}
}
func WithValue(parent Context, key, val interface{}) Context {
// ...
return &valueCtx{parent, key, val}
}
func (c *valueCtx) Value(key interface{}) interface{} {
if c.key == key {
return c.val
}
return c.Context.Value(key)
}
可以看到,WithValue() 實際上就是在 Context 樹形結構中,增加一個節點罷了。
Context 是 immutable 的。
約束 key 的空間
爲了防止樹形結構中出現重複的鍵,建議約束鍵的空間。比如使用私有類型,然後用 GetXxx() 和 WithXxxx() 來操作私有實體。
1 | type privateCtxType string |
這裏使用 WithXxx 而不是 SetXxx 也是因爲 Context 實際上是 immutable 的,所以不是修改 Context 裏某個值,而是產生新的 Context 帶某個值。
Context.Value 是 immutable 的
再多次的強調 Context.Value 是 immutable 的也不過分。
context.Context 從設計上就是按照 immutable (不可變的)模式設計的
同樣,Context.Value 也是 immutable 的
不要試圖在 Context.Value 裏存某個可變更的值,然後改變,期望別的 Context 可以看到這個改變
更別指望着在 Context.Value 裏存可變的值,最後多個 goroutine 併發訪問沒競爭冒險啥的,因爲自始至終,就是按照不可變來設計的
比如設置了超時,就別以爲可以改變這個設置的超時值
在使用 Context.Value 的時候,一定要記住這一點
應該把什麼放到 Context.Value 裏?
應該保存 Request 範疇的值
任何關於 Context 自身的都是 Request 範疇的(這倆同生共死)
從 Request 數據衍生出來,並且隨着 Request 的結束而終結
什麼東西不屬於 Request 範疇?
在 Request 以外建立的,並且不隨着 Request 改變而變化
比如你 func main() 裏建立的東西顯然不屬於 Request 範疇
數據庫連接
如果 User ID 在連接裏呢?(稍後會提及)
全局 logger
如果 logger 裏需要有 User ID 呢?(稍後會提及)
那麼用 Context.Value 有什麼問題?
不幸的是,好像所有東西都是由請求衍生出來的
那麼我們爲什麼還需要函數參數?然後乾脆只來一個 Context 就完了?
1
2
3func Add(ctx context.Context) int {
return ctx.Value("first").(int) + ctx.Value("second").(int)
}
曾經看到過一個 API,就是這種形式:
1 | func IsAdminUser(ctx context.Context) bool { |
這裏API實現內部從 context 中取得 UserID,然後再進行權限判斷。但是從函數簽名看,則完全無法理解這個函數具體需要什麼、以及做什麼。
代碼要以可讀性爲優先設計考慮。
別人拿到一個代碼,一般不是掉進函數實現細節裏去一行行的讀代碼,而是會先瀏覽一下函數接口。所以清晰的函數接口設計,會更加利於別人(或者是幾個月後的你自己)理解這段代碼。
一個良好的 API 設計,應該從函數簽名就清晰的理解函數的邏輯。如果我們將上面的接口改爲:
1 | func IsAdminUser(ctx context.Context, userID string, authenticator auth.Service) bool |
我們從這個函數簽名就可以清楚的知道:
這個函數很可能可以提前被 cancel
這個函數需要 User ID
這個函數需要一個authenticator來
而且由於 authenticator 是傳入參數,而不是依賴於隱式的某個東西,我們知道,測試的時候就很容易傳入一個模擬認證函數來做測試
userID 是傳入值,因此我們可以修改它,不用擔心影響別的東西
所有這些信息,都是從函數簽名得到的,而無需打開函數實現一行行去看。
那什麼可以放到 Context.Value 裏去?
現在知道 Context.Value 會讓接口定義更加模糊,似乎不應該使用。那麼又回到了原來的問題,到底什麼可以放到 Context.Value 裏去?換個角度去想,什麼不是衍生於 Request?
Context.Value 應該是告知性質的東西,而不是控制性質的東西
應該永遠都不需要寫進文檔作爲必須存在的輸入數據
如果你發現你的函數在某些 Context.Value 下無法正確工作,那就說明這個 Context.Value 裏的信息不應該放在裏面,而應該放在接口上。因爲已經讓接口太模糊了。
什麼東西不是控制性質的東西?
Request ID
而 logger 本身不是 Request 範疇,所以 logger 不應該在 Context 裏
非 Request 範疇的 logger 應該只是利用 Context 信息來修飾日誌
只是給每個 RPC 調用一個 ID,而沒有實際意義
這就是個數字/字符串,反正你也不會用其作爲邏輯判斷
一般也就是日誌的時候需要記錄一下
User ID (如果僅僅是作爲日誌用)
Incoming Request ID
什麼顯然是控制性質的東西?
數據庫連接
顯然會非常嚴重的影響邏輯
因此這應該在函數參數裏,明確表示出來
認證服務(Authentication)
顯然不同的認證服務導致的邏輯不同
也應該放到函數參數裏,明確表示出來
例子
調試性質的 Context.Value - net/http/httptrace
https://medium.com/@cep21/go-1-7-httptrace-and-context-debug-patterns-608ae887224a
1 | package main |
net/http 是怎麼使用 httptrace 的?
如果有 trace 存在的話,就執行 trace 回調函數
這只是告知性質,而不是控制性質
http 不會因爲存在 trace 與否就有不同的執行邏輯
這裏只是告知 API 的用戶,幫助用戶記錄日誌或者調試
因此這裏的 trace 是存在於 Context 裏的
1
2
3
4
5
6
7
8
9package http
func (req *Request) write(w io.Writer, usingProxy bool, extraHeaders Header, waitForContinue func() bool) (err error) {
// ...
trace := httptrace.ContextClientTrace(req.Context())
// ...
if trace != nil && trace.WroteHeaders != nil {
trace.WroteHeaders()
}
}
迴避依賴注入 - github.com/golang/oauth2
這裏比較詭異,使用 ctx.Value 來定位依賴
不推薦這樣做
這裏這樣做基本上只是爲了滿足測試需求
1
2
3
4
5
6
7
8package main
import "github.com/golang/oauth2"
func oauth() {
c := &http.Client{Transport: &mockTransport{}}
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, c)
conf := &oauth2.Config{ /* ... */ }
conf.Exchange(ctx, "code")
}
人們濫用 Context.Value 的原因
中間件的抽象
很深的函數調用棧
混亂的設計
context.Value 並沒有讓你的 API 更簡潔,那是假象,相反,它讓你的 API 定義更加模糊。
總結 Context.Value
對於調試非常方便
將必須的信息放入 Context.Value 中,會讓接口定義更加不透明
如果可以儘量明確定義在接口
儘量不要用 Context.Value
總結 Context
所有的長的、阻塞的操作都需要 Context
errgroup 是構架於 Context 之上很好的抽象
當 Request 的結束的時候,Cancel Context
Context.Value 應該被用於告知性質的事物,而不是控制性質的事物
約束 Context.Value 的鍵空間
Context 以及 Context.Value 應該是不可變的(immutable),並且應該是線程安全
Context 應該隨 Request 消亡而消亡
Q&A
數據庫的訪問也用 Context 麼?
之前說過長時間、可阻塞的操作都用 Context,數據庫操作也是如此。不過對於超時 Cancel 操作來說,一般不會對寫操作進行 cancel;但是對於讀操作,一般會有 Cancel 操作。
原文
https://blog.lab99.org/post/golang-2017-10-27-video-how-to-correctly-use-package-context.html
< END >
喜歡就點個在看 or 轉發個朋友圈唄
衣舞晨風