本文翻译自《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。这可以防止Fetch
和Process
的调用者(它们本身可能有不同的context)指定截止时间、请求取消以及在每个调用的基础上附加元数据。例如:用户无法为(*Worker).Fetch
提供截止时间,或仅取消(*Worker).Process
调用。调用者的生命周期与共享的context混合在一起,并且context的作用域局限于创建的Worker
的生命周期。
与通过参数传递(pass-as-argument)的方法相比,API也更容易让用户感到困惑。用户可能会问自己:
- 由于
New
需要一个context.Context
,构造函数做的工作是否需要取消或截止时间? - 传递给
New
的context.Context
是否适用于(*Worker).Fetch
和(*Worker).Process
?两者都不?这一个但不是另一个?
API需要大量文档来明确告诉用户context.Context
的用途。用户可能还必须阅读源代码,而不是能够依赖API传达的信息。
最后,这么设计一个生产级服务可能非常危险,它的每个请求都没有context,因此不能充分执行取消。如果无法设置每次调用的最后时间,你的进程可能会积压并耗尽其资源(例如内存)!
规则的例外:保持向后兼容性
当引入context.Context
的Go 1.7发布时,大量API必须以向后兼容的方式添加context支持。例如,net/http
的Client
方法,像Get
和Do
,是context的绝佳候选者。使用这些方法发送的每个外部请求都将受益于context.Context
附带的截止时间、取消和元数据支持。
有两种方法可以向后兼容的方式添加对context.Context
的支持:在结构体中包含context,我们稍后会看到,以及复制函数,复制的函数接受context.Context
并将Context
作为其函数名后缀。复制方法应优先于在结构体中使用context,并在《保持模块兼容》中进一步讨论。但是,在某些情况下它是不切实际的:例如,如果你的API公开了大量函数,那么复制它们可能是不可行的。
net/http
包选择了在结构体中使用context的方法,它提供了一个有用的案例研究。让我们看看net/http
的Do
。在引入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
作为参数传入;不要将它存储在结构体中。