Skip to content
zswang edited this page Mar 15, 2016 · 1 revision

使用 jpacks 处理二进制结构化数据


背景

随着 Web 技术的流行,JavaScript(以下简称 JS) 要处理数据类型也就变得越来越丰富。 仅处理文本数据(如:JSON、XML、YAML)已经不能满足更多市场需求。 现代 JS 引擎均支持 类型数组(typed arrays),它提供了一个更加高效的机制来存储原始二进制数据。 用 JS 在前后端生成 GIF 图片、ZIP 压缩包,解析 Word 文档、PDF 设计稿,这类功能变得越来越多。

jpacks 是什么?

jpacks 是一套 JS 处理二进制结构化数据组包解包的工具。

设计 jpacks 初衷是什么?

我们有一款社交产品,已经投入市场有三年,服务器是用 C++ 编写,客户端有 iOS 和 Android,服务架构、通信协议趋于稳定。 为扩大业务启动了 Web 端项目,前端功能接近 Native,后端则要兼容已有通信数据格式。 Web 实时通信用 WebSocket,评估成本后选用 NodeJS 为后端实现。

实现业务通信协议的时候,问题来了:

  1. 数据类型多。四十几套通信数据格式(包括请求和应答)
  2. 数据结构复杂。包括:C 语言结构、ProtoBuf 数据、还有 Gzip 数据、Int64 类型,结构中还有穿插和分支。

在调研已有 JS 处理二进制的开源项目后,并没有找到适合的,所以就自己造这个轮子。

设计过程

竞品调研

这个项目比较接近我们的需求

优势

  • 结构化的声明方式
var playerSchema = new _.Schema({
  id: _.type.uint16,
  name: _.type.string(16),
  hp: _.type.uint24,
  exp: _.type.uint32
  ...
});

不足

  • 数据类型缺少太多,不支持 Int64、浮点数、有无符号
  • 不支持 UTF-8 字符串编码
  • 测试用例不多
  • 不容易扩展,Gzip、ProtoBuf 就很难加入

一看这个项目有两年没有更新,就放弃选用。

找到这个项目比较偶然,因为是找 NodeJS 的 ProtoBuf 模块找到。 bytebufferlong(int64 处理)、protobufjs 作者都是 dcodeIO

bytebuffer 采用链式调用

var ByteBuffer = require("bytebuffer");
var bb = new ByteBuffer()
    .writeUint64("21447885544221100")
    .writeIString("Hello world!")
    .writeUTF8String("你好世界!")
    .flip();

优势

  • 数据类型丰富,支持 Int64、Float64、Float32
  • 支持 UTF-8、Base64 字符串编码
  • 高低字节序(Big and little endianness)
  • 文档、测试用例齐全
  • 具备实战,有多个开源项目依赖
  • 项目近一个月有更新

不足

  • 结构不容易复用;
  • 功能不容易扩展。

调研结论

c-struct 的声明方式和 bytebuffer 的丰富数据即是我想要的。

设计思路

抽象

我发现无论什么数据类型都离不开两个方法:组包(pack)和解包(unpack)

  • 组包 pack:将数据转换成二进制
  • 解包 unpack:将二进制转换成数据

所以就抽象出一个描述数据类型存储规则接口 Schema

interface SchemaInterface {
  /**
   * 组包
   * @param {Any} value 要转换为二进制的变量
   * @return {Array of Byte} 返回该变量二进制数据,即:一段 Byte 数组
   */
  public function pack(value) {}
  /**
   * 组包
   * @param {Array of Byte} buffer 二进制数据
   * @return {Any} 返回该二进制标示的类型数据
   */
  public function unpack(buffer) {}
}

举个 bool 类型(16 位)的例子:

var bool16Schema = {
  function pack(value) {
    return value ? [255, 255] : [0, 0];
  }
  public function unpack(buffer) {
    return String(buffer) !== '0,0';
  }
}

数值类型

为了处理速度,我们得尽量使用 JS 引擎提供的标准接口 DataView 就能处理标准数值类型及其数组。

var buffer = new ArrayBuffer(16);
var dv = new DataView(buffer, 0);

dv.setInt16(1, 42);
dv.getInt16(1); //42

标准数值类型如下

Name DataView Type Size Alias Typed Array
int8 Int8 1 shortint Int8Array
uint8 Uint8 1 byte Uint8Array
int16 Int16 2 smallint Int16Array
uint16 Uint16 2 word Uint16Array
int32 Int32 4 longint Int32Array
uint32 Uint32 4 longword Uint32Array
float32 Float32 4 single Float32Array
float64 Float64 8 double Float64Array

字符串

好在现在 utf-8 大行天下,不用考虑兼容 gb2312 的问题 NodeJS 环境 Buffer 类自带字符集的处理,比较好处理

new Buffer(value, 'utf-8');

浏览器环境则麻烦一些,得用 encodeURIComponentescape 系列处理 字符集

function encodeUTF8(str) {
  if (/[\u0080-\uffff]/.test(str)) {
    return unescape(encodeURIComponent(str));
  }
  return str;
}

function decodeUTF8(str) {
  if (/[\u00c0-\u00df][\u0080-\u00bf]/.test(str) ||
    /[\u00e0-\u00ef][\u0080-\u00bf][\u0080-\u00bf]/.test(str)) {
    return decodeURIComponent(escape(str));
  }
  return str;
}

其他

接下来只要实现 结构(Struct)数组(Array) 两种重要的类型,基本 80% 的需求就能满足了。

结构类型实现代码:

function objectCreator(objectSchema) {
  var keys = Object.keys(objectSchema);
  return new Schema({
    unpack: function _unpack(buffer, options, offsets) {
      var result = new objectSchema.constructor();
      keys.forEach(function (key) {
        result[key] = Schema.unpack(objectSchema[key], buffer, options, offsets);
      });
      return result;
    },
    pack: function _pack(value, options, buffer) {
      keys.forEach(function (key) {
        Schema.pack(objectSchema[key], value[key], options, buffer);
      });
    }
  });
};

unpack()pack()options 参数,用来处理配置项,比如字节序(Endian) unpack()offsets,用来处理数据起始偏移位置,避免频繁分配内存空间

功能介绍

处理普通结构

这是最常见的数据类型,也很容易理解,对应着 JS 的 object 类型。

C 类型定义

#define DEF_NICK_NAME_LEN = 50

struct STRU_USER_BASE_INFO {
  int64     miUserID; // 用户 ID
  char      mszNickName[DEF_NICK_NAME_LEN + 1]; // 昵称 // utf-8
}

img

var _ = require('jpacks');
require('jpacks/schemas-extend/bigint')(_); // 引入 int64 扩展

_.def('STRU_USER_BASE_INFO', { // 定义 STRU_USER_BASE_INFO 结构
    miUserID: _.int64,
    mszNickName: _.smallString
});

var buffer = _.pack('STRU_USER_BASE_INFO', { // 将变量用 STRU_USER_BASE_INFO 类型组包
    miUserID: '20160315005',
    mszNickName: 'zswang'
});

console.log(new Buffer(buffer).toString('hex').replace(/..(?!$)/g, '$& '));

7d fe a5 b1 04 00 00 00 06 00 7a 73 77 61 6e 67

处理递归结构类型

如果声明中的类型是字符串,只有在执行组包和解包函数时才会去实例化。 利用这一个特性就声明出递归结构类型。

var _ = require('jpacks');

_.def('User', {
  age: 'uint8',
  token: _.array('byte', 10),
  name: _.shortString,
  note: _.longString,
  contacts: _.shortArray('User')
});

var user = {
  age: 6,
  token: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
  name: 'ss',
  note: '你好世界!Hello World!',
  contacts: [{
    age: 10,
    token: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
    name: 'nn',
    note: '风一样的孩子!The wind of the children!',
    contacts: [{
      age: 12,
      token: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
      name: 'zz',
      note: '圣斗士星矢!Saint Seiya!',
      contacts: []
    }]
  }, {
    age: 8,
    token: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
    name: 'cc',
    note: '快乐的小熊!Happy bear!',
    contacts: []
  }]
};

// 组包
var buffer = _.pack('User', user);

console.log(new Buffer(buffer).toString('hex').replace(/..(?!$)/g, '$& '));

// 06 00 01 02 03 04 05 06 07 08 09 02 73 73 1b 00 00 00 e4 bd a0 e5 a5 bd e4 b8 96
// e7 95 8c ef bc 81 48 65 6c 6c 6f 20 57 6f 72 6c 64 21 02 0a 00 01 02 03 04 05 06
// 07 08 09 02 6e 6e 2c 00 00 00 e9 a3 8e e4 b8 80 e6 a0 b7 e7 9a 84 e5 ad a9 e5 ad
// 90 21 54 68 65 20 77 69 6e 64 20 6f 66 20 74 68 65 20 63 68 69 6c 64 72 65 6e 21
// 01 0c 00 01 02 03 04 05 06 07 08 09 02 7a 7a 20 00 00 00 e5 9c a3 e6 96 97 e5 a3
// ab e6 98 9f e7 9f a2 ef bc 81 53 61 69 6e 74 20 53 65 69 79 61 ef bc 81 00 08 00
// 01 02 03 04 05 06 07 08 09 02 63 63 1f 00 00 00 e5 bf ab e4 b9 90 e7 9a 84 e5 b0
// 8f e7 86 8a ef bc 81 48 61 70 70 79 20 62 65 61 72 ef bc 81 00

Protocol Buffer

现在越来越依赖 ProtoBuf(以下简称 PB)做通信协议,因为 PB 有可读性高、空间占用小、跨平台、跨语言的特性。

在 jpacks 中也能方便的使用。

var _ = jpacks;
var _schema = _.array(
  _.protobuf('test/protoify/json.proto', 'js.Value', 'uint16'), // 指定 PB 文件路径,Message 名称,占用大小标记类型
  'int8'
);
console.log(_.stringify(_schema))
// > array(protobuf('test/protoify/json.proto','js.Value','uint16'),'int8')

var buffer = _.pack(_schema, [{
  integer: 123
}, {
  object: {
    keys: [{
      string: 'name'
    }, {
      string: 'year'
    }],
    values: [{
      string: 'zswang'
    }, {
      integer: 2015
    }]
  }
}]);

console.log(buffer.join(' '));
// > 2 3 0 8 246 1 33 0 58 31 10 6 26 4 110 97 109 101 10 6 26 4 121 101 97 114 18 8 26 6 122 115 119 97 110 103 18 3 8 190 31

console.log(JSON.stringify(_.unpack(_schema, buffer)));
// > [{"integer":123},{"object":{"keys":[{"string":"name"},{"string":"year"}],"values":[{"string":"zswang"},{"integer":2015}]}}]

其他

数组结构、压缩结构、依赖结构也就不一一赘,感兴趣的同学请到项目的代码中详细了解。 jpacks 的示例代码的测试用例和合体的。

最后给出项目地址:jpacks。 最后给出项目地址:jpacks。 最后给出项目地址:jpacks

参考资源