:::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 作为参数传递
要理解不要在结构中存储上下文的建议,让我们考虑首选的上下文作为参数的方法:
// 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 会被任何其他方法使用。 这是因为上下文的范围是它需要的尽可能小的操作,这大大增加了这个包中上下文的实用性和清晰度。
在结构中存储上下文会导致混淆
让我们用不受欢迎的结构中上下文方法再次检查上面的 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,构造函数是否正在执行需要取消或截止日期的工作?
- 传递给 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 的定义如下:
// 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 {
// ...
}
总结
Context 使得在调用堆栈中传播重要的跨库和跨 API 信息变得容易。 但是,必须一致且清晰地使用它,以保持易于理解、易于调试和有效。
当作为方法中的第一个参数传递而不是存储在结构类型中时,用户可以充分利用其可扩展性,以便通过调用堆栈构建一个强大的取消、截止日期和元数据信息树。 而且,最重要的是,当它作为参数传入时,它的范围会被清楚地理解,从而在堆栈上下清晰地理解和调试。
在设计带有上下文的 API 时,请记住以下建议:将 context.Context 作为参数传入; 不要将它存储在结构中。