小马别过河
2022/10/09阅读:25主题:默认主题
GO 概念:context and struct
简介
许多 Go API 中,特别是比较"现代"的,函数或方法的第一个参数经常是 context.Context
。Context 为协程间和 API 间提供了截止时间(deadlines)、取消(caller cancellations)、元数据(request-scoped values)方法。当与数据库、API 之类的远程服务交互时,经常会用到它。
contex 的文档指出:
Contexts should not be stored inside a struct type, but instead passed to each function that needs it.
翻译过来大致是:
Context 不应该内嵌在 struct,而是应该传递给需要它的函数。
本文从这个建议展开,说说这么做的原因。还会介绍 Context 内嵌在结构体的特例,以及如何保证安全。
Context 首选作为参数传递
理解为什么 context 不该内嵌在结构体之前,我们先考虑 Context 作为参数的首选方案:
// 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 在这个包中的实用性和清晰度。
Struct 内嵌 Context 会导致混乱
我们探究下 Struct 内嵌 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 // 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
方法都使用同一个 Context。这让Fetch
和Process
的调用者无法给其中某个方法规定期限、取消、元数据等。比如说:调用者无法只给 (*Worker).Fetch
增加 deadline,或者只给(*Worker).Process
增加 cancel。context 被共享以后,对应的生命周期显得混乱,同时 context 的生命周期和 Worker
一样。
比起通过传参的方案,这个 API 对使用者来说更加困惑。使用者可能问:
-
既然 New
接受 context.Context,那取消或者截止时间也是在构造函数做吗? -
传给 New
的 context.Context 会应用在(*Worker).Fetch
和(*Worker).Process
吗?或者其中某个吗?
API 需要大量的文档来告诉使用者 context.Context 用在哪里。用户还可能阅读代码才能理解。
最后,对于生成环境的服务,每个请求没有单独的 context 的话,这还可能非常危险。无法对每个请求单独设置 deadline,我们的进程可能积压,最后耗尽所有资源(比如内存)。
特例:保持向后兼容
当引入 context.Context 的 Go 1.7 版本发布时,大量的 API 必须以向后兼容的方法添加对 context.Context 的支持。比如,net/http
的 Client
的 方法,比如 Get
和 Do
,很适合增加对 Context 的支持。每个外部的请求都适合带截止时间、取消、元数据。
增加对 context.Context 的向后兼容的支持有两种方案:第一种是 Context 内嵌在 struct,马上就会提及。第二种是复制一份新的函数,复制的新函数接受 context.Context 参数,并且在函数名增加"Context"后缀。后者比前者更优,在文章 Keeping your modules compatible
(https://blog.golang.org/module-compatibility)会深入讨论。然而,第二种方案有时候不切实际:比如,如果你的 API 有大量的函数,把它们全部复制有点不可行。
net/http
包选择 Context 内嵌在 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)
然而,保持向后兼容并遵循 Go1 兼容性的承诺至关重要。所以,维护者选择给结构体 http.Request
增加 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 的支持。比如:
// 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之间的传播信息到整个调用栈变得简化。但是,必须一致且清晰地使用它,以保持可理解、方便调试且高效。
当 Context 作为第一个参数传递,用户可以充分利用它的可扩展性,以便在调用栈构建强大的取消、截止时间、元数据的信息树。最重要的是,作为参数传入时,整个堆栈都有清晰的理解性和可调试性。
当设计一个有 Context 的 API 时,请记住这个建议:把 context.Context 作为参数传递,不要内嵌在结构体里。
作者介绍