在JavaScript中对复杂对象进行序列化的方法

991 阅读10分钟

在JavaScript中序列化复杂对象

网站性能和数据缓存

现代网站通常从许多不同的地方检索数据,包括数据库和第三方API。例如,在验证用户身份时,网站可能会从数据库中查找用户记录,然后通过API调用一些外部服务的数据对其进行润色。尽量减少对这些数据源的昂贵调用,如数据库查询的磁盘访问和API调用的互联网往返,对于维持一个快速、响应的网站至关重要。数据缓存是一种常用的优化技术,用于实现这一目标。

进程在内存中存储其工作数据。如果一个Web服务器在一个单一的进程中运行(如Node.js/Express),那么这些数据可以很容易地使用在同一进程中运行的内存缓存来进行缓存。然而,负载平衡的Web服务器跨越多个进程,即使在使用单一进程时,你也可能希望缓存在服务器重启时持续存在。这就需要一个进程外的缓存解决方案,如Redis,这意味着数据需要以某种方式序列化,并在从缓存中读取时反序列化。

序列化和反序列化在静态类型的语言(如C#)中是比较容易实现的。 然而,JavaScript的动态特性使这个问题变得有点棘手。虽然ECMAScript 6(ES6)引入了类,但这些类上的字段(及其类型)在初始化之前并没有定义--这可能不是在类被实例化的时候,而且字段和函数的返回类型在模式中根本就没有定义。更重要的是,类的结构很容易在运行时被改变--字段可以被添加或删除,类型可以被改变,等等。虽然在C#中使用反射可以做到这一点,但反射代表了该语言的 "黑暗艺术",开发人员希望它能破坏功能。

几年前,我在工作中遇到了这个问题,当时我在Toptal核心团队工作。我们正在为我们的团队建立一个敏捷的仪表盘,它需要快速;否则,开发人员和产品所有者就不会使用它。我们从多个来源提取数据:我们的工作跟踪系统、我们的项目管理工具和数据库。该网站是用Node.js/Express构建的,我们有一个内存缓存,以减少对这些数据源的调用。然而,我们的快速迭代开发过程意味着我们每天要部署(也就是重启)几次,使缓存失效,从而失去了许多好处。

一个明显的解决方案是进程外缓存,如Redis。然而,经过一些研究,我发现没有好的序列化库存在于JavaScript。内置的JSON.stringify/JSON.parse方法返回对象类型的数据,失去了对原始类的原型的任何功能。这意味着反序列化的对象不能简单地在我们的应用程序中 "原地 "使用,因此需要相当大的重构以配合其他设计。

对库的要求

为了支持JavaScript中任意数据的序列化和反序列化,并且反序列化的表现形式和原件可以互换使用,我们需要一个具有以下特性的序列化库。

  • 反序列化的表现形式必须具有与原始对象相同的原型(函数、getters、setters)。
  • 该库应该支持嵌套的复杂类型(包括数组和地图),并正确设置嵌套对象的原型。
  • 应该可以多次序列化和反序列化相同的对象--这个过程应该是empotent的。
  • 序列化格式应该很容易通过TCP传输,并且可以使用Redis或类似的服务进行存储。
  • 将一个类标记为可序列化,应该需要最小的代码修改。
  • 库中的例程应该是快速的。
  • 理想情况下,应该有一些方法来支持类的旧版本的反序列化,通过某种映射/版本划分。

实施

为了填补这一空白,我决定编写Tanagra.js,一个通用的JavaScript序列化库。该库的名称参考了《星际迷航:下一代》中我最喜欢的一集,其中企业号的船员必须学会与一个神秘的外星种族沟通,他们的语言是无法理解的。这个序列化库支持常见的数据格式,以避免此类问题。

Tanagra.js的设计是简单和轻量级的,目前它支持Node.js(尚未在浏览器中测试,但在理论上,它应该可以工作)和ES6类(包括地图)。主要的实现支持JSON,实验性的版本支持谷歌协议缓冲区。该库只需要标准的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中存储序列化protobufs的辅助库
  • 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次试验。

我在我的2017年戴尔XPS15笔记本上进行了测试,内存为32Gb,运行Ubuntu 17.10。

我序列化了以下嵌套对象。

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 }
	}
  },
}

写作性能

序列化方法平均值(Ave.inc.)第一次试验(msStDev.inc.首次试验(毫秒)平均数,前值,第一次试验 (ms)StDev.ex.首次试验 (ms)
JSON0.1150.09030.08790.0256
谷歌原件2.002.7481.130.278
控制组0.01550.007260.01390.00570

读取

序列化方法平均值,第一次试验(毫秒)。StDev.inc.首次试验(ms)平均数,前值,第一次试验(ms)StDev.ex.首次试验 (ms)
JSON0.1330.1020.1040.0429
谷歌原代码2.621.122.280.364
控制组0.01350.007290.01150.00390

总结

JSON序列化器比本地序列化慢了6-7倍。实验性的protobufs序列化器比JSON序列化器慢13倍左右,或比本地序列化慢100倍。

此外,每个序列化器中的模式/结构信息的内部缓存显然对性能有影响。对于JSON序列化器来说,第一次写入的速度比平均速度慢四倍左右。对于protobuf序列化器,它的速度要慢9倍。所以写那些元数据已经被缓存的对象,在这两个库中都要快得多。

读取时也有同样的效果。对于JSON库来说,第一次读取的速度比平均水平慢四倍左右,而对于protobuf库来说,则慢了两倍半左右。

protobuf序列化器的性能问题意味着它仍然处于实验阶段,只有当你因为某些原因需要这种格式时,我才会推荐它。然而,它值得投资一些时间,因为这种格式比JSON更简洁,因此更适合通过电线发送。Stack Exchange在其内部缓存中使用该格式。

JSON序列化器的性能显然要好得多,但仍然比本地实现慢得多。对于小的对象树来说,这种差异并不明显(在50毫秒的请求基础上的几毫秒不会破坏你的网站的性能),但对于极其庞大的对象树来说,这可能成为一个问题,这也是我的开发重点之一。

路线图

该库仍处于测试阶段。JSON序列化器已经得到了合理的测试和稳定。以下是未来几个月的路线图。

  • 改进两个序列化器的性能
  • 更好地支持ES6之前的JavaScript
  • 对ES-Next装饰器的支持

据我所知,没有其他的JavaScript库支持序列化复杂的、嵌套的对象数据,并将其反序列化为原始类型。如果你正在实现的功能能从这个库中受益,请试一试,联系你的反馈,并考虑贡献。

项目主页
GitHub存储库

了解基础知识

对象是如何在JavaScript中存储的?

一般来说,对象是不存储的。它们在需要时被实例化,在处理中被使用,然后在不再需要时从内存中删除。如果我们需要在其他地方临时使用数据,我们会将这些数据序列化和反序列化为另一个结构。

什么是JavaScript对象?

一个对象是一段代码,它封装了一个结构,以及可以在该结构上执行的操作。一般来说,它和任何面向对象的编程语言中的对象是一样的。

什么是数据对象?

数据对象是一段代码,它暂时包含来自数据存储的数据,以便可以在应用程序中读取或处理。除非有办法将该数据保留在内存中,否则当该对象超出范围或不需要时,它将被写回或不被保留。

为什么我们需要序列化和反序列化?

序列化使我们能够保留数据,以便在需要时在运行的进程或其他进程中使用。我们存储数据,然后在其他地方使用时进行反序列化。