通信协议演进与JCE协议详解

一、通信协议概述
通信协议是两个节点之间为了协同工作、实现信息交换,而协商的规则和约定,例如规定字节序,各个字段类型,使用什么压缩算法或加密算法等。

1、原始数据
假设A和B通信,获取或设置用户基本资料,一般开发人员第一步就是定义一个协议结构:

struct userbase
{
unsigned short cmd; //1-get, 2-set
unsigned char gender; //1 – man , 2-woman
char name[8];
};
1
2
3
4
5
6
在这种方式下,A基本不用编码,直接从内存copy出来,再做一下网络字节序变换,发送给B,B也能解析。
这种编码方式,除了数据本身外,没有一点额外冗余信息,可以看成是Raw Data。

2、版本号控制
有一天,A在基本资料里面加一个生日字段,然后告诉B:

struct userbase
{
unsigned short cmd;
unsigned char gender;
unsigned int birthday;
char name[8];
};
1
2
3
4
5
6
7
可是当B收到A的数据包后,并不知道第3个字段到底是旧协议中的name字段,还是新协议中的birthday。

于是他们意识到,一个好的协议应该具有兼容性和可扩展性。

他们决定制定一个以后每个版本兼容的新协议。方法很简单,就是加一个version字段:

struct userbase
{
unsigned short version;
unsigned short cmd;
unsigned char gender;
unsigned int birthday;
char name[8];
};
1
2
3
4
5
6
7
8
这样子以后就可以很方便的扩展,通过版本号来做不同的解析处理。

3、使用tag
过了一段时间,A和B发现又有新的问题:每增加一个字段就要改变一下版本号,这样代码维护起来相当麻烦,每个版本一个case分支,到了最后,代码里面几十个case分支,看起来丑陋而且维护成本相当高。

于是他们决定为每个字段增加一个额外信息来作为一个字段的唯一标识——tag,虽然增加内存和带宽,但是可以容许这些冗余,换取易用性。

struct userbase
{
1 unsigned short version;
2 unsigned short cmd;
3 unsigned char gender;
4 unsigned int birthday;
5 char name[8];
};
1
2
3
4
5
6
7
8
有了tag之后,每个字段都有唯一标识,双方通过tag即可知道第几个字段代表的是什么。于是我们就可以自由地增加字段了,只要保证tag不修改即可。注意一般不删除字段,因为删除字段后tag可能会不小心被复用了,从而类型不匹配导致解码失败。

4、强扩展性的TLV
后来他们发现,name使用8个字节不够用,最大长度可能会达到100个字节。如果每次都按照100个字节这种固定长度打包,太浪费流量了。

于是他们决定使用**<Tag,Length,Value>**三元组编码,简称TLV编码。其中Value字段是可以嵌套的。

TLV具备了很好可扩展性,但是由于其增加了2个额外的冗余信息 Tag 和 Length,特别是如果协议大部分是基本数据类型int ,short, byte,会浪费几倍存储空间。另外Value具体是什么含义,需要通信双方事先得到描述文档,即TLV不具备结构化和自解释特性。

5、自解释性的TTLV
TT[L]V是 <Tag,Type,Length,Value> 四元组编码,其中,当type是定长的基本数据类型如int, short, long, byte时,因为其长度是已知的,所以L不需要。

于是我们可以定义一些type值如下:

类型 Type值 类型描述
bool 1 布尔值
int8 2 带符号的一个字符
uint8 3 不带符号的一个字符
int16 4 16位有符号整型
uint16 5 16位无符号整型
int32 6 32位有符号整型
uint32 7 32位无符号整型
… … …
string 12 字符串或二进制序列
struct 13 自定义的结构,嵌套使用
list 14 有序列表
map 15 无序列表
改完后,不光可以随心所欲地增删字段,还可以在一定程度上修改数据类型,例如把 unsigned short cmd 改成 int cmd,可以无缝兼容。因为 unsigned short 和 int 都是属于整型数的范畴,因此实际数据传输存储中,无论定义的是 unsigned short 还是 int,都是按照实际的数据大小选择type值进行编码的。

6、跨语言特性
有一天来了一个新的同事C,他写了一个新的服务,需要和A通信,但是C使用的是java编码,没有无符号类型,导致负数解析失败。

为了解决这个问题,A重新规划一下协议类型,剥离语言特性,定义一些共性,对使用类型做了强制性约束。虽然带来了约束,但是带来通用性、简洁性和跨语言性,大家表示都很赞同,于是有了一个新的类型(type)规范。

类型 Type值 类型描述
bool 1 布尔值
int8 2 带符号的一个字符
int16 3 16位有符号整型
int32 4 32位有符号整型
… … …
string 12 字符串或二进制序列
struct 13 自定义的结构,嵌套使用
list 14 有序列表
map 15 无序列表
7、代码自动化——IDL语言的产生
后来A和B又发现了新的烦恼,就是每搞一套新的协议,都要从头编解码,调试,虽然TLV很简单,但是写编解码是一个毫无技术含量的枯燥体力活,一个非常明显的问题是,由于大量copy/past,不管是对新手还是老手,非常容易犯错,一犯错,定位排错非常耗时。于是A想到使用工具自动生成代码。

IDL(Interface Description Language),是一种描述语言,也是一个中间语言,IDL的一个使命就是规范和约束,就像前面提到,规范使用类型,提供跨语言特性。通过工具分析 idl 文件,生成各种语言代码:
Gencpp.exe sample.idl 输出 sample.cpp sample.h
Genphp.exe sample.idl 输出 sample.php
Genjava.exe sample.idl 输出 sample.java

于是后续约定协议,其实就变成了使用中间语言来书写sample.idl 协议文件,然后使用Gencpp.exe等工具,生成对应语言的编解码代码。

二、JCE协议
1、什么是JCE协议
JCE是一种二进制、支持字段动态增加、代码自动生成、跨平台的通信、数据传输协议。

JCE是一种类C++语言的IDL,用于生成具体的服务接口文件。

对于结构定义,可以支持扩展字段,即可以增加字段而不影响以前结构的解析。

协议的作用是为了能在网络传输中起到封装数据的作用,是双方对传输数据的一种约定、规则,或者说通信语言。有了共同的语言之后,一方就可以将数据按照协议进行序列化,打包成字节流,底层调用send或者sendto函数发出去,而接收方只需按照约定好的规则对字节流进行反序列化,转换成自己读得懂的数据结构。

2、JCE数据编码
JCE结构体的定义如下:

struct Test
{
0 require string s;
1 optional int i = 23;
};
key[Test, s, i];
1
2
3
4
5
6
说明:

第一列数字表示该字段的唯一标识(Tag),无论结构增减字段,该字段的值都不变,必须和相应的字段对应;
Tag的值必须要 >= 0 且 <= 255(1 byte);
require 表示该字段必选,如果解码发现该Tag缺失,则解码失败;
optional表示该字段可选,如果解码发现该Tag缺失,则忽略;
对于optional字段,可以有一个缺省值;
Key表示结构的小于比较符号,缺省时Struct是没有小于操作的,如果定义了key,则生成小于比较符。
key[Stuct, member…]:
Struct:表示结构的名称
Member:表示该结构的成员变量,可以有多个;
生成的小于比较操作符,按照key中成员变量定义的顺序进行优先 < 比较。
2.1、 基本结构
JCE使用TTLV进行编码,编码数据由 头信息 和 实际数据 两个部分组成。

其中头信息包括以下几个部分:

Type Tag 1 Tag 2
4 bits 4 bits 1 byte
Type表示数据存储类型,用4个二进制位表示,取值范围是0~15,用来标识该数据的类型。不同类型的数据,其后紧跟着的实际数据的长度和格式都是不一样的,详见后面的类型表。

Tag由Tag 1和Tag 2一起表示,取值范围是0~255,用来区分不同的字段(编码之后的数据中,只会存储字段对应的tag,而不会传输字段名,通信双方都有JCE文件,通过tag可以找到对应的字段名)。其中 Tag 2 是可选的,当Tag的值不超过14时,只需要用 Tag 1 就可以表示;当Tag的值超过14而小于256时,Tag 1 固定为15,而用 Tag 2 表示Tag的值。Tag不允许大于255。

2.2、编码类型表
注意,这里的类型与jce文件定义的类型是两个不同的概念,这里的类型只是标识数据存储的类型,而不是数据定义的类型。比如字段age数据定义的类型是 int,当它实际的值是1时,数据存储的类型是 1个字节整型数据。

取值 类型 备注
0 int1 紧跟1个字节整型数据
1 int2 紧跟2个字节整型数据
2 int4 紧跟4个字节整型数据
3 int8 紧跟8个字节整型数据
4 float 紧跟4个字节浮点型数据
5 double 紧跟8个字节浮点型数据
6 String1 紧跟1个字节长度,再跟内容
7 String4 紧跟4个字节长度,再跟内容
8 Map 紧跟一个整型数据表示Map的大小,再跟[key, value]对列表
9 List 紧跟一个整型数据表示List的大小,再跟元素列表
10 自定义结构开始 自定义结构开始标志
11 自定义结构结束 自定义结构结束标志,Tag为0
12 数字0 表示数字0,后面不跟数据
13 SimpleList 简单列表(目前用在byte数组),紧跟一个类型字段(目前只支持byte),紧跟一个整型数据表示长度,再跟byte数据
14 - -
15 - -
2.3、各类型详细描述
2.3.1 基本类型(包括int1、int2、int4、int8、float、double)

头信息后紧跟数值数据。char、bool也被看作整型。所有的整型数据之间不做区分,也就是说一个short的值可以赋值给一个int。由于长度固定,因此不需要长度信息。

2.3.2 数字0

头信息后不跟数据,表示数值0。所有基本类型的0值都可以这样来表示。
这是考虑到数字0出现的概率比较大,所以单独提一个类型,以节省空间。
(即,只有头信息,没有实际数据,解析为数字0)

2.3.3 字符串(包括String1、String4)

String1跟一个字节的长度(该长度数据不包括头信息)(字符的长度小于等于255),接着紧跟内容。

String4跟四个字节的长度(该长度数据不包括头信息)(字符的长度大于255,注意最大长度为2^32 - 1),接着紧跟内容。

2.3.4 Map

紧跟一个整形数据(包括头信息)表示Map的大小,然后紧跟[Key数据(Tag为0),Value数据(Tag为1)]对列表。

在序列化的时候将key和value分开进行存放,先存放其长度,接着存放所有的key,其中key存放的tag为0,value存放的tag为1:

map的tag
map的type
map的size
key的tag(0)
key的type
key1
key2
...
keyN
value的tag(1)
value的type
value1
value2
...
valueN
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2.3.5 List

紧跟一个整形数据(包括头信息)表示List的大小,然后紧跟元素列表(Tag为0)

2.3.6 自定义结构开始

自定义结构开始标志,后面紧跟字段数据,字段按照tag升序顺序排列

2.3.7 自定义结构结束

自定义结构结束标志,Tag为0

2.4、对象持久化
对于自定义结构的持久化,由开始标志与结束标志来标识。

比如如下结构定义:

struct TestInfo
{
1 require int i = 34;
2 optional string s = “abc”;
};

struct TestInfo2
{
1 require TestInfo t;
2 require int a = 12345;
};
1
2
3
4
5
6
7
8
9
10
11
其中,默认的TestInfo2结构编码后结果为:


3、JCE数据解码
JCE解码解码过程:

获取头部信息,读取第一个字节的高4位作为tag值,如果大于等于15,则读取下一个字节的值作为真实的tag值;读取第一个字节的低四位作为type值

将指针偏移到数据部分

获取数据真实大小

读取数据部分成功后返回其值

三、如何通信
1、后台与后台如何通信
以TAF框架为例,后台之间的通信是直接通过TCP、UDP socket进行传输的,而传输的二进制数据,则使用应用层协议JCE进行编码。

TAF框架层统一使用RequestPacket 和 ResponsePacket 作为请求包结构和响应包结构,这些结构通过 JCE 定义,网络传输时,首先使用 JCE 协议将结构编码成二进制流,然后在前面加上整个数据包的长度(表示长度的字节大小+二进制数据大小),组成最终的数据包。

其中请求、响应包结构中的 sBuffer 成员变量,则是业务自己定义的请求、响应包结构体JCE序列化后的二进制数据。

举个例子,假设有一个接口协议定义如下:

int echo(const EchoRequest & req, out EchoResponse & rsp);
1
则发送请求时数据打包的步骤:

将 EchoRequest JCE序列号成二进制数据 vtIn;

将 vtIn 赋值给 RequestPacket 的 sBuffer 成员变量;

填充 RequestPacket 的其他成员变量信息(比如 requestId 等);

将 RequestPacket JCE序列号成二进制数据 vtBody;

计算 vtBody 的大小 bodySize(假设是1024byte);

bodySize自身的大小(一个int32,4byte) + bodySize(1024byte) = 整个数据包的大小作为header(一个值为1028的int32整数);

header + vtBody 作为最终的数据,通过socket发送到目标ip:port上

2、终端与后台如何通信
后台服务之间的通信,是通过JCE协议实现数据的打包和解包的。假设服务A调用服务B的某个接口,那么A填充请求数据包,序列化后通过B的本地代理将发送请求给B。

如果终端也采用这种方式,则需要接入所有目标服务的本地代理,不方便功能的扩展。

实际上,终端和目标后台服务之间有一层代理服务(即接入层),它内部维护了命令字和目标服务代理的映射关系。

终端通过HTTP协议把数据发给接入层(对外暴露统一的域名供客户端访问),HTTP数据的body,是一个JCE结构体序列化之后加密、压缩得到的数据,接入层收到请求,首先对body解压缩、解密,然后反序列化,得到一个请求结构,结构中包含请求的命令字、以及各命令字协议的JCE序列化数据。

接入层从映射表中找到对应的后台服务后,代替终端去和后台服务通信,得到结果后再把结果以HTTP回包的形式返回给终端。

为了实现统一接入,接入层跟后台服务通信的协议必须统一:

xxx(const BusinessRequestHead &head, const vector<char> &requestData, vector<char> &responseData);
1
requestData 和 responseData 是终端与后台服务约定好的 JCE协议结构的序列化数据,接入层只需要透传,无需理解。

这样一来,接入层就可以实现对RPC参数的统一打包解包:tag 1 2 3 分别是 head, requestData,responseData,tag 0 则为ret。

原文地址:https://www.cnblogs.com/ExMan/p/10547497.html