上下文 Context 與結構體 Struct

kevin發表於2021-02-25
原文地址:https://blog.golang.org/context-and-structs
原文作者:Jean de Klerk, Matt T. Proud
譯者:Kevin

介紹

在許多 Go API 中,尤其是現代的 API 中,函式和方法的第一個引數通常是context.Context。上下文(Context)提供了一種方法,用於跨 API 邊界和程式之間傳輸截止時間、呼叫者取消和其他請求範圍的值。當一個庫與遠端伺服器(如資料庫、API 等)直接或間接互動時,經常會用到它。

context 的文件中寫道。

上下文不應該儲存在結構型別裡面,而是傳遞給每個需要它的函式。

本文對這一建議進行了擴充套件,用具體例子解析為什麼傳遞上下文而不是將其儲存在其他型別中很重要。它還強調了一種罕見的情況,即在結構型別中儲存上下文可能是有意義的,以及如何安全地這樣做。

傾向於將上下文作為引數傳遞

為了深入理解不在結構中儲存上下文的建議,我們來考慮一下首選的上下文作為引數的方法。

type Worker struct { /* … */ }

type Work struct { /* … */ }

func New() *Worker {
  return &Worker{}
}

func (w *Worker) Fetch(ctx context.Context) (*Work, error) {
  _ = ctx // 每次呼叫中ctx用於取消操作,截止時間和後設資料。
}

func (w *Worker) Process(ctx context.Context, w *Work) error {
  _ = ctx // A每次呼叫中ctx用於取消操作,截止時間和後設資料。
}

在這個例子中,(*Worker).Fetch(*Worker).Process方法都直接接受上下文。通過這種通過引數傳遞的設計,使用者可以設定每次呼叫的截止時間、取消和後設資料。而且,很清楚傳遞給每個方法的context.Context將如何被使用:沒有期望傳遞給一個方法的context.Context將被任何其他方法使用。這是因為上下文的範圍被限定在了小範圍的必須操作內,這大大增加了這個包中上下文的實用性和清晰度。

將上下文儲存在結構中會導致混亂

讓我們再次使用上下文儲存在結構體中這種方式審視一下上面的Worker例子。它的問題是,當你把上下文儲存在一個結構中時,你會向呼叫者隱藏它的生命週期,甚至可能的是把兩個不同的作用域以不可預料的方式互相干擾:

type Worker struct {
  ctx context.Context
}

func New(ctx context.Context) *Worker {
  return &Worker{ctx: ctx}
}

func (w *Worker) Fetch() (*Work, error) {
  _ = w.ctx // 共享的w.ctx用於取消操作,截止時間和後設資料。
}

func (w *Worker) Process(w *Work) error {
  _ = w.ctx // 共享的w.ctx用於取消操作,截止時間和後設資料。
}

(*Worker).Fetch(*Worker).Process方法都使用儲存在Worker中的上下文。這防止了FetchProcess的呼叫者(它們本身可能有不同的上下文)在每次呼叫的基礎上指定截止日期、請求取消和附加後設資料。例如:使用者無法只為(*Worker).Fetch提供截止日期,也無法只取消(*Worker).Process的呼叫。呼叫者的生命期與共享上下文交織在一起,上下文的範圍是建立Worker的生命週期。

與上下文作為引數的方法相比,該 API 也更容易讓使用者感到疑惑。使用者可能會問自己:

  • 既然New需要一個context.Context,那麼建構函式是否在做取消或截止時間控制的工作?
  • New傳遞進來的context.Context是否適用於(*Worker).Fetch(*Worker).Process?都不適用?有一個而沒有另一個?

API 需要大量的文件來明確告訴使用者context.Context到底是用來做什麼的。使用者可能還需要閱讀程式碼,而不是能夠依靠 API 結構獲得資訊。

最後,如果設計一個生產級伺服器,其每個請求沒有上下文,從而不能充分重視取消操作,這可能是相當危險的。如果沒有能力設定每個呼叫的截止日期,你的程式可能會積壓資源並導致資源耗盡(如記憶體)!

規則的例外:儲存向後的相容性

引入 context.Context的 Go 1.7 釋出時,大量的 API 必須以向後相容的方式新增上下文支援。例如,net/httpClient方法,如GetDo,就是很好的上下文取消操作的應用。每一個用這些方法傳送的外部請求都會受益於context.Context帶來的截止時間、取消和後設資料支援。

有兩種方法可以以向後相容的方式新增對context.Context的支援:將上下文包在一個結構中,正如我們稍後將看到的那樣;複製函式,複製的函式接受context.Context作為引數,並將Context作為其函式名的字尾。複製的方法應該比在結構體中嵌入上下文的方式更可取,在保持模組的相容性中會進一步討論。然而,在某些情況下,這是不切實際的:例如,如果你的 API 暴露了大量的函式,那麼複製所有的函式可能是不可行的。

net/http包選擇了上下文儲存在結構體方式,這提供了一個有用的案例研究。讓我們看看net/httpDo方法。在引入context.Context之前,Do的定義如下:

func (c *Client) Do(req *Request) (*Response, error)

在 Go 1.7 之後,如果不考慮破壞向後的相容性的問題,Do 可能看起來像下面這樣:

func (c *Client) Do(ctx context.Context, req *Request) (*Response, error)

但是,保留向後的相容性,遵守Go 1 的相容性承諾對於標準庫來說是至關重要的。所以,維護者選擇在http.Request結構上新增一個context.Context,以便在不破壞向後相容性的情況下支援context.Context

type Request struct {
  ctx context.Context

  // ...
}

func NewRequestWithContext(ctx context.Context, method, url string, body io.Reader) (*Request, error) {
  // 為了本文演示需要做了簡化。
  return &Request{
    ctx: ctx,
    // ...
  }
}

func (c *Client) Do(req *Request) (*Response, error)

當改造你的 API 以支援上下文時,像上面那樣在一個結構中新增一個context.Context可能是有意義的。但是,你需要首先考慮複製你的函式,這樣可以在不犧牲實用性和理解性的前提下,向後相容地改造context.Context。例如:

func (c *Client) Call() error {
  return c.CallContext(context.Background())
}

func (c *Client) CallContext(ctx context.Context) error {
  // ...
}

總結

上下文使得重要的跨庫和跨 API 資訊很容易在呼叫棧中傳播。但是,為了保持可理解性、易除錯性和有效性,必須統一清晰地使用它。

當作為方法中的第一個引數而不是儲存在結構型別中時,使用者可以充分利用它的可擴充套件性,以便通過呼叫棧建立一個強大的取消、截止日期和後設資料資訊樹。而且,最重要的是,當它作為一個引數傳遞進來時,它的範圍被清晰的理解,從而導致堆疊上下的理解更加清晰和除錯更加容易。

當設計一個帶有上下文的 API 時,請記住這樣的建議:將context.Context作為一個引數傳遞進來,不要將它儲存在結構體中。

更多原創文章乾貨分享,請關注公眾號
  • 上下文 Context 與結構體 Struct
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章