TProtocol是Thrift数据序列化和反序列化的工具类,与Google的ProtocolBuffer类似。
要点:
- CRTP实现TVirtualProtocol
- 装饰器模式实现TProtocolDecorator、TMutiplexedProtocol,TMutilplexedProtocol多协议支持
- 代理模式实现TProtocolTap,与TDebugTProtocol配合调试输出
- TCompactProtocol压缩编码,zigzag和varint实现整数变长编码
1. Thrift TProtocol介绍
- 可接受的数据类型
- 实现了的序列化格式
Thrift中实现了多种序列化格式:
– TBinaryProcotol:自定义的二进制格式
– TJSONProtocol:JSON
2. TVirtualProtocol和TProtocolDefaults的源码分析
在看完TVirtualProtocol和TProtocolDefaults后,我觉得这两个是多余的。甚至不能理解TProtocol中要为每个类型的读写都定义两个函数,一个writeByte,一个writeByte_virt,然后writeByte调用writeByte_virt。
如果是我来实现TProtocol,我可能只会写一个虚的writeByte函数:
class TProtocol
{
virtual uint32_t writeByte(const int8_t byte)=0;
}
为了更方便子类继承TProtocol,也可以有个默认实现类TProtocolDefaults:
class TProtocolDefaults
{
virtual uint32_t writeByte(const int8_t byte)
{
throw TProtocolException(TProtocolException::NOT_IMPLEMENTED,
"this protocol does not support writing (yet).");
}
}
所有的子类都从TProtocolDefaults继承,不需要TVirtualProtocol模板类。
那我们思考一下,为什么Thrift不是以我们这种简单的方式来实现TProtocol,而要搞的那么复杂?
TVirtualProtocol这种方式实现了多态,但是却没有虚拟继承。虚函数调用相比于普通函数调用有性能损失!
性能损失来自几个方面:
- 虚函数无法内联优化
- 虚函数需要重新查虚函数表等更多的流程
- 虚函数导致指令不紧凑,有很大的可能cache miss,在缓存中找不到需要调用的函数代码;影响分支预测
- 影响CPU流水线
TVirtualProtocol采用CRTP(Curiously Recurring Template Pattern:奇特的递归模板模式)的方式静态绑定模板来解决这个问题。
虚函数重写多态是动态绑定(运行时绑定),而CRTP函数覆盖是静态绑定(编译时绑定)。
template<class _Ty>
class Animal
{
public:
void eat()
{
static_cast<_Ty*>(this)->eat();
}
};
class Cat : public Animal<Cat>
{
public:
void eat()
{
printf("Cat eat\n");
}
}
CRTP没有使用虚函数,也实现了多态,牛不牛逼!!!
但是,看上面的代码,大家有没有发现一个问题,如果子类没有覆盖父类的方法,会发生什么奇特的事情?递归调用,一直在自己调用自己,进入了死循环!!!
看看TVirtaulProtocol是如何解决这个问题的:
class TProtocol
{
public:
virtual uint32_t writeByte_virt(const int8_t byte) = 0;
uint32_t writeByte(const int8_t byte) {
return writeByte_virt(byte);
}
virtual uint32_t writeI32_virt(const int32_t i32) = 0;
uint32_t writeI32(const int32_t i32) {
return writeI32_virt(i32);
}
// other code ...
};
/*一个TProtocol的默认实现,覆盖了基类的函数,不会再有递归的问题*/
class TProtocolDefaults : public TProtocol
{
public:
uint32_t writeByte(const int8_t byte) {
throw TProtocolException(TProtocolException::NOT_IMPLEMENTED,
"this protocol does not support writing (yet).");
}
uint32_t writeI32(const int32_t i32) {
throw TProtocolException(TProtocolException::NOT_IMPLEMENTED,
"this protocol does not support writing (yet).");
}
};
/*一个完整的CRTP的基类,没有虚函数,不会有递归*/
template <class Protocol_, class Super_ = TProtocolDefaults>
class TVirtualProtocol : public Super_
{
public:
uint32_t writeByte_virt(const int8_t byte) override {
return static_cast<Protocol_*>(this)->writeByte(byte);
}
uint32_t writeI32_virt(const int32_t i32) override {
return static_cast<Protocol_*>(this)->writeI32(byte);
}
}
上面就是thrift实现TProtocol的代码,从TVirtaulProtocol开始没有虚函数了,而且TVirtaulProtocol虽然是CRTP实现的,但是不再有死循环的递归。
我们实现一个自己的TProtocol,就需要冲TVirtaulProtocol继承:
/*自定义TMyProtocol的实现,继承TVirtualProtocol*/
class TMyProtocol : public TVirtualProtocol<TMyProtocol>
{
public:
// 覆盖父类方法
uint32_t writeByte(const int8_t byte){
// ...
printf("this is TMyProtocol writeByte %d\n", byte);
return 0;
}
}
我们再来看看测试的情况,是否实现了多态:
int main()
{
TProtocol* proto = new TMyProtocol;
proto->writeByte(1);
TMyProtocol* myProto =static_cast<TMyProtocol*>(proto);
myProto->writeByte(1);
proto->writeI32(1);
}
输出的结果肯定都是一样的。
看看proto->writeByte(1)的执行流程:
- 调用TProtocol::writeByte
- 调用TVirtualProtocol::writeByte_virt
- 调用TMyProtocol::writeByte
myProto->writeByte(1)就直接调用了TMyProtocol::writeByte函数了。
思考:既然CRTP模式可以实现多态,为什么TProtocol还需要定义这么多虚函数?为了提供更高的扩展性,其他几个没有继承TVirtaulProtocol的协议就是通过虚函数实现了多态。
3. TMutiplexedProtocol分析
我在一个项目中碰到一个问题:公司的智能设备以前提供了thrift TBinaryProtocol协议的socket服务,现在需要增加一个http api的服务,要能复用原有thrift服务端的handle。
最开始想到的方式是创建一个新的service,打开新的线程,使用新的端口监听服务。
其实不必这样,可以使用TMultiplexedProcessor,添加多个协议支持,既不需要新端口,也不需要新开线程。但是有一点需要注意,需要将以前的协议注册为defaultProtocol;新协议的客户端使用TMultiplexedProtocol协议。
TMultiplexedProtocol继承至TProtocolDecorator,使用了装饰器模式,在writeMessageBegin_virt写入操作名称时,将服务名称和操作名称拼接在一起,使用分隔符隔开。
装饰器模式和代理模式在结构上都是一样的,但是两者的本质差别是:装饰器模式用于增强封装的类型;代理模式用于控制封装的类型,不会改变封装类型的功能。
4. 调试
在开发中,有时候需要调试输出,TProtocolTap和TDebugProtocol可以很方便的实现调试输出。
TProtocolTap接受两个TProtocol作为输出。一个是实际的source_,另一个sink_用于接收输出。
TProtocolTap实现了一个代理模式,代理了source_,并通过sink_将写入的数据输出。
TDebugProtocol用于将数据以易读的格式输出,并且只可写,不可读。
5. 常用协议格式
TBinaryProtocol、TCompactProtocol和TJsonProtocol是常用的封装协议。
- TBinaryProtocol是二进制数据格式。
- TCompactProtocol是紧凑型二进制数据格式,通过共用byte减少空间的使用。
- TJProtocol很容易理解,就是将数据序列化为json格式。
在生成的代码中,可以看到数据是如何写入的:
// thrift IDL :void oneway HelloService::SayHello(string msg)
void HelloServiceClient::SayHello(const std::string& msg)
{
send_SayHello(msg);
}
void HelloServiceClient::send_SayHello(const std::string& msg)
{
int32_t cseqid = 0;
// 1. 消息写入开始,写入消息头,RPC的函数名、函数调用方式声明、函数顺序号id
oprot_->writeMessageBegin("SayHello", ::apache::thrift::protocol::T_ONEWAY, cseqid);
// 2. 写入参数
HelloService_SayHello_pargs args;
args.msg = &msg;
args.write(oprot_);
// 3. 消息写入结束
oprot_->writeMessageEnd();
// 4. 写入结束
oprot_->getTransport()->writeEnd();
// 5. 刷新缓冲区,将缓冲区数据回写
oprot_->getTransport()->flush();
}
uint32_t HelloService_SayHello_pargs::write(::apache::thrift::protocol::TProtocol* oprot) const {
uint32_t xfer = 0;
apache::thrift::protocol::TOutputRecursionTracker tracker(*oprot);
xfer += oprot->writeStructBegin("HelloService_SayHello_pargs");
/** 主要看这部分
* 1. 写入字段开始,字段名称“msg”,字段类型string,字段的顺序号“1”
* 2. 写入实际的数据,string格式的msg参数
* 3. 写入字段结束
*/
xfer += oprot->writeFieldBegin("msg", ::apache::thrift::protocol::T_STRING, 1);
xfer += oprot->writeString((*(this->msg)));
xfer += oprot->writeFieldEnd();
xfer += oprot->writeFieldStop();
xfer += oprot->writeStructEnd();
return xfer;
}
以TBinaryProtocol为例:
/**
* 写入字段头
*/
template <class Transport_, class ByteOrder_>
uint32_t TBinaryProtocolT<Transport_, ByteOrder_>::writeFieldBegin(const char* name,
const TType fieldType,
const int16_t fieldId) {
// 函数体中,对于未使用的形参的会出警告;name 参数由于没被使用,(void)name是为了不被编译器报警告而这样做
(void)name;
uint32_t wsize = 0;
// 1. 写入1字节的字段类型
wsize += writeByte((int8_t)fieldType);
// 2. 写入2字节的字段id
wsize += writeI16(fieldId);
return wsize;
}
template <class Transport_, class ByteOrder_>
template <typename StrType>
uint32_t TBinaryProtocolT<Transport_, ByteOrder_>::writeString(const StrType& str) {
// 判断字符串有没有超过最大长度
if (str.size() > static_cast<size_t>((std::numeric_limits<int32_t>::max)()))
throw TProtocolException(TProtocolException::SIZE_LIMIT);
// 1. 写入4字节的字符串长度
auto size = static_cast<uint32_t>(str.size());
uint32_t result = writeI32((int32_t)size);
if (size > 0) {
// 2. 写入实际的字符串数据
this->trans_->write((uint8_t*)str.data(), size);
}
return result + size;
}
// 写入字段结束
template <class Transport_, class ByteOrder_>
uint32_t TBinaryProtocolT<Transport_, ByteOrder_>::writeFieldEnd() {
return 0;
}
6. TCompactProtocol分析
TCompactProtocol也是二进制传输,是TBinaryProtocol的改进版本,使得数据压缩的更紧凑。
为了实现更紧凑的数据编码,TCompactProtocol在以下几个方面做了改进:
- 字段头采用差分编码,通常只用8bit。(TBinaryProtocol需要24bit)
- 将bool类型字段参数和字段头编码到一起
- 16、32、64bit的整数采用zigzag+varint的变长编码方式,varint编码越小的整数占用的空间越少,而通常绝对值越小的整数使用的概率越高。(但是大整数将比原来占用的空间更多,改进办法是在编码头的类型中进行判断是否进行了变长编码,大整数不编码即可)
6.1 字段头差分编码
先以bool类型参数为例,看看TCompactProtocol的威力。以TBinaryProtocol作为对比,先看看TBinaryProtocol是如何编码bool类型参数的:
// 1. 编码字段头(包含字段类型和字段id,8+16 共占用24bit)
template <class Transport_, class ByteOrder_>
uint32_t TBinaryProtocolT<Transport_, ByteOrder_>::writeFieldBegin(const char* name,
const TType fieldType,
const int16_t fieldId) {
(void)name;
uint32_t wsize = 0;
wsize += writeByte((int8_t)fieldType);
wsize += writeI16(fieldId);
return wsize;
}
// 2. 编码字段实际值(8bit)
template <class Transport_, class ByteOrder_>
uint32_t TBinaryProtocolT<Transport_, ByteOrder_>::writeBool(const bool value) {
uint8_t tmp = value ? 1 : 0;
this->trans_->write(&tmp, 1);
return 1;
}
再看看TCompactProtocol是如何编码的:
// 1. 编码字段头(bool类型不编码字段头,先用booleanField_保存下来)
template <class Transport_>
uint32_t TCompactProtocolT<Transport_>::writeFieldBegin(const char* name,
const TType fieldType,
const int16_t fieldId) {
if (fieldType == T_BOOL) {
booleanField_.name = name;
booleanField_.fieldType = fieldType;
booleanField_.fieldId = fieldId;
} else {
return writeFieldBeginInternal(name, fieldType, fieldId, -1);
}
return 0;
}
// 2. 编码字段值(bool类型把值和字段头编码在一起,true和false当作两种类型进行编码)
template <class Transport_>
uint32_t TCompactProtocolT<Transport_>::writeBool(const bool value) {
uint32_t wsize = 0;
if (booleanField_.name != nullptr) {
// we haven't written the field header yet
wsize
+= writeFieldBeginInternal(booleanField_.name,
booleanField_.fieldType,
booleanField_.fieldId,
static_cast<int8_t>(value
? detail::compact::CT_BOOLEAN_TRUE
: detail::compact::CT_BOOLEAN_FALSE));
booleanField_.name = nullptr;
} else {
// we're not part of a field, so just write the value
wsize
+= writeByte(static_cast<int8_t>(value
? detail::compact::CT_BOOLEAN_TRUE
: detail::compact::CT_BOOLEAN_FALSE));
}
return wsize;
}
// 3. 实际的字段头编码(有两种编码方式,1. 采用差分编码以8bit编码整个字段头;2. 不压缩,与TBinaryProtocol相同的8+16共24个bit编码)
template <class Transport_>
int32_t TCompactProtocolT<Transport_>::writeFieldBeginInternal(
const char* name,
const TType fieldType,
const int16_t fieldId,
int8_t typeOverride) {
(void) name;
uint32_t wsize = 0;
// if there's a type override, use that.
int8_t typeToWrite = (typeOverride == -1 ? getCompactType(fieldType) : typeOverride);
// check if we can use delta encoding for the field id(判断是否可为字段id采用差分编码;当此字段的id大于前一个字段的id,且小于16时可采用差分编码)
if (fieldId > lastFieldId_ && fieldId - lastFieldId_ <= 15) {
// write them together
wsize += writeByte(static_cast<int8_t>((fieldId - lastFieldId_)
<< 4 | typeToWrite));
} else {
// write them separate
wsize += writeByte(typeToWrite);
wsize += writeI16(fieldId);
}
lastFieldId_ = fieldId;
return wsize;
}
在bool类型的编码中,TBinaryProtocol需要8+16+8共32个bit;TCompactProtocol采用将bool的true和false当作两种不同的类型编码到字段头的方式来处理bool类型,同时字段头的编码又包含了差分编码方式;使得通常情况下bool类型只需要8个bit,即使在最差的情况下也只需要24bit。从上面可看出通常情况下采用差分编码的TCompactProtocol编码的字段头只需要8bit,而TBinaryProtocol需要24bit。
6.2 zigzag整数编码与varint变长编码
编码是为了压缩数据的存储空间,最常见的就是haffman编码。压缩编码的策略是:概率越高的码字应该越短。
6.2.1 zigzag
zigzag是将整数映射到非负整数的算法,而且绝对值越小的整数得到的结果也越小。
- 当整数n>=0,zigzag(n) = 2n
- 当整数n<0,zigzag(n) = 2|n|-1
得到以下表:
n | hex | zigzag(n) |
---|---|---|
0 | 0000 0000 | 0 |
-1 | FFFF FFFF | 1 |
1 | 0000 0001 | 2 |
-2 | FFFF FFFE | 3 |
2 | 0000 0002 | 4 |
… | … | … |
C++中算法实现:
/**
* 32位整数zigzag算法实现
* Convert n into a zigzag int. This allows negative numbers to be
* represented compactly as a varint.
*/
template <class Transport_>
uint32_t TCompactProtocolT<Transport_>::i32ToZigzag(const int32_t n) {
return (static_cast<uint32_t>(n) << 1) ^ (n >> 31);
}
/**
* 64位整数zigzag算法实现
* Convert l into a zigzag long. This allows negative numbers to be
* represented compactly as a varint.
*/
template <class Transport_>
uint64_t TCompactProtocolT<Transport_>::i64ToZigzag(const int64_t l) {
return (static_cast<uint64_t>(l) << 1) ^ (l >> 63);
}
在C/C++中,整数用补码存储。对于正数,补码为其自身;对于负数,除符号位外对原码剩余位依次取反然后+1。符号位0为正,1为负。
6.2.2 varint编码
varint编码是一个整数变长编码方式,是一种整数压缩算法。
通常整数都使用int来表示,一个int占4byte。以int a = 100为例,前3个byte都是0填充,只有最后1个byte包含有效数字,这种情况下,可用考虑使用char保存数据,从而减少浪费。
但是,若一个数字太大,比如int a = 1000,8bit的char就存不下了。如果用两个char存储,就涉及到这两个char之间到底是完全独立,还是需要合并算一个值呢?
其实在utf-8编码中也有一样的问题,如何确定变长编码数据的长度?utf-8中采用的是前导1个数的方式判断变长的长度。
varint采用高位补1的方式实现变长,高位为1表示后一个byte是一起的。
看看c++是如何实现的:
/**
* Write an i32 as a varint. Results in 1-5 bytes on the wire.
*/
template <class Transport_>
uint32_t TCompactProtocolT<Transport_>::writeVarint32(uint32_t n) {
uint8_t buf[5];
uint32_t wsize = 0;
while (true) {
if ((n & ~0x7F) == 0) { // 判断是否 n <= 0x7f
buf[wsize++] = (int8_t)n;
break;
} else {
buf[wsize++] = (int8_t)((n & 0x7F) | 0x80); // n > 0x7f,取前7位值,高位补1
n >>= 7;
}
}
trans_->write(buf, wsize);
return wsize;
}
/**
* Write an i64 as a varint. Results in 1-10 bytes on the wire.
*/
template <class Transport_>
uint32_t TCompactProtocolT<Transport_>::writeVarint64(uint64_t n) {
uint8_t buf[10];
uint32_t wsize = 0;
while (true) {
if ((n & ~0x7FL) == 0) {
buf[wsize++] = (int8_t)n;
break;
} else {
buf[wsize++] = (int8_t)((n & 0x7F) | 0x80);
n >>= 7;
}
}
trans_->write(buf, wsize);
return wsize;
}