Alan Hou的个人博客

云原生系列Go语言篇-上下文

本文来自正在规划的Go语言&云原生自我提升系列,欢迎关注后续文章。

服务端需要一种处理单个请求元数据的方式。这些元数据可以分为两大类别:一种是在正确处理请求时所需的元数据,另一种是关于何时停止处理请求的元数据。例如,HTTP服务器可能希望使用追踪ID来标识一系列通过一组微服务的请求。它还可能希望设置一个计时器,在对其他微服务的请求时间过长时,就结束这些请求。很多语言使用threadlocal变量来存储此类信息,将数据关联到特定的操作系统执行线程。但在Go语言中,这种方式不可行,因为goroutine没有可以用来查找值的唯一标识。更重要的是,线程本地变量不够清晰,值放在一个地方,却在另一个地方弹出。

Go语言通过一种称为context的结构来解决请求元数据的问题。我们来看如何正确使用它。

上下文是什么

与向语言添加新功能不同,上下文(context)只是实现了context包中的Context接口的一个实例。我们知道,地道的Go语言鼓励通过函数参数显式传递数据。上下文也是如此。它只是函数中的另一个参数。就像Go语言约定函数的最后一个返回值是error一样,还有另一个Go语言的约定,即上下文作为函数的第一个参数在程序中显式传递。上下文参数常命名为ctx

除了定义Context接口之外,context包还包含了一些用于创建和封装上下文的工厂函数。在没有现成上下文时,比如在命令行程序的入口,可以使用函数context.Background创建一个空的初始上下文。它返回一个context.Context类型的变量。(是的,这是函数调用返回具体类型的常规模式的一个例外。)

空上下文是一个起点;每次向上下文中添加元数据时,你需要使用context包中的工厂函数来封装现有的上下文:

注:还有另一个函数context.TODO,也创建一个空的context.Context。它用于在开发时临时使用。如果你不确定上下文来自哪里或如何使用它,可以使用context.TODO在代码中放置一个占位符。生产代码不应包含context.TODO

在编写HTTP服务器时,需要使用稍微不同的模式来获取和传递上下文,通过多层中间件将其传递到顶层的http.Handler。只是,上下文是有了net/http很久之后才添加到Go API中的。由于兼容性承诺,无法更改http.Handler接口来添加context.Context参数。

兼容性承诺允许向现有类型添加新方法,这正是Go团队所做的。http.Request上有两个与上下文相关的方法:

通常模式如下:

在我们的中间件中,首先使用Context方法从请求中提取现有的上下文。(如果你想跳过这部分,在小节中会讲到如何将值放入上下文中。)在将值放入上下文后,我们使用WithContext方法基于旧请求和当前已填充的上下文创建新请求。最后,调用handler,并将新请求和现有的http.ResponseWriter传递给它。

在实现handler时,使用Context方法从请求中提取上下文,并以上下文作为第一个参数调用业务逻辑,就像我们之前学习的那样:

在应用程序中从一个HTTP服务向另一个服务发起HTTP调用时,可以使用net/http包中的NewRequestWithContext函数构建包含现有上下文信息的请求:

这段代码位于第十四章代码仓库中的sample_code/context_patterns目录中。

我们已经学习了如何获取和传递上下文,下面就开始使用它。先从传值开始。

默认情况下,更应通过显式参数传递数据。之前提到过,Go 语言鼓励使用显式而非隐式,其中包括显式数据传递。如果一个函数依赖于某些数据,应该清楚数据来自哪里。

然而,有些情况下我们无法显式传递数据。最常见的情况是 HTTP 请求handler及相关中间件。正如我们所见,所有的 HTTP 请求处理程序都有两个参数,一个是请求,另一个是响应。如果你想在中间件中使某个值对处理程序可用,需要将它存储在上下文中。可能包括从 JWT(JSON Web Token)中提取用户信息,或者创建一个在多层中间件和处理程序以及业务逻辑间传递的各请求的 GUID。

有一个用于将值放入上下文的工厂方法,context.WithValue。它接受三个值:上下文、用于查找值的键,以及值本身。键和值参数的类型声明为类型anycontext.WithValue函数返回一个上下文,但并不是传入函数的那个上下文。相反,它是一个包含键值对并封装传入的父上下文(context.Context)的子上下文。

注: 我们将多次看到这种封装模式。上下文被视为不可变实例。每当我们向上下文添加信息时,都是通过将现有的父上下文封装子上下来实现的。这使我们可以使用上下文将信息传递到更深层的代码中。上下文从不用于将信息从深层传递到更高层。

context.Context上中的 Value 方法会检查上下文或其父上下文中是否存在某个值。该方法接受一个键(key)并返回与该键关联的值。同样,键参数和值结果的声明类型都是any。如果找不到所提供键的值,则返回nil。使用逗号ok语法将返回的值断言为正确的类型:

注: 读者如果熟悉数据结构,你可能会认识到在上下文链中搜索存储的值是一种线性搜索。在只有少量值时,这不会对性能产生严重影响,但如果在请求期间将几十个值存储在上下文中,性能将会很差。也就是说,如果你的程序在创建包含几十个值的上下文链,那么可能需要进行一些重构。

上下文中存储的值可以是任意类型,但选择正确的键非常重要。就像map的键一样,上下文值的键必须是可比较的。不要只使用像"id"这样的字符串作为键。如果使用字符串或其他预定义或导出类型作为键的类型,不同的包可能会创建相同的键,导致冲突。这会很难调试,比如一个包向上下文中写入数据,覆盖了另一个包写入的数据,或者从上下文中读取由另一个包写入的数据。

有一种地道的模式可以确保键是唯一且可比较的。基于int创建一个新的、未导出的键类型:

在声明你的未导出键类型之后,可以声明一个未导出的该类型的常量:

由于类型和该类型化常量均未导出的,来自包外的代码无法向上下文中放入可能引发冲突的数据。如果你的包需要将多个值放入上下文中,使用我们在错误处理中介绍的 iota 模式为每个值定义一个相同类型的不同键。由于我们只关心用常量的值区分多个键的方法,这是一种iota的完美用法。

接下来,构建一个 API 来将值放入上下文并从上下文中读取值。仅在包外的代码需要读取和写入上下文值时,才将这些函数设为公开。创建带有值的上下文的函数名称应该以 ContextWith 开头。从上下文中返回值的函数名称应该以 FromContext 结尾。以下是从上下文中获取和读取用户的函数实现示例:

现在我们已经编写了用户管理代码,来看如何使用它。我们将编写一个中间件,从 cookie 中提取用户ID:

在该中间件中,我们首先获取用户值。接下来,使用 Context 方法从请求中提取上下文,并使用 ContextWithUser 函数创建一个包含用户的新上下文。在包装上下文时,复用 ctx 变量名是惯用方式。然后,我们使用 WithContext 方法从旧请求和新上下文创建一个新请求。最后,我们使用新请求和传入的 http.ResponseWriter调用处理程序链中的下一个函数。

在大多数情况下,我们希望在请求处理程序中从上下文中提取值,并显式地传递给业务逻辑。Go 函数具有显式参数,不应使用上下文作为绕过 API 传递值的方式:

我们的处理程序使用请求的 Context 方法获取上下文,使用 UserFromContext 函数从上下文中提取用户,然后调用业务逻辑。

完整的示例代码位于第十四章代码仓库sample_code/context_user 目录中。

在某些情况下,将值保留在上下文中更好。之前提到的追踪 GUID 就是其中一种。这种信息用于应用程序的管理,而不是业务状态的一部分。通过在代码中显式传递它,会增加额外的参数,并阻止与不了解你的元信息的第三方库集成。通过在上下文中保留追踪 GUID,它在不需要了解跟踪信息的业务逻辑中以不可见的方式传递,并且在程序写入日志消息或连接到另一个服务器时可用。

下面是一个简单的上下文感知 GUID 实现,用于在服务之间进行跟踪,并创建包含 GUID 的日志:

Middleware 函数从传入的请求中提取 GUID,或者生成一个新的 GUID。无论哪种情况,都会将 GUID 放到上下文中,创建一个带有更新上下文的新请求,并继续调用链。

接下来,我们看看如何使用这个 GUID。Logger 结构提供了一个通用的日志记录方法,它接收一个上下文和字符串。如果上下文中存在 GUID,它会将 GUID 追加到日志消息的开头并输出。Request 函数用于在该服务调用另一个服务时。它接收一个*http.Request,如果上下文中存在 GUID,则添加一个带有 GUID 的header,并返回 *http.Request

有了这个包之后,我们就可以使用错误处理中讨论的依赖注入技术,创建完全不知道任何跟踪信息的业务逻辑。首先,我们声明一个表示日志记录器的接口,一个表示请求修饰器的函数类型,以及依赖于它们的业务逻辑结构体:

接下来实现业务逻辑:

GUID 传递给日志记录器和请求修饰器,业务逻辑本身并不知道它,将程序逻辑所需的数据与程序管理所需的数据分离开来。唯一知道这种关联的地方是 main 中连接依赖项的代码:

可以在第十四章代码仓库sample_code/context_guid  目录中找到完整的跟踪 GUID 的代码。

小贴士: 在标准的 API 中使用上下文来传递值。在需要处理业务逻辑时,将上下文中的值复制到显式参数中。系统维护信息可以直接从上下文中获取。

取消

上下文对传递元数据及解决Go的HTTP API限制方面很有用,但它还有第二个用途。上下文还允许我们控制应用程序的响应及协调并发的协程。我们来看具体是如何做到的。

假设有一个请求,启动了多个goroutine,每个协程调用不同的HTTP服务。如果一个服务返回错误,导致无法返回有效的结果,那么继续处理其他协程就没有意义。在Go中,这被称为取消操作,而上下文提供了其实现机制。

要创建可取消的上下文,可以使用context.WithCancel函数。它接受一个context.Context作为参数,并返回context.Contextcontext.CancelFunc。与context.WithValue类似,返回的context.Context是传递给函数的上下文的子上下文。context.CancelFunc是一个不含参的函数,用于取消上下文,告诉所有正在监听取消操作的代码停止处理。

在创建具有关联取消函数的上下文时,无论处理是否以错误结束,都必须调用该取消函数。如果不这样做,程序将泄漏资源(内存和协程),最终会变慢或崩溃。多次调用取消函数不会引发错误;第一次之后的调用都不会产生任何效果。

确保调用取消函数的最简单方法是使用defer,在取消函数返回后立即调用它:

这就引出了一个问题:如何检测取消操作?可以使用在并发中讨论过的done通道模式。context.Context接口有一个名为Done的方法。它返回一个类型为struct{}的通道。(选择这种返回类型的原因是空结构体不占用内存。)在调用取消函数时,该通道会被关闭。请记住,关闭的通道在尝试读取时会立即返回其零值。

警告:如果对不可取消的上下文调用Done,会返回nil。正如在并发一章中介绍的,从nil通道读取永远不会返回。如果不是在select语句的case内,程序将会挂起。

我们来看看其运行方式。假设有一个从多个HTTP端点收集数据的程序。如果其中有一个失败,而你希望终止所有端点的处理。上下文取消可协助实现。

注意:本例中,我们将使用httpbin.org的优秀服务。可以向它发送HTTP或HTTPS请求,以测试应用程序响应各种情况。我们将使用其中的两个端点:一个会在指定秒数后返回响应,另一个会返回所发送的状态码。

首先,我们创建可取消的上下文、一个用于从协程获取数据的通道,以及一个sync.WaitGroup,以等待所有goroutine完成:

接下来,我们启动两个goroutine,一个调用随机返回错误状态的URL,另一个在延时后发送一个预定义的JSON响应。首先是随机状态的goroutine:

makeRequest函数是一个帮助函数,它使用提供的上下文和URL做HTTP请求。如果得到了一个OK状态,我们会向通道写入一条消息,并休眠一秒钟。在出现错误或我们得到一个错误的状态码时,调用cancelFunc并退出goroutine。

延迟的goroutine类似:

最后,我们使用for/select模式从由goroutine写入的通道中读取数据,等待出现取消操作:

select语句中,有两个分支。一个从消息通道中读取数据,另一个等待done通道关闭。在它关闭时,退出循环并等待goroutine退出。读者可在第十四章代码仓库的sample_code/cancel_http目录中找到此程序。

下面是运行代码时的情况(结果是随机的,所以可以多次运行以查看不同的结果):

有一些点需要注意。首先,我们多次调用了cancelFunc。前面提到过,这不会产生任何问题。接下来,请注意在触发取消后,我们从延迟的goroutine中收到了一个错误。这是因为Go标准库中的内置HTTP客户端会考虑取消操作。我们使用可取消的上下文创建了请求,在它被取消时,请求结束。这会触发协程中的错误路径,并确保不会出现泄漏。

你可能会对导致取消的错误以及如何报告错误感到困惑。WithCancel有一个替代版本WithCancelCause。它返回一个带有错误参数的取消函数。context包中的Cause函数返回第一次调用取消函数时传入的错误。

注: Causecontext包中的一个函数,而不是context.Context的方法,因为通过取消操作返回错误的功能是在Go 1.20中添加到context包中的,这比上下文最初引入的时间要晚很多。如果在context.Context接口中添加了一个新的方法,会破坏实现该接口的第三方代码。另一个选项是定义一个包含此方法的新接口,但现有的代码已经在各个地方传递context.Context,并且转换为具有Cause方法的新接口需要类型断言或类型切换。添加一个函数是最简单的方法。随着时间的推移,有多种方式可以改进我们的API。应当选择对用户影响最小的方法。

我们来重写程序捕获错误。

首先,我们改变了上下文的创建方式:

接下来,我们将对两个协程进行轻微的修改。当前状态协程中for循环体如下所示:

我们删除了fmt.Println语句,并将错误传递给cancelFunc。延迟协程中for循环体如下所示:

保留了fmt.Println,这样可以显示生成的错误并传递给cancelFunc

最后,我们使用context.Cause在初始取消及结束等待协程完成后打印错误:

可在第十四章代码仓库中的sample_code/cancel_error_http目录中找到这个程序。

运行代码的结果如下:

可以看到,一开始在 switch 语句中检测到取消和等待延迟的 goroutine 完成后,会打印出状态协程的错误。请注意,延迟的 goroutine 调用了带有错误的 cancelFunc,但该错误并不会覆盖最初的取消错误。

在代码达到结束处理的逻辑状态时,手动取消非常有用。有时,会希望取消,因为任务花费的时间太长。这时可以使用定时器。

带截止时间的上下文

对服务端而言,管理请求是其最重要的任务之一。初学者常常认为服务端应该接收尽可能多的请求,并尽可能长时间地处理它们,直到为每个客户端返回结果。

问题在于,这种方法不可扩展。服务器是一个共享资源。与所有共享资源一样,每个用户都希望从中获得尽可能多的资源,并且对其他用户的需求并不太关心。共享资源的责任是管理自身,以便为所有用户提供公平的时间。

服务端可以通过以下四种方式来管理其负载:

Go 提供了处理前三种方式的工具。我们在学习并发时已经学习了如何处理前两种方式。通过限制协程的数量,服务端可以管理同一时刻的负载。等待队列的大小通过带缓冲的通道来处理。

上下文提供了一种控制请求运行时间的方法。在构建应用程序时,应对性能进行评估,即在用户对体验不满之前,有多长时间可以完成请求。如果知道请求可以运行的最长时间,可以使用上下文进行强制执行。

注: 虽然 GOMEMLIMIT 提供了一种软性限制 Go 程序使用的内存量的方法,但如果想对单个请求使用的内存或磁盘空间强制实施约束,则需要编写代码来管理。这一话题超出了本书的范畴。

可以使用两个函数来创建一个限时的上下文。第一个是context.WithTimeout。它接受两个参数,现有的上下文和指定上下文自动取消的持续时长的time.Duration。它返回一个上下文,在指定的持续时间后自动触发取消操作,以及一个取消函数,用于立即取消上下文。

第二个函数是context.WithDeadline。这个函数接收现有的上下文和一个指定上下文自动取消时间的time.Time,。与context.WithTimeout类似,它返回一个上下文,在指定的时间过去后自动触发取消操作,以及一个取消函数。

小贴士: 如果将过去的时间传递给context.WithDeadline,所创建上下文将已经取消。

context.WithCancelcontext.WithCancelCause返回的取消函数类似,必须确保至少调用一次context.WithTimeoutcontext.WithDeadline 返回的取消函数。

如果想了解上下文自动取消的时间,请使用context.ContextDeadline 方法。它返回一个表示时间的time.Time,以及一个表示是否设置了超时的bool值。这与我们在读取字典或通道时使用的逗号ok语句相似。

在为请求的总时长设置限制时,可能会希望将该时间划分为子时间段。如果从我们的服务调用另一个服务,可能会希望限制网络调用的持续时间,并保留一些时间用于其余的处理或其他网络调用。通过使用context.WithTimeoutcontext.WithDeadline创建封装父上下文的子上下文,可以控制单次调用的持续时间。

在子上下文上设置的所有超时都受到父上下文中设置的超时的限制;如果父上下文在两秒内超时,可以声明子上下文在三秒内超时,但当父上下文在两秒后超时时,子上下文也会超时。

我们可以通过一个简单的程序来了解:

这上例中,我们在父上下文中指定了两秒的超时,并在子上下文中指定了三秒的超时。然后,通过子context.ContextDone 方法返回的通道来等待子上下文完成。下一小节我们将更深入讨论 Done 方法。

读者可在第十四章代码仓库sample_code/nested_timers 目录中找到此代码,或者在 Go Playground 上运行这段代码。得到的结果为:

由于带有计时器的上下文可能会因超时或显式调用取消函数而取消,上下文 API 提供了一种告知取消原因的方法。如果上下文仍存在,Err 方法返回 nil;如果上下文已被取消,会返回其中一种哨兵错误:context.Canceledcontext.DeadlineExceeded。前者表示显式取消,后者表示超时触发的取消。

我们来看如何使用。我们对httpbin程序进行改造。这里在控制协程运行时间的上下文中添加了一个超时:

注: 如果希望返回取消原因的错误选项,需要将由WithTimeoutWithDeadline创建的上下文封装在由WithContextCause创建的上下文中。还应defer这两个取消函数以防止资源泄漏。

现在,如果返回500状态码或者在3秒内没有获得500状态码,我们的程序将退出。我们对程序的唯一其他更改是在取消发生时打印出Err()返回值:

可在第十四章代码仓库sample_code/timeout_error_http 目录中找到此代码。

结果是随机的,所以请多次运行程序查看不同结果。如果程序超时,会得到如下这样的输出:

注意context.CauseErr方法返回了相同的错误:context.DeadlineExceeded

如果在3秒内出现了状态错误,会得到如下的输出:

此时context.Cause返回的是bad status,但Err返回的是context.Canceled错误。

在自己的代码中处理上下文取消

大多数情况下都无需担心自己的代码超时或取消;它根本不会运行那么长的时间。每当调用另一个HTTP服务或数据库时,应该传递上下文;这些库通过上下文相应处理取消。

如果编写的代码需要运行很长时间需要通过上下文取消来中断,可以使用context.Cause定期检查上下文的状态。

以下是在自己的代码中支持上下文取消的模式:

我们知道,select代码块中的default分支只在没有其他分支能够读取或写入其关联通道时才会调用。空的default分支只是退出select,并且longRunningComputation继续处理。如果将不可取消的上下文传入到longRunningComputation中,Done会返回nil通道。这会一直阻塞,因此默认分支总是会运行并退出select。如果传入的是可取消的上下文,只在上下文被取消时,Done分支才会执行。否则,select代码块将退出,函数继续执行。

以下是一个使用低效的莱布尼兹算法计算π的函数中的循环示例。使用取消上下文可控制其运行时间:

可在第十四章代码仓库sample_code/own_cancellation 目录中找到完整的程序代码。

小结

本章中,我们学习了如何使用上下文管理请求元数据。现在我们可以设置超时、执行显式取消、通过上下文传值,也知道了在何时使用它们。下一章中,我们学习Go语言的内置测试框架并学习如何查找bug以及诊断程序中的性能问题。

退出移动版