Go 部落格

上下文和結構體

Jean Barkhuysen, Matt T. Proud
2021 年 2 月 24 日

引言

在許多 Go API 中,尤其是現代 API,函式和方法的第一個引數常常是 context.Context。Context 提供了一種跨 API 邊界和跨程序傳遞截止時間、呼叫方取消訊號以及其他請求作用域值的方式。它通常用於庫直接或間接與遠端伺服器(如資料庫、API 等)互動的場景。

關於 context 的文件 指出

Context 不應儲存在結構體型別中,而應傳遞給每個需要它的函式。

本文將詳細闡述這一建議,並提供原因和示例,說明為何傳遞 Context 而非將其儲存在另一種型別中非常重要。本文還強調了一種罕見的情況,即在結構體型別中儲存 Context 可能有意義,並說明如何安全地這樣做。

優先使用作為引數傳遞的 contexts

為了理解不將 context 儲存在結構體中的建議,讓我們看看推薦的 context-作為引數傳遞的方法

// Worker fetches and adds works to a remote work orchestration server.
type Worker struct { /* … */ }

type Work struct { /* … */ }

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

func (w *Worker) Fetch(ctx context.Context) (*Work, error) {
  _ = ctx // A per-call ctx is used for cancellation, deadlines, and metadata.
}

func (w *Worker) Process(ctx context.Context, work *Work) error {
  _ = ctx // A per-call ctx is used for cancellation, deadlines, and metadata.
}

在這裡,(*Worker).Fetch(*Worker).Process 方法都直接接受一個 context。使用這種引數傳遞的設計,使用者可以為每次呼叫設定截止時間、取消以及元資料。此外,傳遞給每個方法的 context.Context 將如何使用也一目瞭然:不會期望傳遞給一個方法的 context.Context 會被其他任何方法使用。這是因為 context 的作用域被限制在所需操作的最小範圍內,這極大地提高了此包中 context 的實用性和清晰度。

在結構體中儲存 context 會導致混淆

讓我們再次檢查上面的 Worker 示例,採用不推薦的 context-in-struct 方法。問題在於,當你將 context 儲存在結構體中時,你會對呼叫者模糊其生命週期,或者更糟糕的是,以不可預測的方式將兩個作用域混合在一起

type Worker struct {
  ctx context.Context
}

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

func (w *Worker) Fetch() (*Work, error) {
  _ = w.ctx // A shared w.ctx is used for cancellation, deadlines, and metadata.
}

func (w *Worker) Process(work *Work) error {
  _ = w.ctx // A shared w.ctx is used for cancellation, deadlines, and metadata.
}

(*Worker).Fetch(*Worker).Process 方法都使用了儲存在 Worker 中的 context。這使得 Fetch 和 Process 的呼叫方(它們自身可能有不同的 context)無法按每次呼叫的方式指定截止時間、請求取消和附加元資料。例如:使用者無法僅為 (*Worker).Fetch 提供截止時間,或僅取消 (*Worker).Process 呼叫。呼叫者的生命週期與共享 context 混雜在一起,並且 context 的作用域被限定在建立 Worker 的生命週期。

與引數傳遞方法相比,這種 API 對使用者來說也更加令人困惑。使用者可能會問自己

  • 既然 New 接受一個 context.Context,建構函式是否正在執行需要取消或截止時間的工作?
  • 傳遞給 Newcontext.Context 是否適用於 (*Worker).Fetch(*Worker).Process 中的工作?都不適用?只適用其中一個?

API 將需要大量的文件來明確告訴使用者 context.Context 的具體用途。使用者可能還不得不閱讀程式碼,而不是能夠依賴 API 結構本身傳達的資訊。

最後,設計一個生產級伺服器,如果其請求沒有各自的 context 從而無法充分響應取消,那可能會相當危險。如果無法為每次呼叫設定截止時間,你的程序可能會積壓並耗盡其資源(如記憶體)!

規則的例外:保留向後相容性

當 Go 1.7(引入了 context.Context)釋出時,大量的 API 必須以向後相容的方式新增 context 支援。例如,net/httpClient 方法,如 GetDo,是 context 的絕佳候選。使用這些方法傳送的每個外部請求都將受益於 context.Context 帶來的截止時間、取消和元資料支援。

有兩種以向後相容方式新增對 context.Context 支援的方法:一種是將 context 包含在結構體中,我們稍後會看到;另一種是複製函式,複製的函式接受 context.Context 並以 Context 作為函式名字尾。應優先選擇複製函式的方法,而不是將 context 儲存在結構體中的方法,這在保持模組相容性 中有進一步討論。然而,在某些情況下,複製函式方法可能不切實際:例如,如果你的 API 暴露了大量函式,那麼全部複製可能不可行。

net/http 包選擇了 context-in-struct 方法,這提供了一個有用的案例研究。讓我們看看 net/httpDo 方法。在引入 context.Context 之前,Do 的定義如下

// Do sends an HTTP request and returns an HTTP response [...]
func (c *Client) Do(req *Request) (*Response, error)

如果不破壞向後相容性,Go 1.7 之後 Do 可能看起來像下面這樣

// Do sends an HTTP request and returns an HTTP response [...]
func (c *Client) Do(ctx context.Context, req *Request) (*Response, error)

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

// A Request represents an HTTP request received by a server or to be sent by a client.
// ...
type Request struct {
  ctx context.Context

  // ...
}

// NewRequestWithContext returns a new Request given a method, URL, and optional
// body.
// [...]
// The given ctx is used for the lifetime of the Request.
func NewRequestWithContext(ctx context.Context, method, url string, body io.Reader) (*Request, error) {
  // Simplified for brevity of this article.
  return &Request{
    ctx: ctx,
    // ...
  }
}

// Do sends an HTTP request and returns an HTTP response [...]
func (c *Client) Do(req *Request) (*Response, error)

在改造你的 API 以支援 context 時,像上面那樣將 context.Context 新增到結構體中可能有意義。然而,請記住首先考慮複製你的函式,這可以在不犧牲實用性和理解性的情況下以向後相容的方式改造支援 context.Context。例如

// Call uses context.Background internally; to specify the context, use
// CallContext.
func (c *Client) Call() error {
  return c.CallContext(context.Background())
}

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

結論

Context 使得跨庫和跨 API 的重要資訊能夠輕鬆地沿著呼叫棧傳播。但是,為了保持易於理解、易於除錯和有效,它必須被一致且清晰地使用。

當 Context 作為方法的第一個引數傳遞而不是儲存在結構體型別中時,使用者可以充分利用其可擴充套件性,透過呼叫棧構建一個強大的取消、截止時間和元資訊樹。最重要的是,當它作為引數傳遞時,其作用域是清晰明確的,這使得在棧的上下層都易於理解和除錯。

在設計帶有 context 的 API 時,請記住這條建議:將 context.Context 作為引數傳入;不要將其儲存在結構體中。

進一步閱讀

下一篇文章:2020 年 Go 開發者調查結果
上一篇文章:Go 1.16 中的新模組變化
部落格索引