Protocol Buffer高阶玩法:如何优雅地批量修改proto文件

每个程序员少不了和Protocol Buffer打交道。Protobuf现在几乎已经是业界标准了,设计rpc接口协议不会考虑其他的选择。可能也有人喜欢造轮子发明自己的一套协议,但是请相信我,造这种轮子除了可以用来汇报之外(前提是先说服老板允许你浪费时间造轮子),基本上不会给公司业务、后台服务上带来什么效率的提升。Protobuf有诸多家喻户晓的优点,比如广泛的支持各种语言、具有良好的版本兼容性、体积小解析快等等。作为后台协议这些特性可以说是基本的要求。但是protobuf之所以这么优秀,我认为还离不开它丰富的api和可扩展性。

Protobuf的api让我们可以实现一些非常定制化的需求。虽然protobuf的Generated Code可以涵盖绝大多数使用场景,但是还有些边缘需求仅仅通过Generated Code是完成不了的。遇到这些特殊的场景,去翻一翻protobuf的api文档总会有意外的收获。我就非常享受这种自由自在的感觉。从这一点上来讲,protobuf有点类似于Emacs,都具有良好的可扩展性。开发者的需求一定是千奇百怪的,创造的时候根本无法预测将来会有什么需求。但是也没问题,给开发者留好各种接口,你们有需求自己去实现就好了。

今天就结合我在实际工作中遇到的一个问题,介绍如何使用protobuf的api来高效地完成任务。

痛点

公司有一套基于Hadoop的分布式数据仓库系统(Tencent distributed Data Warehouse,简称TDW),各个业务的海量数据都会存储在tdw中,可以通过Hive sql进行查询计算。Hive支持很多的文件格式,比如Sequence File、Avro、Parquet、ORC文件等等。但是有很多业务的数据都是protobuf格式的,比如广告的曝光点击日志。TDW为了解决这些业务的问题,支持了存储protobuf序列化之后的二进制数据,也支持用sql进行查询。

TDW既然支持pb数据的解析,那么它一定需要知道数据对应的proto,这样才能通过sql查询。因此在tdw上创建pb表的流程会有一些复杂。用户需要将数据所对应的proto文件通过页面上传,tdw内部会再把proto编译成java,才能用于数据的解析。当数据的proto文件更新后,也要在tdw上执行同样的操作来更新表结构。

proto比较简单时这套更新表的流程完全没有问题,只要上传一个proto就好了,用户没有什么额外的负担。但是在具体的业务场景下,proto会变得非常非常复杂,比如广告业务的日志proto就依赖了多达50个proto文件,依赖关系错综复杂,通常是A依赖B,B依赖C,C再依赖D,所依赖的proto又会分散在不同的文件夹下。以如下的目录结构为例。

.
├── A.proto
├── D.proto
└── dir1
    ├── B.proto
    └── dir2
        └── C.proto

几个proto的内容如下:

// A.proto
import "dir1/B.proto";

// B.proto
import "dir1/dir2/C.proto";

// C.proto
import "D.proto";

这种情况下更新pb表结构就非常痛苦了。首先在页面上是无法上传目录的,tdw也不支持先把proto打包再上传,只能一次性上传多个proto文件。这样一来就丢失了目录层级结构,同时也破坏了proto之间的依赖关系。import "dir1/B.proto";是无法成功的,因为已经没有dir1这个目录了。这种情况下,只能手动编辑proto文件,修改50多个proto中的import路径。这是第一个痛点。

第二个痛点是tdw使用2.5版本的protobuf,但是业务后台服务用的是3.7。这就导致tdw不认识proto里的一些新选项,比如cc_enable_arenas。为了顺利的更新表结构,还需要把每个proto里的这个选项清除掉。

一个proto里面有多个message,tdw如何知道哪个message才是数据所对应的呢?它采用了一种简单的解决方案,认为表名就是对应message的名称,比如表名为A,那proto里面一定要有一个message的名字也是A。这点也会令人非常痛苦,在实际的使用场景中,是极有可能message名称和表的名字不同的。比如需要为同一个message创建多张表,表名又不能重复。这时候就要修改message名称了。如果这个message里没有内嵌其他message,手动改动一下也还好。如果有内嵌的message并且被很多地方引用了,那就需要做很多的改动了。

message SearchResponse {
  message Result {
    required string url = 1;
    optional string title = 2;
    repeated string snippets = 3;
  }
  repeated Result result = 1;
}

message SomeOtherMessage {
  optional SearchResponse.Result result = 1;
}

以上面的proto为例,如果把上面的SearchResponse改为NewSearchResponse,那么SomeOtherMessage中的result字段的类型也要一并改为NewSearchResponse.Result,proto才能正常解析。

需求

为了解决上面这些问题,我们可以提炼出几个小的需求。

  1. 将所有的proto放到同一个目录下,并且保持原有的依赖关系。
  2. 去掉2.5版本的protobuf所不识别的option。
  3. 修改message的名称为表名,并且修改所有跟该message相关的字段类型。

这些需求也可以通过脚本编辑proto文件完成,但是一定会非常复杂,不够优雅。我们需求的本质就是修改proto,能否通过api来完成这些事情呢?

api的探索

把大象装进冰箱都分为3步,我们解决这几个需求也可以大概分为3步:解析proto、修改proto、生成新的proto。只要api能提供这些能力,那整个需求就是可行的。

打开protobuf的api文档,我们可以看到有一个namespace叫做google::protobuf::compiler,看起来就像是与proto本身的编译相关的,里面果然有一个parser.h。再看看Parser这个类的介绍,文档说Parser是一个非常底层的类,只会把proto文件转换为FileDescriptorProto,用户应该会对Importer这个类更感兴趣。那就再看看Importer的文档。

看到这里大概明白了,Parser这个类只能parse单个的proto文件,Importer除了解析proto本身之外,还会递归地处理它的依赖。有了Importer,解析的问题就可以解决了。

那下一步咱们应该如何去修改proto呢?Importer的文档已经明确告诉我们会把proto解析成FileDescriptor,那么看来一定要通过这个类来进行修改了。但是仔细看了下FileDescriptor类的方法,只有读取的函数没有修改的函数。忽然想到Parser也是解析proto,但是它是把proto解析成FileDescriptorProto的,这个类有没有什么不同?

但是找了半天好像也没有FileDescriptorProto这个类的文档,protobuf的文档里面,各个类都是有链接的,点击就可以跳转过去。但是这个类就比较特殊,凡是涉及到这个类的地方,都没有链接。最终让我在descriptor.pb.h这个文件里面找到了。原来FileDescriptorProto是一个proto的message,并不是c++的类,难怪没有链接了。

FileDescriptorProto的概念是非常巧妙的:Protocol buffer representations of descriptors. descriptor.h里面都是各种Descriptor类,descriptor.pb.h都是这些类的protobuf的表达。FileDescriptor是描述proto文件的类,那么FileDescriptorProto就是这个类所对应的protobuf message。我是这么理解的,FileDescriptorProto就是proto文件的proto,用一个message来描述整个proto文件的内容。

message FileDescriptorProto {
  optional string name = 1;
  optional string package = 2;

  repeated string dependency = 3;
  repeated int32 public_dependency = 10;
  repeated int32 weak_dependency = 11;

  repeated DescriptorProto message_type = 4;
  repeated EnumDescriptorProto enum_type = 5;
  repeated ServiceDescriptorProto service = 6;
  repeated FieldDescriptorProto extension = 7;

  optional FileOptions options = 8;
  optional SourceCodeInfo source_code_info = 9;
  optional string syntax = 12;
}

proto里面的各种option、message定义以及import依赖,不过是FileDescriptorProto中的一个个字段罢了。修改proto文件,只要像修改正常的pb一样,改这些字段就可以了。

修改proto的问题也解决了,那下一步就是如何把修改后的proto输出了。回到FileDescriptor这个类,我们可以看到有一个DebugString的方法,可以把proto再重新输出为字符串。FileDescriptorProto如何转换为FileDescriptor呢?文档里面也写了,可以使用DescriptorPool这个类。

经过对文档的这一番探索,我们大概就有思路了。修改proto并且输出到新的文件中大概要经历如下具体的几个步骤:

  1. 通过Importer解析需要处理的proto文件以及它的依赖,拿到FileDescriptor对象。
  2. FileDescriptor对象转换为FileDescriptorProto
  3. 按照需求修改FileDescriptorProto
  4. 通过DescriptorPoolFileDescriptorProto转换回FileDescriptor
  5. FileDescriptor的DebugString输出到文件中。

文件路径与依赖的处理

为了将所有的proto都放在一个目录下,并且还保持原来的依赖关系,我们可以采用一种简单的处理方式,就是将路径和文件名拼到一起作为新的文件名,这样可以确保文件名不会冲突。比如dir1/dir2/C.proto修改为dir1_dir2_C.proto。文件名修改后,proto中的import也需要做同样的修改。

const google::protobuf::FileDescriptor *fd = importer.Import(proto);
google::protobuf::FileDescriptorProto fdp;
fd->CopyTo(&fdp);

// iterate through dependencies
for (int i = 0; i < fdp.dependency_size(); i++) {
  string dep(fdp.dependency(i));
  string new_dep(boost::replace_all_copy(dep, "/", "_")); // simple replace

  fdp.set_dependency(i, new_dep);
}

我们通过FileDescriptorProto中的dependency字段遍历proto的依赖,逐个进行简单的字符串替换,再更新dependency字段。这样原来proto中的import "dir1/dir2/C.proto"就变成了import "dir1_dir2_C.proto"

前面讲过我们需要通过DescriptorPoolFileDescriptorProto转换回FileDescriptor,再输出最终的proto到文件中,这里需要用到DescriptorPoolBuildFile方法。但是在实际操作时遇到了问题:BuildFile要求当前proto所依赖的全部文件都已经build成功。

假设proto的依赖关系如上图,我们需要处理A,那么应该先确保A的依赖都已经执行了BuildFile。对于A所依赖的proto也是同样的要求。所以我们要处理整棵树的叶子节点,然后再处理父节点,最后才是根节点。处理的顺序我也标注在节点旁边。这不就是深度优先搜索吗?用递归就可以实现了,非常简单。

int fd_to_proto(const po::variables_map &vm,
                const boost::filesystem::path &dir,
                const google::protobuf::FileDescriptor *fd,
                google::protobuf::DescriptorPool &pool) {
  google::protobuf::FileDescriptorProto fdp;
  fd->CopyTo(&fdp);

  for (int i = 0; i < fd->dependency_count(); i++) {
    const google::protobuf::FileDescriptor *deps_fd = fd->dependency(i);
    fd_to_proto(vm, dir, deps_fd, pool);
  }

  // return if this proto has been processed
  if (pool.FindFileByName(fdp.name()) != NULL) {
    return 0;
  }

  // process fdp

  // build
  const google::protobuf::FileDescriptor *new_fd = pool.BuildFile(fdp);

  // output to file

  return 0;
}

最后还剩一些小问题,比如去掉cc_enable_arenas、修改message名称等等,看看FileDescriptorProto也就知道怎么用了,这里就不再赘述。完整代码请参考protobuf-api-examples

有了这个工具,之前需要花大量时间手工处理的任务,现在一个命令就可以搞定,彻底释放了我的生产力。需求本身确实是非常非常小众的,在stackoverflow上肯定没有解决方案,只能自己探索。如果你跟我不在一个公司没用过tdw,那压根就不会遇到这种场景。这篇文章并不能帮大家解决什么实际的问题,只希望让大家了解到protobuf api的威力,借助api我们确实是可以解决问题的。另外把我解决问题的思路描述出来,下次如果你也有自己的小众需求了,可以用同样的思路尝试解决。