本篇内容是根据2020年8月份#143 context.Context音频录制内容的整理与翻译
Francesc Campoy 和 Isobel Redelmeier 加入小组讨论 Go 的上下文包,包括对其使用和误用的现实见解。
过程中为符合中文惯用表达有适当删改, 版权归原作者所有.
Mat Ryer: 大家好,欢迎收听 Go Time,我是 Mat Ryer。今天我们要讨论的是 Context 包。我们将了解它是什么,如何使用它,以及不该如何使用它。今天的旅程中,我们有幸请到了 Jaana B. Dogan。你好,Jaana。
Jaana Dogan: 嘿!你好吗?
Mat Ryer: 很好,谢谢。欢迎回来。你这一周过得怎么样?
Jaana Dogan: 嗯,这边有点慢... 我不确定你那边怎么样。每天都好像是同一个循环,所以我也不确定这是好事还是坏事...
Mat Ryer: 我明白你的意思,是的... 一切都开始混在一起了,对吧?
Jaana Dogan: 是的。
Mat Ryer: 别担心,这期节目无论如何都会给你带来一些变化。我们还邀请了一位我们最喜欢的 Gopher 之一---
Francesc Campoy。你好,Francesc,欢迎回来!
Francesc Campoy: 嘿!你怎么样?
Mat Ryer: 不错。你这一周过得还好吗?
Francesc Campoy: 嗯,正如 Jaana 刚才说的---
每天都好像是同一天... 但是能和朋友们聊天让我很兴奋,这给我的生活带来了一点变化。我很激动。
Mat Ryer: 就是这样---
只要我们能稍微改变一点,无论是好是坏...
Francesc Campoy: 只要够不同就行。
Mat Ryer: 只要不同就好。 [笑声]
Francesc Campoy: 只要不同。
Mat Ryer: 是的。我们今天还有一位特别的嘉宾---
Isobel Redelmeier。你好,Isobel。
Isobel Redelmeier: 你好,大家好!很高兴能来到这里。
Mat Ryer: 我们也非常高兴你能来。你这一周过得怎么样?我知道今天才星期二,不过你这一周过得怎么样?
Isobel Redelmeier: 还不错... 新工作的第三周了,所以...
Jaana Dogan: 不错。
Isobel Redelmeier: ... 和找工作时的节奏完全不同。
Mat Ryer: 是啊,很酷。
Isobel Redelmeier: 还有等签证的时候也是...
Mat Ryer: 哦... 那可不太好。今天我们要讨论的是 Context。我快速看了一下 Context 包的文档,它说 Context 包定义了 context 类型,该类型携带截止时间、取消信号以及其他请求范围的值,跨越 API 边界和进程之间。强调部分是我加的。谁愿意来解释一下这是什么意思呢?
Isobel Redelmeier: 我可以聊聊后半部分,关于它在像开放遥测(OpenTelemetry)这样的场景中如何变得有趣---
在这里你有很多跨进程或跨网络的分布式 Context 的用例... 或者我们可以之后深入讨论。
Mat Ryer: 是的,实际上这听起来很棒。我想听听这个,因为它确实提到了 API 边界和进程之间,但我只在单个程序中使用过 Context。
Isobel Redelmeier: 我们是想先从进程内的 Context 开始,还是跨进程的?这有道理---
Mat Ryer: 我们先从简单的场景开始。如果有任何新手听众,他们可能还没完全理解---
或者可能他们见过 Context 但不知道该怎么使用,也许他们只是每次都传递 Context... 不过这也挺好。
Francesc Campoy: 它确实能工作...
Mat Ryer: 是的,它能工作。
Isobel Redelmeier: 这总比传 nil
好...
Mat Ryer: 没错,没错。文档确实也提到,你永远不应该传递 nil
。我们可以讨论一下为什么会这样。所以,实际上它主要是一种取消机制;它表明这个进程由于某些原因将要停止。在 HTTP 场景中,每个请求都有一个 Context,由于它运行在自己的 goroutine 中,当然它可以启动其他 goroutine 来执行工作,如果用户取消了请求,在某些情况下中止这些工作可能是个好主意,这样可以节省一些资源...
你也可以用它来传递值,对吧?请求范围的值。这是用来做什么的呢?
Francesc Campoy: 这是个好问题。我其实很喜欢用 Context 包来做取消操作。我认为这是我推荐在你自己进程中使用它的主要方式。至于在 Context 中传递数据---
在我开始这么做之前,我会非常慎重考虑,因为否则你可能最终会有一个装满各种东西的袋子,这可能会是一种不太好的习惯。但对于取消操作---
是的。我通常解释为什么 Context 有意义的方式是,假设你有一些非常耗时的工作,而在一个小时的计算中,用户在一秒钟后决定他们不再关心这个结果了,或者他们的程序崩溃了。你不想为了没有意义的结果而执行整个一小时的计算。这就是取消操作的意义所在。
实现它的方式也很有趣,因为理论上你可以把这些数据存放在其他地方,很多其他语言也是这么做的。例如,在 Java 中我们有 ThreadLocals。Go 并不暴露 ThreadLocals,这意味着你实际上需要显式地传递这些数据。但显式传递这些数据,并且在函数中传递 context 并不是一件容易的事情。你可能需要不断地为你的函数添加更多的参数。Context 解决了这个问题,它还提供了传递你甚至不知道自己在传递的数据的可能性。
所以,作为一个函数,如果我接收到一个 context,也许在该 context 中有某个值,而这个值将被我调用的某个函数获取。我甚至不需要知道它的存在。所以这就有了两方面,一方面是取消操作,另一方面是一种传递你不关心的数据的通用方法。
Jaana Dogan: 我们可以说,在用户请求的关键路径上,你会经过很多不同的东西,对吧?可能是很多微服务,或者是在同一个微服务中你可能在不同的 goroutine 之间切换。一些工作可能在一个 goroutine 中完成,另一些则在另一个 goroutine 中完成... 因此为了协调所有这些工作,我们有时需要传递一些值,同时也需要传递一些生命周期相关的事件信号,比如取消信号... 因为假设用户取消了某些任务,底层的所有服务可能已经接收到了一些请求来完成相关工作... 但是我们在上层服务中已经知道不再需要这些工作了,所以你可能想要传递这个信号来取消所有的工作。
这也适用于同一进程内。你可能在多个 goroutine 之间共享一些工作,然后你想要取消所有这些工作,因为我们已经收到了一些额外的信号,表明这些工作不再需要。
因此,它为我们提供了一种很好的统一方式来在同一进程内传递一些数据和传递一些生命周期信号... 同时它也为我们在跨进程、跨服务时奠定了基础。
Isobel Redelmeier: 完全同意。
Jaana Dogan: 我刚刚总结了你的想法... [笑]
Francesc Campoy: 是的,我其实很好奇---
有两件事情,一个是取消操作,确保不必要的工作不会被执行,另一个是传递值。它们非常不同。而我觉得奇怪的是它们是同一个东西---
Context。我觉得它们本可以是完全不同的东西,因为我们传递值的方式---
这确实很有用。例如,传统的例子是“当你记录日志时,你可能想要记录一个请求 ID”,无论哪个函数在记录日志,这个请求 ID 应该始终保持一致,因为这才有意义。这样你就可以看到这个请求的所有日志。
但这与请求是否应该被取消完全无关。我感觉我们把它们放在一起只是因为,一旦你有了取消功能,并且定义了一个接口,你也可以把值放进去,所以为什么不呢... 但同时,它们本来可以是完全不同的东西。我认为思考 Context 时,理解它的这两个完全无关的功能非常重要,而且你可以只使用其中之一,而完全不需要理解或关注另一个。
Isobel Redelmeier: 我觉得 Context 中的截止时间(deadline)是一个有趣的桥梁,因为一方面,它具有与更通用的取消操作类似的功能,而另一方面,它又是一种特殊的值,你可以检查 Context 上的活动截止时间,如果我没记错的话...
Francesc Campoy: 是的,没错。它有点像元数据---
它是围绕取消操作的数据,所以严格来说你也是在传递值,但你用这个值来进行取消操作。是的,这确实有点居中。
Isobel Redelmeier: 对于那些不熟悉底层工作原理的听众来说,Go 会尊重截止时间(deadline)。所以如果你有某个操作超过了截止时间,它会自动取消。你不需要自己去管理它……当然,前提是你使用的代码能够处理这个情况。比如,SQL 会帮你处理这些。
Mat Ryer: 是的,没错。这在 Go 中确实有点特别,因为它服务于多种目的,对吧?实际上,你可以通过传递值,或者通过使用一个可以关闭的 channel 来实现类似的功能……但是将它作为这种官方 API 的一部分,确实为大家解决了很多广泛的问题。我一直认为,如果你不确定如何处理 context,当你在某个代码库中工作时,你可以简单地传递它;如果你要调用某些需要 context 的函数,只要传递你现有的 context 就行,如果你不想额外管理生命周期的话。
有时候它确实有用,比如你要调用一个第三方 API,你可能会决定“我只等一秒钟,如果超时了,我就使用缓存的版本,或者类似的东西……”然后你可以从另一个 context 创建一个新的 context。那么这样会创建一个类似树状结构的东西吗?
Francesc Campoy: 是的,完全正确。比如说,当我让你做某件事时,你去找另外三个人做其他事情,因为你需要那些事情来完成我交给你的任务。如果我说“哦,我不再关心这件事了”,你也应该通知其他人不需要继续了。而取消操作---
创建这些 contexts 正是为了提供这种功能。
有趣的是,从实现的角度来看,这也是它的工作原理。你在处理截止时间、超时等情况时,实际上是在创建一个新的 context 对象,它引用了它的父 context。因此,确实是在创建一个树状结构。
Isobel Redelmeier: 这也适用于你添加到 context 中的任何值。
Mat Ryer: 那么当你访问这些值时---
这也指出了使用 Context 的一个危险之处……当你从 context 中访问一个值时,你实际上是传递了一个键,它的类型是 interface{},这意味着它可以是任何东西;任何东西都可以作为键……你会得到一个 interface{},因为它在某种程度上是通用的,这就是目前 Go 中的泛型看起来的样子……你还会得到一个布尔值,表示这个值是否存在。那么这种方式有什么风险呢?通过这种方式访问和存储信息,我们会失去什么?
Isobel Redelmeier: 你需要自己处理类型检查和存在性检查,同时还要确保不会出现键冲突。因此,有一种做法,基本上就是为你关心的每个键创建专用的 struct,这样你就不会,比如说,使用一个名为“key”的字符串,导致与其他人的键发生冲突。
Francesc Campoy: 是的,这确实是一个很好的观点。很多人其实并没有这么做,但这是一个好习惯。如果你在创建一个包,并且你知道自己要存储一些数据,之后还会检索这些数据,那么与其选择一个字符串或整数,无论这个字符串有多复杂……你可以找到一个超级复杂的字符串,确保没人会重复使用它,或者你也可以给它申请版权(笑)……但重要的是,你可以直接使用一个空 struct,并且这个 struct 的类型不对外暴露。因为当你比较两个不同的接口类型时,首先要做的就是比较它们的类型是否完全相同。如果类型不同,那么它们就是不同的值。所以你可以做的通常是 type key struct {} - 键用小写字母命名,这样可以确保避免冲突。
Jaana Dogan: 顺便说一下,在 GoDoc 中有一个例子,如果你查看 func WithValue(parent Context, key, val interface{}) Context,你会看到类似的例子。在播客中解释这个概念有点难,但那里有一个例子,基本上就是你如何使用/创建规范的键类型,确保不会发生冲突。你创建自己的键类型,并使用它。
Mat Ryer: 你会建议将这些键导出,以便其他人可以访问这些值吗?或者有没有更好的方法?
Francesc Campoy: 不,绝对不要导出。
Mat Ryer: 为什么不呢?
Francesc Campoy: 因为一旦你导出它,人们就会随意使用它。整个想法是---
Context 可以包含太多东西,如果你允许人们开始使用它,那么你将开始看到非常奇怪的设计出现。比如,一个随机的包可能会依赖于另一个包引入的值,这样你就会有一些奇怪的跨包依赖关系,而这些依赖关系并不是代码依赖。它们不会出现在代码中,没有 import 或其他东西,只是恰好期望某些东西存在。结果可能会导致依赖地狱,只不过是出现在 context 内。
所以我会说,与其这样做……比如对于日志记录,我最近创建了一个小的日志包,它的作用是---
对于一个 HTTP 请求,它会从请求中获取 context,然后执行类似 logger.width 的操作……我甚至不记得了,可能是什么 from context 之类的……它会把一个私有的、别人找不到的键放进 context,值是我的 logger。然后,当你在其他地方想要检索它时,你从 context 中获取它,并传递这个 context,这样你就能得到一个包含正确类型的 context。这样,你就避免了冲突,也避免了从空接口类型转换为实际使用的类型的麻烦。
Isobel Redelmeier: 完全正确。而且这些 from 函数通常……最好返回一个布尔值,或者返回某种空类型,这样它在行为上仍然是一样的,至少用户不会因为你的值而遇到 nil
错误。
Francesc Campoy: 是的。比如对于 logger,当你从 Context 中获取它时,如果你传递的 context 中没有 logger,它就会返回一个默认 logger,将日志记录到标准输出中。
Mat Ryer: 对啊。所以在你之前提到的这个场景中,Francesc,你是老板,我是某个公司的中层管理者,我有一些从我的 context 创建出来的 context,如果其中一个 context 请求某个值,但它不知道这个值是什么,因为这个键是私有的,那么会发生什么呢?context 是如何运作的?
Francesc Campoy: 哦,所以如果你尝试从 context 中获取一个值?
Mat Ryer: 是的,如果 [听不清 00:18:21.12] 这个值不在子 context 中。
Francesc Campoy: 哦,是的,那么你会向上查找。这适用于 Context 的每一个功能,比如截止时间、取消操作和值。如果你的 context 中没有修改过这个值---
因为你可以重新定义它;你可以隐藏之前的键值对。但如果你没有这么做,你会先检查你当前的 context,如果它没有包含这个值,那么你会调用它的父 context 的“get value”函数,依此类推,直到你到达最上层的空 context,也就是从 background
或 todo
context 获取的 context,而这些 context 没有父 context。然后你会停止并返回“找不到”。
Isobel Redelmeier: 这一切会自动为你处理。你不需要主动去考虑这棵树。
Francesc Campoy: 是的,所以有些人会觉得---
当你获取一个值时,感觉好像在使用一个 map,但其实你使用的是链表。
Mat Ryer: 这真的很酷。想象一下取消操作在这棵树中的传播---
因为你可能会启动一些子任务去执行某些工作……你可能只取消其中一个子任务,或者它有自己的截止时间。如果它又创建了其他任务,那么这些任务也会被取消。取消操作就是这样向下传播的,对吧?
Isobel Redelmeier: 正是如此。类似的情况也适用于截止时间,比如你有一个包含多个连续任务的作业,你希望整个作业的耗时不超过一分钟,但你希望每个任务的耗时不超过五秒钟,那么你可以说“好,每个任务的截止时间是从现在起五秒钟,或者是初始的一分钟截止时间中的较早一个。”
Mat Ryer: 这真的很酷。而且你通过使用 Context 包中的那些函数来实现这一点,对吧?每次都传递父 context。
Isobel Redelmeier: 嗯。
Mat Ryer: 这非常酷。
Francesc Campoy: 这里有一个有趣的事---
我很久以前做过一个演讲……我不知道你记不记得,Mat,当时你也在场……
Mat Ryer: 我记得。
Francesc Campoy: 在意大利的 GoLab。我决定做一个带有现场编码的主题演讲,这是一个糟糕的主意;现在我知道了(笑)。我决定做一个关于重新实现 Context 包的演讲,因为有一件事特别酷,那就是当你取消一个 context 时,所有子 context 也会自动取消。这种通过 goroutine 和 channel 管理的方式非常可爱。作为一个 Go 开发者,当你看到它时,你会觉得“哦,是的,这绝对是 Go 语言的惯用做法。”不过这不是我能在播客中解释清楚的内容,所以大家可以去读代码(笑)。
Mat Ryer: 好的,我们会在节目笔记中放上那场演讲的链接,因为你基本上在演讲中实现了 Context 包。这非常令人印象深刻。
Francesc Campoy: 不不不,那演讲有点失败(笑)。不过后来我在 justforfunc 上做了一个视频,我有时间编辑掉了所有失败的部分,所以看起来要好得多。我会提供那个视频的链接。
Mat Ryer: 好的,没问题。我们会 [听不清 00:21:47.04]
Isobel Redelmeier: 原始包的代码也相当容易阅读,并且文档齐全。
Mat Ryer: 是的,而且它在标准库中,对吧?是从 Go 1.15 开始……?
Jaana Dogan: 5.xx……
Francesc Campoy: 1.6?可能是 5 或 6,差不多是那个时间。
Jaana Dogan: 5 或 6,应该是。
Mat Ryer: 几号版本?
Jaana Dogan: 我觉得是 6。唯一让我确信是 6 的原因是我们在 App Engine 运行时支持 1.6 版本好几年了,即使 1.6 早就被淘汰了,因为这与 Context 包有关,特别是与 gRPC 相关的东西,这有点像另一种依赖地狱……但我觉得是 1.6。
Isobel Redelmeier: 在那之前,它在 xContext 中存在了一段时间。
Francesc Campoy: 是的。在那之前,它在 Google 内部使用了很多年。它实际上是网络包的一部分,或者类似的东西。我记得是在内部的网络包……他们在开源之前完全改变了实现,但保留了 API 完全不变。我觉得这清楚地证明了接口的强大之处---
我们从一个做所有事情的实现,转换为多个不同的 context,创建了这个很酷的树状结构。在此之前,它不是树状结构。但从用户的角度来看,你不在乎,所以他们能够重写整个实现,使其更小、更高效,而不改变任何接口。
Mat Ryer: 这也是对接口设计本身的一个很好的证明,对吧?
Francesc Campoy: 是的,绝对如此。
Mat Ryer: 所以如果你在编写一个需要很长时间才能完成的代码---
假设你在遍历文件系统,你使用 filepath.Walk
,你如何对 context 做出响应,处理它的截止时间或取消请求呢?
Francesc Campoy: 首先,你应该获取 context。如果你从一个函数调用中获取它,并且它是第一个参数,它应该始终是第一个参数,虽然没有特别的原因,只是大家都这么做。但你应该接收它并对其做一些处理。如果你没有接收它,并不意味着没有 context。可能是你遗漏了它,很多人都忽略了这一点---
当你有一个 HTTP handler 时,实际上是有一个 context 的。你看不到它,因为它隐藏在 HTTP 请求后面。
所以如果你执行 request.context
,你就能获得 context。这么做是因为如果我们在 handler 的开头添加一个额外的 context 参数,那么我们会破坏 HTTP 包的所有接口,这会很糟糕。
所以,这就是你应该怎么做……我会说,第一步是确保你使用了它,并获取了 context,接下来是检查它是否被取消。好消息是,基本上你要做的就是获取一个通道。所以你需要像处理通道一样使用 select 语句;比如,我可能会收到 context 的取消信号,或者其他事情发生。所以你需要稍微调整一下你的代码,如果你以前没有这么做过,这可能有点混乱,但基本思路是你应该有一个处理真正想要执行的任务的路径,以及一个处理取消操作的路径。
如果你在每个 HTTP handler 中都这么做,比如说“我要调用这个函数,但如果发生这种情况,就取消它”,如果用户发送了请求然后取消了请求,因为 TCP 连接断开了,context 将被取消。所以即使你的 REST API 的用户对 context 一无所知,你也能从中受益良多。
Isobel Redelmeier: 你也可以把这个检查放到一个包装 handler 中,类似于中间件,这样你就不需要记得在每个端点都手动添加它。我知道这只是音频,但基本上,举手示意一下,如果你曾经在每个端点手动添加某些东西,然后忘记了某个端点……我肯定有过这样的经历。
Francesc Campoy: 是的,复制粘贴是最好的。
Jaana Dogan: 你提到了一件对人们影响很大的事情,那就是 context 包是在后来的 Go 版本中加入标准库的,1.6……在 API 方面---
Francesc Campoy: 抱歉打断一下,我刚查了一下,实际上我们错了;它是在 Go 1.7 版本中加入的。
Jaana Dogan: 好的,知道了。
Isobel Redelmeier: 差了一个版本。
Jaana Dogan: 1.7,好的。
Francesc Campoy: 问题出现了,因为 Go 1.6 并没有 Context,而我们当时还在支持这个版本,很多人对此非常不满。我还记得当时的情景。
Jaana Dogan: 是的,确实有一些和 1.6 相关的争议,但那是因为 1.6 没有 Context,而不是因为它有 Context。
Francesc Campoy: 没错。
Mat Ryer: 又是典型的“差一错误”啊,哈哈。
Jaana Dogan: 是的。
Francesc Campoy: 差不多了...
Jaana Dogan: 我们都是程序员嘛,哈哈。因为 Context 的引入,导致了各种不同的 API。比如说,SQL 包在引入 Context 后变得非常复杂,因为他们不得不为 Context 引入一套新的 API。这让人们很难理解,大家会问:“为什么这些 API 都重复了?哪个是最好的选择?”等等。
这也给一些人带来了很多麻烦,因为他们没有历史背景---
Context 是如何出现的。Context 这个词在计算机科学中可能是最容易被误解的术语之一……所以很高兴我们现在能讨论这个话题。
我想说的是,这对一些人来说确实是一个挑战,因为他们不了解 Context 的历史,这是一个后来才出现的功能。所以,如果你正在设计一门新的编程语言,最好一开始就考虑好如何处理上下文的传递,因为这对整个库生态系统有着深远的影响。
Isobel Redelmeier: 完全同意。我记得刚才有人提到,Java 里有 ThreadLocal 变量。问题在于,任何拥有 ThreadLocal 变量的语言,或者只依赖全局变量的语言,最终可能会遇到这样的问题:某些人会需要比线程更细粒度的控制。Goroutine 就是一个例子,大家可能比较熟悉这种情况。但其他语言,比如 Python,也有类似的机制,比如 futures,这又与线程不同。所以 Python 后来加入了 context local 变量,但如果你在写库时,仍然要处理那些可能不支持 context local 变量的情况。
Jaana Dogan: 是的,实际上,我有个跟进问题。在很多语言中,Context 的传递是显式的,而在很多语言中,Context 的传递是隐式的。你觉得这两种方式对人们的使用感知有什么影响吗?比如说,显式传递是否提升了人们对 TLS(线程本地存储)或 Context 传递的意识?你可以用信号来取消操作,或者传递一些值……
从感知度或可用性角度来看,显式方法是否对用户体验有所贡献?你同意吗?
Isobel Redelmeier: 我认为这是一把双刃剑。一方面,隐式传递更容易使用,因为你可以随时随地访问它,不需要担心显式传递,或者担心“如果我用的库还不支持 Context 传递怎么办?如果我有遗留代码没有 Context 传递怎么办?我是否需要到处修改代码来添加它?”所以隐式传递的好处是你可以从任何起点轻松添加它。
但另一方面,隐式传递的滥用也变得更容易。比如在一些使用请求本地变量的语言中,我看到过很多滥用的例子。以 Rails 为例,人们往往会用一个请求对象来传递所有的状态。然后你会在代码库的某个非常深层的地方发现一些小方法,它们本不应该知道这些几乎是全局的状态,但却耦合在这个巨大的对象上。隐式传递看起来很方便,直到它变得不再方便,而一旦出现问题,问题就很严重。
Jaana Dogan: 是的,我见过一些例子,因为无法打破现有 API,所以他们不得不把东西放进 Context,结果就变成了所谓的“神对象”(god object)。基本上,API 全都依赖于 Context 传递,这是一种反模式,但它却时常发生。
Isobel Redelmeier: 没错。
Mat Ryer: 而且在这种情况下,编译器是无法提供安全保障的,对吧?编译器不会帮你检查这些值是否存在,只有在运行时你才能发现问题。
Isobel Redelmeier: 在大多数语言中确实是这样。不过有些语言提供了更多的安全性。
Francesc Campoy: 但你可以在 Go 中做一些假的安全检查。你可以创建一个请求对象,然后随着处理过程的深入,添加更多的字段。每个 handler 都创建一个请求对象,然后调用一些方法。所以你总是保持这个 Context,你总是能访问它。但如果你这么做,你会写出非常难以测试的代码,因为你会有一个不断增长的对象在整个程序中传递。这就是为什么我觉得,当你使用 Context 时,大多数情况下你实际上并不需要访问 Context 中的内容,除非你明确知道自己想要什么。
这样,你就不会被那些你无法访问的东西弄乱,从而使代码更易于处理。Go 语言一直试图做到这一点---
让事情容易使用,更重要的是,难以误用。Context 就是一个很好的例子。
Isobel Redelmeier: “神对象”现在是一个很容易搜索的术语,如果大家想了解更多关于它带来的问题的讨论,可以去查相关的文章。
Francesc Campoy: 我很好奇其他语言在隐式传递 Context 时的表现,Isobel,你可能知道。如果你有一个 for 循环,每次迭代都需要使用不同的 Context,这样的场景在其他语言中能实现吗?还是说你必须使用线程等机制?
Isobel Redelmeier: 这是个好问题,答案因语言而异。如果你只有全局变量(最基础的情况),那么你需要添加锁,以确保并行化的循环不会相互覆盖状态。如果你有像 Python 的 context local 变量,那么你就可以在每个 future 中拥有独立的上下文。我不常写 Python,不过最近开始写得越来越多了,这让我有点怀念 Go,哈哈。
所以,如果你的并行化机制(比如 futures)与 context local 变量兼容,那么你就可以免费获得这些上下文变量。然而,如果你使用 futures,而你只有 ThreadLocal 变量,那么就会出现不匹配的情况,你需要添加锁来确保状态不会被覆盖。这就像经典的全局变量问题,有时候能得到较好的缓解,但有时就不行。
Mat Ryer: 我们刚刚讨论了 HTTP 场景下的 Context……我们可以通过 HTTP 请求访问 Context 方法,也可以使用 withContext
来为请求设置新的 Context。如果我们想在命令行工具中使用 Context,比如处理 Ctrl+C 中断信号,来取消操作,这算是打破规则了吗?有人认为 Context 只能用于请求-响应的场景,但命令行工具其实也可以视为请求-响应的一种形式。
Isobel Redelmeier: 我认为命令行工具也属于请求-响应的一种形式。顺便提一下,Dave Cheney 有两篇相关的并行文章,一篇叫“Context 是用于取消操作的”,另一篇叫“Context 不是用于取消操作的”。
Mat Ryer: 谢谢你,Dave……
Isobel Redelmeier: 我已经有一段时间没看这些文章了,但我记得它们确实涉及到这个话题。
Mat Ryer: 是啊,这就像是“杰基尔博士与海德先生”的故事,哈哈……给我们展示了问题的两个方面。我还看到有个提议---
我觉得进展不错---
正式支持信号取消操作。他们可能会在 Signal 包中添加一些功能,这样你就可以捕捉信号并获得一个可以在接收到这些信号时取消的 Context,这非常有用。 (译者注: 化身博士)
我还想说,如果你还没用过 Context 来实现取消操作,有一个很酷的功能:done
方法会返回一个 channel,当 Context 应该结束时,这个 channel 会关闭。所以如果你在使用 select
语句,你可以监听这个 done
channel,一旦取消发生,你可以在这个分支中处理,比如返回错误、退出,或者执行一些清理工作(希望你已经通过 defer
自动处理了这些)。
你们还有其他类似的编程技巧可以分享吗?
Francesc Campoy: 有一个很酷的技巧,可以显著减少请求的尾部延迟。假设你向服务器发送一个请求,99% 的时间它只需 5 毫秒,但 1% 的时间却需要 1 分钟。这种情况并不好。你可以通过多次发出相同的请求并使用取消操作来解决这个问题。你可以创建一个带有取消功能的 Context,然后对所有请求使用同一个 Context,并在函数的顶部添加 defer cancel
。这样,一旦有一个请求返回结果,其他请求就会被取消。通过这种方式,你可以将 99% 的延迟从 1 分钟降到 5 毫秒。
这种小技巧能极大地提升性能,尤其是在你使用的服务器不是你自己管理的情况下,你无法直接要求他们改进延迟表现,但你仍然可以通过这种小技巧来解决问题。
Mat Ryer: 哇,真有意思!
Isobel Redelmeier: 对那些不熟悉尾部延迟及其影响的人来说,尾部延迟是指少部分请求耗费了大量时间,通常也消耗了大量资源。通过取消这些请求,可以释放资源,让更快的请求获得更多处理能力,从而提高整体吞吐量。
Mat Ryer: 太棒了!为什么其中一个请求会耗时一分钟呢?它在做什么?
Francesc Campoy: 它可能是在 sleep
60 秒吧,哈哈。
Jaana Dogan: 数据库...
Isobel Redelmeier: 对,数据库……哈哈。数据库---
一个服务宕机了,某些地方开始无限重试,网络问题……总是网络问题。
Francesc Campoy: 有一个非常好的演讲,我忘了演讲者是谁了……那个在 Google 做所有事情的人……哈哈。每个人都跟他有些交集。Jaana,你应该知道他是谁。
Jaana Dogan: Jeff Dean?
Francesc Campoy: 对,就是他,Jeff Dean。
Mat Ryer: 哇,这可真厉害。
Jaana Dogan: 我没想到你会忘记他的名字,抱歉...
Francesc Campoy: 他就是那个在 Google 做所有事情的人,哈哈。他有一个非常棒的演讲,讲的是如何管理长尾延迟。这个演讲非常好,虽然不是专门针对 Go 的,但他提出的所有建议都可以用 Go 的 Context 包来实现。
Mat Ryer: 哇,真酷。
Jaana Dogan: 是的,没错。
Isobel Redelmeier: 是的,长尾延迟不是 Go 独有的问题……
Francesc Campoy: 绝对不是。
Isobel Redelmeier: ……如果这个问题只在某一个地方出现,那倒是挺好的。
Jaana Dogan: 这也是为什么 Context 传递、超时和取消在我们的 RPC 系统中是如此重要的原因。Go 的 Context 包其实就是从这个需求中发展起来的。我们内部在讨论 Context 包时,其实是在谈论我们使用的 RPC 框架。当时是 Stubby,现在是 gRPC。这两者的概念类似,Context 传递作为一个构建模块对 gRPC 非常重要。
Isobel Redelmeier: 说到 gRPC 和一些代码示例,很多 gRPC 的 Go 代码关于 Context 的部分也很容易理解。比如有一个 Peer 包,几乎完全是为了处理 Context 的。它基本上有两个函数:一个是把 Peer 添加到 Context 中,另一个是从 Context 中获取 Peer,你可以看到它在实际代码中的工作方式。
Mat Ryer: 哇,这是个好建议。我们之前遇到过一个问题,就是在使用 io.copy
将数据从某个源复制到目标时,io.copy
需要一个 io.Reader/io.Writer
对。我们想要取消这个操作,但由于 io.copy
不能传递 Context,我们的解决方案是自己实现了一个 reader,基本上是包裹了另一个 reader……这个新的 reader 是支持 Context 的,所以它会在每次调用 Read
时检查 Context 是否已经取消,因为 io.copy
会多次调用 Read
来读取源数据……每次读取时,它都会检查 Context 是否被取消,或者是否返回错误。如果取消了,它就会从 read
方法返回错误,随后这个错误会通过 io.copy
传递出来。
这是一个有趣的解决方案,可以为不支持 Context 的地方添加对 Context 的支持。不过,这些事情确实挺疯狂的,对吧?
Francesc Campoy: 这很有意思,我很喜欢这个解决方案。当你提到这个问题时,我心想“哦,是的,我也会这么做”。但这是不是意味着你在结构体中存储了一个 Context……你怎么敢这样做?哈哈。这不是大家都说不该做的事吗?
Mat Ryer: 没有,我们用了闭包,我想……
Francesc Campoy: 哦,好的,可以理解。
Jaana Dogan: 我正想用这个作为例子。很多人经常问,如何在任务完成后清理 goroutine?什么是最标准的 API?一种方法是使用取消(cancel)。如果你有一个无限的 select
,比如说你启动了一个 goroutine,有个无限的 select
语句,你可以通过取消来终止它。我不确定我是否理解了你的例子,Mat,但是不是类似这种情况?你有一个任务,然后通过取消信号来通知任务的生命周期结束?
Mat Ryer: 对,因为你可以随时调用 err
方法,不是吗?可以随时调用。
Isobel Redelmeier: 是的,还有 done
方法。
Mat Ryer: 当 Context 没有被取消时,它是 nil
。我不记得我们是否在这个例子中使用了 done
channel。我记得我们只是检查了错误……
Jaana Dogan: 哦,明白了。
Mat Ryer: 如果返回 nil
,我们就允许父任务继续读取。如果不是,就提前中断。
Francesc Campoy: 这真的很酷。
Mat Ryer: 是的,确实有效。我们还在博客上写了这件事。我会把链接放在节目笔记里,大家可以去看。
Francesc Campoy: 你提到的 io.copy
让我想起了 gRPC 在处理流式传输时的做法。如果你在使用双向流式传输,你不知道谁应该结束……其实你是通过取消 Context 来处理这个问题的。这和 io.copy
有点相似。
Mat Ryer: 哇,这很酷。
Isobel Redelmeier: 循环是一个很好的检查点。如果你在做一些开销很大的操作,比如复制一个非常大的文件,你可以每次只复制一部分,然后在复制后做一次检查。
Mat Ryer: 是的。实际上,当你遍历文件系统时(就像我之前的例子),每次你检查……我经常会检查传递给 walk
函数的错误,如果有错误,我通常会停止处理。之后,我还会检查 Context 的错误。如果有错误,我就以类似的方式返回它。
是的,这很简单,而且很容易阅读。这就是普通的 Go 代码,我很喜欢。我明白隐式传递可能让语言看起来更简洁,但就像我们喜欢 Go 的错误处理方式一样,它是显式的;你可以看到发生了什么,也能控制它,这非常好。
Francesc Campoy: 你说的关于错误处理的观点很好。如果取消是隐式的---
比如不需要做任何事情,代码就自动取消了---
那么你就需要异常处理……而我们没有异常处理。那样的话,就必须有一些额外的奇怪机制,或者出现 panic,那会非常糟糕,也会显得很奇怪。
Isobel Redelmeier: 我能想到的唯一没有异常处理、并且在某些地方有类似隐式 Context 本地变量的语言是 Rust,它的一些异步引擎基本上有 Context 本地变量……但它们不知道如何处理取消操作,所以需要由你的 HTTP 库来实现取消功能。
Mat Ryer: 是的,有时候你确实希望在取消时还能做一些工作。比如处理一些临时文件,或者更新某个任务的状态,存储到数据库中等等。在取消操作发生时,可能还需要执行一些代码。而显式的取消机制让你可以直接在代码中处理这些情况,这对代码的可维护性非常有帮助。
Francesc Campoy: 是的,而且显式处理还让你能够进行某种优化。如果你知道某个任务需要 5 秒钟,在任务开始之前你可以先检查一下截止时间。如果发现只剩下半秒,那就可以直接返回错误,因为你知道任务不可能完成。
Mat Ryer: 哇,这真是个好主意。
Francesc Campoy: 你甚至不需要做这个任务,直接返回就行。
Mat Ryer: 嗯,确实是个好方法。
Francesc Campoy: 还有一个很有趣的话题……哦,是关于这个话题的吗?因为我有另一个很有趣的主题想讨论。
Mat Ryer: 哈哈,来吧,我都忘了我想说什么了。
Francesc Campoy: 好吧,哈哈。话说,为什么我们有 context.TODO
?
Mat Ryer: 这是个好问题,我不知道答案,哈哈。
Francesc Campoy: context.TODO
和 context.Background
做的是完全一样的事情---
它们都返回一个空的 Context。这个空的 Context 没有任何值,没有超时,也不会被取消。它实际上就是一个空的结构体。不同的是,当你返回 background
时,你是在说“哦,这是我从头开始的东西。”你基本上是在说“这里没有之前的 Context,这是我创建的新的东西。”
比如在命令行工具的例子中---
你运行命令行工具时,一开始并没有之前的 Context,或者任何其他东西;也许某个时候我们会有来自信号的 Context,那会是很有趣的事情。但除此之外,我们没有任何 Context。所以你会调用 background
。
context.TODO
是为了让你在构建一系列调用并传递 Context 的函数时,逐步添加 Context。如果你从顶部开始,你不能传递函数,直到它们被接受。但如果你从底部开始,你可以构建一个函数,先接受一个 Context,然后调用它的地方可以说“哦,我应该有一个 Context,但我现在还没有。”这时你可以调用 context.TODO
,而不是 context.Background
,因为 background
意味着“我没有 Context,也永远不会有”,而 TODO
则表示“我现在还没有,但以后会有”。所以 TODO
实际上是为了让你以后能找到那些需要继续处理的地方,哈哈。我觉得这很酷,他们甚至考虑到了这些……当然,你也可以调用 context.Background
,然后在上面加个注释“TODO:传递一个真正的 Context”。但他们这样做是为了让它更显式,你甚至可以做代码分析,看到哪些地方还没有完成。
Isobel Redelmeier: 这确实是一个很现实的用例。我遇到过好几次这样的情况,尝试为现有代码库添加分布式追踪,而这些代码库还没有使用 Context。我之前提到过,大多数开源的分布式追踪库至少使用 Context 来传递 Trace 和 Span ID,这些是用于追踪的元数据。所以如果你要把它添加到现有的代码库中,而这些代码还没有使用 Context,你不希望从零开始创建 Span;你希望在某个时候能将这些 Span 连接起来,但不一定要从第一天就做到这一点,特别是当你有一个几十万行代码的代码库,还有大量的 Context 需要重新添加时。
Mat Ryer: 是的,这确实是个很好的观点。你可以看出这确实是从实际工程中产生的需求,Context 的设计非常优雅,正如 Francesc 之前所说的,这是一个非常优雅的解决方案,值得研究。正如你说的,它的代码其实不多,值得一看。代码里还有一些有趣的东西。比如 Context 常常有一个 string
方法,所以当你打印它们时,它们会给出一些有意义的信息。我第一次在代码中发现这个时感到很惊讶。
Francesc Campoy: 我其实不知道这个。
Isobel Redelmeier: 我也不知道……
Mat Ryer: 是的……如果我错了,这段会被剪掉,这样我就不会显得像个傻瓜。
Francesc Campoy: 哦,你是编出来的吗?哈哈。
Isobel Redelmeier: 我确实打印过 Context……你可以打印它们。不过在我的经验中,这些输出并不太漂亮,但……当你不确定时,打印 Context 是个不错的选择。
Mat Ryer: 是的。
Jaana Dogan: 如果只是字符串,或者只是一些基本的东西---
是的,打印会告诉你一些信息。但在大多数情况下,比如在我的工作中,通常是另一个结构体,而它不会提供一个很好的字符串输出。所以,是的,作为诊断工具使用它会变得更难。
Mat Ryer: 是的,可能确实不太行。但我至少看过它能告诉你上下文的类型,有时……虽然希望不多,但……
Francesc Campoy: 是的,我试过这个,挺有趣的,它打印出来的方式很酷。我写了一个小程序,调用 context.Background
,然后添加一个值……比如 key 是 1,value 是 2,然后我打印出来。它打印的内容是 context.Background.withValue
,然后显示里面的值。所以它有点像一个链表,展示你获取了哪些东西并打印出来……key 是 int
类型,所以它不会告诉我具体的 key 是什么,而 value 不是一个字符串化的输出。所以,它显示的是“不是字符串”。这就是它的输出……所以,是的。
Jaana Dogan: 好吧……这没什么用处。 [笑]
Francesc Campoy: 确实没什么大用……
Jaana Dogan: 但也可能有用。
Francesc Campoy: 我几乎可以肯定,他们不打印值或 key,是为了防止人们通过解析上下文的打印结果来获取值……
Mat Ryer: 因为那样可能会很危险,对吧?
Jaana Dogan: 是的,可能会有风险。不错的担忧点。
Francesc Campoy: 是啊,那样会留下一个漏洞……
Isobel Redelmeier: 我认为我成功地用它来查看一个 key 是否被添加了,基本上就是“哦,当我期望某个 key 存在时,我仍然有一个空的 context.Background
。”或者查看某个 key 是否可能被覆盖了……但我不记得我是怎么看到这些细节的。
Francesc Campoy: 是的,我猜它会告诉你 key 的类型。所以,理论上,如果你只使用了那种私有 key,并且每种类型只有一个值。在这种情况下,你确实知道那是什么值。
Isobel Redelmeier: 是的。
Mat Ryer: 好吧,在我们为本期节目的上下文调用取消之前,是时候进入“不受欢迎的意见”环节了!让我们听听看……这周有人有什么不受欢迎的意见吗?
Isobel Redelmeier: JSON 并没有大家说的那么糟糕……
Mat Ryer: 嗯,告诉我更多。Jason 是谁?你说的这个 Jason 是谁?
Isobel Redelmeier: 是的,那位总是被抨击的 JSON……
Mat Ryer: 你为什么总是为他辩护?
Isobel Redelmeier: 我见过很多人在我看来过早地切换到 protobufs,特别是在某些时候切换到 thrift,你其实只是从一个问题跳到了另一个问题……尤其是对于那些在你的公司外部使用的东西,比如开源代码,protobufs 可能会变得非常复杂,特别是如果你要暴露一些要在多种语言中使用的东西。在 Go 中使用它很好,但在 Ruby 或 PHP 中使用就不那么好玩了。
Mat Ryer: 是的,或者在浏览器中使用。
Isobel Redelmeier: 是的,这个是一个大问题。
Mat Ryer: 事实上,我们最近还做了一期关于这个的节目。我们称它为“Encoding JSON”,并且我们实际上用 JSON 拼写了这期节目的标题,看看有没有播客技术容易受到 JSON 注入攻击…… [大笑] 到目前为止,一切都很好,有点可惜。
但是的,我完全同意。我觉得我们程序员有时有点痴迷于---
二进制协议的吸引力很大。它看起来如此小巧,不是吗?二进制数据如此小巧,而文本则是这个浪费空间的大块东西……所以我明白为什么人们会被这种纯技术的工程驱动所吸引。
Isobel Redelmeier: 还有些人把 gRPC 和 HTTP/2 联系在一起,认为你必须使用 gRPC 才能使用 HTTP/2,而事实并非如此……所以他们认为“为了使用 HTTP/2,我必须使用 gRPC;为了使用 gRPC,我必须使用 protobuf”,这改变了很多东西,而你通常可以通过仅切换到 HTTP/2 获得更便宜的部分收益。
Mat Ryer: 很好。
Isobel Redelmeier: 或者使用 MessagePack,使用流媒体……有很多类似的选项,而不需要全盘切换。
Mat Ryer: 嗯。很棒的观点。
Francesc Campoy: 你可以在 JSON 中使用流媒体,而不需要---
或者这是你该考虑切换到 gRPC 的时候?
Isobel Redelmeier: 你可以,这不是总是那么愉快,尽管我也见过 gRPC 流媒体的问题。正如 Mat 提到的,我认为你仍然不能在浏览器中使用 gRPC 流媒体。例如,浏览器中没有 gRPC 的支持,只有单次请求。但也许某天我会写一篇博客,讲讲如何进行基础流媒体传输以及它何时会出现问题。
Mat Ryer: 是的,请写。我做过一个 JSON 流媒体的项目,基本上是使用换行符,但是我们也在那期节目中发现,如果你只是使用 JSON 编码器在请求体上进行编码或解码,每次读取这些行时会有风险……如果你想知道具体风险是什么,你得去听我们的 JSON 节目。这就是我们播客的交叉营销。 [笑] 还有其他不受欢迎的意见吗?我喜欢这个,但我不知道它是否真的不受欢迎……但可能是。
Francesc Campoy: 我不知道这是否受欢迎或不受欢迎,但我认为 Go 中的泛型是个好主意。我觉得在我认识的很多人中这可能是不受欢迎的……但我是这么认为的。我之前做过一个关于 Go 中函数式编程的演讲,基本上是为什么不应该做函数式编程,最大的原因有两个:第一个是没有尾递归优化,这意味着你的程序仅因此就会慢十倍……所以这可能是我们应该修复的小问题。但更大的问题是,如果你想在没有泛型的情况下做任何类型的有趣的类型组合,你就没办法了。你不得不到处使用空接口。
所以泛型---
我对此非常兴奋。我试用了它们,仔细研究了一下,现在合同(contracts)已经基本上消失了,或者至少它们变得没那么复杂,我非常期待能使用它。我不知道它什么时候会正式发布,但我非常期待。
Mat Ryer: 是的,我觉得设计工作非常棒。我喜欢我们能够看到它的演变过程。我觉得这本身就是一个非常有趣的语言设计研究。而且,这又是 GoTime 播客的另一期节目,我们采访了设计者 Ian Lance Taylor 和 Robert Griesemer。
Francesc Campoy: 不错。
Mat Ryer: 是的……所以记得去听听。Jaana,你有不受欢迎的意见吗?
Jaana Dogan: 我有一个有争议的意见。
Mat Ryer: 来吧。让我重新录一下主题曲。
Jaana Dogan: [笑]
Mat Ryer: 你有什么有争议的意见?
Jaana Dogan: 我确实认为---
我真的很喜欢 Go 这门语言,它的简洁性和冗长度方面是你能找到的最好的选择之一……但是所有 proto 生成的工件让一切变得有些混乱。每次我不得不接触一些 proto 生成的东西时,它看起来就不像 Go 了。它变得非常复杂……所有这些类型都在标准库之上,我还得学习它们……Proto 有它自己的 struct……各种混乱,甚至连时间戳类型都是完全不同的表示方式。所以你基本上得适应这个冗长的替代宇宙,这就是我主要的痛点。
我一直在收集所有关于 proto 的陷阱和技巧,已经很长时间了,我可以告诉你,我已经收集了至少 20 页的内容,有很多小贴士……但我仍然需要回去查看那个文档,参考一下“嘿,如果我看到一个 proto 生成的类型,我应该做什么”。这对我来说是个非常大的挑战。
他们一直在尝试改进生成的工件,但我觉得现在已经太晚了,无法做出任何重大改进。老实说,作为一个在 proto 非常常用的公司工作的人,proto 类型是我日常接触的东西,而这让我失去了从 Go 中获得的所有乐趣……因为到最后,我接触的 proto 比其他任何东西都多。
Mat Ryer: 是的。这很有趣,因为我发现了同样的问题,实际上我刻意避免使用 gRPC 和协议缓冲区(protobufs)就是因为这个原因。我们实际上做了---
我肯定之前跟你提到过这个项目;让我快速告诉你我们的另一个替代项目,它是一个代码生成器,但它使用 Go 的接口来描述 RPC。所以你为每个服务都有一个接口,然后在接口中定义方法,使用 Go 类型。然后它使用 Packages
包和 AST
分析这些 Go 类型……然后通过模板生成服务桩、客户端桩以及 TypeScript 客户端等。这是基于 JSON-over-HTTP 的,因为正如 Isobel 之前提到的,这对大多数情况来说已经足够了,实际上在某些情况下更好,因为我们可以打开 web 客户端,查看请求和响应,看看 JSON,并且默认情况下它是漂亮打印的。
Jaana Dogan: 是的。
Mat Ryer: 所以,是的,存在权衡。有时我们过于专注于一件事,而忽视了其他东西,我认为可维护性和熟悉度,以及只使用 JSON/HTTP 的方式,鉴于已经有这么多人非常熟悉这一点,确实具有这种感觉。我们会在节目笔记中放上这个项目的链接---
它叫 Oto。
Isobel Redelmeier: 我去年做了一个关于 gRPC 的演讲,基本上就是 gRPC 的战争故事…… [笑]
Mat Ryer: 不错。
Isobel Redelmeier: 不过我认为没有被录下来……
Mat Ryer: 哦,没录?
Isobel Redelmeier: 至少我没找到录音。
Mat Ryer: 哦,那真可惜。
Isobel Redelmeier: gRPC 大会。
Mat Ryer: 是的,我很想看看那个演讲。
Isobel Redelmeier: 当时有摄像机……所以 Google 可能有录音,但我没权限查看。
Mat Ryer: 嗯,这很有趣。好的。
Jaana Dogan: 我跟 gRPC 团队谈过这个问题,他们中的一位负责人确实对重新考虑 proto 生成器的设计感兴趣……但那个人离开了 Google。我得再次发起基层运动来推动这个事情。
但这真是太奇怪了……也许是因为 Go 是一个非常简单的小语言。任何不符合这个类别的东西都显得太过突兀,你会感到沮丧……因为你选择这门语言是因为它小而简单,但现实却不符合这种期望。另一方面,没有其他方法可以做跨语言的东西……如果你想使用 proto 风格的传输层,并且需要多种语言支持的话。
有一些其他的选项,但它们都存在同样的问题,因为这本质上是一个非常难解决的问题。你需要在不同语言之间保持兼容性。所以,当然,你会在标准类型之上引入一些额外的类型……但最终我觉得,作为用户,这有时会让我感到不开心。
Mat Ryer: 是的。还有一件事我希望它不做……当它生成接口时,某种方式使得你没有实现那个接口时不会得到编译错误。我认为它嵌入了一个自动实现所有方法的类型,所以永远不会出现编译错误。而我觉得很棒的一点是,当定义发生变化时,你重新生成代码,然后编译器会帮助确保你实现了那个接口。这大概又是一个务实的原因,就像 context.TODO
一样。
在 Oto 中,比如说,如果你没有实现接口,那就是一个编译错误,这真的帮助我们确保所有东西都是正确的。
Francesc Campoy: 是的。我觉得背后的原因可能是为了让你可以在定义中添加新方法,而那些还没有实现该方法的服务器,虽然没有实现,但仍然会编译并告诉你“哦,这个方法没有实现。”所以从这个角度讲,确实有道理。但我最近也因此被坑了,我并不喜欢这个。我想“为什么能编译?不要编译啊。你缺少一个方法啊。不要通过编译。”
Jaana Dogan: [笑]
Mat Ryer: 它会返回一个错误还是只是一个无操作?因为你需要一个响应。
Francesc Campoy: 有一个实现只是返回“未实现”。
Mat Ryer: 嗯。
Francesc Campoy: 至少这说得通。一旦你得到这个错误,比如你有一个代理忘了更新,现在代理返回“未实现”而不是全程传递到服务器,你不知道,因为它通过了编译。就像在 Go 中,如果它编译通过了,它就应该能运行……所以你打破了这一点。这不太好。
Jaana Dogan: [笑]
Isobel Redelmeier: 如果你没有 panic 处理---
我最近看到了一些这样的代码,里面到处都是“未实现”,但没有 panic 处理器。所以如果你路由配置错误了,突然之间你可能会无意中自我 DDoS。
Francesc Campoy: 有意思。
Mat Ryer: 是的。恐怕今天的时间差不多了。今天的对话真的很棒,非常感谢。我觉得我学到了很多关于 context 的知识。别忘了查看节目笔记,里面有很多我们今天讨论的内容。非常感谢 Francesc Campoy。Francesc,谢谢你,谢谢你的到来。
Francesc Campoy: 谢谢你邀请我。
Mat Ryer: 是的,随时欢迎。请再来一次,好吗?你现在愿意在录音时承诺吗?
Francesc Campoy: 迟早吧……好吧……
Mat Ryer: 迟早,对吧。 [笑]
Francesc Campoy: 给我发个消息,我可能迟早会回复…… [笑]
Mat Ryer: 我们会的。还有 Isobel,非常感谢你的到来。你也一定要再来一次。当然,Jaana,总是很高兴见到你……
Jaana Dogan: 谢谢,是的,非常感谢。
Mat Ryer: 好吧,这是我们做过的最长的告别……最长的一次告别。感谢收听,我们下次再见。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。