在 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存储库