JSON在网络上广泛使用,是一种基于文本的数据传输方式。在本集中,我们将与 Daniel Marti[1] 一起探索 Go 的 encoding/json 包
和其他包。
本篇内容是根据2020年7月份[#141 {"encoding":"json"}](https://changelog.com/gotime/141 "#141 {"encoding":"json"}")音频录制内容的整理与翻译
过程中为符合中文惯用表达有适当删改, 版权归原作者所有.
Mat Ryer: 大家好,欢迎来到 Go Time!我是 Mat Ryer[2],今天我们要聊聊 JSON。你可能会觉得这是最无聊的一期节目,但我向你保证,绝对不会!今天和我一起的是 Johnny Boursiquot[3]。你好,Johnny!
Johnny Boursiquot: 你好,Mat。
Mat Ryer: 这一周过得还好吗?
Johnny Boursiquot: 嗯……我们即将讨论 JSON,所以我不知道……我们拭目以待吧。
Mat Ryer: (笑) 我们拭目以待。不过别担心,今天我们有一位非常棒的嘉宾……他是 Go 语言的高产贡献者,你可能见过他的名字---
Daniel Martí。你好,Daniel。
Daniel Martí: 嗨,很高兴来到这里。
Mat Ryer: 欢迎来到节目。很高兴你能来。你这一周过得怎么样?
Daniel Martí: 还不错。我们在英国的气温差点达到了 20 度,这很不错……不过几周前夏天就结束了。它只持续了一个周末。
Mat Ryer: 是的。这周晚些时候还会有一些热浪,所以请大家拭目以待……我也不知道为什么我说得像个新闻主播……(笑声) 我只是试图表现得像个正常人,但有时我觉得这很难。
好吧,那我们开始吧……先对初学者快速介绍一下,万一有人真的完全不熟悉---
JSON 是什么?你是这么念的吗?你说 JSON,还是有别的发音?
Daniel Martí: 我就说 JSON,如果我要向一个小孩子解释,我会说这是一种表示数据的方式……不过一开始我就失败了,这计划显然不适合小孩。
Mat Ryer: 不,小孩能理解。我们假设这是个非常聪明的小孩。
Daniel Martí: 好吧。这是一种非常通用的数据表示方式。不管接收方是谁,他们大概率都能读懂这些数据。
Mat Ryer: 对。它的全称是 JavaScript Object Notation,起源于 JavaScript……但后来它在很多领域非常有用---
几乎每种语言现在都支持 JSON。
Daniel Martí: 它几乎无处不在。几乎所有现代编程语言都必须支持 JSON,因为你无法避免。而且你的电脑---
你可能看不到,但它肯定在某个层面上运行着 JSON。
Mat Ryer: 对。它像一个对象,有字段,这些字段有一些类型……这些类型也是我们在 Go 中常见的类型,比如字符串、数字、布尔值。还有其他对象、数组之类的东西。我想这差不多就是全部了。为什么它在网络上如此流行?我觉得它对网络技术而言非常完美。
Daniel Martí: 我会说这源于浏览器和现代网络的成功……突然间,HTTP、HTML、CSS、JavaScript 和 JSON 这些技术让所有人都感到意外。最初大家觉得它们只是玩具,但现在人们用它们创建了真正的公司……而 JSON 已经有了太大的势头,我认为它不太可能被其他东西取代。
Mat Ryer: 这很有意思。
Johnny Boursiquot: Mat,等等……我想补充一下。我很喜欢你刚才说的,Daniel……我觉得还有另一个原因---
至少个人认为---
为什么 JSON 能迅速流行起来。对我来说,主要是因为它不是 XML。在 JSON 之前,如果你想要一种可以替换的格式,与其他系统交互,XML 是默认的选择。然后我们围绕 XML 解析、XSLT、模板、样式表等创建了一个完整的生态系统。回想起来,这些技术在当时非常出色,但也真的很难用。你真的需要依赖机器生成 XML,手动编辑 XML,尤其是很大、很复杂的文档,那真是令人头疼。
于是 JSON 出现了,它非常简单,人类可读性很强……这就像一股清新的空气。所以,确实如你所说,它与 HTML、JavaScript、CSS 的兴起密不可分,从系统和数据交换的角度来看,它也是一场革命。
Mat Ryer: 对,而且它比 XML 简单得多……因为在 XML 中你可以对结构做一些奇怪的操作,比如让元素并列在一起,处理起来非常复杂。而在 JSON 中,这种情况不会发生,它的结构更紧凑,我觉得这也帮助了它的流行。
在使用 JSON 时,有没有什么需要注意的陷阱?有没有初学者需要特别留意的地方?我想到的一点是,在 Go 中如果你有一个 time.Time
类型,要把时间表示为 JSON,它会把时间转换成字符串,对不对?
Daniel Martí: 是的。其实我不太确定默认行为是什么,因为我通常会写自定义代码来处理 JSON 中的时间……
Mat Ryer: 真的吗?为什么?
Daniel Martí: 大多数情况下,人们希望时间戳是特定格式的……所以他们会写代码来处理。我其实不太记得默认行为是什么,但对,JSON 没有时间戳类型,所以它最终会变成字符串。
Mat Ryer: 是的。只要解析方也能理解这个格式,并能够处理它,那就没问题……但这确实是一个有趣的点---
JSON 只有一些基础类型,有时你需要做一些魔法处理,把你特定的数据转化为能在这种文本格式中工作的东西。
另一个有点奇怪的地方是---
默认情况下,所有的数字都是 float64
类型。如果你用 Go 中的 map[string]interface{}
来解析 JSON,它会工作;它会像对象一样填充这个 map。但如果其中有数字,它不确定是浮点数还是整数……所以它会使用 float64
,因为这是最通用的类型。我第一次在 Go 中处理 JSON 时觉得这很奇怪。
Daniel Martí: 其实,数字这点非常有趣。我认为 JSON 本来可以有两种不同的处理方式。一种是像 Go 这样,区分整数和浮点数,并定义它们的位数。比如,在 Go 中,你可以有 int64
和 float64
。这样做的好处是更严格,如果你想用其中一种,它会一直保持不变,不会有精度丢失。
但另一方面,如果你只是说它是一个数字,那么它可以支持任意精度的数字,也就是大数,Go 也通过一个不同的包支持这种大数。
Mat Ryer: 说到 encoding/json
包,顺便提一句,Daniel,你其实是 encoding/json
包的共同维护者,对吧?
Daniel Martí: 是的,没错。但在我们继续之前,我得提前说一下,我注意到我的笔记本内存使用率在过去 15 分钟内逐渐上升……我不确定这是 Zoom 的问题,还是我录音软件的问题,但我觉得我的笔记本大概会在 10 分钟内崩溃……所以如果发生这种情况,请大家知道这是为什么。
Mat Ryer: 这挺刺激的……就像有个炸弹要爆炸,我们正在等待。
Daniel Martí: 是啊。它一开始是 30%,现在是 92%……大概还有五分钟的时间。我也不知道发生了什么。
Mat Ryer: 好吧。如果你突然消失,我们就知道是怎么回事了。我只希望不要在 Johnny 说完话后你立刻消失,那样他会觉得很受伤的。
Daniel Martí: 抱歉。你刚才问我是不是共同维护 encoding/json
包,答案是是的。我已经帮忙大概 3-4 年了……JSON 确实有活跃的维护者,包括 Russ、Joe[4] 和 Brad。我最初只是帮忙修一些小 bug 和做一些小优化;但随着时间的推移,这些人都很忙,所以现在我做的工作几乎和他们一样多……一方面这很有成就感,因为这是一个非常有用的包,有很多人在用。但另一方面,压力也很大。
Mat Ryer: 是吗?为什么?
Daniel Martí: 一方面是因为几乎有成百上千的人使用这个包……
Mat Ryer: 哦,他的内存用光了。
Johnny Boursiquot: 就这样……(笑)
Mat Ryer: 是的。
Johnny Boursiquot: 真的是这样。
Mat Ryer: Linux。
Mat Ryer: Daniel,维护 encoding/json
包的挑战是什么?为什么会有压力?
Daniel Martí: 我觉得这非常有成就感,因为你一修复一个 bug,马上就会有很多人感到高兴……显然,很多人非常关心 JSON 包的运行速度。但另一方面,因为它有如此多的用户,如果你搞砸了,人们会非常生气。而且还有一个叫做 Go 1 兼容性保证的东西,这意味着如果你的程序能在 Go 1.0 上运行,它也应该能在 Go 1.2、Go 1.3 等版本上运行。
Mat Ryer: 有意思。如果最初的 JSON 版本里有个错误,这个错误也要继续支持吗?
Daniel Martí: 这是一个很好的问题。我觉得有多种理解方式。最严格的理解是只有那些文档上明确写明的行为需要保持不变。所以如果你写的代码依赖于某些实现细节,那这些细节未来是允许发生变化的……这通常是我对它的理解。但更保守的理解是“几乎所有你做的事情,只要是合理的,即使没有写在文档上,也应该继续工作,因为我们不想破坏用户的程序。”在这两者之间,团队需要找到一个中间点。
Mat Ryer: 哇,这确实是个微妙的平衡。但这个承诺非常重要,因为正是它让我们能够依赖 Go 的未来版本,确保我们的系统依然可以正常运行。对我来说,这也是 Go 吸引力之一,所以我非常感谢你们的努力,因为我知道这并不容易。
我本以为 JSON 包在最初编写完成后就差不多了,它已经在工作了。那么它还需要维护什么呢?
Daniel Martí: 这是个好问题,我觉得这和 JSON 的灵活性有关……因为 JSON 没有模式,它只是某种结构中的数据。你可以用它做很多事情,人们确实也用它做了很多奇怪的事情……于是他们来到标准库中的 encoding/json
包,希望它能适应他们的工作流。所以他们可能会希望“哦,根据某个字段的值解码某些字段”,或者他们会说“我希望能够流式处理一个非常大的对象,即使它无法全部放入内存”,诸如此类的用例。
所以总是有源源不断的功能请求,还有优化和修复以前更改带来的 bug。
Mat Ryer: 明白了……我猜这就像其他软件一样……你可以改进它,但在改进的过程中也会引入一些问题……不过它是经过良好测试的,对吧?encoding/json
包的测试还是不错的。
Daniel Martí: 是的,大多数情况下我认为它的测试是不错的。
Mat Ryer: 对,这很重要。这让你可以有信心去做更改。你提到你不想打破向后兼容的承诺……单元测试是确保这一点的关键,对吧?
Daniel Martí: 是的。检查你的包是否经过良好测试其实是一门艺术。你可以查看 Go 工具生成的代码覆盖率,但那并不足够……因为你可能覆盖了一行代码,但并没有覆盖其中的所有逻辑……或者你可能没有触发某个会导致 panic 的边界情况。
Mat Ryer: 是的。我总是告诉大家不要在应用代码中追求 100% 的代码覆盖率,因为这可能会让你的测试与实现紧密耦合。这个包是个例外吗?在这个包中追求 100% 的代码覆盖率是否合理?
Daniel Martí: 我会说大多数情况下确实应该尽可能提升覆盖率,因为这个包大部分就是一些带有逻辑的 if-else 语句。但也有一些地方会有 panic,比如一些不应该发生的情况,或者一些边界情况……比如指向 nil 的指针。
Mat Ryer: 有趣的是,JSON 在 Go 的 API 中有些奇怪的地方。你需要传入一个指针,当你想要解码 JSON 数据时,你传入的实际上是指向目标对象的指针,也就是你希望 JSON 解码后存放数据的地方。而且在传入这些指针时有一些有趣的、复杂的规则,对吧?
Daniel Martí: 没错。你可以传入指向任何有效数据的指针……但不能是指向 nil 或 0 的指针,因为那样就无法存储任何数据。所以,它期望的是指向一个结构体的指针,能够存储和解码传入的 JSON 数据。这里有一些规则,比如你传入一个空接口,它会根据数据类型做出猜测。如果看到一个数字,它会假设是 float64
;如果看到一个对象,它会使用 map
。但如果你传入的是一个有具体字段类型的结构体,它会按照你的定义解码,如果类型不匹配,它就会返回错误。
Johnny Boursiquot: 这个包其实内置了一些智能功能,我通常挺喜欢的。最近我在做代码审查时,发现有位开发者创建了一个结构体,并给字段添加了 JSON 标签,但并没有要解码的数据。在这种情况下,除非你真的预料到需要将不同的字段名映射到结构体中,否则其实不需要给字段加标签。JSON 包会按照你定义的字段名输出 JSON。所以你不需要特别去加这些标签。这个包里确实有很多智能功能,我个人挺欣赏的,后面我们会深入探讨……
我喜欢使用标准库,可能是因为工作的性质吧,我倾向于不去寻找第三方包来解决问题,如果标准库中有相应的功能,即使有点难用,或者性能稍差。我想如果你在 Go 社区待过一段时间,你可能会遇到一些社区构建的第三方包,它们在 JSON 解析、编码和解码方面做了不同的权衡……很多第三方包似乎都专注于速度和性能。
Dave Cheney 也做过相关实验,并发布了结果。希望他今天也在场能讨论这个话题。大家都想要速度快的工具,对吧?“哦,这个更快,那我就该用它。”但实际上,这里面也有权衡。你不能仅仅因为它快就选它。我很想知道你的看法,为什么会选择一种实现而不是另一种?在这方面你做了哪些权衡?
Daniel Martí: 我觉得这个话题是讨论的核心,因为对于很多人来说,标准库中的 JSON 解码器已经是最快的解码器之一,但有些人可能没有意识到这个速度背后的权衡……对于所有这些第三方 JSON 实现,我的感觉是复杂的。它们中的一些确实有意义,比如某些场景下你确实需要最高性能,可能这是系统的瓶颈,你不介意 Go 为你生成代码来自动编写一个 JSON 解码器。在这种情况下,你可以使用像 easyjson
这样的包,它挺受欢迎……但权衡在于你需要运行 go generate
,而且你的二进制文件会变大很多,因为它包含了很多额外的代码。但这些额外的代码会直接编码所有的逻辑,不使用反射,也没有额外的解引用操作。所以我认为在某些使用场景下,这种做法是合理的。
Mat Ryer: 我喜欢你这样分析这个问题。你说“也许这是你系统的瓶颈……” 这就是关键所在---
一旦你发现这确实是需要改进的地方,才值得去承受额外的复杂性,比如学习新的 API 或者处理更多的复杂代码。我喜欢这种方式,因为……其实我们应该一直这样做。正如 Johnny 提到的,我们可能有点过于执着于“为什么不选择最快的东西?”而答案可能是“标准库已经足够好了。”
有哪些包,它们又有什么不同呢?
Daniel Martí: 最近我看到一个有趣的包,但我忘记了它的名字,好像是以某个公司命名的……他们的做法是保留了和标准库相同的 API,所以自称是一个可以直接替换的包。但它的底层实现非常有趣,它没有使用 reflect
包(反射是 encoding/json
性能不佳的主要原因之一),而是直接使用了 unsafe
。权衡在于,使用 unsafe
可以做很多“魔法”,速度很快,但同时也不安全。所以我对它是有些矛盾的。如果告诉人们这是一个可以直接替换的包,他们可能会觉得“哦,我只需要更改一下导入路径,速度就能提高一倍”,但他们没意识到这可能会带来巨大的安全漏洞……
Mat Ryer: 哦,这确实是个问题。
Daniel Martí: ……因为虽然标准库的反射机制本身也使用了 unsafe
,但反射经过了严格的审查和审计,它遵循了 Go 的规则,确保只能设置合法的字段。但如果你直接使用 unsafe
,你就绕过了所有这些限制,完全依赖自己了。
Mat Ryer: 标准库使用反射的原因是,它在某种程度上是动态的,对吧?你并不知道你要解码的 JSON 的结构,尤其是当你解码到 map[string]interface{}
时,你无法提前知道 JSON 的结构……这种灵活性非常强大,但也容易被滥用。你提到的那个使用 unsafe
的方法确实有它的道理,但风险也很大。
我之前在一个命令行工具中用过 JSON,可能有点奇怪,也可能不奇怪。它通过标准输入接收一行行的 JSON,然后输出也是一行行的 JSON。我们有一系列工具可以按不同的方式组合使用,这些工具会接收不同的 JSON 对象,每个对象都在一行上……在这种情况下,使用 JSON 的编码器和解码器非常合适,因为它们可以处理 io.Reader
,不断解码对象,遇到换行符时分隔开每个对象。
所以对于这种设计来说,这种方式非常完美。这些工具在接收到一行 JSON 数据之前不会执行任何操作,然后处理数据,最后输出一行 JSON。当然,你也可以直接使用 marshal
和 unmarshal
函数。它们之间的关键区别是什么呢?
Daniel Martí: 我认为大多数人会说关键区别在于流处理。如果你使用 marshal
或 unmarshal
,你可以从函数签名中看到,它们接收和返回的是字节切片……所以很容易看出,如果你想要解码一块 JSON 数据,你必须将整个 JSON 数据加载到内存中。而如果你使用 decoder
,它接收的是 reader
,你可能会以为“哦,它会流式处理 JSON,这样我就不用把所有数据都加载到内存里了”,但其实并不是这样。我认为这是当前 API 的一个问题,我不会说它是错误的,但它确实有些误导……因为它会将整个 JSON 对象缓冲到内存中,然后再进行解码。这样做的原因是 encoding/json
包优先保证正确性……
举个例子,当你解码到一个 map 时,如果 map 中本来有键 foo,然后你解码了一个新键 bar,最终你会得到包含两个键 foo 和 bar 的 map。它不会直接用一个新的 map 替换原来的 map。这在某些场景下是有用的。
但大多数人只是在解码到一个空值,他们不关心之前有没有数据。所以对于大多数人来说,这个行为是出乎意料的,因为他们不在意这种特性。而 encoding/json
包为了实现这种特性,会对输入做两次扫描,第一遍是为了确保 JSON 语法正确,不会有任何错误。
Mat Ryer: 这确实有道理。我看到过另一个 JSON 实现,它不尝试将 JSON 转换成结构化数据,而是让你可以直接查找特定的键路径。比如你可以说“给我这个 JSON 流或 JSON 字符串中的 author.firstname。”它只是简单地扫描数据,而不去解析所有的字段和数据类型……这是另一种方式。如果你只关心某个特定字段,这种方式会非常快。
Johnny Boursiquot: 我突然回想起 XPath[5] 了。(笑声)
Mat Ryer: 哈哈,对。
Daniel Martí: 你提到的这个点非常好,我差点忘了这个额外的用例……我记得有个库好像叫 json-iterator
,或者类似的名字,挺有名的……它有两个主要的用例。一个是你提到的,只获取某个字段或某个值。如果 JSON 数据很大,你可以跳过不必要的部分,直接查找你需要的内容。另一个用例是,当你不知道数据的具体结构时,它会很有用。因为 encoding/json
包要求你提前知道数据的结构。你可以使用 json.RawMessage
来延迟解码部分 JSON 数据,但这相当于让你自己去做多次解码。
如果你只是想快速查看某个字段,然后根据它的值决定做什么,像这个包那样的实现可能会让你的工作更轻松。但我认为大多数情况下,人们是知道 JSON 数据的结构的。
Mat Ryer: 是的,按照我的经验,最好是你能知道 JSON 数据的结构。不要被那种“我的应用程序可以支持任何数据结构”的想法所诱惑,因为最终你会因此而头疼。RawMessage
实际上是做什么的呢?它只是一个字符串类型,还是字节切片之类的东西?
Daniel Martí: 它实际上就是一个命名的字节切片,实现了 UnmarshalJSON
接口。它的作用就是接受 JSON 数据并存储下来,仅此而已。这个功能非常强大,因为它让你可以自由决定如何处理数据。
不过在我们继续讨论之前,我的内存快满了,我需要暂停录音并重新开始……给我两分钟时间。
Mat Ryer: 好的,没问题。休息一下吧。停一下世界的转动。
Daniel Martí: 我用的这个程序有个有趣的 bug……它似乎随着录音时间的增加,内存占用越来越大。每次保存文件并完全关闭它后,重新打开才会恢复正常……
Mat Ryer: 是的,感觉它好像是在把音频都存到 RAM 里了,是吧?
Daniel Martí: 我猜是的,但我现在显然没有时间去修复它……
Mat Ryer: 那它使用的内存是不是和你保存的文件大小一样,都是那么多?
Daniel Martí: 我保存的 WAV 文件大概有 160 兆,但它用了我 15 GB 的内存,所以我也不知道它在干嘛……抱歉,我又在抱怨了。
Johnny Boursiquot: 嗯,有个问题是来自我们频道的 Jon Calhoun,他提到 Go 1.0 的兼容性承诺。我想所有在生产环境中使用 Go 的开发者都非常看重这个承诺……关于标准库中的 JSON 包,有没有什么你希望能加入其中的,但因为兼容性承诺的限制而无法实现的功能?这些功能有可能会出现在 Go 的下一个版本中,允许打破向后兼容的约束吗?
Daniel Martí: 这是个好问题。我觉得有两类问题我会想修复。第一类是高级 API 改动。我们之前提到过,使用 reader
和 writer
让人误以为它是流式处理,但实际并不是。如果我们修改这些部分,几乎每个使用 JSON 的程序都会被打破,所以这在 v1 版本中是完全不可能的。
另一类是一些微妙的 bug 和历史遗留问题,它们已经成为事实上的行为,很多人依赖于此。举个例子,有一个类型叫 json.Number
,它可以方便地支持大数,实际上是一个字符串类型。所以当你解码一个像 50 位长的数字时,它不会关心是否超出了 int32
或 int64
的范围,因为它会保持原样,作为字符串处理。这是处理大数的最简单方法。
但 json.Number
的实现中,如果输入的 JSON 是一个包含数字的字符串,它也会接受,尽管这不是一个 JSON 数字。这并不是文档中提到的行为,文档只说它解码数字,没有提到字符串。所以我们尝试修复这个问题---
我记得是别人提出来的修复,我负责审查---
结果很多人说“这破坏了我的代码!”我告诉他们“只需三行代码就能修复,这非常简单,我还给你们提供了 Playground 链接。”但他们还是说“不,不,不,这打破了生产环境,违反了兼容性承诺。”
Mat Ryer: 哦,这确实是个灰色地带。你不应该这样使用它,但既然它有效,那该怎么办呢?确实棘手。
Daniel Martí: 是的,这很难处理。你必须评估“我是不是打破了太多用户的代码?多少用户算是‘太多’呢?”我不知道大家是如何使用 JSON 包的。我也许可以查看一些开源项目,看看他们的代码是什么样的,但这只能触及表面。我猜用 JSON 最多的 Go 代码很可能不是开源的……所以很难判断某些改动是否可行。
Mat Ryer: 对,所以你现在有一个 encoding/json
的 v2 版本草案,对吗?
Daniel Martí: 嗯。
Mat Ryer: 这个文档是为了什么?是你心目中的完美设计,如果可以的话你会想要的东西吗?
Daniel Martí: 目前来说,这只是我用来整理自己想法的一个文档……因为我维护 JSON 已经几年了,积累了很多类似“我没法修复这个”的小问题。如果我试图修复某个问题,人们可能会不高兴。我也不能碰这个功能,因为它被 API 限制住了……所以我整理了我的所有想法---
至少是我能记住的那些---
但我还没到设计新 API 的程度,因为在某种程度上,我觉得这有点徒劳……因为即使我设计了一个新的 JSON API,它也不会取代现有的 API。据我所知,目前也没有计划推出标准库包的第二版本。我可能会在外部写点东西,但我并不想再增加 Go 中已有的 50 个 JSON 包的复杂性。
Mat Ryer: 嗯……我想知道有什么合理的办法,是否可以在 JSON 包中添加一些新的方法……
Daniel Martí: 是的,这确实是个好点子。有一些 bug……比如,有一个我会说影响了大多数代码库的 bug,就是标准的---
你有一个 HTTP 端点,body 是 JSON,所以你想要解码它……你会做的事情是获取 r.body
,然后用 json.NewDecoder().Decode()
来解码到某个结构体中。如果你这么做了,那是有问题的。
Mat Ryer: 我刚开始学习 Go……你说有问题?![笑声] 请告诉我为什么。
Daniel Martí: 大约一年前,Joe(其中一位维护者)发现了这个问题……bug 是解码器本应适用于 JSON 值的流。比如当你用 JSON 标志运行 go test
时,它会给你一个换行分隔的 JSON 值流、JSON 对象流。
Mat Ryer: 是的,我在那些工具中使用它时,正是这样的。
Daniel Martí: 是的,正是如此。
Mat Ryer: 某种程度上,它确实是流式的……它读取 reader
。对于每个对象,它会缓冲它,我猜,但它会丢弃之前的那个对象,对吧?
Daniel Martí: 是的。
Mat Ryer: ……下一次时。对。所以从某种意义上来说,它是流式的。
Johnny Boursiquot: 它看起来像是在流式处理,但实际上它并不是这么做的。
Mat Ryer: 但它每次只处理一个对象,你可以说这是一个流;只是如果这是一个非常大的对象,那么---
Daniel Martí: 确实如此。
Johnny Boursiquot: 你就麻烦了。
Mat Ryer: 你可能会。
Daniel Martí: 所以我会说,假设你的值是小的;它并没有考虑到你可能会有一个 200 MB 的 JSON 对象。如果你有,它就会说,“哎呀!我只能缓冲这个。”
Mat Ryer: 嗯。比如说,你现在的机器就做不了这个,因为内存不够。
Johnny Boursiquot: [笑声]
Daniel Martí: 如果你想让我离开,你可以直接说。[笑声]
Mat Ryer: 拜托别走。你八分钟后就得走了。
Daniel Martí: 目前我还剩 30% 的内存,还能撑 7-8 分钟。
Mat Ryer: 我在想,这是不是和你说的多少话有关。当你说话时,它肯定会消耗更多的 RAM……[笑声]
Daniel Martí: 好吧,让我对着麦克风大喊一声,看 RAM 是不是会增加。
Mat Ryer: 是的,我知道它在做什么---
它把音频存储成 JSON 了,不是吗?
Daniel Martí: 也许吧……也许每一波音频都是一个 JSON 对象,正在某个地方被流式传输。
Mat Ryer: 对。它并不是每种数据的完美格式,对吧?有时候二进制数据会更好。
Johnny Boursiquot: 其实,这引出了一个很好的话题,因为是的,JSON 很棒,它是人类可读的……但大多数时候,我们是让机器相互通信。所以在某些情况下,为了传输和存储的效率,选择二进制格式而不是基于文本的 JSON 可能更有意义……尤其是当它是一种数据流,或者你正在接受大量的信息时……除非你作为开发人员在本地调试,否则你不可能通过大量的 JSON 数据,试图阅读并利用其可读性。那么,什么时候可以给自己一个理由,不必为了使用 JSON 而使用 JSON,仅仅因为大家都在用 JSON?有哪些好的标准可以帮助你做出不使用 JSON 的决定?
Daniel Martí: 这是个好问题。在回答这个问题之前,我想简要提一下刚才提到的那个 bug。
Johnny Boursiquot: 对,我们还没猜出来。
Mat Ryer: 抱歉,这都是我蠢的错。别担心,这只是编辑们的更多工作。拍一下!好吧……[笑声] 你只要拍拍手就能修复它。
促销广告: “你遇到过这种情况吗?现在介绍---
拍手器。跟着音乐拍手!很简单……拍手开,拍手关……!拍手器。”
Mat Ryer: 所以 Daniel,告诉我们 r.body 解码时的那个 bug 是什么?
Daniel Martí: 这个 bug 是你只解码了一个对象,但如果 body 中包含了多个值,用多个换行符分隔呢?你不会注意到,你只会在解码第一个对象后立即关闭字节流。所以即使你不支持它,如果客户端想要发送三个对象,用换行符分隔,你会使用第一个对象,忽略后两个,这很可能不是你想要的。你要么应该抛出一个错误,要么应该使用所有数据。
Mat Ryer: 是啊,这很有趣。如果你到达流的末尾,当你尝试使用解码器解码时会发生什么?
Daniel Martí: 嗯,我想它会返回 EOF 错误,或者类似的东西……
Mat Ryer: 是的,你会得到 EOF……嗯。所以你可以通过一个循环来支持它,一直循环并解码,但我不知道,这确实有点奇怪……当你想到 JSON 中的数组时,数组可以包含许多对象,这往往是有效载荷的一部分,而这实际上仍然是可行的,不会触发这个 bug。这只是针对换行符分隔的 JSON 对象。
Daniel Martí: 是的。在这种情况下,你可以很容易地修复代码。你只需在解码结束时添加一个检查,看看解码器是否还有更多的令牌需要解码,如果有,就抛出错误。你可以这么做。但问题是,人们必须记住去做这个检查。最初,没有人知道需要这么做……所以我会说这是一个复杂的 API 设计,因为它很容易被误用。
Mat Ryer: 是的,但坦白说,我不知道有哪个 API 会让你这样发送多行 JSON。我可能错了,但我好像没见过。
Daniel Martí: 是的。如果有这样的 API,你可能会正确地实现它。我同意,这在现实生活中可能不是个大问题,但它仍然是一个存在的边缘用例,很少有人考虑到,技术上来说这也是个 bug。
Mat Ryer: 是的。我喜欢那些为我们维护这些包的人……这真的很难,你必须关心所有细节……但这很好,因为这意味着我们其他人不用去操心这些问题了。
Daniel Martí: 回到 Johnny 的问题,他问“什么时候应该选择 JSON 或其他纯文本格式,什么时候应该选择二进制格式?”我认为在这个问题上有多种观点,但我认为大多数程序员的共识是,如果这是人类要处理的东西,比如调试、查看或使用,或者是人类要写的东西,你很可能会选择纯文本格式,比如 JSON 或 YAML 等。但如果你需要高效处理数据,可能是因为数据量非常大,或者仅仅是机器之间的通信,那么你可能会考虑使用更高效的二进制格式,它占用更少的空间,等等。
Mat Ryer: 是的,我认为这个论点也适用于 gRPC 和 JSON API 的争论……这是同样的问题,你可能有很好的理由需要一个低级的二进制格式;你希望它尽可能高效。但这样你就损害了开发者的友好程度。它在构建时很不错,但即使在使用时,如果你想探索发生了什么,你有时可以在浏览器的网络选项卡中查看 HTTP 请求,看看 JSON 的内容……我发现这非常有用,尤其是在开发时……所以,如果你使用 gRPC 之类的东西,你可能需要额外的工具来做到这一点,我想……
Daniel Martí: 我完全同意。我会说默认情况下使用纯文本格式,只有在仔细考虑后才选择二进制格式。或者更好的是,支持两者。很多构建 gRPC 服务的人会在其上添加一个 REST 网关,这样客户端可以选择使用哪一个。也许机器使用 gRPC,而调试时的人类会使用带有 JSON 的 REST。
Mat Ryer: 是的,绝对如此。我认为这是一个合理的做法。但我同意,首先使用 JSON,因为一开始它是最容易使用的。也许这就是你所需要的一切。
Johnny Boursiquot: 你是在说这是一个 YAGNI(你不会需要它的)情况吗?
(译者注: YAGNI 是 "You Aren't Gonna Need It" 的缩写,翻译为“你不会需要它”。这是软件开发中的一种设计原则,强调在编写代码时,应该避免过度设计或实现尚未需要的功能。)
Mat Ryer: YAGNI……!
Daniel Martí: 什么是 YAGNI?
Johnny Boursiquot: 哦,你还没有被 Ruby 生态系统洗礼。YAGNI 是 Ruby 生态系统中一个非常受欢迎的框架作者推广的概念。YAGNI 代表“你不会需要它”(You Ain't Gonna Need It)。[笑声]
Daniel Martí: 我记下了。
Mat Ryer: 很好,不是吗?
Johnny Boursiquot: 是的,确实如此。
Mat Ryer: 我们需要这个。
Johnny Boursiquot: 我偶尔还会拿出来用。
Daniel Martí: 但我确实认为我们可能漏掉了一个点,那就是定义你的数据模型……我认为这是 JSON 最短板的地方,也是它最让人头疼的地方。这也是 JSON Schema 之类的东西出现的原因……但我不会说它们是非常好的解决方案。它们大多试图将 20 年前的 XML 解决方案移植到 JSON。我认为这不是一个很好的方法。我认为像 protobuf 和 gRPC 这样的模式语言更好……所以你必须在“我要使用简单的 JSON 并快速开始”与“我要使用一个能够让我正确定义类型的模式语言”之间做出权衡。
Mat Ryer: 是的,这可能也取决于用例。在某些情况下,如果你处理的是通用数据,而你不知道这些数据的结构……这种情况确实会发生。我确实参与过一些项目,它们是某种平台,你无法提前知道数据的形状……那么这确实会影响你的选择。
不过 JSON 的好处在于,你可以随时向其中添加字段,不是吗?你可以随时添加字段,之前的代码也会继续工作……因为在 Go 的结构体中,如果结构体缺少字段,而 JSON 中存在该字段,默认情况下它会被忽略,对吧?
Daniel Martí: 是的,这是个很好的点。JSON 确实很容易实现向后兼容性,只要你愿意维护之前的字段,等等。我认为大多数格式都是这样的。例如,protobuf 也是如此,只要你在最后添加带有新 ID 的字段就可以了,但它不如 JSON 直观。我承认这确实增加了一些复杂性。
Johnny Boursiquot: 但它能保持我的旧代码继续工作,所以……我觉得这是一个我愿意接受的权衡。
Mat Ryer: 现有的实现中还有什么其他提升效率的可能?比如在解码 JSON 的过程中减少内存分配?
Daniel Martí: 是的,这确实是我的大部分工作所在……因为如我之前所说,我不想仅仅写一个新包,增加 Go 新开发者在 20 个 JSON 包之间做选择的困扰。所以我确实对内部做了一些改动,比如避免重复工作,或者缓存一些东西,或者去掉某些平衡检查之类的东西……我记得是在 Go 1.10 到 Go 1.13 之间,如果你主要使用结构体,而不是 map,JSON 解码速度提高了大约 30% 到 50%,这蛮不错的……
Mat Ryer: 哇……
Daniel Martí: 但你得明白,基点其实很低……
Johnny Boursiquot: [笑声]
Mat Ryer: 你不用这么说……只关注改进部分就好了。[笑声]
Johnny Boursiquot: 是的,没错。
Mat Ryer: 提速了 30%!
Daniel Martí: 但我也想说,那些声称比 encoding/json
快十倍的包---
他们的基准测试可能做得很早,现在可能只快四倍左右。
Mat Ryer: 有趣……嗯。
Daniel Martí: 我确实认为还有更多工作可以做,但所有的低垂果实已经被摘走了,主要是我和其他几个人……不过还有一些事情可以做,而不会改变 API 或破坏用户。我认为最大的一个---
这与 Dave 的工作有关---
就是重写标记器。标记器负责读取字节并识别“哦,这是一个字符串,这是一个左大括号,这是一个逗号”,等等。
Mat Ryer: 嗯。那么,这个过程是边解析边构建数据结构,还是它以某种方式在某个中间数据结构中描述解析的结构,如果你能明白我的意思?
Daniel Martí: 一种方法确实是构建某种树结构,比如当你解析 Go 文件时,你会得到 Go 代码的语法树……
Mat Ryer: 是的。
Daniel Martí: 它并不是这样做的。它的工作方式是先对一个值(例如一个 JSON 对象)进行标记化处理;它开始遍历字节,逐个识别 “标记,标记,标记”,但它会忘记这些标记,因为这是第一次遍历。它只是想检查 JSON 是否有效。当它到达末尾时,例如遇到第一个大括号的闭合括号,它会回到缓冲区的开头,然后再次进行标记化处理……但这次当它遇到“打开对象”时,它会真正开始在目标值中创建一个对象。如果它看到一个字符串,它会尝试将该字符串解码为当前的目标值,依此类推。
Mat Ryer: 这很有趣。我很惊讶它会这么做……因为你会认为它只需要做一次,不是吗?为什么它要这样做?
Daniel Martí: 它这样做两次的原因是为了防止部分解码。比如说我给你一个包含 9,000 个元素的数组,但没有闭合标记,这就不是有效的 JSON。那么你会怎么办?你要花时间把这 9,000 个元素全部解码到目标值中吗?如果目标值之前有数据,那你可能会破坏它。对于数组来说,这可能没什么意义,但想象一下,如果是一个 map 呢……
Mat Ryer: 对。
Daniel Martí: 所以你不想这么做,至少在 JSON 包中不会这么做。它更注重正确性,所以它会先确保 JSON 是有效的,然后再进行解码。
Mat Ryer: 哦,真有趣。嗯。
Daniel Martí: 我觉得你可以说它应该保持一棵树而不是字节,这样可能会更高效一些,因为不会重复工作,但我会说这样可能会增加分配对象的成本。
Mat Ryer: 我的想法是,我会只遍历一次,不用太担心正确性,做完所有的工作,然后如果最后发现有错误,那就得到一个错误。但你可能要等到最后。我感觉这更像是一种乐观的设计。你觉得这是个糟糕的设计吗?
Daniel Martí: 我不确定。我大概是 50/50。我觉得两种情况都是合理的。我认为当前的 API 尽量保持简单。它基本上只有一个入口点,就是 decoder.Decode()
,而 Unmarshal()
只是它的一个包装器……因为如果你看 Unmarshal()
,它只是为你处理了底层的事情。
Mat Ryer: 哦,不是反过来的吗?我以为解码器会用---
Johnny Boursiquot: 你以为解码器用了 Marshal()
?
Mat Ryer: 是啊,或者 Unmarshal()
。
Daniel Martí: 解码器的好处在于它保留了一些东西以便后续重用。如果解码器使用 Marshal()
,那么 Marshal()
就不会有解码器对象来重用这些东西了。
Mat Ryer: 对,明白了。嗯……很酷。很酷。当然,这些代码都是开源的,所以如果我们真的想了解它是如何工作的,我们可以去阅读代码。
Daniel Martí: 是的,但我会说不要把这些代码和 API 当作典型的 Go 代码来看,因为很多东西都是十多年前写的。而且不仅仅是我,很多其他人也动过手,所以它现在有点像个僵尸了。
Johnny Boursiquot: 你刚提到的这个点非常好,因为我们 Go 社区的很多老成员经常告诉新手:“去读标准库吧,这是写 Go 代码的一个很好的例子”,但这并不总是对的。[笑] 我们从那时起学到了很多东西,有些做法是该避免的,有些是最佳实践,还有一些是更符合 Go 语言惯例的做法……而 encoding/json
包可能并不是我们今天所取得进步的最佳代表。
Mat Ryer: 是的,另一个问题是它包含了很多优化,而它确实应该这样做……但这也可能带来代码复杂性和“丑陋性”的代价……不过你不会介意,因为这是一个非常重要的地方。但确实,一个初级开发者可能会去看这些代码,看到一些东西后觉得“哦,这是该怎么做的”,但实际上你可能不想这么做。
Daniel Martí: 我完全同意。
Johnny Boursiquot: 我们一定要确保在时间用完前讨论到一些不受欢迎的观点……
Daniel Martí: 我不受欢迎的观点是,encoding/json
已经足够快了……
Johnny Boursiquot: [笑] 哦,拜托……!
Mat Ryer: 哇!你可是负责让它变快的人…… [笑声]
Daniel Martí: 嗯,我是说总体上,当然大多数情况是这样的……但这可能不适用于那 1% 的人,比如处理 20GB 的 JSON 数据的人……但大多数人不会遇到这种情况。我的观点回到权衡上。是的,如果你选择另一个包,你可能会得到 2 倍、3 倍,甚至可能 4 倍的提升,但在那个时候还坚持使用 JSON 真的值得吗?那些不得不使用 JSON 的人和那些需要处理大量数据的人之间的重叠非常小……因为那些需要处理大量数据的人通常会选择更好的格式,这些格式的解码速度更快。
Mat Ryer: 我觉得这个论点非常合理……嗯,这个观点对我来说并不“不得人心”。我觉得你说得很好。
Daniel Martí: 你会认为那些抱怨 encoding/json
速度太慢的人会不同意……
Johnny Boursiquot: [笑]
Mat Ryer: 当然了,那是因为我们给了他们基准测试工具。我不明白你还能期待什么。当然了。
Daniel Martí: 我收回我的话。[笑声]
Mat Ryer: 好吧,Daniel,感谢你来参加我们的节目并花时间和我们交流。非常棒。你一定要再来。
Daniel Martí: 很荣幸。
Mat Ryer: 是的,非常感谢。感谢大家的收听,我们下次再见。
Daniel Martí: 顺便说一下,我找到了那个 bug。
Mat Ryer: 哦,你找到了?
Daniel Martí: 是的。如果我看我的录音程序,它会持续占用更多的内存……但如果我切换到另一个窗口,它就不会了。
Mat Ryer: 就像量子现象一样。
Daniel Martí: 它停止增长了。所以我想是 UI 的问题。UI 不断显示我的声音波形,但可能将整个 UI 保存在内存中……而当我切换视图时,它停止渲染,然后就不再占用更多内存了。
Mat Ryer: 所以它在你看着它的时候才会这样。别看它!
Daniel Martí: 我现在正在看,它增长到了 31%、32%……然后我停止看它,它就不再增长了。
Mat Ryer: 施罗丁格的猫文件。
Johnny Boursiquot: [笑] 哦,天啊……
Mat Ryer: 是的,一旦它的行为就改变了。哦,这太奇怪了……你绝对不会想到要去检查这个,对吧?这就是那种经典的计算机 bug。这正是发生的事情……!
Daniel Martí: 显然,当我做五秒的录音时,时间不够长,没注意到内存的异常。
Mat Ryer: 所以实际上,如果你最小化窗口或者把它切换到另一个屏幕,它就不再这样了吗?内存会跳回原来的水平吗?
Daniel Martí: 不,它只是保持在那里。所以在第二段中,它攀升到了 30%,然后我最小化了窗口,它就停在那里了。
Mat Ryer: 对。你只是想“我不想再为这个烦心了。我不想看它”,然后它就正常了……你找到了原因。
Daniel Martí: 如果你看到我在抬头,那是我在检查内存使用情况,并祈祷它不会崩溃……[笑声] 但再次抱歉。
Mat Ryer: 好吧,你找到问题了。
Johnny Boursiquot: 太棒了。
Mat Ryer: Daniel,你一定要再回来,帮我们调试更多的技术小鬼。
Daniel Martí: 哦,我的天啊……哦,我的天啊……不,拜托。这次真的太有压力了。[笑声]
Daniel Marti: https://github.com/mvdan
[2]Mat Ryer: https://github.com/matryer
[3]Johnny Boursiquot: https://github.com/jboursiquot
[4]Joe: https://github.com/dsnet
[5]XPath: https://developer.mozilla.org/en-US/docs/Web/XPath