Go 部落格

上下文和結構體

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

引言

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

有關 context 的文件指出:

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

本文將透過原因和示例來擴充套件此建議,描述為什麼將 Context 作為引數傳遞而不是將其儲存在其他型別中很重要。它還強調了一個罕見的、儲存 Context 在 struct 型別中可能是有意義的情況,以及如何安全地進行。

優先使用作為引數傳遞的上下文

為了理解不將 context 儲存在 struct 中的建議,讓我們考慮首選的 context-as-argument 方法。

// 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 儲存在 struct 中會導致混淆

讓我們再次檢查上面帶有不推薦的 context-in-struct 方法的 Worker 示例。問題在於,當您將 context 儲存在 struct 中時,您會向呼叫者隱藏生命週期,或者更糟的是,以不可預測的方式混合兩個範圍。

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 包含在 struct 中,如我們稍後將看到的,以及複製函式,其中複製的函式接受 context.Context 並在函式名字尾上新增 Context。應優先使用複製方法而不是 context-in-struct 方法,並且在使模組保持相容中有更詳細的討論。然而,在某些情況下,這是不切實際的:例如,如果您的 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 向後相容性承諾對於標準庫至關重要。因此,維護者選擇將 context.Context 新增到 http.Request struct 中,以便在不破壞向後相容性的情況下支援 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 新增到 struct 中可能是合理的,如上所示。但是,請記住首先考慮複製您的函式,這可以以向後相容的方式改造 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 資訊向下傳播到呼叫堆疊。但是,為了保持可理解性、易於除錯和有效性,必須一致且清晰地使用它。

當作為方法的第一個引數傳遞而不是儲存在 struct 型別中時,使用者可以充分利用其可擴充套件性,透過呼叫堆疊構建一個強大的取消、截止時間和元資料資訊樹。而且,最重要的是,當它作為引數傳遞時,其範圍清晰可懂,從而在呼叫堆疊的上下文中實現了清晰的理解和除錯能力。

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

延伸閱讀

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