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 中的新模块变化
博客索引