Golang 如何正確使用 Context

視頻信息

How to correctly use package context by Jack Lindamood at Golang UK Conf. 2017html

視頻:www.youtube.com/watch?v=-_B… 博文:medium.com/@cep21/how-…git

爲何須要 Context

  • 每個長請求都應該有個超時限制
  • 須要在調用中傳遞這個超時
    • 好比開始處理請求的時候咱們說是 3 秒鐘超時
    • 那麼在函數調用中間,這個超時還剩多少時間了?
    • 須要在什麼地方存儲這個信息,這樣請求處理中間能夠中止

若是進一步考慮。github

如上圖這樣的 RPC 調用,開始調用 RPC 1 後,裏面分別調用了 RPC 2, RPC 3, RPC 4,等全部 RPC 用成功後,返回結果。

這是正常的方式,可是若是 RPC 2 調用失敗了會發生什麼?golang

RPC 2 失敗後,若是沒有 Context 的存在,那麼咱們可能依舊會等全部的 RPC 執行完畢,可是因爲 RPC 2 敗了,因此其實其它的 RPC 結果意義不大了,咱們依舊須要給用戶返回錯誤。所以咱們白白的浪費了 10ms,徹底不必去等待其它 RPC 執行完畢。數據庫

那若是咱們在 RPC 2 失敗後,就直接給用戶返回失敗呢?安全

用戶是在 30ms 的位置收到了錯誤消息,但是 RPC 3 和 RPC 4 依然在沒意義的運行,還在浪費計算和IO資源。

因此理想狀態應該是如上圖,當 RPC 2 出錯後,除了返回用戶錯誤信息外,咱們也應該有某種方式能夠通知 RPC 3 和 RPC 4,讓他們也中止運行,再也不浪費資源。bash

因此解決方案就是:服務器

  • 用信號的方式來通知請求該停了
  • 包含一些關於什麼時間請求可能會結束的提示(超時)
  • 用 channel 來通知請求結束了

那乾脆讓咱們把變量也扔那吧。😈微信

  • 在 Go 中沒有線程/go routine 變量
    • 其實挺合理的,由於這樣就會讓 goroutine 互相產生依賴
  • 很是容易被濫用

Context 實現細節

context.Context:併發

  • 是不可變的(immutable)樹節點
  • Cancel 一個節點,會連帶 Cancel 其全部子節點 (從上到下
  • Context values 是一個節點
  • Value 查找是回溯樹的方式 (從下到上

示例 Context 鏈

完整代碼:play.golang.org/p/ddpofBV1Q…

package main
func tree() {
  ctx1 := context.Background()
  ctx2, _ := context.WithCancel(ctx1)
  ctx3, _ := context.WithTimeout(ctx2, time.Second * 5)
  ctx4, _ := context.WithTimeout(ctx3, time.Second * 3)
  ctx5, _ := context.WithTimeout(ctx3, time.Second * 6)
  ctx6 := context.WithValue(ctx5, "userID", 12)
}
複製代碼

若是這樣構成的 Context 鏈,其形以下圖:

那麼當 3 秒超時到了時候:

能夠看到 ctx4 超時退出了。

當 5秒鐘 超時到達時:

能夠看到,不只僅 ctx3 退出了,其全部子節點,好比 ctx5 和 ctx6 也都退出了。

context.Context API

基本上是兩類操做:

  • 3個函數用於限定何時你的子節點退出
  • 1個函數用於設置請求範疇的變量
type 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 了,那通常是你作錯了
ctx, 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 的終止行爲。

type Group struct {
	cancel  func()
	wg      sync.WaitGroup
	errOnce sync.Once
	err     error
}
複製代碼

建立一個 group 和 context:

func WithContext(ctx context.Context) (*Group, context.Context) {
  ctx, cancel := context.WithCancel(ctx)
  return &Group{cancel: cancel}, ctx
}
複製代碼

這樣就返回了一個能夠被提早 cancel 的 group。

而調用的時候,並非直接調用 go func(),而是調用 Go(),將函數做爲參數傳進去,用高階函數的形式來調用,其內部纔是 go func() 開啓 goroutine。

func (g *Group) Go(f func() error) {
  g.wg.Add(1)
  go func() {
    defer g.wg.Done()
    if err := f(); err != nil {
      g.errOnce.Do(func() {
        g.err = err
        if g.cancel != nil {
          g.cancel()
        }
      })
    }
  }()
}
複製代碼

當給入函數 f 返回錯誤,則使用 sync.Once 來 cancel context,而錯誤被保存於 g.err 之中,在隨後的 Wait() 函數中返回。

func (g *Group) Wait() error {
  g.wg.Wait()
  if g.cancel != nil {
    g.cancel()
  }
  return g.err
}
複製代碼

注意:這裏在 Wait() 結束後,調用了一次 cancel()。

package main
func DoTwoRequestsAtOnce(ctx context.Context) error {
  eg, egCtx := errgroup.WithContext(ctx)
  var resp1, resp2 *http.Response
  f := func(loc string, respIn **http.Response) func() error {
    return func() error {
      reqCtx, cancel := context.WithTimeout(egCtx, time.Second)
      defer cancel()
      req, _ := http.NewRequest("GET", loc, nil)
      var err error
      *respIn, err = http.DefaultClient.Do(req.WithContext(reqCtx))
      if err == nil && (*respIn).StatusCode >= 500 {
        return errors.New("unexpected!")
      }
      return err
    }
  }
  eg.Go(f("http://localhost:8080/fast_request", &resp1))
  eg.Go(f("http://localhost:8080/slow_request", &resp2))
  return eg.Wait()
}
複製代碼

在這個例子中,同時發起了兩個 RPC 調用,當任何一個調用超時或者出錯後,會終止另外一個 RPC 調用。這裏就是利用前面講到的 errgroup 來實現的,應對有不少並不是請求,並須要集中處理超時、出錯終止其它併發任務的時候,這個 pattern 使用起來很方便。

Context.Value - Request 範疇的值

context.Value API 的萬金油(duct tape)

膠帶(duct tape) 幾乎能夠修任何東西,從破箱子,到人的傷口,到汽車引擎,甚至到NASA登月任務中的阿波羅13號飛船(Yeah! True Story)。因此在西方文化裏,膠帶是個「萬能」的東西。在中文裏,恐怕萬金油是更合適的對應詞彙,從頭疼、腦熱,感冒發燒,到跌打損傷幾乎無所不治。

固然,治標不治本,這點東西方文化中的潛臺詞都是同樣的。這裏說起的 context.Value 對於 API 而言,就是這類性質的東西,啥均可以幹,可是治標不治本。

  • value 節點是 Context 鏈中的一個節點
package 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() 來操做私有實體。

type privateCtxType string
var (
  reqID = privateCtxType("req-id")
)
func GetRequestID(ctx context.Context) (int, bool) {
  id, exists := ctx.Value(reqID).(int)
  return id, exists
}
func WithRequestID(ctx context.Context, reqid int) context.Context {
  return context.WithValue(ctx, reqID, reqid)
}
複製代碼

這裏使用 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 就完了?
func Add(ctx context.Context) int {
  return ctx.Value("first").(int) + ctx.Value("second").(int)
}
複製代碼

曾經看到過一個 API,就是這種形式:

func IsAdminUser(ctx context.Context) bool {
  userID := GetUser(ctx)
  return authSingleton.IsAdmin(userID)
}
複製代碼

這裏API實現內部從 context 中取得 UserID,而後再進行權限判斷。可是從函數簽名看,則徹底沒法理解這個函數具體須要什麼、以及作什麼。

代碼要以可讀性爲優先設計考慮。

別人拿到一個代碼,通常不是掉進函數實現細節裏去一行行的讀代碼,而是會先瀏覽一下函數接口。因此清晰的函數接口設計,會更加利於別人(或者是幾個月後的你本身)理解這段代碼。

一個良好的 API 設計,應該從函數簽名就清晰的理解函數的邏輯。若是咱們將上面的接口改成:

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
    • 只是給每一個 RPC 調用一個 ID,而沒有實際意義
    • 這就是個數字/字符串,反正你也不會用其做爲邏輯判斷
    • 通常也就是日誌的時候須要記錄一下
      • 而 logger 自己不是 Request 範疇,因此 logger 不該該在 Context 裏
      • 非 Request 範疇的 logger 應該只是利用 Context 信息來修飾日誌
  • User ID (若是僅僅是做爲日誌用)
  • Incoming Request ID

什麼顯然是控制性質的東西?

  • 數據庫鏈接
    • 顯然會很是嚴重的影響邏輯
    • 所以這應該在函數參數裏,明確表示出來
  • 認證服務(Authentication)
    • 顯然不一樣的認證服務致使的邏輯不一樣
    • 也應該放到函數參數裏,明確表示出來

例子

調試性質的 Context.Value - net/http/httptrace

medium.com/@cep21/go-1…

package main
func trace(req *http.Request, c *http.Client) {
  trace := &httptrace.ClientTrace{
    GotConn: func(connInfo httptrace.GotConnInfo) {
      fmt.Println("Got Conn")
    },
    ConnectStart: func(network, addr string) {
      fmt.Println("Dial Start")
    },
    ConnectDone: func(network, addr string, err error) {
      fmt.Println("Dial done")
    },
  }
  req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
  c.Do(req)
}
複製代碼

net/http 是怎麼使用 httptrace 的?

  • 若是有 trace 存在的話,就執行 trace 回調函數
  • 這只是告知性質,而不是控制性質
    • http 不會由於存在 trace 與否就有不一樣的執行邏輯
    • 這裏只是告知 API 的用戶,幫助用戶記錄日誌或者調試
    • 所以這裏的 trace 是存在於 Context 裏的
package 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 來定位依賴
  • 不推薦這樣作
    • 這裏這樣作基本上只是爲了知足測試需求
package 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 操做。

原文

blog.lab99.org/post/golang…

我的微信公衆號:

我的github:

github.com/jiankunking

我的博客:

jiankunking.com

相關文章
相關標籤/搜索