自定义通信层协议

1,730 阅读6分钟

背景

相信大家都接触过很多应用层的通信协议,例如http, websocket, IMAP/POP, mysql等,都是基于tcp的应用层协议(传输层除了tcp,还有其他的协议,e.g. UDP)。
不知道大家脑中对各种应用层协议的概览是怎么样的,对我来说就是:
1 定义了消息的规范(怎么开始,怎么结束,序列化/反序列化的规则);
2 通过系统调用传输层接口实现1;
... 刚好今天看到了阿里的一个通讯层协议(Bolt)的Nodejs实现,想通过源码了解下 Bolt 是如何定制自己的协议的。在阅读之前我会先把自己的疑问列出来,所以可能会有自己的偏重,而不是完整的分析。
在分析过程中也遇到了一些关键的nodejs API或插件,这里也会简单说明下

基于nodejs构建一个通讯服务

我一直有一个误解,传输层tcp必须和应用层绑定在一起(不然就是一堆tcp通讯格式内容和一堆01的机器码),但实际上不是这样的,tcp传输层已经封装好了这一层,在nodejs中提供了Net接口(libuv核心库提供),即使不使用任何协议,也可以正常收发信息,而通讯层协议只需要将双方通讯规范化;
我们来试一下在nodejs中构建一个tcp服务(来源参考文章: 入门 Node.js Net 模块构建 TCP 网络服务):

// server side
const net = require('net');
const HOST = '127.0.0.1';
const PORT = 3002;

// 创建一个 TCP 服务实例
const server = net.createServer();

// 监听端口
server.listen(PORT, HOST);

server.on('listening', () => {
    console.log(`服务已开启在 ${HOST}:${PORT}`);
});

server.on('connection', socket => {
    // data 事件就是读取数据
    socket.on('data', buffer => {
        const msg = buffer.toString();
        console.log(msg);

        // write 方法写入数据,发回给客户端
        socket.write(Buffer.from('你好 ' + msg));
    });
})

// client side
const net = require('net');
const client = net.createConnection({
    host: '127.0.0.1',
    port: 3002
});

client.on('connect', () => {
    // 向服务器发送数据
    client.write('Nodejs 技术栈');
})

client.on('data', buffer => {
    console.log(buffer.toString());
})

bingo,我们收到的是实实在在的buffer对象,通过buffer提供的string方法即可获得客户端发送的 字 符 串!
也就是说我们基于tcp的各种应用层协议已经不需要处理任何通讯过程的大部分繁琐内容,只需要规范化tcp封装后的字符串即可;

规范

Bolt协议相对于http已经简化了很多(因为 Bolt 主要是用于RPC调用),也更方便我进行源码阅读,规范如下:

proto: 协议标识位,bolt v1 是 0x01,bolt v2 是 0x02
ver1: bolt 协议版本,从 v2 开始 proto 不会再变,升级只变这个版本号
type: request/response/request oneway
cmdcode: request/response/heartbeat,和 type 有交叉
ver2: 应用层协议的版本(暂时没用)
requestId: 数据包唯一标识 id
codec: body 序列化方式,目前支持 hessian/hessian2/protobuf
switch: 是否开启 crc32 校验
headerLen: 自定义头部长度
contentLen: 内容长度
CRC32: 整个数据包通过计算出的 crc32 值(ver1 > 1 时支持)

因为本文目的是分析如何实现规范,而规范本身的内容对这篇文章而言并不重要,只需要看一下官方提供的规范说明.

实现

这里主要是通过demo跑一下源码看下完整的执行过程,分析 Bolt 如何实现规范的,Bolt 主要是解决两个问题:
1 Buffer 对象和规范之间的相互映射;
2 body 序列化的方式;
带着问题我们来看下源码(只写主要逻辑部分代码),调用 Bolt 的客户端代码demo是example/client.js:

const net = require('net');
const pump = require('pump');
const protocol = require('../lib');

// 创建tcp连接
const socket = net.connect(12200, '127.0.0.1');
// 例化 encoder 和 decoder 对象
const encoder = protocol.encoder(options);
const decoder = protocol.decoder(options);

// 绑定 Stream 流动方向
pump(encoder, socket, decoder, err => {
  console.log(err);
});
// 发送请求
encoder.writeRequest(1, {
  args: [{
    $class: 'java.lang.String',
    $: 'peter',
  }],
  serverSignature: 'com.alipay.sofa.rpc.quickstart.HelloService:1.0',
  methodName: 'sayHello',
  timeout: 3000,
})

这里非常难理解的地方是 net 是 stream-based 的,每个 Stream 内部的事件流转都是自动的,不需要额外再注册。继续看发送请求 writeRequest 调用的是/lib/encoder.js:

// 外层数据封装
writeRequest(id, req, callback) {
    this._writePacket({
        packetId: id,
        packetType: 'request',
        req,
        meta: this._createMeta(this.encodeOptions),
    }, callback);
}
_writePacket(packet, callback = noop) {
    // 调用不同类型的encode方法(四种类型: 'request', 'response', 'heartbeat', 'heartbeatAck')
    // 这里主要是将一些会变化的参数和客户端需要发送的消息body传入后面的encode方法中
    buf = this['_' + packet.packetType + 'Encode'](packet);
    //this.write会将已经序列化好的 Buffer 对象流入到 socket 中, 触发socket.write发送出去
    this._limited = !this.write(buf, err => {
        callback(err, packet);
    });
}

_writePacket第一个方法调用的 encode,这里会通过 byte 库的方法根据规范的顺序将数据插入 Buffer 对象中

exports.encode = (obj, options) => {
  byteBuffer.reset();
  byteBuffer.put(0x01); // bp=1 代表是 bolt
  byteBuffer.put(options.rpcType);
  byteBuffer.putShort(options.cmdCode);
  byteBuffer.put(0x01);
  byteBuffer.putInt(options.id);
  byteBuffer.put(Constants.codecName2Code[options.codecType]);
  
  if (options.rpcType === RpcCommandType.RESPONSE) {
    byteBuffer.putShort(obj.responseStatus);
  } else {
    byteBuffer.putInt(obj.timeout || 0);
  }

  const offset = byteBuffer.position();
  byteBuffer.skip(8);

  let start = byteBuffer.position();
  obj.serializeClazz(byteBuffer);
  byteBuffer.putShort(offset, byteBuffer.position() - start);

  start = byteBuffer.position();
  obj.serializeHeader(byteBuffer);
  byteBuffer.putShort(offset + 2, byteBuffer.position() - start);

  start = byteBuffer.position();
  obj.serializeContent(byteBuffer);
  byteBuffer.putInt(offset + 4, byteBuffer.position() - start);
  return byteBuffer.array();
};

这里已完成了数据发送的流程。但还有一个重点的问题,byte 这个库是如何按照规范插入/提取协议数据的呢?(查看后面关于byte的描述)

pump

pump是一个小的nodejs模块,作用是形成一个管道将Stream在管道里流动,并在完成后销毁。
注: 一般的pipe函数是在执行完毕后将返回值传递到下一个函数,但 pump 中每个节点都会订阅 Stream 的 'close' 事件,只有当前一个事件 'closed' 后,才会流动到下一个节点。

byte

主要是看下里面映射的方法绑定, 他是按顺序存取数据的,取完之后就会把offset位置移动到下一位:

// numbers 绑定了number.js里面的对象,对象包含存取方法名称/size等信息
Object.keys(numbers).forEach(function(type) {
  const putMethod = 'put' + type;
  const getMethod = 'get' + type;
  const handles = numbers[type];
  const size = handles.size;

  ByteBuffer.prototype[putMethod] = function(index, value) {
    // index, value
    // value
    if (value === undefined) {
      // index, value
      value = index;
      index = this._offset;
      this._offset += size;
      this._checkSize(this._offset);
    }

    const handle = this._order === BIG_ENDIAN ?
      handles.writeBE :
      handles.writeLE;
    this._bytes[handle](value, index);
    return this;
  };

  ByteBuffer.prototype[getMethod] = function(index) {
    if (typeof index !== 'number') {
      index = this._offset;
      this._offset += size;
    }

    const handle = this._order === BIG_ENDIAN ?
      handles.readBE :
      handles.readLE;
    return this._bytes[handle](index);
  };
});

源码地址: github.com/node-module…

问题记录

1 Bolt 在客户端创建socket时,并没有显式调用net的发送接口,这个接口是在什么时候触发的? 这个问题真的蛋疼,搞了半天没头绪.原来 net 是 stream-based(基于流的),当数据流动到 socket 的时候就会触发 write 事件。
2 为什么pump是绑定三个节点(例如客户端在接收数据的时候是不应该经历encoder节点的)?
这里将三个节点绑定在一起,但不会是全部执行的,发送数据绑定的是createWriteStream,只会从encoder到socket,而接收到的数据是createReadStream,从socket到decoder

参考文章

1 源码仓库: github.com/sofastack/s…
2 入门 Node.js Net 模块构建 TCP 网络服务: www.nodejs.red/#/nodejs/ne…