thrift的binaryprotocol协议分析
出处:http://hi.baidu.com/yancncen/blog/item/b6e0ad38031b06cbd4622547.html)
联系作者,请加qq:1-1-3-8-5-7-1-5-4
thrift里的c++ lib支持很多种协议,不过鉴于有些协议在别的语言的thrift lib并没有得到支持,如php只支持binaryprotocol协议。所以,建议交互都走binaryprotocol协议,在此也只分析 binaryprotocol的协议。
废话不说了,直入正题
binaryprotocol协议分析:
使用工具:
strace、tcpdump、vi、thrift及其源码
(1)0-4个字节:
协议版本|消息类型 (对于TBinaryProtocol.cpp而言协议版本为0x80010000和消息类型进行或运算)
其中消息类型包括:call == 1, reply == 2, excetpion == 3
(2)4-8字节:
请求的函数名称的长度,这里假设为functionNameLen
(3)8-functionNameLen字节{functionNameLen为请求函数名的长度}:
请求的函数名称名称
(4)functionNameLen-functionNameLen+4个字节:(4个字节)
请求的序列号
(貌似生成的代码里面,不管是哪个请求序列号都为0)
如果function没有参数:
最后一个字节为0
参数具有以下形式:{类型}{序号}{值}, 详细请看下面的解释
如果function有参数:
(5)functionNameLen+4-functionNameLen+5个字节:(1个字节)
参数1的类型, 包括以下类型:
参数类型表:
T_STOP = 0,
T_VOID = 1,
T_BOOL = 2,
T_BYTE = 3,
T_I08 = 3,
T_I16 = 6,
T_I32 = 8,
T_U64 = 9,
T_I64 = 10,
T_DOUBLE = 4,
T_STRING = 11,
T_UTF7 = 11,
T_STRUCT = 12,
T_MAP = 13,
T_SET = 14,
T_LIST = 15,
T_UTF8 = 16,
T_UTF16 = 17
(6)functionNameLen+5-functionNameLen+7个字节:(2个字节)
参数序号,取决于你定义的idl文件中参数所定义的序号
(7)接下来的n个字节,取决于参数的类型:
a)对于以下具有固定长度的简单数据类型的参数:
T_STOP = 0, n==1
T_VOID = 1, n==1
T_BOOL = 2, n==1
T_BYTE = 3, n==1
T_I08 = 3, n==1
T_I16 = 6, n==2
T_I32 = 8, n==4
T_U64 = 9, n==8 ? (虽然给出了定义,但是TBinaryProtocol.cpp并没有实现,
所以,如果要使用该数据类型的必须注意, 应该使用string类型来代替它
unsigned long long 类型就需要特别注意了)
T_I64 = 10, n==8
T_DOUBLE = 4, n==8 (double在目标机器上必须是8个字节的,符合IEC-559标准的浮点数,貌似对我们没有影响)
这n个字节就是参数的值。
以下的数据类型被我归为复合数据类型:
b)对于String,这n个字节包含以下内容:
前面4个字节:字符串的长度stringLen
接下来的stringLen个字节:字符串的内容
c)对于struct,这n个字节包含以下内容:
假设这个结构体包含m个字段:(为了便于说明问题,下面所说的字节偏移是相对于struct内部结构而言的)
0-1字节:字段的数据类型
1-3字节:字段序号,取决于你定义的idl文件中参数所定义的序号
接下来的k个字节:goto (7)
以此类推,直至m个字段
其实,struct的字段和函数参数具有一样的编码方式
d)对于set,这n个字节包含以下内容:(为了便于说明问题,下面所说的字节偏移是相对于set内部结构而言的)
0-1字节:set里面的元素的数据类型
1-5字节:元素个数
假设元素的数据类型的长度为k个字节,那么接下来每k个字节作为一个元素值,至于元素值的分析,请goto(7)
注意,这里和函数参数/struct的区别在于,这里不存在元素的序号值
e)对于list,这n个字节包含以下内容:
和set类似,这里就不重复累赘了。
值得注意的一点是,thrift白皮书提到了“list<type> An ordered list of elements.”,实际上,list在映射到stl的list时并不是经过排序的list。
f)对于map,这n个字节包含以下内容:(为了便于说明问题,下面所说的字节偏移是相对于map内部结构而言的)
0-1字节:key的数据类型。注意,可能是复合数据类型
1-2字节:value的数据类型。
假设key的数据类型的长度为k个字节,value的数据类型的长度为v个字节
那么接下来每k+v个字节作为一个key-value值,
至于key的值的分析和value的值的分析,请goto(7)
总结:
(1)关于定义idl的一些总结,尽量避免定义过于复杂的数据结构。
从上面的协议分析来看,复合数据类型的存在着一个递归包含的关系。
不过thrift在生成封/解包的代码里,并没有出现递归调用来封/解包,而是采用了循环嵌套循环的方式来生成代码,
这种做法避免了频繁递归调用封/解包函数,可提高封/解包的效率。同时带来的问题就是生成的代码量的急剧膨胀。
虽然没有递归调用来封/解包, 如果定义太过于复杂的数据结构也会随之产生多重循环,看下面的例子
假设定义以下一个数据结构:
map< string, list< set<string> > >,thrift将会产生类似于以下的循环来进行封/解包:
foreach (key in map)
{
foreach ( set in map[key] )
{
foreach (string in set )
encode()/decode();
}
}
假设map,list,set的元素各有100个,这将是一个严重影响性能的地方,应该避免。
(2)建议使用了unsigned long long的字段使用string类型,而不是u64类型,因为目前的thrift不支持。
(3)在网络IO层一个潜在的瓶颈
由于thrift的binaryprotocol协议的包头没有任何的字节描述了整个网络包的长度的信息。
所以thrift的binaryprotocol协议在解包的时候是每次都只能采用从socket读取一个变量的类型接着读取变量的值出来这样的解释方式。
这种解包的方式可能引起的潜在问题:
当请求的client数量非常多,交互的数据量也非常多(这里可能是交互了很多字段,或者使用了太多复合数据类型)时,tcp/ip协议栈的缓冲区可能会被塞满了
还没有被处理的数据,就会严重影响服务质量。
至于为何不提供某些字节来标识整个数据包的长度,是因为thrift的binaryprotocol协议需要支持复杂数据类型,像set,list,map,而这些复合数据类型的大小是难以确定的。
为了支持标识整个数据包长度,封包前需要知道set,list,map的总体大小,那么就需要遍历set,list,map的大小,这是相当不划算,会增加运算逻辑,而且还会导致协议变得很复杂。
(4)如何做到兼容旧接口
当我们的server更新接口的同时,还需要保证旧client能够和新server交互,那么在定义IDL时就需要特别注意。
假设,我们定义以下一个这个一个结构体来交互用户信息。
struct user
{
1:username string,
2:password string,
3:sex i16,
}
当我们需求变更时,假设以下两种情况:
a)需要新增字段, age来表示年龄
struct user
{
1:username string,
2:password string,
3:sex i16,
4:age i32,
}
注意,原来字段的序号标识一定不能被改变,1:username string, 不能改成 5:username string。
此时,如果server是新的,client是旧的,并不影响client的工作,client从server那边收到的包里包含了age的信息,只是没有decode出来而已。
b)删除字段sex,新增字段age
struct user
{
1:username string,
2:password string,
//3:sex i16,
4:age i32, (注意,为了保证a)所定义的client能够和b)的server交互,这里的字段序号必须定义为4)
}
此时,如果server是新的,client是a)所生成的也并不影响和b)的server交互,因为client从server那边收到的包虽然没有包含sex的信息,但是client并不会崩溃,只是缺少了sex的信息。
因此,我们需求变更时,尽量保存旧的字段不要删除,做到只增不减的方式来兼容旧接口。
这里字段序号是唯一标识字段的关键。