Contexts 和 Struct
Jean de Klerk, Matt T. Proud
2021 年 2 月 24 日
介绍
许多 Go API ,尤其是较为现代的,都以 context.Context
作为函数或方法的第一个参数。 Context (上下文)提供了一种跨越API甚至进程边界传递到期时间、上游取消和其它请求作用域内值的手段。
它通常被应用于那些直接或间接地与远程服务器,例如数据库、API等交互的库中。
Context 的文档 中建议:
Context 不应该被存储在结构体内部,而应该被直接传递给每个需要它的函数
这篇文章解释了这一建议的理由,并给出了一些示例来说明为何选择传递而不是在其它类型中储存 Context 非常重要。文章中也提出了一些少见的可以合理地将 Context 储存在其它类型中的场景,并说明如何能安全地这样做。
将 Context 作为参数传递
为了理解「不要将 Context 存储在结构体中」这一建议,我们先给出一种将 Context 作为参数传递的用法。
// Worker 通过远程任务编排服务器获取和新增任务
type Worker struct { /* … */ }
type Work struct { /* … */ }
func New() *Worker {
return &Worker{}
}
func (w *Worker) Fetch(ctx context.Context) (*Work, error) {
_ = ctx // 使用请求维度上下文传递取消行为、到期时间和元数据
}
func (w *Worker) Process(ctx context.Context, work *Work) error {
_ = ctx // 使用请求维度上下文传递取消行为、到期时间和元数据
}
这里, (*Worker).Fetch
和 (*Worker).Process
两个方法都直接接收一个上下文参数。通过这种参数传递的方式,用户可以为每次调用单独设置到期时间、取消行为和元数据。 并且,我们可以清楚地知道传递给每个方法的 context.Context
是如何被使用的,因为传递给一个方法的 context.Context
不会被另一个方法访问。这些性质源自上下文被限定在单个操作的小范围内,这显著提升了该包中上下文的实用性和清晰度。
将 Context 存储在结构体中会引起困惑
现在我们用不被建议的「在结构体中存储上下文」的方式重写上文 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
中存储的上下文。这使得调用者无法在函数调用的维度分别为获取任务和添加任务操作(它们本身可能有不同的上下文)分别指定取消行为、到期事件和元数据。例如:用户无法单独为 (*Worker).Fetch
设置到期时间,或者单独取消 (*Worker).Process
。调用者的生存期被混杂在共享的上下文中,而这一上下文又存活在 Worker
创建时的作用域内。
和通过参数传递上下文相比,这些 API 也更加让使用者困惑。用户可能会纠结:
New
接收一个context.Context
是否意味着构造函数的工作可以被取消或设置到期时间?- 传递给
New
的context.Context
会应用于(*Worker).Fetch
和(*Worker).Process
的二者还是其中之一,或者根本不被任何一个方法使用?
这些 API 必须有一份冗长的文档来显式地告诉用户 context.Context
被使用在哪些地方。用户也许不得不去阅读源码而不是仅仅依靠 API 自身表达的结构来理解用法。
最后需要注意,生产级别服务器中请求没有自己的上下文而不能被恰当地取消是十分危险的。缺少为每个请求单独设置超时时间的能力可能导致你的 进程中出现积压 并耗尽计算资源(例如内存)!
规则例外:保持向后兼容
当 context.Context 随 Go 1.7 发布 时,一大批 API 不得不以向后兼容的方式添加对上下文的支持。例如, net/http
中 Client
的方法,像 Get
和 Do
,能与上下文很好地搭配。每个通过这些方法发送的外部请求都可以享受到 context.Context
带来的对到期时间、取消和元数据的支持。
有两种在保持向后兼容的前提下添加对 context.Context
支持的方式:其中之一是在结构体中储存上下文,我们稍后分析;另一种是为函数新增以 Context
为名称前缀,并接收 context.Context
为第一个参数的副本。添加函数副本的方式理应被认为优于在结构体中存储上下文,更多的讨论可以在 保持模块兼容性 中查看。然而,在某些场景下这是不现实的:例如,如果你的 API 暴露了大量的函数,你很难为它们全部添加副本。
net/http
包选择了在结构体中存储上下文的方式,为我们提供了一个有价值的案例。让我们看向
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 请求
// ...
type Request struct {
ctx context.Context
// ...
}
// NewRequestWithContext 返回一个包含指定方法, URL ,以及可选的请求体的新 Request
// [...]
// 提供的 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
。
例如:
// Call 内部使用 context.Background ,如需指定上下文,请使用 CallContext
func (c *Client) Call() error {
return c.CallContext(context.Background())
}
func (c *Client) CallContext(ctx context.Context) error {
// ...
}
结论
上下文简化了沿调用栈传递跨越库或 API 界面的重要信息的方式。然而,只有清晰一致地去使用它才能保持代码易于理解、易于调试且高效率。
当把上下文作为函数的第一个参数传递,而不是将其存储于结构体中时,用户才能享受属于其扩展性的全部优点,在调用栈上构造出一棵功能强大的包含取消、超时和元数据信息的树。最大的好处在于,通过参数传递上下文时,它的作用范围是清晰的,这使得整个调用栈更加易于理解和易于调试。
在设计使用上下文的 API 时,牢记这一建议:将 context.Context
作为参数传递,而不要将它储存在结构体中。
本译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。
推荐文章: