JavaScript的二进制协议
对JSON的原生支持是开发全栈JavaScript应用程序的乐趣之一。 JSON是简单的、无模式的和人类可读的--在开发的早期阶段,当我们的数据模型仍然容易变化时,这些品质特别有用。不过,这种灵活性是以运行时的大小和处理的开销为代价的。
JSON作为一种基于文本的格式,在处理非文本数据时,将所有的值编码为UTF-8,从而导致大小开销。它是无模式的,这意味着我们必须将我们的数据模型的结构(如对象键)与数据一起编码。我们在处理时也做了额外的工作,因为我们必须在编码前将数值转换为文本表示,并在解析为JSON前将二进制解码为文本。
诚然,对于一般的网络应用来说,这种开销不是问题;而且通过使用压缩和JavaScript引擎擅长解析JSON的事实,这种开销可以得到缓解。然而,在有些情况下,开销是有问题的,例如,压缩是低效的,可能会增加消息的大小,例如在收集遥测数据时交换小消息,在实时应用程序中交换数据,或发送通知。为了解决JSON的这些限制,我们可以采用二进制格式。
二进制格式
在已有的数据序列化格式¹中,有大量的二进制格式,具有各种属性。为了解决JSON的局限性,我们必须关注两个属性:a)它们是否基于模式,b)支持零拷贝操作。
无模式的二进制格式,如MessagePack或FlexBuffers,与JSON相比,尺寸有所减少。这些格式的主要优点是它们可以用最少的工作来替代JSON。然而,a)我们仍然在对数据的结构进行编码,因此,有很大的开销,b)我们不能进行零拷贝操作。使用基于模式的格式,如Protocol Buffers、FlatBuffers或Cap'n Proto,我们可以避免在消息中编码结构信息,尽管有些开销以偏移指针的形式存在。
这里的零拷贝操作是指我们能够在不复制数据或解码整个消息的情况下查询消息的部分内容。例如,在服务器上,我们可以先检查一个请求的重要部分,而不需要解析整个请求主体,在客户端--解析和渲染大型响应的部分,以尽量减少我们的FCP²时间。这意味着在某些情况下,处理时间可以减少几个数量级。在数据序列化格式中,Cap'n Proto和FlatBuffers支持零拷贝操作,而Protocol Buffers、JSON和无模式格式则不支持。然而,Cap'n Proto和FlatBuffers都优先考虑内存访问速度,而不是消息大小,从而导致数据对齐所用的填充物造成的大小开销。
带视图的原始缓冲区
为了实现零拷贝访问的最小尺寸,我们可以采用所谓的原始缓冲区。例如,为了对一个JavaScript对象进行编码,我们可以计算它的每个字段所需的大小,按顺序排列以得出布局模式,并使用它来对ArrayBuffer中的字段进行编码。得到的缓冲区有 "原始 "数据,没有任何关于其结构的信息。我们可以对它进行整体解码,或者使用布局模式访问单个字段,因为它是所有类型的对象所共有的。我们将需要对可选的和可变长度的字段使用指针,但开销仍然大大低于键编码或数据对齐的费用。
Structurae的View接口正是这样做的:给定一个对象(或数组,或任何支持的类型)的JSON Schema,View将计算布局模式,将其存储为一个原始缓冲区,并创建一个类来处理扩展DataView的缓冲区。
View使用JSON Schema进行模式定义。除了为开发者所熟悉之外,使用JSON模式还可以在其他工具中重用模式,如JSON验证器,作为单一真理的来源。模式和所有派生类都是强类型的,以利用IDE中的类型提示和智能感应。与其他流行的基于模式的格式不同,View不需要预编译--布局是在初始化时计算一次。总的来说,View旨在很好地适应以JSON为中心的架构和现代Web应用程序的开发工作流程。
需要注意的是,View的目标不是要在整个全栈JavaScript应用程序中取代JSON。而是在应用程序的某些部分实现性能最大化,这些部分可以从大幅减少的消息大小中受益,并通过使用零拷贝操作避免额外的解析步骤。我们可以在所有JSON不适用的情况下使用它:从二进制编码中受益的数字密集型数据,实时应用程序或工作进程之间的高速消息交换,或用于验证的消息的部分处理。
一个全栈实例
为了展示View结构的作用,我们将使用一个熟悉的留言板例子;这不是使用二进制的最好例子,但更合适的例子是特定领域的,很难遵循。
想象一下,你正在为你当地的 "节制联盟 "分会维护一个留言板。让我们来定义我们的消息结构。
现在,在客户端,我们可以使用MessageView 类对我们的消息进行编码。由于它是一个DataView,我们可以直接使用Fetch API将其作为一个请求体发送。
在服务器上,例如使用Express.js的Node,我们可以将消息作为一个缓冲区来接收,并对其进行操作,而无需解析或复制请求体。
例如,现在我们可以进行访问控制,检查消息作者是否可以在线程中发帖,并拒绝未经授权的请求,而无需解析整个请求正文。
从缓冲区中读取一个数字比解析JSON要快好几个数量级,更不用说从长远来看减少了GC的负载。
在这个例子中,我们可以走得更远。视图使用了一个特殊的类StringView来处理UTF-8编码的字符串,该类扩展了DataView的几个字符串相关的方法,可以对编码的字符串进行操作,而不用将其解码为JavaScript字符串。假设我们决定引入一点审查制度,阻止含有 "zer b-vord "的信息。
通过view.getView ,我们在缓冲区的一部分上实例化一个DataView(这里是一个StringView),而不对这部分进行解码,然后使用一个方法在编码数据里面搜索。同样,我们在速度和GC压力方面都节省了对无效请求的解析。
最后,我们可以将我们的消息解码成一个JavaScript对象。在本例中,由于我们在创建视图类时提供了构造器类,所以变成了一个BoardMessage 实例。
视图还利用了JavaScript引擎使用的隐藏类优化⁴:视图不是创建一个空的对象{} ,然后用解码后的字段填充,而是使用一个提供的构造函数(如上面的例子),或者为这样的构造函数生成代码,这样每个新对象的实例化都有相同的字段数量和顺序。这导致了对对象的快速操作,以及减少GC压力。
因此,通过减少消息大小、零拷贝零解析检查和序列化优化,我们已经高度改进了我们的板子的消息处理能力。现在就让那些新来的狼人尝试着给我们发垃圾邮件吧!