写点什么

Context 和 struct

用户头像
Rayjun
关注
发布于: 3 小时前
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 当做参数传递传递的优点:


// 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 中。这样做问题在于这样会让调用者的生命周期变得模糊,或者会把这两者的作用域混在一起,这样更糟糕。


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 之前,它是这样定义的:


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


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


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


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



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



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


发布于: 3 小时前阅读数: 3
用户头像

Rayjun

关注

程序员,王小波死忠粉 2017.10.17 加入

非著名程序员,还在学习如何写代码,公众号同名

评论

发布
暂无评论
Context 和 struct