Context 這三個應用場景,你知嗎

用戶發送 開始消費 請求時:開啓多個協程開始消費消息隊列某個 topic 的信息;

用戶發送 結束消費 請求時:把消費中的 topic 相關的協程關閉掉,結束消費;

跨服務傳遞信息

現在具備一定規模的互聯網公司都用微服務形式讓各系統組合起來爲用戶提供服務,一個簡單的業務在流程上可能需要十幾個甚至幾十個系統間互相調用。由於每個系統內部的正確性無法保證,若出現了 case,比如用戶反饋積分少發了,就需要排查這十幾個系統的日誌信息,看問題出在哪裏。

此處需要一個 ID 憑證,ID 是請求級別的,在各個系統中記錄着與此請求相關的日誌信息,我們把它叫做 trace ID。把日誌採集並落盤到 ES 這樣的存儲中,有 case 時只需要拿到請求的 trace ID 就可以把全流程的關鍵信息還原出來。如圖所示:

在 Golang web 服務中,每個請求都是開一個協程去處理的。系統間傳遞信息時,若通信協議用 HTTP,那 trace ID 等信息可放在 HTTP Header 中,在 web 框架的 middle 層把這些信息存入 Context。demo 如下:

// 檢測上游服務是否傳遞traceID信息,若傳遞了直接使用
if v, ok := req.Header["my-awesome-trace-ID"]; ok {
   traceID = v[0]
} else {
  // 若沒傳則用公共庫生成一個全局唯一的traceID信息
  traceID = GenTraceID()
  req.Header["my-awesome-trace-ID"] = []string{traceID}
}
// 處理完各種請求上下文信息後,把這些信息統一存儲到ctx中,傳遞給業務層的對應Handler
ctx = context.WithValue(ctx, ContentKey, record)

Context 處理請求上下文這塊主要用到了WithValue,這個函數接收一個 ctx 和一對 k-v。把 k-v 對存起來後返回一個子 ctx,這次我們先簡單介紹其使用場景,下篇文章會從源碼層面理解這個函數。

ctx 的生命週期是 伴隨請求開始而誕生、請求結束而終止的。在請求中 ctx 會跨越多個函數多個協程,在打日誌時,第一個參數預留給 ctx 是因爲日誌庫需要從 Context 中抽取 trace ID 等信息,從而記錄下完整的日誌。獲取信息時只需要調用 context 的 Value 方法,demo 如下:

// 從Context中獲取traceID, 打到日誌裏
v := ctx.Value("my-awesome-trace-ID")

這裏畫個圖幫助理解:

若我們的系統也需要請求第三方服務,同樣應把 trace ID 等信息放入 HTTP Header 後發送請求,其他服務按照同樣的流程接收到 trace ID 後開始內部邏輯處理。這樣一個請求在多個系統中就通過 trace ID 串聯起了整個流程。除 trace ID 外,Context 還可以傳遞 URL Path、請求時間、Caller 等信息。


多協程消費 demo:

func main() {
 // 此協程負責監聽錯誤信息,開啓消費
 go func() {
  for {
   select {
   // code
   }
  }
 }()

 // 此協程負責監聽re-balance信息,開啓消費
 go func() {
  for {
   select {
   // code
   }
  }
 }()
  // ...
}

func main() {
 ctx, cancel := context.WithCancel(context.Background())

 // 此協程負責監聽錯誤信息,開啓消費
 go func() {
  for {
   select {
   case <-ctx.Done():
    fmt.Println("退出監聽錯誤協程")
    return
   default:
    fmt.Println("邏輯處理中...")
   }
  }
 }()

 // 此協程負責監聽re-balance信息,開啓消費
 go func() {
  for {
   select {
   case <-ctx.Done():
    fmt.Println("退出監聽re-balance協程")
    return
   default:
    fmt.Println("邏輯處理中...")
   }
  }
 }()

 // 調用cancelFunc, 結束消費
 cancel()
}

控制協程關閉

上面代碼用到了WithCancel方法,調用它會返回一個可被取消的 ctx 和 CancelFunc,需要取消 ctx 時,調用 cancel 函數即可。context 有個 Done 方法,這個方法返回一個 channel,當 Context 被取消時,這個 channel 會被關閉。消費中的協程通過 select 監聽這個 channel,收到關閉信號後一個 return 就能結束消費。

CancelFunc可以預防系統做不必要的工作。比如用戶請求 A 接口時,A 接口內部需要請求 A database、B cache 、C System 獲取各種數據,把這些數據經過計算後組裝到一起返回給調用方。這是正常情況的時序圖:

但如果用戶在訪問網站時覺得沒意思,去其他網站了。此時若你的服務收到用戶請求後繼續去訪問其他 C system、B database 就是浪費資源。比較符合直覺的做法是:當業務請求取消時,你的系統也應該停止請求下游系統。前面我們介紹過 context 在系統中貫穿請求週期,那麼當用戶取消訪問時,只要 context 監聽取消事件並在用戶取消時發送取消事件,就可以取消請求了。

這裏有份 demo 代碼,項目啓動後,可以用curl localhost:8888訪問這個接口,若 1s 內取消請求,服務端會打印出request canceleld,正常情況下,服務會返回process finished

func main() {
 http.ListenAndServe(":8888", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  ctx := r.Context()
  fmt.Println("get request")
  select {
  case <-time.After(1 * time.Second):
   w.Write([]byte("process finished"))
  case <-ctx.Done():
   fmt.Println("request canceleld")
  }
 }))
}

除了用戶中途取消請求的情況,還有一種情況也可以用到 cancelFunc:服務 A 的返回數據依賴服務 B 和服務 C 的相關接口,若服務 B 或者服務 C 掛了,此次請求就算失敗了,沒必要再訪問另一個服務,此時也可以用CancelFunc。Demo 如下:

func getUserInfoBySystemA(ctx context.Context) error {
 time.Sleep(100 * time.Millisecond)
 // 模擬請求出錯的情況
 return errors.New("failed")
}

func getOrderInfoBySystemB(ctx context.Context) {
 select {
 case <-time.After(500 * time.Millisecond):
  fmt.Println("process finished")
 case <-ctx.Done():
  fmt.Println("process cancelled")
 }
}

func main() {
 ctx, cancel := context.WithCancel(context.Background())

 //併發從兩個服務中獲取相關數據
 go func() {
  err := getUserInfoBySystemA(ctx)
  if err != nil {
   // 發生錯誤,調用cancelFunc
   cancel()
  }
 }()

 getOrderInfoBySystemB(ctx)
}

控制超時取消

如果你的服務對外承諾的 SLA 是 100ms,但系統依賴的服務 B 的 HTTP 接口有點不穩定,有時 50ms 就能返回結果,有時 100ms 才能返回結果,爲了保證你服務的 SLA,可以用 Context 的WithTimeout方法設置一個超時時間,demo 如下:

func main() {
 // 設置超時時間100ms
 ctx, _ := context.WithTimeout(context.Background(), 100*time.Millisecond)

 // 構建一個HTTP請求
 req, _ := http.NewRequest(http.MethodGet, "https://www.baidu.com/", nil)
 // 把ctx信息傳進去
 req = req.WithContext(ctx)

 client := &http.Client{}
 // 向百度發送請求
 res, err := client.Do(req)
 if err != nil {
  fmt.Println("Request failed:", err)
 }
 fmt.Println("Response received, status code:", res.StatusCode)
}

正常情況下,會得到這樣的輸出:

Response received, status code: 200

如果我們請求百度超時了,會得到這樣的輸出:

Request failed: Get https://www.baidu.com/: context deadline exceeded

歡迎加入 隨波逐流的薯條 微信羣。

薯條目前有草帽羣、木葉羣、琦玉羣,羣交流內容不限於技術、投資、趣聞分享等話題。歡迎感興趣的同學入羣交流。

入羣請加薯條的個人微信:709834997。並備註:加入薯條微信羣。

歡迎關注我的公衆號~

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/uIL7gsdUn89MopbAxE2rjQ