Go 博客
上下文和结构体
引言
在许多 Go API 中,尤其是现代 API 中,函数和方法的第一个参数通常是 context.Context
。Context 提供了一种跨 API 边界和进程传输截止时间、调用者取消以及其他请求范围值的方法。当库直接或间接与远程服务器(如数据库、API 等)交互时,通常会使用它。
有关 context 的文档指出:
Context 不应存储在 struct 类型中,而应传递给每个需要它的函数。
本文将通过原因和示例来扩展此建议,描述为什么将 Context 作为参数传递而不是将其存储在其他类型中很重要。它还强调了一个罕见的、存储 Context 在 struct 类型中可能是有意义的情况,以及如何安全地进行。
优先使用作为参数传递的上下文
为了理解不将 context 存储在 struct 中的建议,让我们考虑首选的 context-as-argument 方法。
// 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
会被其他任何方法使用。这是因为 context 的范围被限制在它需要的大小,这大大增加了此包中 context
的实用性和清晰度。
将 context 存储在 struct 中会导致混淆
让我们再次检查上面带有不推荐的 context-in-struct 方法的 Worker
示例。问题在于,当您将 context 存储在 struct 中时,您会向调用者隐藏生命周期,或者更糟的是,以不可预测的方式混合两个范围。
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 中的 context。这阻止了 Fetch 和 Process 的调用者(他们自己可能有不同的 context)在每次调用时指定截止时间、请求取消和附加元数据。例如:用户无法仅为 (*Worker).Fetch
提供截止时间,或仅取消 (*Worker).Process
调用。调用者的生命周期与共享 context 交织在一起,而 context 的范围被限制在创建 Worker
的生命周期内。
与按参数传递的方法相比,API 对用户来说也更加混乱。用户可能会问自己:
- 既然
New
接受context.Context
,那么构造函数是否正在执行需要取消或截止时间的工作? - 传递给
New
的context.Context
是否适用于(*Worker).Fetch
和(*Worker).Process
中的工作?都不适用?一个适用,另一个不适用?
API 需要大量的文档来明确告知用户 context.Context
的确切用途。用户可能还需要阅读代码,而不是依赖 API 的结构来传达信息。
最后,设计一个生产级的服务器,其请求没有各自的 context,因此无法充分响应取消,这是非常危险的。如果没有能力设置每个调用的截止时间,您的进程可能会积压并耗尽其资源(如内存)!
例外情况:保持向后兼容性
当 Go 1.7 — 引入了 context.Context — 发布时,大量 API 必须以向后兼容的方式添加 context 支持。例如,net/http
的 Client
方法,如 Get
和 Do
,非常适合 context。使用这些方法发送的每个外部请求都将受益于 context.Context
带来的截止时间、取消和元数据支持。
有两种方法可以以向后兼容的方式添加对 context.Context
的支持:将 context 包含在 struct 中,如我们稍后将看到的,以及复制函数,其中复制的函数接受 context.Context
并在函数名后缀上添加 Context
。应优先使用复制方法而不是 context-in-struct 方法,并且在使模块保持兼容中有更详细的讨论。然而,在某些情况下,这是不切实际的:例如,如果您的 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 向后兼容性承诺对于标准库至关重要。因此,维护者选择将 context.Context
添加到 http.Request
struct 中,以便在不破坏向后兼容性的情况下支持 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
添加到 struct 中可能是合理的,如上所示。但是,请记住首先考虑复制您的函数,这可以以向后兼容的方式改造 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 信息向下传播到调用堆栈。但是,为了保持可理解性、易于调试和有效性,必须一致且清晰地使用它。
当作为方法的第一个参数传递而不是存储在 struct 类型中时,用户可以充分利用其可扩展性,通过调用堆栈构建一个强大的取消、截止时间和元数据信息树。而且,最重要的是,当它作为参数传递时,其范围清晰可懂,从而在调用堆栈的上下文中实现了清晰的理解和调试能力。
在设计带有 context 的 API 时,请记住这个建议:将 context.Context
作为参数传递;不要将其存储在 struct 中。
延伸阅读
下一篇文章:Go 开发者调查 2020 年结果
上一篇文章:Go 1.16 中的新模块更改
博客索引