阅读 1427

Go blog:关于 context 的一点最佳实践

2021 年 2月 24 日,官方 blog 中详细讲了关于 context 使用的一些最佳实践,提供了代码示例,告诉你为何 context不应存储在 struct 内部,最好的方式是作为函数的第一个参数传递,以及如何在非常必要的情况下(保持向后兼容)以一种最安全的方式将 context 存储到 struct 中。下面是原文的主要内容。

Introduction

在许多 Go API,尤其是现代 API 中,函数和方法的第一个参数通常是 context.Contextcontext 提供了很多方法例如 WithCancelWithDeadlineWithValue、以实现跨 API 的流程控制。很多 lib 在与远程服务器(如数据库、api等)交互时,经常使用 context 做控制。

context 的文档中讲到:

	context 不应存储在 struct 内部,最好的方式是作为函数的第一个参数传递
复制代码

本文在此建议的基础上讲解了原因和示例,说明了为什么要使用函数传递 Context 而不是将其存储在 struct 中的重要性。 还以 net/http 的代码为例,解释了应该在何种情况下可以在 struct 中 存储 Context

推荐 context 作为函数参数传递

让我们先看下在函数中传递 context

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, w *Work) error {
  _ = ctx // A per-call ctx is used for cancellation, deadlines, and metadata.
}
复制代码

这里 (*Worker).Fetch(*Worker).Process 都直接将 context 作为函数第一个参数。这样从 context 的生成到结束,调用方可以很清晰地知道 context 的传递路线。

将 context 存储到 strcut 所带来的一些困惑

在结构体中嵌套 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(w *Work) error {
  _ = w.ctx // A shared w.ctx is used for cancellation, deadlines, and metadata.
}
复制代码

(*Worker).Fetch(*Worker).Process 方法同时使用了 Worker 结构体中的 context ,这种情况下使得调用方无法定义不同的 context ,比如有调用方想用 WitchCancel,有的想用 WithDeadline,也很难理解上面传来的 context 的作用是 cancel?还是 deadline? 调用者所使用 context 的生命周期被绑定到了一个共享的 context 上面。

特殊情况:保留向后兼容性

当 go 1.7 版本发布时,大量的的 API 需要以向后兼容的方式支持 context.Context,例如,net/httpClient 方法(例如Get和Do)是使用 context 的典范。使用这些方法发送的 http 请求都将受益于 context.Context 附带的 WithDeadlineWithCancelWIthValue 等方法支持。

一般有两种方式能够在支持 context.Context 的同时保持代码的向后兼容:

  1. 在 struct 中添加 context (稍后我们将看到);
  2. 复制原有函数,在函数第一个参数中使用 context,举个栗子,database/sql 这个 package 的 Query 方法的签名一直是:
func (db *DB) Query(query string, args ...interface{}) (*Rows, error)
复制代码

context package 引入的时候,Go team 新增了这样一个函数:

func (db *DB) QueryContext(ctx context.Context, query string, args ...interface{}) (*Rows, error)
复制代码

并且只修改了一处代码:

func (db *DB) Query(query string, args ...interface{}) (*Rows, error) {
    return db.QueryContext(context.Background(), query, args...)
}
复制代码

通过这种方式,Go team 能够在平滑地升级一个 package 的同时不对代码的可读性、兼容性造成影响。类似的代码在 golang 源码中随处可见。更多的保持代码兼容性的讨论可见 [Go team 关于如何保持 Go Modules 兼容性的一些实践]。

然而,在某些情况下,比如你的 API 公开了大量 function,重写所有函数是不切实际的。

package net/http 选择在 struct 中添加 context.Context,这是一个结构体嵌套 context 比较恰当的范例。先让我们看下 net/httpDo 函数。在引入 context 之前,Do 的定义如下:

func (c *Client) Do(req *Request) (*Response, error)
复制代码

在 1.7 引入 context 后,为了遵循 net/http 这种标准库的向后兼容原则,考虑到该核心库所包含的函数过于多,maintainers 选择在结构体 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 {
  // ...
}
复制代码

Conclusion

通过 context,可以轻松地在调用堆栈中传播重要的跨 lib 和跨 API 信息。 但是,必须一致、清晰地使用它,以使其易于理解,易于调试且有效。

context 作为方法中的第一个参数传递而不是存储在 struct 中时,用户可以充分利用其可扩展性,可以通过调用堆栈构建 WithCancelWithDeadlineWithValue 的传递树。 而且最重要的是,当将其作为参数传入时,可以清楚地了解其传播范围,从而可以轻松地理解和调试。

最后一句话总结本文,When designing an API with context, remember the advice: pass context.Context in as an argument; don't store it in structs.


文章分类
后端
文章标签