开源项目SMSS发开指南(三)——protobuf协议设计
本文的第一部分将介绍protobuf使用基础以及如何利用protobuf设计通信协议。第二部分会给出smss项目的协议设计规范和源码讲解。
一.Protobuf使用基础
什么是protobuf
protobuf是谷歌研发的一种数据序列化和存储技术。主要可以用来解决网络通讯中异构系统的通讯和数据持久化,与同类技术相比(JSON或XML),官方宣称的数据量长度减少3~10倍,运算速度20~100倍。由于与平台无关,因此非常适合使用在多系统的交互中。
目前常见的使用版本是2和3,个人推荐如果你打算在项目中引入protobuf技术,不妨直接选择版本3。以下的所有介绍也都基于protobuf3作为标准。
从个人的使用感受来看,protobuf的优点还是相当明显的。不过,也有一些问题需要注意。
如何使用protobuf以及常见问题
protobuf依赖于一个可阅读的描述文件,后缀以.proto结束。编写proto描述文件有固定的格式,详细说明参照官方文档。smss项目的doc目录下也有提供,描述文件可阅读,因此不存在太大难度。只是需要注意,目前如果你打算使用protobuf3版本需要在文件开头注明:syntax="proto3"。后期不清楚谷歌是否会更改,因此建议使用者应该关注官方说明。
一般来说,protobuf依赖谷歌提供的编译工具将描述文件(.proto)翻译为对应语言的源码。你也可以在项目中直接引入protobuf的依赖包利用动态编译(反射)直接使用。建议使用前一种方式,无论是难度上还是效率上都更直接。注意:编译工具和protobuf的开发依赖有版本对应,需要保持统一。
protobuf的C++版本会生成.h和.cc两个文件。对于C/C++程序员来说,使用的方式和Struct几乎一致。
protobuf的Java版本会生成一系列路径(如果有设置的话)和对应的.java文件。Java开发人员可以直接复制到项目下使用,通过构造器模式来创建对象。
官方示例:
Person john = Person.newBuilder() .setId(1234) .setName("John Doe") .setEmail("jdoe@example.com") .build(); output = new FileOutputStream(args[0]); john.writeTo(output);
protobuf的JS版本会生成.js文件,这里不再赘述。
不同语言的构造命令可以参考smss项目的脚本文件。
使用Protobuf特别需要关注你的使用场景,通常来说需要注意以下两点:
(1)protobuf专注于对小型字符数据的序列化/反序列化操作。如果你需要传输大型文件或二进制数据是不适合使用的。
(2)如果你打算在TCP/IP层来应用protobuf协议,依然需要设计包解析机制。TCP/IP传输会发生粘包或长包,这些问题protobuf无法帮你解决。如果你传输的包中包含多条数据,交给protobuf解析的时候,它只能反序列化出最后一条。这就要求在数据包的设计中必须包含一些必要字段。
目前smss项目的用法是在一个包的头部增加了4个字节的包头标识(AB47)和4个字节的小端整型数表示protobuf序列化后的包长度。根据笔者的实践发现,相同的包结构如果设置的数据不同可能最后序列化的数据长度有差异。因此在设置包长度的时候一定要根据实际的序列化为标准。
什么场景下适合使用protobuf
相信在了解了protobuf的基本使用后,大多数有经验的开发人员会有自己的判断。我在这里仅抛砖引玉提供一些个人的思考:
(1)内部系统开发:目前protobuf并未被大规模的实践。如果你的项目需要对接外部系统,请对方提供或支持protobuf协议难度较大。因此,内部系统开发进行交互推荐使用。
(2)TCP/IP层数据通信:目前的Java微服务应用大多使用http应用层协议,好处是实现过程相对简单。而且由于各种开源框架对JSON-POJO的映射功能非常完备,如果从开发效率上考虑,显然protobuf还不具备优势。如果在业务中新增一些数据中台业务,需要开发更加高效的通信过程,利用protobuf是更加合理的方案。
(3)异构系统:不少物联网项目会涉及多种语言多种设备间的通信。例如C++直接使用struct的序列化后传输给Java来处理,就必要麻烦。这类需求是使用protobuf的最佳使用场景。
二.SMSS项目协议设计规范
目前smss项目利用protobuf协议作为通信的主要手段,正如前文介绍的那样。为了提高通信效率,项目各端内部使用TCP/IP层通信。因此在包头设计了包头标识和包长度标识(8个字节)。另外,与http协议不同的是,TCP/IP由于是长连接且是面向连接设计,因此需要设计应用层的规范。smss将一个完整的应用数据包分为数据头和数据体,结构如下:
message MsgHeader { int32 msg_size = 1; // 消息体的长度 int64 msg_id = 2; // 消息ID,作为服务器应答时候的对应 MsgType msg_type = 3; // 消息类型 // 服务器为0 uint32 from = 4; // 消息发送方 uint32 to = 5; // 消息接收方 string token = 6; // 令牌 }
message LoginReq { string username = 1; string password = 2; bool is_need_key = 3; // 是否需要请求私钥 } message LoginResp { enum LoginRespType { OK = 0; //登陆成功 ERROR=1; //用户名密码错误 NOUSER=2; //用户不存在 } LoginRespType resp = 1; string token = 2; // 通讯令牌 uint32 id = 3; // 用户id string alias = 4; // 用户别名 string prv_key = 5; // 登录成功携带用户通讯私钥 }
MsgHeader表示数据头,除了提供发送发接收方等常用信息外,主要依赖消息类型(MsgType)和消息体长度(msg_size)作为数据包的反序列化依据。由于在通信的过程中需要加密,消息体是用过protobuf序列化完成后再使用算法进行加密传输。因此服务端只需要解析头数据即可完成对消息的转发和处理。为解析TCP包增加的8个字节,作为包长度的标识特指数据头的长度,数据体的长度通过反序列化数据头来确定。
数据的序列化与反序列化源码主要包含在服务端(socket_manager.cpp)和客户端(smss_socket_event.js)文件中。
服务端源码:
void SocketManager::ReadCb() { char flag[5] = {0}; int head_size = 0; // 判断报文头 int len = bufferevent_read(buff_ev_, flag, 4); if (len <= 0 || strcmp(flag, PKT_FLAG) != 0) { return; } // 获得消息头大小 len = bufferevent_read(buff_ev_, &head_size, 4); if (len <= 0) { return; } char *head = new char[head_size]; len = bufferevent_read(buff_ev_, head, head_size); // 解析消息头对象 MsgHeader msg_header; msg_header.ParseFromArray(head, head_size); delete[] head; char *msg_buff = new char[msg_header.msg_size()]; // FIX:if msg_body too large len = bufferevent_read(buff_ev_, msg_buff, msg_header.msg_size()); switch (msg_header.msg_type()) { case MsgType::CONNECT_REQ: RecvConnectReqest(&msg_header, msg_buff, len); break; case MsgType::CLIENT_LOGIN_REQ: // 登录请求 RecvLoginRequest(&msg_header, msg_buff, len); break; case MsgType::HEART_BEAT: // 心跳 RecvHeartBeat(msg_buff, len); break; case MsgType::USER_INFO_REQ: // 用户信息请求 if (msg_header.token() != this->token_) { LOG4CPLUS_ERROR(SimpleLogger::Get()->LoggerRef(), "用户通信令牌(token)验证错误!"); return; } RecvUserInfoRequest(&msg_header, msg_buff, len); break; case MsgType::MSG_SEND_REQ: // 消息发送请求 { if (msg_header.token() != this->token_) { LOG4CPLUS_ERROR(SimpleLogger::Get()->LoggerRef(), "用户通信令牌(token)验证错误!"); return; } LOG4CPLUS_DEBUG(SimpleLogger::Get()->LoggerRef(), "MsgType::MSG_SEND_REQ"); work_thread_->SendToNetBus(msg_header.SerializeAsString().c_str(), msg_header.ByteSizeLong(), msg_buff, msg_header.msg_size()); } break; case MsgType::USER_STATUS_REQ: // 用户状态请求 if (msg_header.token() != this->token_) { LOG4CPLUS_ERROR(SimpleLogger::Get()->LoggerRef(), "用户通信令牌(token)验证错误!"); return; } LOG4CPLUS_DEBUG(SimpleLogger::Get()->LoggerRef(), "MsgType::USER_STATUS_REQ"); RecvUserStatusRequest(&msg_header, msg_buff, len); break; case MsgType::SERVICE_REGIST: // 服务注册 RecvServiceRegist(&msg_header, msg_buff, len); break; case MsgType::FILE_DOWNLOAD_NOTICE: work_thread_->SendToNetBus(msg_header.SerializeAsString().c_str(), msg_header.ByteSizeLong(), msg_buff, msg_header.msg_size()); break; default: { stringstream ss; ss << "缺少对应的消息类型处理函数:" << msg_header.msg_type(); LOG4CPLUS_ERROR(SimpleLogger::Get()->LoggerRef(), ss.str()); } } delete[] msg_buff; }
ReadCb()是数据接收的直接处理方法,首先读取4个字节判断包头标识:int len = bufferevent_read(buff_ev_, flag, 4),判断成功代表当前数据包是完整的。再读取4个字节的整型数来判断后续一个数据头的长度:bufferevent_read(buff_ev_, &head_size, 4)。接下来收取数据头的完整数据并通过protobuf反序列化:msg_header.ParseFromArray(head, head_size)。最后根据数据头的msg_type字段判断应该如何处理数据体。
客户端源码:
onData(data) { // 处理粘包,循环读取 let readSize = 0; while (readSize < data.length) { let flag = data.toString("utf8", readSize, readSize + 4); if (flag !== "AB47") { readSize += 4; continue; } readSize += 4; let headerSize = data.readInt32LE(readSize); readSize += 4; // 消息头反向序列化 let msgHeader = MsgHeader.deserializeBinary( data.subarray(readSize, headerSize + readSize) ); readSize += headerSize; // 消息类型 let msgType = msgHeader.getMsgType(); // 消息大小 let msgSize = msgHeader.getMsgSize(); switch (msgType) { case MsgType.USER_INFO_RESP: this.onUserInfoResp( UserInfoResp.deserializeBinary( data.subarray(readSize, msgSize + readSize) ) ); break; case MsgType.USER_STATUS_NOTICE: this.onUserStatusNotice( UserStatus.deserializeBinary( data.subarray(readSize, msgSize + readSize) ) ); break; case MsgType.MSG_SEND_REQ: this.onSmsSendReq(data.subarray(readSize, msgSize + readSize)); break; case MsgType.FILE_DOWNLOAD_NOTICE: new DownloadEvent( this.$store.state.User.userID, this.$store.state.User.userToken, msgHeader, FileDownloadNotice.deserializeBinary( data.subarray(readSize, msgSize + readSize) ) ); break; default: } readSize += msgSize; } }
/** * 连接事件处理 * * @param {*} socket * @param {*} userID */ function ConnectEvent(socket, userID) { return new Promise((resolve, reject) => { fs.open("./data/.shadow/server.pem", "r", (err, fd) => { let connectReq = new ConnectReq(); connectReq.setTimestamp(new Date().getTime()); if (err) { connectReq.setIsNeedKey(true); } else { connectReq.setIsNeedKey(false); } let connectReqBuffer = connectReq.serializeBinary(); let msgHeader = new MsgHeader(); msgHeader.setMsgSize(connectReqBuffer.length); msgHeader.setMsgId(0); msgHeader.setMsgType(MsgType.CONNECT_REQ); msgHeader.setFrom(userID); msgHeader.setTo(0); // 发送给服务器 const headerBuffer = msgHeader.serializeBinary(); let packageHeader = Buffer.alloc(8); packageHeader.write("AB47"); packageHeader.writeInt32LE(headerBuffer.length, 4); const packageBuffer = Buffer.concat([ packageHeader, headerBuffer, connectReqBuffer ]); socket.write(packageBuffer, () => { socket.once("data", data => { let flag = data.toString("utf8", 0, 4); if (flag !== "AB47") { return; } let headerSize = data.readInt32LE(4); // 消息头反向序列化 let msgHeader = MsgHeader.deserializeBinary( data.subarray(8, headerSize + 8) ); // 消息类型 let msgType = msgHeader.getMsgType(); // 消息大小 let msgSize = msgHeader.getMsgSize(); if (msgType !== MsgType.CONNECT_RESP) { reject("ConnectEvent RES MsgType Error!"); } else { let resp = ConnectResp.deserializeBinary( data.subarray(8 + headerSize, msgSize + 8 + headerSize) ); if (resp.getPubKey() !== "") { fs.writeFile( "./data/.shadow/server.pem", resp.getPubKey(), err => { if (err) { reject(err); }; // 连接完成后进行登录 resolve(resp); } ); } else { resolve(resp); } } }) }); }); }); }
处理的过程和服务端的思路一致,也是从包头到数据头最后是数据体的解析。由于JavaScript在对网络调用和文件读取的时候大量需要使用回调函数,因此smss项目在客户端利用Promise进行了封装。学习的时候建议先熟悉一下Promise的使用方式。
完整源码已经发布在码云上。
相关文件:《开源项目SMSS开发指南》