在 JavaScript 中序列化复杂对象
已发表: 2022-03-11网站性能和数据缓存
现代网站通常从多个不同位置检索数据,包括数据库和第三方 API。 例如,在对用户进行身份验证时,网站可能会从数据库中查找用户记录,然后通过 API 调用使用来自某些外部服务的数据对其进行修饰。 最大限度地减少对这些数据源的昂贵调用,例如用于数据库查询的磁盘访问和用于 API 调用的 Internet 往返,对于维护一个快速响应的站点至关重要。 数据缓存是用于实现此目的的常用优化技术。
进程将其工作数据存储在内存中。 如果 Web 服务器在单个进程中运行(例如 Node.js/Express),则可以使用在同一进程中运行的内存缓存轻松缓存这些数据。 但是,负载平衡的 Web 服务器跨越多个进程,即使使用单个进程,您可能希望缓存在服务器重新启动时保持不变。 这需要进程外缓存解决方案,例如 Redis,这意味着数据需要以某种方式进行序列化,并在从缓存中读取时进行反序列化。
序列化和反序列化在 C# 等静态类型语言中相对容易实现。 然而,JavaScript 的动态特性使问题变得有点棘手。 虽然 ECMAScript 6 (ES6) 引入了类,但这些类(及其类型)上的字段直到它们被初始化时才被定义——这可能不是在类被实例化时——并且没有定义字段和函数的返回类型完全在架构中。 更重要的是,类的结构可以在运行时轻松更改——可以添加或删除字段,可以更改类型等。虽然这可以在 C# 中使用反射,但反射代表了该语言的“黑魔法”,并且开发人员希望它会破坏功能。
几年前,当我在 Toptal 核心团队工作时,我在工作中遇到了这个问题。 我们正在为我们的团队构建一个敏捷的仪表板,它需要快速; 否则,开发人员和产品所有者不会使用它。 我们从多个来源提取数据:我们的工作跟踪系统、我们的项目管理工具和一个数据库。 该站点是在 Node.js/Express 中构建的,我们有一个内存缓存来最大限度地减少对这些数据源的调用。 然而,我们快速、迭代的开发过程意味着我们每天部署(并因此重新启动)多次,使缓存失效,从而失去了它的许多好处。
一个明显的解决方案是进程外缓存,例如 Redis。 但是,经过一番研究,我发现 JavaScript 没有好的序列化库。 内置 JSON.stringify/JSON.parse 方法返回对象类型的数据,丢失原始类原型上的任何函数。 这意味着反序列化的对象不能简单地在我们的应用程序中“就地”使用,因此需要进行大量重构才能使用替代设计。
对图书馆的要求
为了支持 JavaScript 中任意数据的序列化和反序列化,使反序列化的表示和原始数据可以互换使用,我们需要一个具有以下属性的序列化库:
- 反序列化的表示必须与原始对象具有相同的原型(函数、getter、setter)。
- 该库应支持嵌套复杂类型(包括数组和映射),并正确设置嵌套对象的原型。
- 应该可以多次序列化和反序列化相同的对象——这个过程应该是幂等的。
- 序列化格式应该可以通过 TCP 轻松传输,并且可以使用 Redis 或类似服务进行存储。
- 将类标记为可序列化应该需要最少的代码更改。
- 库例程应该很快。
- 理想情况下,应该有某种方法通过某种映射/版本控制来支持类的旧版本的反序列化。
执行
为了填补这个空白,我决定编写Tanagra.js ,一个用于 JavaScript 的通用序列化库。 图书馆的名称参考了我最喜欢的《星际迷航:下一代》剧集之一,其中企业的船员必须学会与一个语言难以理解的神秘外星种族交流。 此序列化库支持常见的数据格式,以避免此类问题。
Tanagra.js被设计为简单和轻量级,它目前支持 Node.js(尚未在浏览器中测试,但理论上应该可以工作)和 ES6 类(包括 Maps)。 主要实现支持 JSON,实验版本支持 Google Protocol Buffers。 该库只需要标准 JavaScript(目前使用 ES6 和 Node.js 进行测试),不依赖于实验性功能、 Babel转译或TypeScript 。
可序列化的类在导出类时使用方法调用进行标记:
module.exports = serializable(Foo, myUniqueSerialisationKey)
该方法向该类返回一个代理,该代理拦截构造函数并注入唯一标识符。 (如果未指定,则默认为类名。)该键与其余数据一起序列化,并且该类还将其公开为静态字段。 如果该类包含任何嵌套类型(即,具有需要序列化类型的成员),它们也在方法调用中指定:
module.exports = serializable(Foo, [Bar, Baz], myUniqueSerialisationKey)
(该类的先前版本的嵌套类型也可以以类似的方式指定,例如,如果您序列化 Foo1,则可以将其反序列化为 Foo2。)
在序列化过程中,该库递归地构建一个类键的全局映射,并在反序列化过程中使用它。 (请记住,密钥与其余数据一起序列化。)为了知道“顶级”类的类型,库要求在反序列化调用中指定它:
const foo = decodeEntity(serializedFoo, Foo)
一个实验性的自动映射库遍历模块树并从类名生成映射,但这仅适用于唯一命名的类。
项目布局
该项目分为多个模块:
- tanagra-core - 不同序列化格式所需的通用功能,包括将类标记为可序列化的功能
- tanagra-json - 将数据序列化为 JSON 格式
- tanagra-protobuf - 将数据序列化为 Google protobuffers 格式(实验性)
- tanagra-protobuf-redis-cache - 一个帮助库,用于在Redis中存储序列化的 protobuf
- tanagra-auto-mapper - 遍历Node.js中的模块树以构建类映射,这意味着用户不必指定要反序列化到的类型(实验性)。
请注意,该库使用美国拼写。
示例用法
以下示例声明了一个可序列化的类并使用 tanagra-json 模块对其进行序列化/反序列化:
const serializable = require('tanagra-core').serializable class Foo { constructor(bar, baz1, baz2, fooBar1, fooBar2) { this.someNumber = 123 this.someString = 'hello, world!' this.bar = bar // a complex object with a prototype this.bazArray = [baz1, baz2] this.fooBarMap = new Map([ ['a', fooBar1], ['b', fooBar2] ]) } } // Mark class `Foo` as serializable and containing sub-types `Bar`, `Baz` and `FooBar` module.exports = serializable(Foo, [Bar, Baz, FooBar]) ... const json = require('tanagra-json') json.init() // or: // require('tanagra-protobuf') // await json.init() const foo = new Foo(bar, baz) const encoded = json.encodeEntity(foo) ... const decoded = json.decodeEntity(encoded, Foo)
表现
我将两个序列化程序( JSON序列化程序和实验性protobufs序列化程序)的性能与一个控制(本机 JSON.parse 和 JSON.stringify)进行了比较。 我一共进行了 10 次试验。

我在运行 Ubuntu 17.10 的 2017 年戴尔 XPS15笔记本电脑上进行了测试,该笔记本电脑具有 32Gb 内存。
我序列化了以下嵌套对象:
foo: { "string": "Hello foo", "number": 123123, "bars": [ { "string": "Complex Bar 1", "date": "2019-01-09T18:22:25.663Z", "baz": { "string": "Simple Baz", "number": 456456, "map": Map { 'a' => 1, 'b' => 2, 'c' => 2 } } }, { "string": "Complex Bar 2", "date": "2019-01-09T18:22:25.663Z", "baz": { "string": "Simple Baz", "number": 456456, "map": Map { 'a' => 1, 'b' => 2, 'c' => 2 } } } ], "bazs": Map { 'baz1' => Baz { string: 'baz1', number: 111, map: Map { 'a' => 1, 'b' => 2, 'c' => 2 } }, 'baz2' => Baz { string: 'baz2', number: 222, map: Map { 'a' => 1, 'b' => 2, 'c' => 2 } }, 'baz3' => Baz { string: 'baz3', number: 333, map: Map { 'a' => 1, 'b' => 2, 'c' => 2 } } }, }
写入性能
序列化方法 | 大道公司一审(毫秒) | 标准差。 公司一审(毫秒) | 大道前。 一审(毫秒) | 标准差。 前任。 一审(毫秒) |
JSON | 0.115 | 0.0903 | 0.0879 | 0.0256 |
谷歌 Protobufs | 2.00 | 2.748 | 1.13 | 0.278 |
控制组 | 0.0155 | 0.00726 | 0.0139 | 0.00570 |
读
序列化方法 | 大道公司一审(毫秒) | 标准差。 公司一审(毫秒) | 大道前。 一审(毫秒) | 标准差。 前任。 一审(毫秒) |
JSON | 0.133 | 0.102 | 0.104 | 0.0429 |
谷歌 Protobufs | 2.62 | 1.12 | 2.28 | 0.364 |
控制组 | 0.0135 | 0.00729 | 0.0115 | 0.00390 |
概括
JSON序列化器比本机序列化慢大约 6-7 倍。 实验性的protobufs序列化器比JSON序列化器慢大约 13 倍,或者比原生序列化慢 100 倍。
此外,每个序列化程序中模式/结构信息的内部缓存显然会对性能产生影响。 对于 JSON 序列化程序,第一次写入比平均速度慢四倍左右。 对于 protobuf 序列化程序,它慢了九倍。 因此,在任一库中写入元数据已被缓存的对象要快得多。
对于读取观察到相同的效果。 对于 JSON 库,第一次读取大约比平均速度慢四倍,而对于 protobuf 库,它大约慢两倍半。
protobuf 序列化程序的性能问题意味着它仍处于实验阶段,只有当您出于某种原因需要格式时,我才会推荐它。 但是,值得投入一些时间,因为格式比 JSON 更简洁,因此更适合通过网络发送。 Stack Exchange 使用该格式进行内部缓存。
JSON 序列化器的性能显然要好得多,但仍然比本机实现慢得多。 对于小型对象树,这种差异并不显着(在 50 毫秒请求之上的几毫秒不会破坏您网站的性能),但这可能会成为极大对象树的问题,并且是我的开发重点之一。
路线图
该库仍处于测试阶段。 JSON 序列化程序经过了合理的良好测试和稳定。 以下是未来几个月的路线图:
- 两个序列化程序的性能改进
- 更好地支持 pre-ES6 JavaScript
- 支持 ES-Next 装饰器
我知道没有其他 JavaScript 库支持序列化复杂的嵌套对象数据并反序列化为其原始类型。 如果您正在实现将从库中受益的功能,请尝试一下,与您的反馈联系,并考虑做出贡献。
项目主页
GitHub存储库