Context和结构体

本文翻译自《Contexts and structs》。

Jean de Klerk, Matt T. Proud

24 February 2021

介绍

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

Context(上下文)的文档指出:Context不应该存储在结构体类型中,而是传递给每个需要它的函数。

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

首选作为参数传递的context

为了理解不将context存储在结构体中的建议,让我们考虑首选的把context作为参数的方法:

// Worker获取work并将其添加到远程工作编排服务器。
type Worker struct { /* … */ }

type Work struct { /* … */ }

func New() *Worker {
  return &Worker{}
}

func (w *Worker) Fetch(ctx context.Context) (*Work, error) {
  _ = ctx // 每次调用ctx用于取消、截止时间和元数据。
}

func (w *Worker) Process(ctx context.Context, work *Work) error {
  _ = ctx // 每次调用ctx用于取消、截止时间和元数据。
}

在这里,(*Worker).Fetch(*Worker).Process方法都直接接受context。通过这种作为参数传递的设计,用户可以设置每次调用的截止时间、取消和元数据。而且,很清楚如何使用传递给每个方法的context.Context:没有悬念传递给一个方法的context.Context将被任何其他方法使用。这是因为context被限定为尽可能小的操作,这极大地增加了这个包中context的实用性和清晰度。

在结构体中存储context会导致混乱

让我们用不受欢迎的在结构体中使用context(context-in-struct)方法再次检查上面的Worker示例。它的问题在于,当你将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 // 共享的w.ctx用于取消、截止时间和元数据。
}

func (w *Worker) Process(work *Work) error {
  _ = w.ctx // 共享的w.ctx用于取消、截止时间和元数据。
}

(*Worker).Fetch(*Worker).Process方法都使用存储在Worker中的context。这可以防止FetchProcess的调用者(它们本身可能有不同的context)指定截止时间、请求取消以及在每个调用的基础上附加元数据。例如:用户无法为(*Worker).Fetch提供截止时间,或仅取消(*Worker).Process调用。调用者的生命周期与共享的context混合在一起,并且context的作用域局限于创建的Worker的生命周期。

与通过参数传递(pass-as-argument)的方法相比,API也更容易让用户感到困惑。用户可能会问自己:

  • 由于New需要一个context.Context,构造函数做的工作是否需要取消或截止时间?
  • 传递给Newcontext.Context是否适用于(*Worker).Fetch(*Worker).Process?两者都不?这一个但不是另一个?

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

最后,这么设计一个生产级服务可能非常危险,它的每个请求都没有context,因此不能充分执行取消。如果无法设置每次调用的最后时间,你的进程可能会积压并耗尽其资源(例如内存)!

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

引入context.Context的Go 1.7发布时,大量API必须以向后兼容的方式添加context支持。例如,net/httpClient方法,像GetDo,是context的绝佳候选者。使用这些方法发送的每个外部请求都将受益于context.Context附带的截止时间、取消和元数据支持。

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

net/http包选择了在结构体中使用context的方法,它提供了一个有用的案例研究。让我们看看net/httpDo。在引入context.Context之前,Do定义如下:

// Do发送一个HTTP请求并返回一个HTTP响应 [...]
func (c *Client) Do(req *Request) (*Response, error)

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

// Do发送一个HTTP请求并返回一个HTTP响应 [...]
func (c *Client) Do(ctx context.Context, req *Request) (*Response, error)

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

// Request代表服务器接收到的HTTP请求或将由客户端发送的HTTP请求。
// ...
type Request struct {
  ctx context.Context

  // ...
}

// NewRequestWithContext在给定method、URL和可选body的情况下返回一个新请求。
// [...]
// 给定的ctx用于Request的生命周期。
func NewRequestWithContext(ctx context.Context, method, url string, body io.Reader) (*Request, error) {
  // 为简洁起见,本文进行了简化。
  return &Request{
    ctx: ctx,
    // ...
  }
}

// Do发送一个HTTP请求并返回一个HTTP响应 [...]
func (c *Client) Do(req *Request) (*Response, error)

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

// Call在内部使用context.Background;要指定context,请使用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作为参数传入;不要将它存储在结构体中。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注