Protobuf 编码及序列化的记录

     工作中用到了protobuf,然后之前在面试的时候面试官就问了一个问题,如果将int32类型的字段的值设置为0,那还会将该值进行序列化吗?当时是懵了的,因为自己还没有研究这部分。当时给的结果是不会,猜测protobuf中int32的默认值是0,既然默认值是0的,那应该就不会进行序列化了。

      那次面试之后就觉得自己应该了解一下这部分了,结果这两天了解完之后,发现自己猜错了。好记性不如烂笔头,也顺便记录下这两天了解到的吧。如果觉得写得有点乱了,请原谅。这里使用的是protobuf版本是2.6.1。

1. protobuf简单介绍

       即Protocol Buffer,是一个灵活的、高效的、自动化的用于对结构化数据进行序列化的协议,与XML相比,Protocol buffers序列化后的码流更小、速度更快、操作更简单,还是支持向前兼容和向后兼容的。

      protobuf中使用了反射机制,可以根据字段的名字直接得到字段的值,更深入的我还没有了解,所以需要大家去百度下了。后面了解完的话,我会再写篇博客介绍的。

      一个简单的proto文件内容如下:

message PbInfo
{
    optional uint64 uid     = 1;
    optional uint32 time    = 2;
    optional uint32 type    = 3;
    required string account = 5;    
    repeated string key     = 7;   
}

         可以看到每个字段都是由字段规则、字段类型、字段值、字段的编号组成,字段的规则有三种:optional fields(可选字段)、required fields(必须字段)、repeated fields(可重复字段),message内每个字段的编号都要是唯一的。

         那protobuf是怎么做到向前及向后兼容的呢?靠的就是这个字段的编号,在反序列化的时候,protobuf会从输入流中读取出字段编号,然后再设置message中对应的值。如果读出来的字段编号是message中没有的,就直接忽略,如果message中有字段编号是输入流中没有的,则该字段不会被设置。所以即使通信的两端存在一方比另一方多出编号,也不会影响反序列化。但是如果两端同一编号的字段规则或者字段类型不一样,那就肯定会影响反序列化了。所以一般调整proto文件的时候,尽量选择加字段或者删字段,而不是修改字段编号或者字段类型。

2. protobuf怎么知道哪些字段需要序列化

     下面的代码,是上面的文件编译生成c文件的一部分。

inline bool PbInfo::has_uid() const {
  return (_has_bits_[0] & 0x00000001u) != 0;
}
inline void PbInfo::set_has_uid() {
  _has_bits_[0] |= 0x00000001u;
}
inline void PbInfo::clear_has_uid() {
  _has_bits_[0] &= ~0x00000001u;
}
inline void PbInfo::clear_uid() {
  uid_ = GOOGLE_ULONGLONG(0);
  clear_has_uid();
}

inline void PbInfo::set_uid(::google::protobuf::uint64 value) {
  set_has_uid();
  uid_ = value;
  // @@protoc_insertion_point(field_set:proto.PbInfo.uid)
}

      通过上面的代码,我们明显的看到对于每个message,protobuf都会生成一个对应的类,并且类中会有一个_has_bits_的成员变量(位图)来记录哪个字段是被设置过的。如上面的,当调用set_uid设置uid字段的值时,就会调用set_has_uid来设置_has_bits_中对应的位,序列化的时候在根据_has_bits_的值来决定序列化哪些字段。这里字段的顺序决定每个字段对应_has_bits_中哪个位,而不是根据字段的编号。

     所以上面面试官问我的那个问题我的回答是错的,即使是设置成0,也会被序列化。

      接下来,让我们再继续深入了解下,看看protobuf是怎么序列化及反序列化的。

3. protobuf序列化与反序列化

      提前说下,我这里关于序列化与反序列化的介绍,只是大概介绍下流程而已,可能需要结合源码查看。

3.1 序列化

      序列化一般是用SerializeToString或者SerializeToArray,这里只跟踪了SerializeToArray函数,其调用的过程如下图:

protobuf序列化

        通过调用过程可以看到,序列化的最后是先用ListFields来获取message中所有被设置过的字段,然后再对每个字段调用SerializeFieldWithCachedSizes进行序列化。ListFields函数定义在generated_message_reflection.cc中,内容如下:


void GeneratedMessageReflection::ListFields(
    const Message& message,
    vector<const FieldDescriptor*>* output) const {
  output->clear();

  // Optimization:  The default instance never has any fields set.
  if (&message == default_instance_) return;

  for (int i = 0; i < descriptor_->field_count(); i++) {
    const FieldDescriptor* field = descriptor_->field(i);
    if (field->is_repeated()) {
      if (FieldSize(message, field) > 0) {
        output->push_back(field);
      }
    } else {
      if (field->containing_oneof()) {
        if (HasOneofField(message, field)) {
          output->push_back(field);
        }
      } else if (HasBit(message, field)) {
        output->push_back(field);
      }
    }
  }

  ...
}

         上面去掉了一小部分内容。可以看到,对于字段规则为repeated的,如果长度大于0则会被序列化,containing_oneof目前还没找到是什么作用,但是可以看到有个HasBit的判断,即使判断这个字段是否被设置过,如果被设置过则添加到vector中。

3.2 protobuf反序列化

         反序列化一般调用ParseFromArray或者ParseFromString,这里分析了ParseFromArray的调用过程,如下图:

protobuf反序列化

        可以看到,InlineMergeFromCodedStream中是让传入的message自己去反序列化输入的数据。而最终的反序列化函数ParseAndMergePartial中会不断调用ReadTag从输入数据中读出一个tag,再从tag中获取字段编号,进而获取到对应field,最终调用ParseAndMergeField来反序列化这个字段的数据。

         而在ParseAndMergeField函数中,则会根据对应field的类型(注意这里是根据本地proto文件,对端并不会传送字段类型的信息),调用对应的反序列化代码。例如如果是int32,则调用AddInt32或者SetInt32函数,对于enum类型,则调用SetEnum。但是注意到一点比较奇怪的,SetInt32在解析输入并将值设置到message的时候,没有调用SetBit函数去设置该message中的_has_bits_的对应位,但是在SetString和SetEnum的时候,跟进后可以看到最终都会调用SetBit函数设置_has_bits_。

// generated_message_reflection.cc

inline void GeneratedMessageReflection::SetBit(                               
    Message* message, const FieldDescriptor* field) const {                                                   
    MutableHasBits(message)[field->index() / 32] |= (1 << (field->index() % 32));                                                
}  

inline uint32* GeneratedMessageReflection::MutableHasBits(
    Message* message) const {
  void* ptr = reinterpret_cast<uint8*>(message) + has_bits_offset_;
  return reinterpret_cast<uint32*>(ptr);
}

        上面是SetBit和MutableHasBits的函数定义,可以看到在message的对象中,保存了_has_bits_在message空间中的偏移量has_bits_offset_,这样子就可以直接得到_has_bits_了,而每个field中又存有该field对应在_has_bits_中的哪一位(filed->index()),这样子就可以直接通过SetBit函数执行和上面set_has_uid()一样的操作了。这就是映射机制的其中一部分吧。

3.3 序列化的格式

        这里仅做简单介绍。

        对于String类型的,是直接将字符串数据写入到缓冲区中,使用的是WriteString(io/coded_stream.h),WriteString中调用WriteRaw(io/coded_stream.cc)写入。

        对于整数型的,int32的函数如下:


void CodedOutputStream::WriteVarint32(uint32 value) {
  if (buffer_size_ >= kMaxVarint32Bytes) {
    // Fast path:  We have enough bytes left in the buffer to guarantee that
    // this write won't cross the end, so we can skip the checks.
    uint8* target = buffer_;
    uint8* end = WriteVarint32FallbackToArrayInline(value, target);
    int size = end - target;
    Advance(size);
  } else {
    // Slow path:  This write might cross the end of the buffer, so we
    // compose the bytes first then use WriteRaw().
    uint8 bytes[kMaxVarint32Bytes];
    int size = 0;
    while (value > 0x7F) {
      bytes[size++] = (static_cast<uint8>(value) & 0x7F) | 0x80;
      value >>= 7;
    }
    bytes[size++] = static_cast<uint8>(value) & 0x7F;
    WriteRaw(bytes, size);
  }
}

          通过上面的函数可以看到protobuf对于整数的序列化方式是用一个字节的低七位来保存数值的七位,第八个位则用来记录下一个字节是否也是属于该数字的,并且是反向的。也就是说原值中的第二个低七位会被保存到下一个字节,这样子不论序列化还是反序列化的时候都很方便。看下面例子即可:

保存值             二进制            实际保存二进制
3                 00000 0011        0000 0011
258               1 0000 0010       1000 0010 0000 0010

3.4 字段信息的保存

           protobuf是怎么保存字段相关的信息的呢?通过查看上面反序列化时用的ReadTag涉及到的函数,我们就可以很清楚的了解了。

// coded_stream.h
inline uint32 CodedInputStream::ReadTag() {
  if (GOOGLE_PREDICT_TRUE(buffer_ < buffer_end_) && buffer_[0] < 0x80) {
    last_tag_ = buffer_[0];    
    Advance(1);
    return last_tag_;
  } else {
    last_tag_ = ReadTagFallback();  
    return last_tag_;
  }
}

// wire_format_lite.h   
static const int kTagTypeBits = 3;
static const uitn32 kTagTypeMask = (1 << kTagTypeBits) - 1;
inline WireFormatLite::WireType WireFormatLite::GetTagWireType(uint32 tag) {                                      
{   
    return static_cast<WireType>(tag & kTagTypeMask);
}

inline int WireFormatLite::GetTagFieldNumber(uint32 tag) {
    return static_cast<int>(tag >> kTagTypeBits);
}

       可以看到对于读到的一个tag,低三位是字段规则,而除此以外的都是字段编号使用。但是在读取该字段对应的tag的时候,如果tag用到了不止一个字节(和整型值一样的压缩方式),则会调用ReadTagFallback函数读取tag,这里代码就不贴出来了,也很容易知道大概的读取操作了。

 

4. 总结

       以上,就是这段时间根据源码学到的protobuf相关的知识了,有点杂,不过应该能加深下对protobuf的理解了吧。接下来需要找下时间了解下反射机制了。


版权声明:本文为u014209688原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。