本文的第一部分将介绍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 = ; // 消息体的长度
int64 msg_id = ; // 消息ID,作为服务器应答时候的对应
MsgType msg_type = ; // 消息类型
// 服务器为0
uint32 from = ; // 消息发送方
uint32 to = ; // 消息接收方
string token = ; // 令牌
}
message LoginReq
{
string username = ;
string password = ;
bool is_need_key = ; // 是否需要请求私钥
} message LoginResp
{
enum LoginRespType
{
OK = ; //登陆成功
ERROR=; //用户名密码错误
NOUSER=; //用户不存在
}
LoginRespType resp = ;
string token = ; // 通讯令牌
uint32 id = ; // 用户id
string alias = ; // 用户别名
string prv_key = ; // 登录成功携带用户通讯私钥
}

MsgHeader表示数据头,除了提供发送发接收方等常用信息外,主要依赖消息类型(MsgType)和消息体长度(msg_size)作为数据包的反序列化依据。由于在通信的过程中需要加密,消息体是用过protobuf序列化完成后再使用算法进行加密传输。因此服务端只需要解析头数据即可完成对消息的转发和处理。为解析TCP包增加的8个字节,作为包长度的标识特指数据头的长度,数据体的长度通过反序列化数据头来确定。

数据的序列化与反序列化源码主要包含在服务端(socket_manager.cpp)和客户端(smss_socket_event.js)文件中。

服务端源码:

void SocketManager::ReadCb()
{
char flag[] = {};
int head_size = ;
// 判断报文头
int len = bufferevent_read(buff_ev_, flag, );
if (len <= || strcmp(flag, PKT_FLAG) != )
{
return;
}
// 获得消息头大小
len = bufferevent_read(buff_ev_, &head_size, );
if (len <= )
{
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开发指南》

开源项目SMSS开源项目(三)——protobuf协议设计的更多相关文章

  1. Protobuf协议应用干货

    Protobuf应用广泛,尤其作为网络通讯协议最为普遍.本文将详细描述几个让人眼前一亮的protobuf协议设计,对准备应用或已经应用protobuf的开发者会有所启发,甚至可以直接拿过去用. 这里描 ...

  2. 开源项目SMSS发开指南(四)——SSL/TLS加密通信详解

    本文将详细介绍如何在Java端.C++端和NodeJs端实现基于SSL/TLS的加密通信,重点分析Java端利用SocketChannel和SSLEngine从握手到数据发送/接收的完整过程.本文也涵 ...

  3. 开源项目SMSS发开指南(五)——SSL/TLS加密通信详解(下)

    继上一篇介绍如何在多种语言之间使用SSL加密通信,今天我们关注Java端的证书创建以及支持SSL的NioSocket服务端开发.完整源码 一.创建keystore文件 网上大多数是通过jdk命令创建秘 ...

  4. 开源项目SMSS开发指南(二)——基于libevent的线程池

    libevent是一套轻量级的网络库,基于事件驱动开发.能够实现多线程的多路复用和注册事件响应.本文将介绍libevent的基本功能以及如何利用libevent开发一个线程池. 一. 使用指南 监听服 ...

  5. Golang优秀开源项目汇总, 10大流行Go语言开源项目, golang 开源项目全集(golang/go/wiki/Projects), GitHub上优秀的Go开源项目

    Golang优秀开源项目汇总(持续更新...)我把这个汇总放在github上了, 后面更新也会在github上更新. https://github.com/hackstoic/golang-open- ...

  6. 巨杉数据库 MySQL兼容项目正式开源

    9月7日.8日,2018  ODF 开源数据库论坛,在北京盛大开幕.在大会上,巨杉数据库正式发布了巨杉全新的MySQL/MariaDB兼容架构,并将项目正式开源. 开源数据库论坛(ODF)是中国开源数 ...

  7. 开源OSS.Social微信项目进阶介绍

    在开源OSS.Social微信项目解析的随笔中,我简单给大家分享了进行中微信项目的概要设计,主要在讲述解决思路和过程,没有详细实现和使用介绍.本着不能马虎的态度,这篇文章我来给大家分解一下项目结构,使 ...

  8. 【分享】2017 开源中国新增开源项目排行榜 TOP 100

    2017 年开源中国社区新增开源项目排行榜 TOP 100 新鲜出炉! 这份榜单根据 2017 年开源中国社区新收录的开源项目的关注度和活跃度整理而来,这份最受关注的 100 款开源项目榜单在一定程度 ...

  9. 我们是如何通过全球第一免费开源ERP Odoo做到项目100%交付

    传统友商ERP的交付过程 一.先初步需求调研,后选型功能模块 传统友商ERP第一件事情先对客户方进行初步的调研,客户方无论说什么,友商听过算过,只关心你人数多少,有哪些人涉及到哪些模块,接着对模块进行 ...

随机推荐

  1. CentOS7 network.service loaded failed 处理技巧

    某一日,用systemctl --failed查看到如下错误信息,但实际上网络是OK的,真奇怪: 1 2 3 4 5 6 7 8 [root@localhost.localdomain media]# ...

  2. C# 16 进制字符串转 int

    最近在写硬件,发现有一些测试是做 16 进制的字符串,需要把他转换为整形才可以处理. 本文告诉大家如何从 16 进制转整形 如果输入的是 0xaa 这时转换 int 不能使用 Parse 不然会出现异 ...

  3. 几种常见排序算法的基本介绍,性能分析,和c语言实现

    本文介绍6种常见的排序算法,以及他们的原理,性能分析和c语言实现: 为了能够条理清楚,本文所有的算法和解释全部按照升序排序进行 首先准备一个元素无序的数组arr[],数组的长度为length,一个交换 ...

  4. 在js中arguments对象的理解

    一.在函数调用的时候,浏览器每次都会传递进两个隐式参数 函数的上下文对象this 封装实参的对象arguments 二.arguments 对象 arguments 对象实际上是所在函数的一个内置类数 ...

  5. jquery的offset().top和js的offsetTop的区别,以及jquery的offset().top的实现方法

    jquery的offset().top和js的offsetTop的区别,以及jquery的offset().top的实现方法 offset().top是JQ的方法,需要引入JQ才能使用,它获取的是你绑 ...

  6. Vue2.0 Vue.set的使用

    原文链接: https://blog.csdn.net/qq_30455841/article/details/78666571

  7. Javascript 防扒站,防止镜像网站

    自己没日没夜敲出来的站,稍微漂亮一点,被人看上了就难逃一扒,扒站是难免的,但不能让他轻轻松松就扒了: 前些天有个朋友做的官网被某不法网站镜像,严重影响到 SEO,当时的解决方法是通过屏蔽目标 IP 来 ...

  8. APK签名替换检测

    APK二次打包的危害 APK二次打包是Android应用安全风险中的一部分, 一般是通过反编译工具向应用中插入广告代码与相关配置,再在第三方应用市场.论坛发布.打包党对移动App带来的危害有以下几种: ...

  9. python 练习题3

    # 计算阶乘def factorial(n): if n>2: return n*factorial(n-1) else: return nprint(factorial(2))print(fa ...

  10. Wannafly挑战赛25 因子 [数论]

    一.题意 令 X = n!, 给定一大于1的正整数p 求一个k使得 p ^k | X 并且 p ^(k + 1) 不是X的因子 输入为两个数n, p (1e18>= n>= 10000 & ...