thrift源码分析(四)TProtocol数据编码协议实现与CRTP

15 篇文章 0 订阅

TProtocol是Thrift数据序列化和反序列化的工具类,与Google的ProtocolBuffer类似。

要点:

  1. CRTP实现TVirtualProtocol
  2. 装饰器模式实现TProtocolDecorator、TMutiplexedProtocol,TMutilplexedProtocol多协议支持
  3. 代理模式实现TProtocolTap,与TDebugTProtocol配合调试输出
  4. TCompactProtocol压缩编码,zigzag和varint实现整数变长编码

1. Thrift TProtocol介绍

在这里插入图片描述

  1. 可接受的数据类型
  2. 实现了的序列化格式
    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这种方式实现了多态,但是却没有虚拟继承。虚函数调用相比于普通函数调用有性能损失!
性能损失来自几个方面:

  1. 虚函数无法内联优化
  2. 虚函数需要重新查虚函数表等更多的流程
  3. 虚函数导致指令不紧凑,有很大的可能cache miss,在缓存中找不到需要调用的函数代码;影响分支预测
  4. 影响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)的执行流程:

  1. 调用TProtocol::writeByte
  2. 调用TVirtualProtocol::writeByte_virt
  3. 调用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是常用的封装协议。

  1. TBinaryProtocol是二进制数据格式。
  2. TCompactProtocol是紧凑型二进制数据格式,通过共用byte减少空间的使用。
  3. 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在以下几个方面做了改进:

  1. 字段头采用差分编码,通常只用8bit。(TBinaryProtocol需要24bit)
  2. 将bool类型字段参数和字段头编码到一起
  3. 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

得到以下表:

nhexzigzag(n)
00000 00000
-1FFFF FFFF1
10000 00012
-2FFFF FFFE3
20000 00024

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;
}
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值