小马别过河

V1

2022/10/09阅读:14主题:默认主题

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。这让FetchProcess的调用者无法给其中某个方法规定期限、取消、元数据等。比如说:调用者无法只给 (*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/httpClient的 方法,比如 GetDo,很适合增加对 Context 的支持。每个外部的请求都适合带截止时间、取消、元数据。

增加对 context.Context 的向后兼容的支持有两种方案:第一种是 Context 内嵌在 struct,马上就会提及。第二种是复制一份新的函数,复制的新函数接受 context.Context 参数,并且在函数名增加"Context"后缀。后者比前者更优,在文章 Keeping your modules compatible(https://blog.golang.org/module-compatibility)会深入讨论。然而,第二种方案有时候不切实际:比如,如果你的 API 有大量的函数,把它们全部复制有点不可行。

net/http包选择 Context 内嵌在 Struct 的方案,这个案例值得我们学习。我们看net/httpDo方法。引入 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 作为参数传递,不要内嵌在结构体里。

分类:

后端

标签:

Golang

作者介绍

小马别过河
V1