Context 和 struct

原文地址:https://go.dev/blog/context-and-structs

在很多 Go 的 API 中,特别是新的 API,函数或者方法的第一个参数通常是 context.Context。context.Context 可以在不同的 API 之间传递一些信号,比如 deadline、调用者的取消信号,也可以传递一些请求范围内的数据。在一个库需要直接或者间接的与数据库、远程 API 等等远程服务进行交互的时候会用到。

在 Context 的文档中这样说到:context 只应该在每个函数需要用到它的时候传递,而不应该存储在 struct 中

这篇文章会通过一些例子来说明为什么应该直接传递 context,而不是把它存储在另一个类型中。同时也会介绍一个把 context 安全存储在 struct 中的少见案例,并会解释为什么要这么做。

把 context 作为参数

我们先来看一个把 context 作为参数的例子,来看看把 context 当做参数传递传递的优点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Worker 把 work 添加到远程的服务运行
type Worker struct { /* … */ }

type Work struct { /* … */ }

func New() *Worker {
return &Worker{}
}

func (w *Worker) Fetch(ctx context.Context) (*Work, error) {
_ = ctx // 一个提前传入的 ctx 用来控制请求的 deadline、取消以及元数据
}

func (w *Worker) Process(ctx context.Context, work *Work) error {
_ = ctx // 一个提前传入的 ctx 用来控制请求的 deadline、取消以及元数据
}

在这里 (*Worker).Fetch(*Worker).Process 都直接把 context 作为第一个参数。用户可以为每一次调用设置 deadline、取消和元数据。并且这样可以很清晰的看到 context 在每个方法中的使用方式,这样就不会让传递到一个方法中的 context 会在其他的方法中被调用。这是因为 conetext 的作用域限制到了真正需要它的地方,这样 context 就会实用而清晰。

把 context 存进 struct 会造成误解

我们再看一下上面的例子,并做一点小改动,把 context 放进 struct 中。这样做问题在于这样会让调用者的生命周期变得模糊,或者会把这两者的作用域混在一起,这样更糟糕。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 用来控制请求的 deadline、取消以及元数据
}

func (w *Worker) Process(work *Work) error {
_ = w.ctx // 共享的 w.ctx 用来控制请求的 deadline、取消以及元数据
}

这里 (*Worker).Fetch(*Worker).Process 共用一个存储在 Worker 中的 context。这将会让 Fetch 和 Process 的调用者无法指定 deadline、取消请求或者获取元数据,因为一个请求中可能有不同的 context。举个例子来说,无法只为 (*Worker).Fetch 指定 deadline,也无法只取消 (*Worker).Process。调用者的生命周期被一个共享的 context 打乱了,而且 context 的生命周期与 Worker 的生命周期相同。

相比于之前的那种写法,这种更容易让人疑惑。用户可能会问他们自己:

  • 在创建一个新的 context 的时候,怎么知道后面是需要取消请求还是设置一个 deadline
  • 这个 context 能不能在 (*Worker).Fetch 和 (*Worker).Process 继续传递,两个都不能?还是一个可以,一个不行

在这个 API 中,我们就需要在文档中明确的告诉用户这里的 context 是用来做什么的。用户可能得通过阅读代码,而不是直接通过 API 的结构来判断 context 的用途。

例外:向后保持兼容性

当 Go1.17 发布的时候,大量的 API 需要添加 context 以保证 API 的向后兼容性。比如 net/http 中的 Client 方法,Get、Do 都需要添加 context。每一个通过这些方法发送的外部请求,都可以通过 context 来传递 deadline、取消请求、传递元数据。

这里有两种可以保持向后兼容的方式来添加对 context 的支持:第一个方法就是把 context 放在一个 struct 中,就像我们前面看到的那样,另一种方法就是重新写一个不同名称的方法,添加 context 参数。就像我们在如何保证模块的兼容性中讨论的那样,第二种方法应该要优于第一种方法。但是在一些情况下,是无法这样实现的:比如你的 API 暴露了大量的方法,然后把它们全部都重写一遍,这样可能会让代码很混乱。

net/http 包选则了第一种方法,这里也提供了一个很值得学习的例子。我们来看一下其中的 Do 方法,在添加 context 之前,它是这样定义的:

1
2
// Do 发送 http 请求并且返回 http 的响应
func (c *Client) Do(req *Request) (*Response, error)

在 Go1.17 之后,如果我们不管向后的兼容性,Do 的定义可能是下面这样:

1
2
// Do 发送 http 请求并且返回 http 的响应 
func (c *Client) Do(ctx context.Context, req *Request) (*Response, error)

但为了保护兼容性,并且遵守 Go 对标准库兼容性的保证非常重要。所以,维护者选择在 http.Request 中添加一个 context 来保证这个 API 的向后兼容性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

type Request struct {
ctx context.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 添加到 struct 中。然而,在不破坏代码的可用性和可读性时,为了保证代码的向后兼容,还是应该重新创建一个方法,像下面这样:

1
2
3
4
5
6
7

func (c *Client) Call() error {
return c.CallContext(context.Background())
}

func (c *Client) CallContext(ctx context.Context) error {
}

小结

在一个调用栈中,Context 在跨库或者跨 API 传递信息时非常有用。但为了保证可读性、可调试性和有效性,它必须保持简洁和连贯。

当通过参数传递 context 而不是存储在 Context 中时,用户可以完全利用它的扩展性在调用栈中构造一个由取消、deadline 和元数据信息组成的树,并且在通过参数传递时,它们的作用域是非常清晰,这让代码的可读性和可调试性都非常好。

当在设计一个带 context 的 API 时,记住一点:通过参数传递 context,不要把它存在 struct 中。

文 / Rayjun

© 2020 Rayjun    PowerBy Hexo    京ICP备16051220号-1