Go 部落格

背景與結構

Jean de Klerk、Matt T. Proud
2021 年 2 月 24 日

簡介

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

背景文件指出

不可將背景儲存在結構類型中,而應傳遞給每個需要的函式。

本文進一步說明該建議的理由和範例,說明傳遞背景而非將其儲存在其他類型中很重要的原因。它也重點說明在結構類型中儲存背景的罕見案例可能有意義,以及如何安全地執行此操作。

優先傳遞背景作為引數

要了解不將背景儲存在結構中的建議,讓我們來考慮優先選擇以背景為引數的方式

// 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 的效用和明確程度。

將內容儲存在結構會造成混亂

讓我們再次檢查上面範例中的 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 // 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 內的內容。這會阻礙 Fetch 和 Process 的呼叫者(本身可能具有不同的內容)指定最後期限、要求取消,以及針對每個呼叫附加資料。例如:使用者無法僅對 (*Worker).Fetch 提供最後期限,或僅取消 (*Worker).Process 呼叫。呼叫者的生命週期會與共用內容混在一起,而且內容的範圍會限制在建立 Worker 的生命週期內。

與傳遞引數的方式相較之下,此 API 對使用者而言也會混淆許多。使用者可能會問自己

  • 由於 New 會接收 context.Context,建構函式是否會執行需要取消或最後期限的工作?
  • 傳遞給 Newcontext.Context 是否會套用於 (*Worker).Fetch(*Worker).Process 內的工作?兩者皆否?是其一,但不是另一者?

此 API 需要大量文件來明確告知使用者 context.Context 的確切用途。使用者可能也必須閱讀程式碼,而不是能依賴 API 的結構傳達的內容。

最後,設計一個每個要求都沒有內容的生產等級伺服器可能很危險,因為這樣一來無法適當尊重新取消。若無法設定每個呼叫的最後期限,你的程序可能會造成積壓,而且會耗盡其資源(例如記憶體)!

規則的例外:維護後向相容性

當推出 Go 1.7 (導入 context.Context) 時,數量龐大的 API 必須以回溯相容的方式加入對 context 的支援。例如,net/httpClient 方法(如 GetDo)是 context 的最佳候選者。傳送至這些方法的每個外部要求都會受益於 context.Context 附帶的截止時間、取消以及元資料支援。

有兩種方法可以以回溯相容的方式加入對 context.Context 的支援:在結構中納入 context(稍後會說明)以及複製函式(其中,重複的函式會接受 context.Context,並將 Context 作為它們的函式名稱字尾)。複製法應該是優先考量,而不是結構中的 context,且在 維持模組相容性 中進一步加以討論。然而,在某些情況下,這並不可行:例如,如果您的 API 揭露大量的函式,那麼複製它們會不可行。

net/http 套件選擇結構中的 context 方法,提供了有用的個案研究。我們來看看 net/http 中的 Do。在導入 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 的 API 時,請記住這個建議:將 context.Context 傳遞為參數,不要儲存在結構中。

延伸資料

下一篇:2020 年 Go 开发人员问卷结果
上一篇:Go 1.16 中的新增模块变更
Blog 索引