Go:上下文与结构体
May 21, 2021 22:30 · 2813 words · 6 minute read
译文
介绍
诸多 Go API,尤其是现在的,函数和方法经常使用 context.Context 作为第一个参数。Context 提供了一种跨 API 边界的在进程之间传递截止时间、调用者取消信号和其他请求范围内值的方法。当与远程服务器(比如数据库、API)直接交互时经常会用到它。
context 包的文档提到:
Context 不应该被存储在结构体类型中,而是传参给需要它的方法。
本文将详细说明要传递 Context 而不是将其存储在另一种类型中的原因。还会强调一种将 Context 存储到结构体类型导致问题的罕见案例,以及如何安全地这么做。
最好以参数传递上下文
为了理解不要再 struct 中存储上下文的建议,我们先看下上下文作为参数:
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
的实用性。
在结构体中存储上下文会导致混乱
再看上面的 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 的客户端方法,像 Get
和 Do
,就是上下文完美的适用对象。每个通过这些方法发送的请求都会受益于 context.Context
带来的截止时间、取消和元数据支持。
有两种以向下兼容的方式支持 context.Context
的办法:在结构体中包含上下文,还有是复制出一个以 Context 作为函数名后缀,一模一样的函数来接收 context.Context
,相比前者后者更好,在保持模块兼容一文中会进一步讨论。但是,在某些情况下这是不现实的,如果你的 API 暴露的大量函数,那全部复制一遍是不可行的。
net/http 包选择了前者,提供了一个很有用的学习案例。我们来看 net/http 的 Do
,在引入 context.Context
之前,Do
这也定义:
func (c *Client) Do(req *Request) (*Response, error)
在 Go 1.7 之后如果不考虑向下兼容 Do
可能长这样:
func (c *Client) Do(ctx context.Context, req *Request) (*Response, error)
但是,兼容对标准库来说是至关重要的。所以,维护者选择在 http.Request
结构体中添加 context.Context
来支持上下文而又不破坏向下兼容。
type Request struct {
ctx context.Context
// ...
}
func NewRequestWithContext(ctx context.Context, method, url string, body io.Reader) (*Request, error) {
// Simplified for brevity of this article.
return &Request{
ctx: ctx,
// ...
}
}
func (c *Client) Do(req *Request) (*Response, error)
当改造你的 API 来支持上下文时,在结构体中添加 context.Context
有可能是说得通的,就像上面那样。但是要先考虑复制函数,这样可以在牺牲实用性和可读性的前提下向下兼容地改造 context.Context
:
func (c *Client) Call() error {
return c.CallContext(context.Background())
}
func (c *Client) CallContext(ctx context.Context) error {
// ...
}
总结
通过上下文可以轻松写意地在调用栈中跨库和跨 API 传播重要的信息。但也要一致、清楚地使用它来保持可读性、易于调试。
当以第一个参数传入而不是存储在结构体中,用户可以充分利用其扩展性,通过调用栈构建一棵强大的取消,截止时间和元数据信息树。最重要的是当作为参数传入时,可以清楚地知道其作用域,有着清晰的可读性。
当设计一个有上下文的 API,记住以下建议:以参数来传递 context.Context
,不要把它存在结构体中。
原文
Introduction
In many Go APIs, especially modern ones, the first argument to functions and methods is often context.Context. Context provides a means of transmitting deadlines, caller cancellations, and other request-scoped values across API boundaries and between processes. It is often used when a library interacts — directly or transitively — with remote servers, such as databases, APIs, and the like.
The documentation for context states:
Contexts should not be stored inside a struct type, but instead passed to each function that needs it.
This article expands on that advice with reasons and examples describing why it’s important to pass Context rather than store it in another type. It also highlights a rare case where storing Context in a struct type may make sense, and how to do so safely.
Prefer contexts passed as arguments
To understand the advice to not store context in structs, let’s consider the preferred context-as-argument approach:
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.
}
Here, the (*Worker).Fetch and (*Worker).Process methods both accept a context directly. With this pass-as-argument design, users can set per-call deadlines, cancellation, and metadata. And, it’s clear how the context.Context
passed to each method will be used: there’s no expectation that a context.Context
passed to one method will be used by any other method. This is because the context is scoped to as small an operation as it needs to be, which greatly increases the utility and clarity of context
in this package.
Storing context in structs leads to confusion
Let’s inspect again the Worker
example above with the disfavored context-in-struct approach. The problem with it is that when you store the context in a struct, you obscure lifetime to the callers, or worse intermingle two scopes together in unpredictable ways:
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.
}
The (*Worker).Fetch
and (*Worker).Process
method both use a context stored in Worker. This prevents the callers of Fetch and Process (which may themselves have different contexts) from specifying a deadline, requesting cancellation, and attaching metadata on a per-call basis. For example: the user is unable to provide a deadline just for (*Worker).Fetch
, or cancel just the (*Worker).Process
call. The caller’s lifetime is intermingled with a shared context, and the context is scoped to the lifetime where the Worker
is created.
The API is also much more confusing to users compared to the pass-as-argument approach. Users might ask themselves:
- Since New takes a
context.Context
, is the constructor doing work that needs cancelation or deadlines? - Does the
context.Context
passed in to New apply to work in(*Worker).Fetch
and(*Worker).Process
? Neither? One but not the other?
The API would need a good deal of documentation to explicitly tell the user exactly what the context.Context
is used for. The user might also have to read code rather than being able to rely on the structure of the API conveys.
And, finally, it can be quite dangerous to design a production-grade server whose requests don’t each have a context and thus can’t adequately honor cancellation. Without the ability to set per-call deadlines, your process could backlog and exhaust its resources (like memory)!
Exception to the rule: preserving backwards compatibility
When Go 1.7 — which introduced context.Context — was released, a large number of APIs had to add context support in backwards compatible ways. For example, net/http’s Client methods, like Get and Do, were excellent candidates for context. Each external request sent with these methods would benefit from having the deadline, cancellation, and metadata support that came with context.Context
.
There are two approaches for adding support for context.Context
in backwards compatible ways: including a context in a struct, as we’ll see in a moment, and duplicating functions, with duplicates accepting context.Context
and having Context
as their function name suffix. The duplicate approach should be preferred over the context-in-struct, and is further discussed in Keeping your modules compatible. However, in some cases it’s impractical: for example, if your API exposes a large number of functions, then duplicating them all might be infeasible.
The net/http package chose the context-in-struct approach, which provides a useful case study. Let’s look at net/http’s Do. Prior to the introduction of context.Context, Do was defined as follows:
func (c *Client) Do(req *Request) (*Response, error)
After Go 1.7, Do might have looked like the following, if not for the fact that it would break backwards compatibility:
func (c *Client) Do(ctx context.Context, req *Request) (*Response, error)
But, preserving the backwards compatibility and adhering to the Go 1 promise of compatibility is crucial for the standard library. So, instead, the maintainers chose to add a context.Context on the http.Request struct in order to allow support context.Context
without breaking backwards compatibility:
type Request struct {
ctx context.Context
// ...
}
func NewRequestWithContext(ctx context.Context, method, url string, body io.Reader) (*Request, error) {
// Simplified for brevity of this article.
return &Request{
ctx: ctx,
// ...
}
}
func (c *Client) Do(req *Request) (*Response, error)
When retrofitting your API to support context, it may make sense to add a context.Context
to a struct, as above. However, remember to first consider duplicating your functions, which allows retrofitting context.Context
in a backwards compatibility without sacrificing utility and comprehension. For example:
func (c *Client) Call() error {
return c.CallContext(context.Background())
}
func (c *Client) CallContext(ctx context.Context) error {
// ...
}
Conclusion
Context makes it easy to propagate important cross-library and cross-API information down a calling stack. But, it must be used consistently and clearly in order to remain comprehensible, easy to debug, and effective.
When passed as the first argument in a method rather than stored in a struct type, users can take full advantage of its extensibility in order to build a powerful tree of cancelation, deadline, and metadata information through the call stack. And, best of all, its scope is clearly understood when it’s passed in as an argument, leading to clear comprehension and debuggability up and down the stack.
When designing an API with context, remember the advice: pass context.Context in as an argument; don’t store it in structs.