Thrift之TProtocol类体系原理及源码详细解析之紧凑协议类TCompactProtocolT(TCompactProtocol)
我的新浪微博:http://weibo.com/freshairbrucewoo。
欢迎大家相互交流,共同提高技术。
这个协议类采用了zigzag 编码,这种编码是基于Variable-length quantity编码提出来的,因为Variable-length quantity编码对于负数的编码都需要很长的字节数,而zigzag 编码对于绝对值小的数字,无论正负都可以采用较少的字节来表示,充分利用了 Varint技术。所以这个协议类采用zigzag 编码可以节省传输空间,使数据的传输效率更高。至于zigzag具体的编码实现方式可以网上查查,其实就是把从低位到最后一个还存在1(二进制)的最高位表示出来就可以了。这个协议类对外提供的方法和上面介绍的二进制协议相同,这样可以很方便使用者从一种协议改变到另一种协议。
下面我同样结合scribe提供的Log方法来分析这个协议类的功能,不过不会像上面二进制协议在把整个过程分析了,我只会分析与协议相关的部分了,分析一些比较难懂的一些函数功能,分析的思路还是按照函数调用过程来分析。
首先还是分析writeMessageBegin函数,下面是这个函数的实现代码:
1 template <class Transport_> uint32_t TCompactProtocolT<Transport_>::writeMessageBegin( 2 3 const std::string& name, const TMessageType messageType, const int32_t seqid) { 4 5 uint32_t wsize = 0; 6 7 wsize += writeByte(PROTOCOL_ID);//写入这个协议的产品ID号:为0x82 8 9 wsize += writeByte((VERSION_N & VERSION_MASK) | (((int32_t)messageType << TYPE_SHIFT_AMOUNT) & TYPE_MASK));//写入此协议的版本号和消息类型:前3位是消息类型,后面5位是协议版本号 10 11 wsize += writeVarint32(seqid);//写入请求序列号 12 13 wsize += writeString(name);//写入消息名称(也就是函数调用名称) 14 15 return wsize;//返回写入的大小,多少字节 16 17 }
因为这些协议类都是模板类,所以每一个函数也就是模板函数了。函数具体的功能代码里有详细注释,其中的writeByte函数就是写入一个字节到服务器。这里与二进制协议不同的是这里写入请求序列号(也就是对于所有的整型数)都调用的writeVarint32函数,这个函数就是采用zigzag编码写入整型数到服务器,代码如下:
1 template <class Transport_> uint32_t TCompactProtocolT<Transport_>::writeVarint32(uint32_t n) { 2 3 uint8_t buf[5];//对于一个整数,zigzag编码最大采用5个字节保存 4 5 uint32_t wsize = 0; 6 7 while (true) { 8 9 if ((n & ~0x7F) == 0) {//判断除了最低7位是否还有其他高位为1(二进制) 10 11 buf[wsize++] = (int8_t)n;//没有了代表着就是最后一个字节 12 13 break;//退出循环 14 15 } else { 16 17 buf[wsize++] = (int8_t)((n & 0x7F) | 0x80);//取最低7位加上第8位(为1代表后续还有字节属于这个整数,为0代表这是这个整数的最后一个字节了。 18 19 n >>= 7;//移走已经编码的位数 20 21 } 22 23 } 24 25 trans_->write(buf, wsize);//写入编码的字节数 26 27 return wsize;//返回写入的字节数 28 29 }
这个函数的功能就是对整数进行Variable-length quantity编码后写入,如果为负数需要处理。如果不处理那么每一个负数都需要5个字节来编码,因为最高位表示符号位,而负数的符号位用1表示(也就是说负数的最高位永远为1)。处理的方式也很简单(就是zigzag编码),就是把最高位(符号位)移动到最低位,最低位到次高位一次向高位移动一位,代码如下(就一句就实现了):
1 template <class Transport_> 2 3 uint32_t TCompactProtocolT<Transport_>::i32ToZigzag(const int32_t n) { 4 5 return (n << 1) ^ (n >> 31); 6 7 }
上面写入整数和处理负数都是针对的32位的,当然也有64位的相应函数,实现方式相同。我们在回到writeMessageBegin函数,里面还有一个writeString函数用来写入一个字符串的,与二进制不同的是写入字符串长度也是采用了可变长度编码的方式写入,然后写入字符串的具体数据,它是调用另一个函数writeBinary写入,writeBinary实现代码如下:
1 template <class Transport_> 2 3 uint32_t TCompactProtocolT<Transport_>::writeBinary(const std::string& str) { 4 5 uint32_t ssize = str.size(); 6 7 uint32_t wsize = writeVarint32(ssize) + ssize;//写入字符串的长度并计算写入的长度(包括字符串的长度) 8 9 trans_->write((uint8_t*)str.data(), ssize);//写入字符串的数据 10 11 return wsize; 12 13 }
写消息函数分析完毕以后我们在来看看对应的读消息函数readMessageBegin,看这个函数必须和写入消息的函数对应起来看,不然就不能理解它读取和处理的流程代码,具体实现如下代码:
1 template <class Transport_> uint32_t TCompactProtocolT<Transport_>::readMessageBegin( 2 3 std::string& name, TMessageType& messageType, int32_t& seqid) { 4 5 uint32_t rsize = 0; 6 7 int8_t protocolId; 8 9 int8_t versionAndType; 10 11 int8_t version; 12 13 rsize += readByte(protocolId);//读取协议产品ID号 14 15 if (protocolId != PROTOCOL_ID) {//判断是不是这个协议的产品ID号,不是就抛出异常 16 17 throw TProtocolException(TProtocolException::BAD_VERSION, "Bad protocol identifier"); 18 19 } 20 21 rsize += readByte(versionAndType);//读取此协议的版本号和消息类型 22 23 version = (int8_t)(versionAndType & VERSION_MASK);//取出协议版本号 24 25 if (version != VERSION_N) {//判断是不是对应的协议版本号,不是抛出异常 26 27 throw TProtocolException(TProtocolException::BAD_VERSION, "Bad protocol version"); 28 29 } 30 31 messageType = (TMessageType)((versionAndType >> TYPE_SHIFT_AMOUNT) & 0x03);//取出消息类型 32 33 rsize += readVarint32(seqid);//读取请求序列号 34 35 rsize += readString(name);//读取消息名称(函数名称) 36 37 return rsize;//返回读取的长度(字节) 38 39 }
通过对照写入消息的函数就很容易理解,因为你写入什么我就读什么并且判断是不是相同协议写入的,具体分析可以看上面的代码和详细的注释。而且还有一点就是具体的写入数据类型的函数也是采用对应类型的读函数,例如读可变长整型写入就是采用可变长读函数readVarint32,写字符串对应读字符串函数readString,对照相应的写入函数来看这些读数据函数就非常好理解了,就不具体分析这些读函数了。
下面在分析几个复合数据类型的写入函数,因为这些写入函数存在一定技巧不容易(或者说不那么直观吧)理解清楚。首先看看struct类型的数据写入的过程,它分为写入开始、中间处理和写入结束。下面是开始写入struct的代码:
1 template <class Transport_> 2 3 uint32_t TCompactProtocolT<Transport_>::writeStructBegin(const char* name) { 4 5 (void) name; 6 7 lastField_.push(lastFieldId_);//把最后写入的字段ID压入堆栈 8 9 lastFieldId_ = 0;//重新设置为0 10 11 return 0; 12 13 }
这开始写入的函数没有做什么具体的工作,只是把最后写入的字段ID压入堆栈,这样做的目的是处理那种struct嵌套的数据结构类型。
Struct里面的是一个一个的字段,所以根据struct的字段个数分别调用字段写入函数依次写入,字段写入函数定义如下:
1 template <class Transport_> int32_t TCompactProtocolT<Transport_>::writeFieldBeginInternal( 2 3 const char* name, const TType fieldType, const int16_t fieldId, int8_t typeOverride) { 4 5 (void) name;//为了防止编译器产生警告信息 6 7 uint32_t wsize = 0; 8 9 // 如果存在对于对应的类型就转换为对应的 10 11 int8_t typeToWrite = (typeOverride == -1 ? getCompactType(fieldType) : typeOverride); 12 13 // 检查字段ID是否使用了增量编码 14 15 if (fieldId > lastFieldId_ && fieldId - lastFieldId_ <= 15) {//如果使用了增量编码并增量且小于等于15 16 17 wsize += writeByte((fieldId - lastFieldId_) << 4 | typeToWrite);//字段ID和数据类型一起写入 18 19 } else {//否则单独写入 20 21 wsize += writeByte(typeToWrite);//写入数据类型 22 23 wsize += writeI16(fieldId);//写入字段ID 24 25 } 26 27 lastFieldId_ = fieldId;//保存写入字段ID为最后一个写入的ID 28 29 return wsize;//返回写入的长度 30 31 }
当结构体里面的每一个字段都写入以后还需要调用writeStructEnd函数来处理结束一个struct的写入,主要处理是字段ID的相关内容,实现代码如下:
1 template <class Transport_> uint32_t TCompactProtocolT<Transport_>::writeStructEnd() { 2 3 lastFieldId_ = lastField_.top();//取得最后一次压入堆栈的字段ID号 4 5 lastField_.pop();//弹出以取得的字段ID 6 7 return 0; 8 9 }
同样的结构体也有对应的读取函数,具体实现就不在具体分析了!下面继续分析一些特殊的处理代码,首先看看负数在进行zigzag编码前怎样处理,对于32位和64位都是一句代码就搞定,如下代码:
1 return (n >> 1) ^ -(n & 1);
这句代码的作用就是把最高位的符号位移动到最低位,然后最低位到次高位依次向高位移动一位,这样就避免了所有负数都需要最长的字节来编码。在看看读可变长编码写入整型数的函数,32位和64位都是相同的实现,因为32位也是调用64位的函数实现的,实现代码如下:
1 template <class Transport_> uint32_t TCompactProtocolT<Transport_>::readVarint64(int64_t& i64) { 2 3 uint32_t rsize = 0; 4 5 uint64_t val = 0; 6 7 int shift = 0; 8 9 uint8_t buf[10]; // 64 位采用zigzag编码最长可能是10字节 10 11 uint32_t buf_size = sizeof(buf); 12 13 const uint8_t* borrowed = trans_->borrow(buf, &buf_size);//并不是所有transport都支持 14 15 if (borrowed != NULL) {// 快路径,要读的数据已经在缓存中 16 17 while (true) { 18 19 uint8_t byte = borrowed[rsize]; 20 21 rsize++; 22 23 val |= (uint64_t)(byte & 0x7f) << shift;//取得对应编码数据的7位 24 25 shift += 7;//后7位 26 27 if (!(byte & 0x80)) {//是否还有属于这个数的编码字节,字节的最高位表示:0表示没有了 28 29 i64 = val;//读取解码后的真正有效值 30 31 trans_->consume(rsize);//消耗了多少字节,即表示这个编码用了多少字节 32 33 return rsize; 34 35 } 36 37 // 检查编码数据是否超过了最长限制,是就抛出一个无效的异常 38 39 if (UNLIKELY(rsize == sizeof(buf))) { 40 41 throw TProtocolException(TProtocolException::INVALID_DATA, "Variable-length int over 10 bytes."); 42 43 } 44 45 } 46 47 } 48 49 else {// 慢路径,要读的数据还没有存在缓存中 50 51 while (true) { 52 53 uint8_t byte; 54 55 rsize += trans_->readAll(&byte, 1);//读取一个字节 56 57 val |= (uint64_t)(byte & 0x7f) << shift;//取得7位的编码数据 58 59 shift += 7; 60 61 if (!(byte & 0x80)) { 62 63 i64 = val; 64 65 return rsize; 66 67 } 68 69 if (UNLIKELY(rsize >= sizeof(buf))) {//同样检查数据的有效性:最大字节长度不超过10个字节 70 71 throw TProtocolException(TProtocolException::INVALID_DATA, "Variable-length int over 10 bytes."); 72 73 } 74 75 } 76 77 } 78 79 }
由于采用了可变长度编码的原因,所以不知道一次性应该读取多少个字节是一个完整的数据。为了读取效率所以一次性直接读取最长可能的字节数量,也就是10字节,因为64位最长的可变长编码就是10字节长,然后根据实际消耗的字节数从读取元跳过已经消耗的字节数。不过底层的传输层,有些协议可能不支持这种预读取方式,所以就只有一个字节一个字节的读取。
这个协议最大的特点就是采用了可变长度编码,并且采用zigzag编码处理负数总是需要采用最长的编码字节的问题,所以相对于比较二进制而言效率提高了不少。