在 Go 中使用 Context
这篇文章也发布在我的博客上了!
目录
介绍
当你因为倦怠和生存危机而崩溃时,是否会因为自己对着虚空发出的绝望呼喊无人回应而感到恼火?对此我无能为力,但我可以提供一些方法来设置对外部或内部服务的调用超时。我一直在研究和使用 Go 语言的一些标准库,其中我发现最有用的库之一是 context 库。它可以用来控制运行缓慢的系统(无论出于何种原因),或者强制执行服务调用的质量标准。这个小库之所以成为标准库是有原因的。对于任何生产级别的系统来说,为了保持良好的流程控制,context 库都是必不可少的。
上下文库由Sameer Ajmani创建,于 2014 年推出,并在 Go 1.7 版本中成为标准库。如果您查看过一些 Go 库的源代码,就会发现大量需要传递上下文的示例。这只是我最近使用的一个示例。上下文是一个可以传递给代码中正在运行的进程的截止时间。这个截止时间可以指示进程在满足特定条件后停止运行并返回。这在调用外部 API、数据库(如上所示)或系统命令时非常有用。
以下内容假设读者已了解 goroutine 和 channel 以及它们如何协同工作。在介绍完 context 之后,我将深入探讨并发,因为 context 库是并发的一部分。不过,现在我们先来了解一下:goroutine 是可以为进程启动的轻量级线程,而 channel 是用于在这些新进程之间传递数据的管道。
上下文接口
上下文库定义了一个名为 Context 的新接口。Context 接口包含一些有趣的字段,如下所示:
“截止日期”字段返回工作预计完成的时间,并指示何时应取消上下文。
“已完成”字段是一个通道,当需要取消针对特定上下文已完成的工作时,该通道将被关闭。此操作可以异步执行。如果关联的上下文永远无法取消,则该通道可以返回 nil。不同的上下文类型会根据具体情况安排工作的取消方式,我们稍后会详细介绍。
在 Done 对象关闭之前,Err将返回 nil。之后,如果上下文已取消,Err将返回Canceled;如果上下文的截止时间已过, Err 将返回DealineExceeded 。
Value 字段是一个键值对接口,它将返回与上下文关联的值(作为键),如果没有关联的值,则返回 nil。Value 字段应谨慎使用,因为它们并非用于向函数传递参数,而是用于请求范围内的数据传输过程和 API 边界。
语境中的语境
在 Go 中创建上下文时,很容易编写一个静态上下文来存储和重用。但就我目前的研究来看,这并非使用上下文库的最佳方式。上下文应该根据每次使用的需求而变化。它应该是无形的,或者用李小龙的话来说,要像水一样。你的上下文应该贯穿你的代码,并根据需要不断演进。
但也有一些例外情况。对于更高级别的流程,当还没有可供传递的上下文时,可以传入一个空上下文。这些空上下文可以作为重构前的占位符。
背景
“Background”函数返回一个非空的空上下文。它没有关联的截止时间,也没有取消机制。这通常可以在主函数中使用,用于测试,或者用于创建一个顶层上下文以供其他用途。查看源代码可知,除了返回一个空上下文之外,它没有任何其他逻辑。
快速提示:
通常情况下,上下文在声明时会被命名为ctx。我在大多数上下文实现中都见过这种情况,所以如果你在源代码的随机位置看到ctx,它很可能指的是一个上下文。
上下文.待办事项
TODO函数的作用相同,它返回一个非空的空上下文。这同样适用于尚未提供相应函数的高级函数。在许多情况下,当扩展程序以使用上下文库时,它会被用作占位符。如果您看过 Sameer Ajmani 关于在 Google 重构代码时引入上下文库的演讲,就会发现他们使用 context.TODO来逐步将上下文引入 Google 代码库,而不会破坏任何现有功能。
快速补充说明:
还有一点需要提及,有人曾建议将TODO 部分与静态分析工具兼容,以便查看程序中的上下文传播情况。据我所知,这可能是编写源代码注释的人随口提到的。我最近几天一直在寻找这样的工具,但目前似乎还没有。我很想研究如何开发这样的工具,不过我还是决定去看场电影。
Context.WithCancel
假设我正在搭建一个影评网站。有很多 API 可以用来提供电影信息。我最近发现的一个 API 是吉卜力工作室 API,这是一个公共 API,我们可以直接从中获取数据。所以,对于网站上专门介绍吉卜力工作室电影的版块,我们将使用它。`WithCancel`函数会返回一个父上下文的副本,并添加一个新的`Done`通道。这个新的` Done`通道会在调用 `Cancel` 函数时关闭,或者在父上下文的`Done`通道关闭时关闭,以先发生的事件为准。
以下是一个实际应用示例:
这里我们将使用longRunningProcess函数模拟一个挂起的进程。在这个例子中,该函数会出错,但我们必须在从 API 请求 JSON 数据之前运行它。longRunningProcess 函数会返回一个错误,该错误会触发上下文中的cancel()函数。
对于ghibliReq函数,我们将使用 API 设置一个简单的 HTTP 请求,并传递一个用于从 API 中查找内容的字符串。设置好请求后,我们有一个 case 语句来接收通道数据。根据哪个先发生,select 语句会发送当前时间或传入上下文中的“Done”通道。如果“Done”通道已关闭,则抛出错误;否则,返回请求的状态码。
我们的主要代码首先使用一个新的Background()上下文来设置上下文,然后将其传递给WithCancel()上下文。由于新上下文是空的,所以目前还没有发生任何事情。接下来,我们创建一个新的 goroutine 来创建一个新线程并调用longRunningProcess。调用 longRunningProcess 后,我们会检查错误,由于我们事先设计了这种机制,错误会返回。如果存在错误,我们可以调用上下文中的cancel()函数。最后,我们使用上下文来发起请求。运行后,我们发现请求失败了,因为请求耗时过长,并且调用了cancel()函数。
在这个例子中,我们在发出请求之前运行了 ` longRunningProcess`,因为在调用请求之前需要先执行 `longRunningProcess`。如果函数出错,我们需要能够调用 `cancel()` 来处理 ` ghibliReq()`函数的错误。我们目前的设置是在函数有机会运行之前就调用了上下文的`cancel ()`。这样做是为了演示 `cancel()` 的工作原理。我们可以很容易地将`longRunningProcess`中的`time.Sleep()`改为 1000 毫秒,这样请求函数就会在调用`cancel()`之前运行。但在生产环境中,如果目标是确保调用栈的流畅性,我们应该确保不会返回错误,也不会在这个上下文中调用`cancel()`。
快速提示:
请记住,除非必要,否则特定上下文的调用不应是阻塞操作。一切都是为了保持程序运行。
上下文.截止日期
`WithDeadline`函数需要两个参数:一个是父上下文,另一个是新的时间对象。该函数会根据传入的新时间对象调整父上下文。需要注意的是,如果传入的上下文时间早于传入的时间对象,则源代码会直接返回一个与父上下文相同的取消要求的`WithCancel`上下文(您可以在源代码中查看)。新的截止时间过后,`Done`通道会关闭。您也可以手动返回取消函数,或者当父上下文的 ` Done`通道关闭时,该函数也会关闭。以先发生的事件为准。
下面我们将详细介绍WithDeadline 的工作原理:
我们将继续推进搭建电影评论网站的想法。说实话,我很有可能会创建一个专门讨论吉卜力工作室电影的网站。上面的例子类似于withCancel示例。我们将复用一个函数来演示我们的上下文。复用那些行之有效的代码,可以节省时间。我们将发出一个请求并返回该请求的状态。区别在于我们如何处理上下文。
假设我们需要创建大量级联请求,并且希望确保整个调用堆栈中的所有操作都能按时完成。为了跟踪时间并在必要时优雅地处理错误,我们可以继续使用截止时间,并为额外的调用增加时间。在我们的示例中,我们创建一个后台上下文,然后将其与一个新的时间一起传递。现在,我们在ctx变量中获得了一个大约 1 秒的返回上下文。在我们的示例中,如果请求处理时间超过 1 秒,则上下文会调用取消函数并关闭Done通道,从而导致请求出错。
我们可以看到,这取决于我们设定的标准。设定时间意味着你对某件事需要多长时间有一个大致的了解。这可能取决于你的服务器可用性、网络连接、硬件限制等等。我也见过有人抱怨某些服务级别协议(SLA)保证在特定时间范围内归还资产。考虑到可用性,结合上下文,设定截止日期可以帮助确保我们能够在合理的时间内获取信息,并在无法获取时及时归还。
上下文.超时
下一个相关的函数是 ` WithTimeout`函数。它与`WithDeadline`函数略有不同。为了实现一些创新,`WithTimeout`函数会返回一个`WithDeadline`上下文,并将传入的时间参数添加到截止时间中。换句话说,它的行为类似于`WithDeadline`函数,即它会获取父上下文并增加时间,从而返回一个派生上下文,并将新时间添加到调用取消函数和关闭“完成”通道之前的时间中。我再举个更简单的例子:
与之前的示例相同,我们设置超时时间,以便在指定时间后关闭“完成”通道。在我们的例子中,如果半秒钟后我们仍在等待调用,则超时。我喜欢 HTTP Go 库,因为它有一个内置函数,可以返回添加了新上下文的请求副本。
上下文.WithValue
我要讲的最后一部分是 ` ContextWithValue`函数。这个函数有点争议,因为据我所知,它的本质与上下文的初衷相悖。上下文应该是一种确保程序间数据流动的方式。然而,上下文中的值部分可以用来传递信息。这个函数允许你传入一个键值对接口,以便在调用中传递。
从关于上下文的原始帖子来看,“`WithValue` 提供了一种将请求范围的值与上下文关联起来的方法”。我想简单谈谈它不应该用于哪些情况。我看到的大多数文章或教程似乎都认同,传递请求本身之外的信息是一个糟糕的想法。数据库连接、函数参数,任何不在请求中创建和销毁的信息,可能都不是好的设计模式。也就是说,将值与上下文一起传递有时是有用的。
我们来看一段代码:
我们将沿用上一个例子中的代码。只不过这次我们要创建一个新函数来计算一个虚假的请求 ID。假设我想保存一个记录所有请求的数据库,因为……我也不知道,我可能有点变态。或者,我为美国国家安全局工作,为了国家安全,我正在开发一些间谍软件来监视我的前任。由于他们没有对我进行过作战情报方面的训练,我不知道如何区分哪些数据真正有意义,哪些是无意义的噪音,所以我什么都收集。甚至包括那些看似无害的、用于查找动画电影信息的开放 API 调用。我现在很累。
在我们的示例中,我们执行与上述相同的操作:设置一个超时时间为半秒的上下文。不同之处在于,现在我们有一个辅助方法,用于计算新的请求 ID,并将该 ID 作为新的接口在上下文中传递,以便我们可以访问并对其进行操作。在这个模拟场景中,我们会记录此信息并关闭上下文。这符合我们自己设定的标准,即只保留与该调用相关的信息。信息万岁!
关于如何在上下文中传递值,还有很多值得探讨的地方。我见过一些文章,其中提到使用中间件在两个服务之间执行一些操作,以改善某些功能。我可能会深入研究一下,但由于这有点超出本文的范围,我可能会在以后写一篇相关的文章。谁知道呢,我得睡觉了。
结论
上下文库有助于让程序中的调用更加清晰易懂。在设计程序时,应该尽早将上下文引入函数。正如前面提到的,以前很容易用TODO作为占位符来创建函数,然后在重构时再修改。此外,程序也应该能够优雅地处理失败。我曾经花费大量时间编写晦涩难懂的失败信息,包括我自己在内,根本无法理解。用户不应该知道某个调用失败了,而应该只知道他们没能在半秒钟内看到电影标题。
Sameer 的演讲中提到了一种很棒的方式,可以形象地说明这些上下文的用途。他谈到了对冲调用(schedule call)的实践,即调用冗余服务,然后选择耗时更短的那一个。谷歌的人都追求速度和优化。这正是创建上下文来贯穿整个程序的一个好处。当一个服务返回时,另一个服务就会被取消,从而释放该线程可能占用的资源。上下文是一个小巧但功能强大的库,应该经常使用,并且在将其融入程序之前要经过深思熟虑和周密规划。我希望读完这篇文章后,我们都能更好地理解上下文及其用法!如果你喜欢这篇文章,有任何问题或评论,或者你只是想吐槽《星球大战:最后的绝地武士》有多烂(虽然它并不完美,但对于一个尚未准备好迎接它的世界来说,它仍然是一部震撼人心的电影),欢迎在Twitter上联系我!我喜欢时事评论。
文章来源:https://dev.to/georgeoffley/working-with-context-in-go-75e






