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