八、数据格式——查看 JSON 之外的不同数据类型

我们几乎已经完成了关于服务器端 JavaScript 的讨论。 有一个话题似乎不太引人注目,但在与其他系统交互或甚至提高速度时却经常出现,那就是以不同的格式传输数据。 最常见(如果不是最常见的话)的格式之一是 JSON。 JSON 是最容易使用的数据格式之一,特别是在 JavaScript 中。

在 JavaScript 中,我们不必担心与类不匹配的 JSON 对象。 如果我们使用的是强类型语言,如 Java(或使用它的 TypeScript),我们必须担心以下事情:

  • 创建一个模拟 JSON 对象格式的类。
  • 根据嵌套对象的数量创建一个保持嵌套的映射结构。
  • 基于我们得到的 JSON 创建动态类。

这些都不是很困难,但当我们与用这些语言编写的系统进行交互时,它可以增加速度和复杂性。 对于其他数据格式,我们可能会获得一些主要的速度优势; 不仅可能来自较小的数据传输,而且来自能够更容易地解析对象的其他语言。 当我们转移到基于模式的数据格式时,还有更多的好处,比如版本控制,它可以使向后兼容更容易。

考虑到所有这些,让我们来看看 JSON,看看它的一些好处,以及利用它所带来的损失。 在此基础上,我们将研究一种新的自定义格式,我们将为我们的服务创建这种格式,以便以更小的尺寸传输数据。 在此之后,我们将研究无模式数据格式,如 JSON,最后,我们将研究基于模式的格式。

这一章可能比几乎所有其他章节都要轻,但它将证明在开发企业应用或与它们交互时是有用的。

本章涉及的主题如下:

  • 使用 JSON
  • 在 JSON 编码
  • 在 JSON 解码
  • 查看数据格式

在 TypeScript 中,如果我们想的话,我们可以只使用any类型,但这在一定程度上违背了 TypeScript 的目的。 虽然我们不会在本书中看到 TypeScript,但知道它的存在是很好的,而且很容易看到开发人员在开发后端应用时如何碰到它。

技术要求

完成本章需要的工具如下:

使用 JSON

如前所述,JSON 提供了易于使用和操作的接口,用于在服务之间发送和接收消息。 对于那些不知道的人,JSON 代表JavaScript 对象表示法,这是它与 JavaScript 接口如此之好的原因之一。 它模仿了 JavaScript 对象的很多行为,除了一些基本类型(比如函数)。 这也使得它很容易解析。 我们可以使用内置的JSON.parse函数将 JSON 的字符串化版本转换为对象,或者使用JSON.stringify将其中一个对象转换为其有线格式。

那么使用 JSON 有哪些缺点呢? 我们首先遇到的问题是,当通过网络发送数据时,格式会变得非常冗长。 考虑一个具有以下格式的对象数组:

{
    "name" : "Bob",
    "birth" : "01/02/1993",
    "address" : {
        "zipcode" : 11111,
        "street" : "avenue of av.",
        "streetnumber" : 123,
        "state" : "CA",
        "country" : "US"
    },
    "contact" : {
        "primary" : "111-222-3333",
        "secondary" : "444-555-6666",
        "email" : "bob@example.com"
    }
}

对于使用过联系人表单或客户信息的人来说,这可能是常见的情况。 现在,虽然我们应该对一个网站进行某种形式的分页,但我们仍然可能一次抓取100甚至500。 这很容易导致巨大的电汇成本。 我们可以用下面的代码来模拟:

const send = new Array(100);
send.fill(json);
console.log('size of over the wire buffer is: ',
 Buffer.from(JSON.stringify(send)).byteLength);

通过使用这个方法,我们可以得到正在发送的数据的字符串化100项所产生的缓冲区的字节长度。 我们将看到,它产生了大约 22 KB 的数据。 如果我们将其提高到500,我们可以推断出它大约有 110 KB 的数据。 虽然这看起来可能不是很多数据,但我们可以看到这种类型的数据被发送到智能手机,我们想要限制我们传输的数据量,以避免耗尽电池。

我们没有过多地讨论移动电话和我们的应用,特别是在前端,但这是我们需要越来越意识到的事情,因为我们正变得越来越远的商业世界。 许多用户,即使没有手机版本的应用,仍然会尝试使用它。 一个个人轶事是,由于一些功能在移动版本的应用中不可用,所以使用了桌面应用的电子邮件服务。 我们总是需要注意传输的数据量,但移动设备已经让这一想法成为主要目标。

解决这个问题的一种方法是利用某种类型的压缩/解压格式。 一个相当知名的格式是gzip。 这种格式速度非常快,数据质量没有损失(一些压缩格式就有这种特性,比如 JPEG),并且在网页中无处不在。

让我们继续使用 Node.js 中的zlib模块,gzip该数据。 下面的代码展示了一个易于使用的gzip方法在zlib内,并展示了原始版本和 gzip 版本之间的大小差异:

gzipSync(Buffer.from(JSON.stringify(send))).byteLength

现在我们将看到 gzip 版本只有 301 字节,而对于 500 长度的数组,我们看到的 gzip 版本大约有 645 字节。 真是省了不少钱! 然而,这里有两点需要记住。 首先,我们对数组中的每一项使用完全相同的对象。 压缩算法是基于模式的,所以一遍又一遍地看到完全相同的对象会给我们一种被压缩的原始形式的错误感觉。 这并不意味着这不能说明未压缩和压缩数据之间的大小差异,但在测试各种格式时要记住这一点。 基于不同的站点,我们将看到 4-10 倍于原始数据的压缩比(这意味着如果原始数据是 1 MB,我们将看到从 250 KB 到 100 KB 的压缩大小)。

我们可以自己创建一种格式,以更紧凑的方式表示数据,而不是利用 JSON。 首先,我们将只支持三种项类型:整数、浮点数和字符串。 其次,我们将在消息头中存储所有的键。

模式可以被最好地描述为传入数据的定义。 这意味着我们将知道如何解释传入的数据,而不必寻找特殊的编码符号来告诉我们有效负载的结束时间(即使我们的格式将使用 body 结束信号)。

我们的模式如下所示:

  1. 我们将为消息的头和正文使用包装器字节。 标题用0x10字节表示,正文用0x11字节表示。
  2. 我们将支持以下类型,它们的转换看起来类似如下:

  3. 整数:0x01后面跟着一个 32 位整数

  4. 浮点数:0x02后面跟着一个 32 位整数
  5. 字符串:0x03后面跟着字符串的长度,后面跟着数据

这应该足以理解数据格式的概念,以及它们与仅仅编码和解码 JSON 有何不同。 在接下来的两节中,我们将看到如何利用流实现编码器和解码器。

实现编码器

我们将利用转换流实现编码器和解码器。 这将给我们在实际实现流方面提供最大的灵活性,而且它已经有很多我们需要的行为,因为我们在技术上转换数据。 首先,我们需要一些通用的帮助程序来编码和解码特定的数据类型,我们将把所有这些方法放在一个helpers.js帮助程序文件中。 编码函数如下所示:

export const encodeString = function(str) {
    const buf = Buffer.from(str);
    const len = Buffer.alloc(4);
    len.writeUInt32BE(buf.byteLength);
    return Buffer.concat([Buffer.from([0x03]), len, buf]);
}
export const encodeNumber = function(num) {
    const type = Math.round(num) === num ? 0x01 : 0x02;
    const buf = Buffer.alloc(4);
    buf.writeInt32BE(num);
    return Buffer.concat([Buffer.from([type]), buf]); 
}

对字符串进行编码将接收字符串并输出保存解码器要处理的信息的缓冲区。 首先,我们将把字符串改为Buffer格式。 接下来,我们创建一个缓冲区来保存字符串的长度。 然后,我们使用writeUInt32BE方法存储缓冲区的长度。

For those that do not know byte/bit conversions, 8 bits of information (a bit is either a 1 or 0- the lowest form of data we can supply) makes up 1 byte. A 32-bit integer, what we are trying to write, is then made up of 4 bytes (32/8). The U portion of that method means it is unsigned. Unsigned means we only want positive numbers (lengths can only be 0 or positive in our case). With this information, we can see why we allocated 4 bytes for this operation and why we are utilizing this specific method. For more information on both the write/read portions for buffers, go to https://nodejs.org/api/buffer.html as it explains in depth the buffer operations we have access to. We will only explain the operations that we will be utilizing.

一旦我们将字符串转换为缓冲区格式和字符串的长度,我们将写出一个以type作为第一个字节的缓冲区,在我们的例子中是0x03字节; 字符串的长度,这样我们就知道传入缓冲区中有多少是字符串; 最后是弦本身。 这应该是两个助手方法中最复杂的一个,但从解码的角度来看,它应该是有意义的。 当我们读取缓冲区时,我们不知道一个字符串会有多长。 因此,我们需要这种类型的前缀中的一些信息,以知道实际要读取多少。 在我们的例子中,0x03告诉我们该类型是一个字符串,我们知道,根据我们之前建立的数据类型协议,接下来的 4 个字节将是字符串的长度。 最后,我们可以使用这个信息提前读取缓冲区中的字符串,获取字符串并将其解码回字符串。

encodeNumber方法更容易理解。 首先,我们检查四舍五入是否等于这个数本身。 如果是,那么我们知道我们处理的是一个整数,否则,我们将其视为一个浮点数。 对于那些没有意识到,在大多数情况下,知道这些信息并不重要太多在 JavaScript 中(尽管有一些优化的 V8 引擎利用当它知道,它是处理整数),但是如果我们想要用这个数据格式与其他语言,那么这种差异很重要。

接下来,我们分配了 4 个字节,因为我们只打算写出 32 位有符号整数。 签署意味着他们将支持正面和负面的数字(再一次,我们不会进入两者之间的巨大差异,但对于那些好奇,我们实际上限制最大值可以存储在这里,如果我们利用整数签署以来我们利用的一位告诉我们是否数量是负数)。 然后我们写出最后的缓冲区,其中包含我们的类型和缓冲区格式的数字。

现在,使用帮助器方法和helper.js文件中的以下常量,按如下步骤进行:

export const CONSTANTS = {
    object : 0x04,
    number : 0x01,
    floating : 0x02,
    string : 0x03,
    header : 0x10,
    body : 0x11
}

我们可以创建encoder.js文件:

  1. 导入必要的依赖项,并创建SimpleSchemaWriter类的外壳:
import { Transform } from 'stream';
import { encodeString, encodeNumber } from './helper.js';

export default class SimpleSchemaWriter extends Transform {
}
  1. 创建构造函数,并确保objectMode总是打开的:
// inside our SimpleSchemaWriter class
constructor(opts={}) {
    opts.writableObjectMode = true;
    super(opts);
}
  1. 添加一个私有的#encode帮助函数,它将为我们做底层的数据检查和转换:
// inside of our SimpleSchemaWriter class
#encode = function(data) {
    return typeof data === 'string' ?
            encodeString(data) :
            typeof data === 'number' ?
            encodeNumber(data) :
            null;
}
  1. 为我们的Transform流编写主要的_transform函数。 详情如下:
_transform(chunk, encoding, callback) {
    const buf = [];
    buf.push(Buffer.from([0x10]));
    for(const key of Object.keys(chunk)) { 
        const item = this.#encode(key);
        if(item === null) {
            return callback(new Error("Unable to parse!"))
        }
        buf.push(item);
    }
    buf.push(Buffer.from([0x10])); 
    buf.push(Buffer.from([0x11]));
    for(const val of Object.values(chunk)) { 
        const item = this.#encode(val);
        if(item === null) {
            return callback(new Error("Unable to parse!"))
        }
        buf.push(item);
    }
    buf.push(Buffer.from([0x11]));
    this.push(Buffer.concat(buf)); 
    callback();
}

总的来说,transform函数看起来应该与我们之前实现的_transform方法相似,但有一些例外:

  1. 编码的第一部分是包装头文件(对象的键)。 这意味着我们需要写出头文件的描述符,即0x10字节。

  2. 我们将遍历对象的所有键。 从这里开始,我们将使用private方法,encode。 这个方法将检查键的数据类型,并使用我们前面讨论过的一个帮助器方法返回编码。 如果它没有得到它能理解的类型,它将返回null。 我们将返回一个Error,因为我们的数据协议不理解类型。

  3. 一旦我们遍历了所有的键,我们将再次写出0x10字节,说明我们已经完成了头文件,并写出0x11字节告诉解码器我们从消息体开始。 (我们可以在这里使用helpers.js文件中的常量,我们可能也应该这样做,但这应该有助于理解底层协议。 解码器将利用这些常量来展示更好的编程实践。)
  4. 现在,我们将遍历对象的值,并通过与处理头文件相同的编码系统运行它们,如果不理解数据类型,还将返回一个Error
  5. 一旦我们完成了 body,我们将再次推0x11字节,表示我们完成了 body。 这将是给解码器的信号,它将停止转换这个对象,并将它已经转换的对象发送出去。 然后,我们将把所有这些数据推入Transform流的Readable部分,并使用回调函数表示我们已经准备好处理更多数据。

有一些问题我们编码方案的总体结构(我们不应该为我们的包装,因为他们可以很容易地使用单一字节被误解我们的编码器和译码器),我们应该支持更多的数据类型,但这应该给一个很好的理解,如何建立更广泛使用的编码器的数据格式。

现在,我们将无法测试它,除了它吐出正确的编码,但一旦我们有解码器,并运行,我们将能够测试,看看我们是否得到相同的对象在两边。 现在让我们看看这个系统的解码器。

实现译码器

解码器比编码器有更多的状态,这通常是真实的数据格式。 在处理原始字节时,试图解析其中的信息通常比将数据以原始格式输出要困难得多。

让我们看看我们将使用的帮助器方法来解码我们支持的数据类型:

import { CONSTANTS } from './helper.js';

export const decodeString = function(buf) {
    if(buf[0] !== CONSTANTS.string) {
        return false;
    }
    const len = buf.readUInt32BE(1);
    return buf.slice(5, 5 + len).toString('utf8');
}
export const decodeNumber = function(buf) {
    return buf.readInt32BE(1);
}

decodeString方法展示了我们如何在数据格式不正确的情况下处理错误,decodeNumber方法没有展示这一点。 对于decodeString方法,我们需要从缓冲区中获取字符串的长度,我们知道这是将要传入的缓冲区的第二个字节。 基于此,我们知道可以从缓冲区中的第 5 个字节开始获取字符串(第一个字节告诉我们这是一个字符串; 接下来的四个是绳子的长度),我们抓住所有东西,直到我们得到绳子的长度。 然后我们通过toString方法运行这个缓冲区。

decodeNumber是相当简单的,因为我们只需要读取第一个字节后的 4 个字节,告诉我们它是一个数字(再次,我们应该在这里做一个检查,但我们保持它的简单)。 这展示了我们解码所支持的数据类型所需要的两个主要帮助器方法。 接下来,我们将看一下实际的解码器。 它看起来就像下面这样。

如前所述,解码过程稍微复杂一些。 这有以下几个原因:

  • 我们直接在字节上工作,所以我们必须做相当多的处理。
  • 我们处理的是标题和正文部分。 如果我们创建了一个非基于模式的系统,那么我们可以在没有太多状态的情况下编写解码器。
  • 同样,由于我们是直接处理缓冲区,所有的数据可能不会一次进入,所以我们需要处理这种情况。 编码器不需要担心这个问题,因为我们在对象模式下操作可写流。

记住这一点,让我们运行解码流:

  1. 我们将设置我们的解码流与所有相同的类型的设置,我们已经做了Transform流在过去。 当我们通过解码器时,我们将设置一些私有变量来跟踪状态:
import { Transform } from 'stream'
import { decodeString, decodeNumber, CONSTANTS } from './helper.js'

export default class SimpleSchemaReader extends Transform {
    #obj = {}
    #inHeaders = false
    #inBody = false
    #keys = []
    #currKey = 0
}
  1. 接下来,我们将在整个解码过程中使用索引。 我们不能一次简单地读取一个字节,因为解码过程以不同的速度通过缓冲区运行(当我们读取一个数字时,我们正在读取 5 个字节; 当我们读取一个字符串时,我们至少读取 6 个字节)。 正因如此,while循环会更好:
#decode = function(chunk, index, type='headers') { 
        const item = chunk[index] === CONSTANTS.string ?
            decodeString(chunk.slice(index)) :
            decodeNumber(chunk.slice(index, index + 5));

        if( type === 'headers' ) {
            this.#obj[item] = null;
        } else {
            this.#obj[this.#keys[this.#currKey]] = item;
        }
        return chunk[index] === CONSTANTS.string ?
            index + item.length + 5 :
            index + 5;
    }
    constructor(opts={}) {
        opts.readableObjectMode = true;
        super(opts);
    }
    _transform(chunk, encoding, callback) {
        let index = 0; //1
        while(index <= chunk.byteLength ) {
        }
    }
  1. 现在,我们对当前字节进行检查,以确定它是标题标记还是正文描述标记。 这将让我们知道我们是在处理对象键还是对象值。 如果我们检测到headers标志,我们将设置#inHeaders布尔值,说明我们在头文件中。 如果我们在身体里,我们有更多的工作要做:
// in the while loop
const byte = chunk[index];
if( byte === CONSTANTS.header ) { 
    this.#inHeaders = !this.#inHeaders
    index += 1;
    continue;
} else if( byte === CONSTANTS.body ) { 
    this.#inBody = !this.#inBody
    if(!this.#inBody ) { 
        this.push(this.#obj);
        this.#obj = {};
        this.#keys = [];
        this.#currKey = 0;
        return callback();
    } else {
        this.#keys = Object.keys(this.#obj); 
    }
    index += 1;
    continue;
}
if( this.#inHeaders ) { 
    index = this.#decode(chunk, index);
} else if( this.#inBody ) {
    index = this.#decode(chunk, index, 'body');
    this.#currKey += 1;
} else {
    callback(new Error("Unknown state!"));
}
  1. 接下来,下面的段落将解释获取每个 JSON 对象的头和值的过程。

首先,我们将修改 body 的布尔值,使其与当前的值相反。 接下来,如果我们从身体内部到身体外部,这意味着我们已经完成了这个对象。 因此,我们可以推出当前正在处理的对象,并重置所有内部状态变量(临时对象,#obj; 我们从头文件中获得的#keys的临时集合; 以及#currKey来知道我们在身体里工作的是哪一个键)。 一旦我们有了这个,我们可以运行回调(我们在这里返回,所以我们不会运行更多的主体)。 如果我们不这样做,我们将继续循环,我们将处于糟糕的状态。

否则,我们已经遍历了有效负载的头部,并且已经达到了每个对象的值。 我们将把我们的 private#keys变量设置为对象的键(因为,此时,头文件应该已经从头文件中获取了所有的键)。 现在我们可以看到解码过程了。

如果我们在头文件中,我们将运行我们的 private#decode方法,而不使用第三个参数,因为默认情况下是运行方法,就像我们在头文件中一样。 否则,我们将像在主体中一样运行它,并传递第三个参数来表示我们在主体中。 此外,如果我们在主体中,我们将增加#currKey变量。

最后,我们来看看解码过程的核心——#decode方法。 我们根据缓冲区中的第一个字节获取项目,这将告诉我们应该运行哪个解码助手方法。 然后,如果我们在 header 模式下运行这个方法,我们将为临时对象设置一个新键,并将其值设置为 null,因为一旦我们到达 body,它将被填充。 如果处于 body 模式,则在进入 body 模式后,将设置与正在循环的#keys数组中的#currKey索引对应的键值。

有了这些代码解释,正在发生的基本过程可以总结为几个基本步骤:

  1. 我们需要遍历头文件并将对象的键值设置为这些值。 我们暂时将这些键的值设置为 null,因为稍后将填充它们。
  2. 一旦我们从 header 部分移到 body 部分,我们就可以从临时对象中获取所有的键,此时我们所做的解码运行应该与数组中当前键的下标对应。
  3. 一旦我们离开主体,我们就重置状态的所有临时变量,并发送相应的对象,因为我们已经完成了解码过程。

这可能看起来令人困惑,但我们所做的只是将头部与 body 元素对齐在同一索引处。 如果我们想把一个键和值数组放在一起,它将类似于下面的代码:

const keys = ['item1', 'item2', 'item3'];
const values = [1, 'what', 2.2];
const tempObj = {};
for(let i = 0; i < keys.length; i++) {
    tempObj[keys[i]] = null;
}
for(let i = 0; i < values.length; i++) {
    tempObj[keys[i]] = values[i];
}

这段代码几乎与我们使用前面缓冲区所做的完全相同,除了我们必须使用原始字节而不是字符串、数组和对象等高级项之外。

解码器和编码器都完成后,我们现在可以通过编码器和解码器运行一个对象,看看是否得到相同的值。 让我们运行以下测试工具代码:

import encoder from './encoder.js'
import decoder from './decoder.js'
import json from './test.json'

const enc = new encoder();
const dec = new decoder();
enc.pipe(dec);
dec.on('data', (obj) => {
    console.log(obj);
});
enc.write(json);

我们将使用以下测试对象:

{
    "item1" : "item",
    "item2" : 12,
    "item3" : 3.3
}

我们将看到,当我们将数据通过编码器输送到解码器时,我们将输出相同的对象。 现在,我们创建了自己的编码和解码方案,这很好,但与 JSON 相比,它如何保持传输大小(因为我们试图做得更好,而不仅仅是字符串化和解析)? 有了这个有效载荷,我们实际上是在增加尺寸! 如果我们仔细想想,这是有道理的。 我们必须添加所有特殊的编码项(数据之外的所有信息,如0x100x11字节),但是现在我们开始添加更多的数字项到我们的列表中,这些数字项相当大。 我们将看到我们开始击败基本的JSON.stringifyJSON.parse:

{
    "item1" : "item",
    "item2" : 120000000,
    "item3" : 3.3,
    "item4" : 120000000,
    "item5" : 120000000,
    "item6" : 120000000
}

这是因为字符串化的数字被转换成字符串版本的数字,所以当我们得到大于 5 字节的数字时,我们开始节省字节(1 字节用于数据类型,4 字节用于 32 位数字编码)。 对于字符串,我们永远不会看到节省,因为我们总是添加额外的 5 字节的信息(1 字节为数据类型,4 字节为字符串长度)。

在大多数编码和解码方案中,都是这样。 它们处理数据的方式取决于所传递的数据类型。 在我们的例子中,如果我们通过网络发送大的、高数值的数据,我们的方案可能会工作得更好,但如果我们通过网络发送字符串,我们将不会从这种编码和解码方案中受益。 请记住这一点,因为我们将看到一些在野外大量使用的数据格式。

Remember, this encoding and decoding scheme is not meant to be used in actual environments as it is riddled with issues. However, it does showcase the underlying theme of building out data formats. While most of us will never have to build data formats, it is nice to understand what goes on when building them out, and where data formats may have to specialize their encoding and decoding schemes based on the type of data that they are primarily working with.

查看数据格式

现在我们已经了解了自己的数据格式,接下来让我们看看当前一些相当流行的数据格式。 本文不是对这些内容的详尽介绍,而是对数据格式的介绍,以及我们可能在野外发现的内容。

我们将看到的第一个数据格式是无模式格式。 如前所述,基于模式的格式要么提前发送数据的模式,要么随数据本身发送模式。 这通常允许以更紧凑的形式传入数据,同时也确保两个端点在接收数据的方式上达成一致。 另一种形式是无模式的,我们以新形式发送数据,但解码数据的所有信息都是通过规范完成的。

JSON 就是其中一种格式。 当我们发送 JSON 时,我们必须对它进行编码,然后在另一边解码。 另一种无模式数据格式是 XML。 对于 web 开发人员来说,这两种情况都应该很熟悉,因为我们广泛地使用 JSON,并且在组合前端(HTML)时使用 XML 形式。

另一种流行的格式是MessagePack(https://msgpack.org/index.html)。 MessagePack是一种比 JSON 更小的有效负载的格式。 MessagePack的另一个优点是,有许多语言为它们编写了原生库。 我们将看一下 Node.js 版本,但请注意,它既可以用于前端(在浏览器中),也可以用于服务器。 所以让我们开始:

  1. 我们将使用以下命令npm install``what-the-pack扩展:
> npm install what-the-pack
  1. 一旦我们这样做了,我们就可以开始利用这个图书馆了。 通过下面的代码,我们可以看到通过网络使用这种数据格式是多么容易:
import MessagePack from 'what-the-pack';
import json from '../schema/test.json';

const { encode, decode } = MessagePack.initialize(2**22);
const encoded = encode(json);
const decoded = decode(encoded);
console.log(encoded.byteLength, Buffer.from(JSON.stringify(decoded)).byteLength);
console.log(encoded, decoded);

我们在这里看到的是在what-the-pack(https://www.npmjs.com/package/what-the-pack)页面上的示例的一个轻微修改版本。 导入包,然后初始化库。 这个库的一个不同之处在于,我们需要为编码和解码过程初始化一个缓冲区。 这就是2**22initialize方法中所做的。 我们正在初始化一个大小为 2 的 22 次方字节的缓冲区。 通过这种方式,它可以很容易地切片缓冲区并复制它,而无需昂贵的基于数组的操作。 敏锐的观察人士会注意到的另一件事是,该图书馆并非基于流媒体。 他们这样做很可能是为了在浏览器和 Node.js 之间兼容。 除了这些小问题,整个库的工作方式就像我们想象的那样。

第一个控制台日志向我们显示,编码的缓冲区比 JSON 版本少 5 个字节。 虽然这确实说明了库提供了更紧凑的形式,但应该注意的是,在某些情况下,MessagePack可能并不比相应的 JSON 小。 它也可能比内置的JSON.stringifyJSON.parse方法运行得慢。 记住,一切都是一种权衡。

有很多无模式的数据格式,每种格式都有自己的技巧,可以使编码/解码时间更快,并使通过网络传输的数据更小。 然而,当我们处理企业系统时,我们很可能会看到使用基于模式的数据格式。

有几种定义模式的方法,但在本例中,我们将使用 proto 文件格式:

  1. 让我们继续创建一个原型文件来模拟我们已有的test.json文件。 模式可能类似于以下内容:
package exampleProtobuf;
syntax = "proto3";

message TestData {
    string item1 = 1;
    int32  item2 = 2;
    float  item3 = 3;
}

我们在这里声明的是,这个名为TestData的消息将存在于一个名为exampleProtobuf的包中。 这个包主要用于对类似的项进行分组(这在 Java 和 c#等语言中被大量使用)。 语法告诉编码器和解码器我们将使用的协议是proto3。 这个协议还有其他版本,而这个是最新的稳定版本。

然后我们声明一个名为TestData的新消息,该消息有三个条目。 一个名为item1,类型为string,一个为整数item2,最后一个为浮点数item3。 我们还为他们提供了 id,因为这使得索引和自引用类型等事情更容易(也因为它是强制性的protobuf工作)。 我们不会详细介绍它的具体功能,但请注意它对编码和解码过程有帮助。

  1. 接下来,我们可以编写一些代码,使用它在代码中创建一个TestData对象,该对象可以专门处理这些消息。 这看起来像以下内容:
protobuf.load('test.proto', function(err, root) {
    if( err ) throw err;
    const TestTypeProto = 
     root.lookupType("exampleProtobuf.TestData");
    if( TestTypeProto.verify(json) ) {
        throw Error("Invalid type!");
    }
    const message2 = TestTypeProto.create(json);
    const buf2 = TestTypeProto.encode(message2).finish();
    const final2 = TestTypeProto.decode(buf2);
    console.log(buf2.byteLength, 
     Buffer.from(JSON.stringify(final2)).byteLength);
    console.log(buf2, final2);
});

注意,除了一些验证和创建过程外,这与我们看到的大多数代码相似。 首先,库需要读取我们拥有的原型文件,并确保它实际上是正确的。 接下来,我们根据命名空间和给它的名称创建对象。 现在,我们验证有效负载并从中创建一条消息。 然后通过特定于此数据类型的编码器运行它。 最后,我们解码消息并进行测试,以确保得到的数据与输入的数据相同。

从这个例子中应该注意到两件事。 首先,数据量非常小! 这是基于模式/protobuf 相对于无模式数据格式的一个优势。 因为我们提前知道应该是什么类型,所以我们不需要将该信息编码到消息本身。 其次,我们将看到浮点数没有返回为 3.3。 这是由于精度误差,这是我们应该注意的东西。

  1. 现在,如果我们不想像这样读入原始文件,我们可以在代码中构建如下消息:
const TestType = new protobuf.Type("TestType");
TestType.add(new protobuf.Field("item1", 1, "string"));
TestType.add(new protobuf.Field("item2", 2, "int32"));
TestType.add(new protobuf.Field("item3", 3, "float"));

这应该类似于我们在原型文件中创建的消息,但我们将遍历每一行以显示它是相同的protobuf对象。 在本例中,我们首先创建一个名为TestType的新类型(而不是TestData)。 接下来,我们添加三个字段,每个字段都有自己的标签、索引号和存储在其中的数据类型。 如果我们通过相同类型的验证,创建,编码,解码过程运行它,我们会得到与之前相同的结果。

虽然这并不是对不同数据格式的全面概述,但它应该有助于识别何时可能使用无模式(当我们不知道数据可能是什么样子时)以及何时使用模式(当在未知系统之间通信时或我们需要减少有效负载大小时)。

总结

虽然我们的大多数启动应用都是利用 JSON 在不同的服务器之间传递数据,甚至是应用的不同部分,但我们应该注意到我们可能不想使用它。 通过利用其他数据格式,我们可以确保我们的应用获得尽可能多的速度。

我们已经看到了构建我们自己的数据格式所需要的东西,然后我们又看了看当前其他流行的格式。 这应该是我们在 Node.js 中构建高性能服务器应用所需要的最后一条信息。 虽然我们将使用一些库来处理数据格式,但我们也应该注意到,我们实际上只使用了 Node.js 附带的普通库。

接下来,我们将看一个静态服务器缓存信息的实际示例。 从这里开始,我们将利用前面所有的概念来创建一个高可用性和快速的静态服务器。