:::info 日期:2021 年 02 月 24 日
作者:Jean de Klerk, Matt T. Proud
原文链接:https://go.dev/blog/context-and-structs :::

介绍

在许多 Go API 中,尤其是现代 API,函数和方法的第一个参数通常是 context.Context。 上下文提供了一种跨 API 边界和进程之间传输截止日期、调用方取消和其他请求范围值的方法。 当库直接或传递地与远程服务器(例如数据库、API 等)交互时,通常会使用它。
上下文文档语句:

上下文不应该存储在结构类型中,而是传递给需要它的每个函数。

本文通过原因和示例扩展了该建议,描述了为什么传递 Context 而不是将其存储在另一种类型中很重要。 它还强调了一种罕见的情况,在这种情况下,将 Context 存储在结构类型中可能有意义,以及如何安全地这样做。

更喜欢 contexts 作为参数传递

要理解不要在结构中存储上下文的建议,让我们考虑首选的上下文作为参数的方法:

  1. // Worker fetches and adds works to a remote work orchestration server.
  2. type Worker struct { /* … */ }
  3. type Work struct { /* … */ }
  4. func New() *Worker {
  5. return &Worker{}
  6. }
  7. func (w *Worker) Fetch(ctx context.Context) (*Work, error) {
  8. _ = ctx // A per-call ctx is used for cancellation, deadlines, and metadata.
  9. }
  10. func (w *Worker) Process(ctx context.Context, work *Work) error {
  11. _ = ctx // A per-call ctx is used for cancellation, deadlines, and metadata.
  12. }

这里, (Worker).Fetch 和 (Worker).Process 方法都直接接受上下文。 通过这种传递作为参数的设计,用户可以设置每次调用的截止日期、取消和元数据。 而且,传递给每个方法的 context.Context 将如何使用是很清楚的:没有期望传递给一个方法的 context.Context 会被任何其他方法使用。 这是因为上下文的范围是它需要的尽可能小的操作,这大大增加了这个包中上下文的实用性和清晰度。

在结构中存储上下文会导致混淆

让我们用不受欢迎的结构中上下文方法再次检查上面的 Worker 示例。 它的问题在于,当您将上下文存储在结构中时,您会掩盖调用者的生命周期,或者更糟糕的是,以不可预测的方式将两个范围混合在一起:

  1. type Worker struct {
  2. ctx context.Context
  3. }
  4. func New(ctx context.Context) *Worker {
  5. return &Worker{ctx: ctx}
  6. }
  7. func (w *Worker) Fetch() (*Work, error) {
  8. _ = w.ctx // A shared w.ctx is used for cancellation, deadlines, and metadata.
  9. }
  10. func (w *Worker) Process(work *Work) error {
  11. _ = w.ctx // A shared w.ctx is used for cancellation, deadlines, and metadata.
  12. }
  1. (*Worker).Fetch (*Worker).Process 方法都使用存储在 Worker 中的上下文。 这可以防止 Fetch Process 的调用者(它们本身可能具有不同的上下文)指定截止日期、请求取消以及在每次调用的基础上附加元数据。 例如:用户无法仅为 (*Worker).Fetch 提供截止日期,或仅取消 (*Worker).Process 调用。 调用者的生命周期与共享上下文混合在一起,上下文的范围是创建 Worker 的生命周期。

与作为参数传递的方法相比,API 也让用户更加困惑。 用户可能会问自己:

  • 由于 New 采用 context.Context,构造函数是否正在执行需要取消或截止日期的工作?
  • 传递给 New 的 context.Context 是否适用于 (Worker).Fetch 和 (Worker).Process 中的工作? 两者都不? 一个而不是另一个?

API 需要大量文档来明确告诉用户 context.Context 的用途。 用户可能还必须阅读代码,而不是能够依赖 API 传达的结构。

而且,最后,设计一个生产级服务器可能是非常危险的,它的请求不是每个都有上下文,因此不能充分尊重取消。 如果无法设置每次调用的截止日期,您的进程可能会积压并耗尽其资源(如内存)!

规则的例外:保持向后兼容性

当 Go 1.7(引入了 context.Context)发布时,大量 API 不得不以向后兼容的方式添加上下文支持。 例如,net/http 的 Client 方法,如 Get 和 Do,是上下文的绝佳候选者。 使用这些方法发送的每个外部请求都将受益于 context.Context 附带的截止日期、取消和元数据支持。

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

net/http 包选择了 context-in-struct 方法,它提供了一个有用的案例研究。 我们来看看net/http的Do。 在引入 context.Context 之前,Do 的定义如下:

  1. // Do sends an HTTP request and returns an HTTP response [...]
  2. func (c *Client) Do(req *Request) (*Response, error)

在 Go 1.7 之后,Do 可能看起来像下面这样,如果不是因为它会破坏向后兼容性:

  1. // Do sends an HTTP request and returns an HTTP response [...]
  2. func (c *Client) Do(ctx context.Context, req *Request) (*Response, error)

但是,保持向后兼容性并遵守 Go 1 的兼容性承诺对于标准库来说至关重要。 因此,维护人员选择在 http.Request 结构上添加 context.Context 以允许支持 context.Context 而不破坏向后兼容性:

  1. // A Request represents an HTTP request received by a server or to be sent by a client.
  2. // ...
  3. type Request struct {
  4. ctx context.Context
  5. // ...
  6. }
  7. // NewRequestWithContext returns a new Request given a method, URL, and optional
  8. // body.
  9. // [...]
  10. // The given ctx is used for the lifetime of the Request.
  11. func NewRequestWithContext(ctx context.Context, method, url string, body io.Reader) (*Request, error) {
  12. // Simplified for brevity of this article.
  13. return &Request{
  14. ctx: ctx,
  15. // ...
  16. }
  17. }
  18. // Do sends an HTTP request and returns an HTTP response [...]
  19. func (c *Client) Do(req *Request) (*Response, error)

在改造您的 API 以支持上下文时,将 context.Context 添加到结构体可能是有意义的,如上所述。 但是,请记住首先考虑复制您的函数,这允许在不牺牲实用性和理解性的情况下以向后兼容性改造 context.Context。 例如:

  1. // Call uses context.Background internally; to specify the context, use
  2. // CallContext.
  3. func (c *Client) Call() error {
  4. return c.CallContext(context.Background())
  5. }
  6. func (c *Client) CallContext(ctx context.Context) error {
  7. // ...
  8. }

总结

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

当作为方法中的第一个参数传递而不是存储在结构类型中时,用户可以充分利用其可扩展性,以便通过调用堆栈构建一个强大的取消、截止日期和元数据信息树。 而且,最重要的是,当它作为参数传入时,它的范围会被清楚地理解,从而在堆栈上下清晰地理解和调试。

在设计带有上下文的 API 时,请记住以下建议:将 context.Context 作为参数传入; 不要将它存储在结构中。