`

一种自动反射消息类型的 Google Protobuf 网络传输方案

阅读更多

这篇文章要解决的问题是:在接收到 protobuf 数据之后,如何自动创建具体的 Protobuf Message 对象,再做的反序列化。“自动”的意思是:当程序中新增一个 protobuf Message 类型时,这部分代码不需要修改,不需要自己去注册消息类型。其实,Google Protobuf 本身具有很强的反射(reflection)功能,可以根据 type name 创建具体类型的 Message 对象,我们直接利用即可。

本文假定读者了解 Google Protocol Buffers 是什么,这不是一篇 protobuf 入门教程。

本文以 C++ 语言举例,其他语言估计有类似的解法,欢迎补充。

本文的示例代码在: https://github.com/chenshuo/recipes/tree/master/protobuf

网络编程中使用 protobuf 的两个问题

Google Protocol Buffers (Protobuf) 是一款非常优秀的库,它定义了一种紧凑的可扩展二进制消息格式,特别适合网络数据传输。它为多种语言提供 binding,大大方便了分布式程序的开发,让系统不再局限于用某一种语言来编写。

在网络编程中使用 protobuf 需要解决两个问题:

  • 长度 ,protobuf 打包的数据没有自带长度信息或终结符,需要由应用程序自己在发生和接收的时候做正确的切分;
  • 类型 ,protobuf 打包的数据没有自带类型信息 ,需要由发送方把类型信息传给给接收方,接收方创建具体的 Protobuf Message 对象,再做的反序列化。

第一个很好解决,通常的做法是在每个消息前面加个固定长度的 length header,例如我在 《Muduo 网络编程示例之二: Boost.Asio 的聊天服务器 》 中实现的 LengthHeaderCodec,代码见 http://code.google.com/p/muduo/source/browse/trunk/examples/asio/chat/codec.h

第二个问题其实也很好解决,Protobuf 对此有内建的支持。但是奇怪的是,从网上简单搜索的情况看,我发现了很多山寨的做法。

山寨做法

以下均为在 protobuf data 之前加上 header,header 中包含 int length 和类型信息 。类型信息的山寨做法主要有两种:

  • 在 header 中放 int typeId,接收方用 switch-case 来选择对应的消息类型和处理函数;
  • 在 header 中放 string typeName,接收方用 look-up table 来选择对应的消息类型和处理函数。

这两种做法都有问题。

第一种做法要求保持 typeId 的唯一性,它和 protobuf message type 一一对应。如果 protobuf message 的使用范围不广,比如接收方和发送方都是自己维护的程序,那么 typeId 的唯一性不难保证,用版本管理工具即可。如果 protobuf message 的使用范围很大,比如全公司都在用,而且不同部门开发的分布式程序可能相互通信,那么就需要一个公司内部的全局机构来分配 typeId,每次增加新 message type 都要去注册一下,比较麻烦。

第二种做法稍好一点。typeName 的唯一性比较好办,因为可以加上 package name(也就是用 message 的 fully qualified type name),各个部门事先分好 namespace,不会冲突与重复。但是每次新增消息类型的时候都要去手工修改 look-up table 的初始化代码,比较麻烦。

其实,不需要自己重新发明轮子,protobuf 本身已经自带了解决方案。

根据 type name 反射自动创建 Message 对象

Google Protobuf 本身具有很强的反射(reflection)功能,可以根据 type name 创建具体类型的 Message 对象。但是奇怪的是,其官方教程里没有明确提及这个用法,我估计还有很多人不知道这个用法,所以觉得值得写这篇 blog 谈一谈。

以下是Protobuf  class diagram,点击查看原图

protobuf_classdiagram

我估计大家通常关心和使用的是图的左半部分:MessageLite、Message、Generated Message Types (Person, AddressBook) 等,而较少注意到图的右半部分:Descriptor, DescriptorPool, MessageFactory。

上图中,其关键作用的是 Descriptor class,每个具体 Message Type 对应一个 Descriptor 对象。尽管我们没有直接调用它的函数,但是Descriptor在“根据 type name 创建具体类型的 Message 对象”中扮演了重要的角色,起了桥梁作用。上图的红色箭头描述了根据 type name 创建具体 Message 对象的过程,后文会详细介绍。

原理简述

Protobuf Message class 采用了 prototype pattern ,Message class 定义了 New() 虚函数,用以返回本对象的一份新实例,类型与本对象的真实类型相同。也就是说,拿到 Message* 指针,不用知道它的具体类型,就能创建和它类型一样的具体 Message Type 的对象。

每个具体 Message Type 都有一个 default instance,可以通过 ConcreteMessage::default_instance() 获得,也可以通过 MessageFactory::GetPrototype(const Descriptor*) 来获得。所以,现在问题转变为 1. 如何拿到 MessageFactory;2. 如何拿到 Descriptor*。

当然,ConcreteMessage::descriptor() 返回了我们想要的 Descriptor*,但是,在不知道 ConcreteMessage 的时候,如何调用它的静态成员函数呢?这似乎是个鸡与蛋的问题。

我们的英雄是 DescriptorPool,它可以根据 type name 查到 Descriptor*,只要找到合适的 DescriptorPool,再调用 DescriptorPool::FindMessageTypeByName(const string& type_name) 即可。眼前一亮?

在最终解决问题之前,先简单测试一下,看看我上面说的对不对。

简单测试

本文用于举例的 proto 文件:query.proto,见 https://github.com/chenshuo/recipes/blob/master/protobuf/query.proto

package muduo;

message Query {
  required int64 id = 1;
  required string questioner = 2;

  repeated string question = 3;
}

message Answer {
  required int64 id = 1;
  required string questioner = 2;
  required string answerer = 3;

  repeated string solution = 4;
}

message Empty {
  optional int32 id = 1;
}
其中的 Query.questioner 和 Answer.answerer 是我在前一篇文章这提到的《分布式系统中的进程标识
》。

以下代码验证 ConcreteMessage::default_instance()、ConcreteMessage::descriptor()、 MessageFactory::GetPrototype()、DescriptorPool::FindMessageTypeByName() 之间的不变式 (invariant):

https://github.com/chenshuo/recipes/blob/master/protobuf/descriptor_test.cc#L15

  typedef muduo::Query T;

  std::string type_name = T::descriptor()->full_name();
  cout << type_name << endl;

  const Descriptor* descriptor
 = DescriptorPool::generated_pool()->FindMessageTypeByName
(type_name);
  assert(descriptor == T::descriptor());

  cout << "FindMessageTypeByName() = " << descriptor << endl;
  cout << "T::descriptor()         = " << T::descriptor() << endl;
  cout << endl;

  const Message* prototype
 = MessageFactory::generated_factory()->GetPrototype
(descriptor
);
  assert(prototype == &T::default_instance());

  cout << "GetPrototype()        = " << prototype << endl;
  cout << "T::default_instance() = " << &T::default_instance() << endl;
  cout << endl;

  T* new_obj = dynamic_cast<T*>(prototype->New()
);
  assert(new_obj != NULL);
  assert(new_obj != prototype);
  assert(typeid(*new_obj) == typeid(T::default_instance()));

  cout << "prototype->New() = " << new_obj << endl;
  cout << endl;
  delete new_obj;

根据 type name 自动创建 Message 的关键代码

好了,万事具备,开始行动:

  1. 用 DescriptorPool::generated_pool() 找到一个 DescriptorPool 对象,它包含了程序编译的时候所链接的全部 protobuf Message types
  2. 用 DescriptorPool::FindMessageTypeByName() 根据 type name 查找 Descriptor。
  3. 再用 MessageFactory::generated_factory() 找到 MessageFactory 对象,它能创建程序编译的时候所链接的全部 protobuf Message types。
  4. 然后,用 MessageFactory::GetPrototype() 找到具体 Message Type 的 default instance。
  5. 最后,用 prototype->New() 创建对象。

示例代码见 https://github.com/chenshuo/recipes/blob/master/protobuf/codec.h#L69

Message* createMessage(const std::string& typeName)
{
  Message* message = NULL;
  const Descriptor* descriptor = DescriptorPool::generated_pool()->FindMessageTypeByName(typeName);
  if (descriptor)
  {
    const Message* prototype = MessageFactory::generated_factory()->GetPrototype(descriptor);
    if (prototype)
    {
      message = prototype->New();
    }
  }
  return message;
}

调用方式:https://github.com/chenshuo/recipes/blob/master/protobuf/descriptor_test.cc#L49

  Message* newQuery = createMessage("muduo.Query")
;
  assert(newQuery != NULL);
  assert(typeid(*newQuery) == typeid(muduo::Query::default_instance()));

  cout << "createMessage(\"muduo.Query\") = " << newQuery << endl;

古之人不余欺也 :-)

注意,createMessage() 返回的是动态创建的对象的指针,调用方有责任释放它,不然就会内存泄露。在 muduo 里,我用 shared_ptr<Message> 来自动管理 Message 对象的生命期。

线程安全性

Google 的文档说,我们用到的那几个 MessageFactory 和 DescriptorPool 都是线程安全的,Message::New() 也是线程安全的。并且它们都是 const member function。

关键问题解决了,那么剩下工作就是设计一种包含长度和消息类型的 protobuf 传输格式

Protobuf 传输格式

陈硕设计了一个简单的格式,包含 protobuf data 和它对应的长度与类型信息,消息的末尾还有一个 check sum。格式如下图,图中方块的宽度是 32-bit。

protobuf_wireformat1

用 C struct 伪代码描述:

 struct ProtobufTransportFormat __attribute__ ((__packed__))
 {
   int32_t  len;
   int32_t  nameLen;
   char     typeName[nameLen];
   char     protobufData[len-nameLen-8];
   int32_t  checkSum; // adler32 of nameLen, typeName and protobufData
 };
注意,这个格式不要求 32-bit 对齐,我们的 decoder 会自动处理非对齐的消息。

例子

用这个格式打包一个 muduo.Query 对象的结果是:

protobuf_wireexample

设计决策

以下是我在设计这个传输格式时的考虑:

  • signed int 。消息中的长度字段只使用了 signed 32-bit int,而没有使用 unsigned int,这是为了移植性,因为 Java 语言没有 unsigned 类型。另外 Protobuf 一般用于打包小于 1M 的数据,unsigned int 也没用。
  • check sum 。虽然 TCP 是可靠传输协议,虽然 Ethernet 有 CRC-32 校验,但是网络传输必须要考虑数据损坏的情况,对于关键的网络应用,check sum 是必不可少的。对于 protobuf 这种紧凑的二进制格式而言,肉眼看不出数据有没有问题,需要用 check sum。
  • adler32 算法 。我没有选用常见的 CRC-32,而是选用 adler32,因为它计算量小、速度比较快,强度和 CRC-32差不多。另外,zlib 和 java.unit.zip 都直接支持这个算法,不用我们自己实现。
  • type name 以 '\0' 结束 。这是为了方便 troubleshooting,比如通过 tcpdump 抓下来的包可以用肉眼很容易看出 type name,而不用根据 nameLen 去一个个数字节。同时,为了方便接收方处理,加入了 nameLen,节省 strlen(),空间换时间。
  • 没有版本号 。Protobuf Message 的一个突出优点是用 optional fields 来避免协议的版本号(凡是在 protobuf Message 里放版本号的人都没有理解 protobuf 的设计),让通信双方的程序能各自升级,便于系统演化。如果我设计的这个传输格式又把版本号加进去,那就画蛇添足了。具体请见本人《分布式系统的工程化开发方法 》第 57 页:消息格式的选择。

示例代码

为了简单起见,采用 std::string 来作为打包的产物,仅为示例。

打包 encode 的代码:https://github.com/chenshuo/recipes/blob/master/protobuf/codec.h#L35

解包 decode 的代码:https://github.com/chenshuo/recipes/blob/master/protobuf/codec.h#L99

测试代码: https://github.com/chenshuo/recipes/blob/master/protobuf/codec_test.cc

如果以上代码编译通过,但是在运行时出现“cannot open shared object file”错误,一般可以用 sudo ldconfig 解决,前提是 libprotobuf.so 位于 /usr/local/lib,且 /etc/ld.so.conf 列出了这个目录。

$ make all # 如果你安装了 boost,可以 make whole

$ ./codec_test
./codec_test: error while loading shared libraries: libprotobuf.so.6: cannot open shared object file: No such file or directory

$ sudo ldconfig

与 muduo 集成

muduo 网络库将会集成对本文所述传输格式的支持(预计 0.1.9 版本),我会另外写一篇短文介绍 Protobuf Message <=> muduo::net::Buffer 的相互转化,使用 muduo::net::Buffer 来打包比上面 std::string 的代码还简单,它是专门为 non-blocking 网络库设计的 buffer class。

此外,我们可以写一个 codec 来自动完成转换,就行 asio/char/codec.h 那样。这样客户代码直接收到的就是 Message 对象,发送的时候也直接发送 Message 对象,而不需要和 Buffer 对象打交道。

消息的分发 (dispatching)

目前我们已经解决了消息的自动创建,在网络编程中,还有一个常见任务是把不同类型的 Message 分发给不同的处理函数,这同样可以借助 Descriptor 来完成。我在 muduo 里实现了 ProtobufDispatcherLite 和 ProtobufDispatcher 两个分发器,用户可以自己注册针对不同消息类型的处理函数。预计将会在 0.1.9 版本发布,您可以先睹为快:

初级版,用户需要自己做 down casting: https://github.com/chenshuo/recipes/blob/master/protobuf/dispatcher_lite.cc

高级版,使用模板技巧,节省用户打字: https://github.com/chenshuo/recipes/blob/master/protobuf/dispatcher.cc

基于 muduo 的 Protobuf RPC?

Google Protobuf 还支持 RPC,可惜它只提供了一个框架,没有开源网络相关的代码,muduo 正好可以填补这一空白。我目前还没有决定是不是让 muduo 也支持以 protobuf message 为消息格式的 RPC,muduo 还有很多事情要做,我也有很多博客文章打算写,RPC 这件事情以后再说吧。

注:Remote Procedure Call (RPC) 有广义和狭义两种意思。狭义的讲,一般特指 ONC RPC ,就是用来实现 NFS 的那个东西;广义的讲,“以函数调用之名,行网络通信之实”都可以叫 RPC,比如 Java RMI,.Net Remoting,Apache Thriftlibevent RPC ,XML-RPC 等等。

(待续)

分享到:
评论

相关推荐

    实现protobuf与json的互转

    Protocol Buffers是Google推出的一种数据序列化协议,而JSON(JavaScript Object Notation)是一种轻量级的数据交换格式,广泛用于Web服务和API之间传递数据。 描述中提到的“protobuf的Descriptor技术”,...

    protobuf-java-2.5.0.jar

    Protocol Buffers是一种高效、灵活且跨平台的数据序列化机制,可将结构化数据序列化为二进制流,用于网络通信、数据存储等场景。在2.5.0版本中,protobuf-java被认为是最稳定的一个版本,这通常意味着它经过了广泛的...

    protobuf-net r668.zip

    Protocol Buffers是一种轻量级的数据序列化协议,由Google开发,用于结构化数据的串行化,类似于XML、JSON,但更小、更快、更简单。 标题"protobuf-net r668.zip"表明这是protobuf-net的一个特定版本,即r668版本的...

    protobuf常用序列化和反序列化API

    protobuf(Protocol Buffers)是Google开发的一种数据序列化协议,广泛应用于网络通信、数据存储等领域。它提供了一种高效、灵活且跨平台的方式来编码和解码结构化数据。protobuf的核心在于定义消息类型(.proto文件...

    protobuf-lite-3.0.0.jar

    Protocol Buffers是一种高效、灵活且跨平台的数据序列化机制,可以将结构化数据转化为二进制格式,方便在网络传输或存储。在Android开发中,protobuf-lite是protobuf的轻量级版本,适用于资源有限的环境,比如移动...

    protobuf-net-master.zip

    protobuf(Protocol Buffers)是Google开发的一种数据序列化协议,而protobuf-net是它的.NET实现,它允许开发者将结构化数据序列化为字节流,以便于存储或通过网络传输。这个版本的protobuf-net不仅提供了基本的序列...

    protobuf:用于 Go 的基于反射的协议缓冲区

    协议缓冲区(Protocol Buffers)是Google推出的一种数据序列化协议,它可以将结构化的数据序列化为二进制流,便于网络传输和存储。在Go语言中,protobuf提供了一种高效且灵活的方式来处理结构化数据,而基于反射的...

    Muduo_网络库使用手册

    - **2.5 一种自动反射消息类型的Google Protobuf网络传输方案**:结合Google Protobuf,实现自动化的消息类型反射机制。 - **2.6 在muduo中实现Protobuf编解码器与消息分发器**:详细介绍如何在Muduo框架下使用...

    供文章使用proto.7z

    ProtoBuf(Protocol Buffers)是由Google开发的一种数据序列化协议,它允许开发者定义数据结构,然后生成能够序列化和反序列化这些结构的代码,以进行高效的数据交换。在C#环境下,ProtoBuf被广泛应用于服务端通信,...

    jars_20220921.tar.gz

    每个.proto文件代表一种特定的协议消息类型,比如: 1. `parquet.proto`:可能与Apache Parquet有关,Parquet是一种列式存储格式,常用于大数据处理,如Hadoop和Spark,它可以高效地处理大规模结构化数据。 2. `...

    Protocol Buffer技术深入理解(C++实例)

    Protocol Buffer技术是Google开发的一种数据序列化协议,用于高效地编码和解码结构化数据。在C++中,Protocol Buffer提供了强大的工具`protoc`,用于将`.proto`文件转换为对应编程语言(如C++)的源代码,使得开发者...

    protostuff fastjson gson 高性能序列化jar包

    - Fastjson提供了一种简单易用的API,使得开发者可以方便地进行JSON对象的操作,例如,通过`parseObject()`和`toJSONString()`方法进行转换。 - 此外,Fastjson还支持Java反射机制,可以自动识别和序列化/反序列化...

    Linux多线程服务端编程:使用muduo C++网络库

    《Linux多线程服务端编程:使用muduo C++网络库》主要讲述采用现代C++在x86-64 Linux上编写多线程TCP网络服务程序的主流常规技术,重点讲解一种适应性较强的多线程服务器的编程模型,即one loop per thread。...

    开源项目-golang-go.zip

    Go语言是Google开发的一种静态类型的编译型编程语言,它以其简洁、高效和并发支持而受到开发者们的欢迎。在Go语言中,结构体类型转换是一个常见的操作,用于在不同结构体之间进行数据迁移。 在Go语言中,结构体标签...

    抠脚的RPC实现

    RPC(Remote Procedure Call)是一种分布式计算技术,允许一个程序在执行过程中调用另一个不在同一内存空间的程序。在Java中,RPC框架如Hessian、Dubbo、gRPC等广泛应用于微服务架构中,以实现服务间的高效通信。...

    Java工程师应用技术汇总

    - **Protobuf**:Google开发的一种数据交换格式。 **1.2.9 引用类型** - **虚引用**:最弱的一种引用关系,仅用来在GC时注册回调。 - **弱引用**:GC时会被回收的对象。 - **软引用**:内存不足时才会被回收的对象...

    RMI与RPC

    RPC是一种通用的远程过程调用协议,它允许一个程序调用另一个不在同一台计算机上的程序。RPC协议隐藏了网络通信的细节,使得开发者能够像调用本地函数一样调用远程服务,极大地简化了分布式系统的设计。应用案例包括...

    javafileread源码-Mini-Google-Protocol-Buffers:开发了一个程序,该程序可以读取指定类名和字段的JSON

    Protocol Buffers提供了一种高效的方式将结构化数据序列化,便于在网络间传输或存储于文件中。它定义了一种语言无关、平台无关的接口,使得程序可以轻松地解析和生成数据。通过定义.proto文件,开发者可以描述数据...

    ytools-starter

    Google的protobuf是一种轻量级的、跨平台的数据序列化协议,它比XML更小巧、更快,并且更易于解析。protobuf支持多种编程语言,包括Java,可以将数据结构转换为可读性强的文本格式或高效的二进制格式,适合在网络...

    remoting-dotnet:.NET Core,.NET 5和更高版本的远程处理

    同时,远程协议是定义如何在网络上传输数据的规范,如SOAP、REST或gRPC的gRPC/ProtoBuf协议。 **六、反射发射(Reflection Emit)和动态方法** 反射发射允许在运行时动态生成类型和方法。这对于实现自定义序列化器...

Global site tag (gtag.js) - Google Analytics