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 必须以向后兼容的方式添加上下文支持。例如,net/httpClient 方法,如 GetDo,是上下文的优秀候选对象。使用这些方法发送的每个外部请求都将从 context.Context 附带的截止日期、取消和元数据支持中受益。

有两种方法可以以向后兼容的方式添加对 context.Context 的支持:在结构体中包含上下文,正如我们将在稍后看到的那样,以及复制函数,复制的函数接受 context.Context 并且在函数名称后缀中带有 Context。复制方法应优先于上下文在结构体中的方法,并在 保持模块兼容性 中进行了进一步讨论。但是,在某些情况下,这并不实用:例如,如果您的 API 公开了大量函数,那么复制所有这些函数可能不可行。

net/http 包选择了上下文在结构体中的方法,这提供了一个有用的案例研究。让我们看一下 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。例如

// 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 {
  // ...
}

结论

上下文使跨库和跨 API 的重要信息在调用堆栈中传播变得容易。但是,它必须以一致且清晰的方式使用,以便保持可理解性、易于调试和有效性。

当作为方法的第一个参数传递而不是存储在结构体类型中时,用户可以充分利用其可扩展性,以便通过调用堆栈构建强大的取消、截止日期和元数据信息树。而且,最重要的是,当它作为参数传递进来时,它的作用域是清晰易懂的,从而导致在整个堆栈中清晰的理解和可调试性。

在使用上下文设计 API 时,请记住以下建议:将 context.Context 作为参数传递进来;不要将其存储在结构体中。

进一步阅读

下一篇文章:Go 开发者调查 2020 年结果
上一篇文章:Go 1.16 中的新模块更改
博客索引