RPC 通信协议之 Protobufs 在 Node.js 中的应用

1,845 阅读4分钟

Protobuf 是一种用于 序列化反序列化 对象的格式规范(rpc 通信协议)。其他的序列化方式还有 hessian、thrift 等等。它们与 JSON 类似,又有些不同。下面介绍在 Node.js 中如何使用 protobufs,以及如何在 Express 中使用它。

注意❗️ RPC 可以直接建立在 tcp 之上,也可以建立在 http 协议之上。对于 rpc 来说,这都是一样的,都是把通讯的内容塞进报文里。

其实 http 是最常用的承载 RPC 的通信协议之一。而且我们可以在 http 上传输 xml 和 json 这样的文本协议,也可以是 protobuf 和 thrift 这样的二进制协议,这都不是问题。大家常用的 REST api 就可以很好的封装成 rpc。当然,http 这种协议是笨重一些,但它的穿透性比较好,配套的设施也齐全,也比较简单直观,还是比较收欢迎的。比如说著名的 grpc 就通过 http 来传输。

Protobuf 介绍

Protobuf 与 非结构化格式(如 JSON、XML)最大的区别在于,你必须为 protobufs 定义数据类型,最常用的方式是定义 .proto 文件。下面是 .proto 文件的示例,它将 User 定义为具有两个属性的对象:name 字符串;age 整型。

package userpackage;
syntax = "proto3";

message User {
  string name = 1;
  int32 age = 2;
}

npm 中有一些处理 protobuf 的包,其中下载量最多的是 protobufjs。所以,我们在本文中用它来演示。首先,导入 protobufjs 并且调用 load() 函数来告诉 protobufjs 加载 user.proto 文件:

const protobuf = require('protobufjs');

run().catch(err => console.log(err));

async function run() {
  const root = await protobuf.load('user.proto');

  const User = root.lookupType('userpackage.User');
}

现在,protobufjs 知道了 User 类型, 你可以使用 User 来验证或序列化对象。下面是调用 User.verify() 函数来验证对象的 nameage 属性类型是否正确:

console.log(User.verify({ name: 'test', age: 2 })); // null
console.log(User.verify({ propertyDoesntExist: 'test' })); // null
console.log(User.verify({ age: 'not a number' })); // "age: integer expected"

注意,protobuf 定义的属性默认非必填。所以上面例子中,对于没有定义 name 属性的对象 verify() 并没有报错。

Encoding and Decoding

虽然 protobufs 可以做基本的数据验证,但它最主要用于 序列化反序列化 对象。序列化对象,你可以调用 User.encode(obj).finish(), 它会返回一个包含该对象 protobuf 表示的 buffer

const buf = User.encode({ name: 'Bill', age: 30 }).finish();

console.log(Buffer.isBuffer(buf)); // true
console.log(buf.toString('utf8')); // Gnarly string that contains "Bill"
console.log(buf.toString('hex')); // 0a0442696c6c101e

反序列化对象,你可以调用 decode() 函数:

const buf = User.encode({ name: 'Bill', age: 30 }).finish();

const obj = User.decode(buf);
console.log(obj); // User { name: 'Bill', age: 30 }

Protobufs 通常用于在网络中传输数据,作为 JSON / XML 的替代方案。原因之一是序列化的 protobuf 体积要小得多,因为它不需要包含属性名。例如,下面展示了分别使用 protobuf 和 JSON 来表示数据,在大小上的差异:

const asProtobuf = User.encode({ name: 'Joe', age: 27 }).finish();
const asJSON = JSON.stringify({ name: 'Joe', age: 27 });

asProtobuf.length; // 7
asJSON.length; // 23, 3x bigger!

由于 protobuf 提前在 .proto 文件中知道了对象的 keys,所以 protobufs 比 JSON 更节省空间。虽然你可以使用 Mongoose aliases 等模式来缩小 JSON 的键名长度,但你永远不可能缩到和 protobufs 一样小,因为 protobufs 根本不序列化键名!

HTTP 发送 Protobufs

将数据存储在文件中或通过网络发送,protobufs 才会有价值。因此,让我们看看如何使用 protobufs 来处理 HTTP 请求和响应。下面是使用 Axios 向 Express 服务器发送 protobufs 的示例:

const axios = require('axios');
const express = require('express');
const protobuf = require('protobufjs');

const app = express();

run().catch(err => console.log(err));

async function run() {
  const root = await protobuf.load('user.proto');

  const doc = { name: 'Bill', age: 30 };
  const User = root.lookupType('userpackage.User');

  app.get('/user', function(req, res) {
    res.send(User.encode(doc).finish());
  });

  app.post('/user', express.text({ type: '*/*' }), function(req, res) {
    // Assume `req.body` contains the protobuf as a utf8-encoded string
    const user = User.decode(Buffer.from(req.body));
    Object.assign(doc, user);
    res.end();
  });

    await app.listen(3000);

  let data = await axios.get('http://localhost:3000/user').then(res => res.data);

  // "Before POST User { name: 'Bill', age: 30 }"
  console.log('Before POST', User.decode(Buffer.from(data)));
  const postBody = User.encode({ name: 'Joe', age: 27 }).finish();
  await axios.post('http://localhost:3000/user', postBody).
    then(res => res.data);

  data = await axios.get('http://localhost:3000/user').then(res => res.data);
  // "After POST User { name: 'Joe', age: 27 }"
  console.log('After POST', User.decode(Buffer.from(data)));
} 

总结

Protobufs 是通过网络传输数据的一种不错的选择,尤其是与 grpc 结合在一起使用。protobufs 提供了最小的类型检查机制,因为 decode() 会在类型异常时报错,并且 protobufs 比 JSON 处理的数据更小!然而,缺点是 protobuf 不是人类可读的(不像 JSON 或 XML 那样的文本格式),而且没有 .proto 文件就无法解码数据。protobufs 更难调试,也更难使用,但如果你非常关心通过网络发送的数据大小,那么 protobufs 就非常有用!

你也可以在 Github 上找到本文的示例代码。

参考