深入 ProtoBuf - 反射原理解析

在介绍了 ProtoBuf 序列化原理之后,本文介绍 ProtoBuf 的反射技术原理。

反射技术简介

对于反射大家应该不会陌生,如果你接触过一些框架(如 ORM、IOC、OSGi 等) 的内部实现,应该更能体会反射技术的应用可谓无处不在。

笔者读研期间所在实验室的核心技术并与反射息息相关,在反射基础之上衍生出了许多非常有趣的应用。关于反射可讲的东西很多,后续想单独写一篇文章介绍,这里先只做一个简介。

反射概念最早出现于人工智能领域,20 世纪 70 年代末被引入到程序语言设计中。1982 年 MIT 的 Smith, Brian Cantwell 在他的博士论文中最早提出了程序反射的概念:

既然我们可以构造“有关某个外部世界表示”的计算过程, 并通过它来对那个外部世界进行推理; 那么我们也可以构造能够对自身表示和计算进行推理的计算过程 ,它包含负责管理有关自身的操作和结构表示的内部过程。

—— 1982年 Smith, Brian Cantwell 博士论文首次提出

从某种角度来看,所谓编程实际上就是在构造 “关于外部世界” 的计算过程。如果用 F 表示这个构造过程,用 X 表示外部世界,那么编写一个计算系统可表示为 F(X)。

那么非常有趣的点就在于:我们完全可以构造对上述过程本身进行描述和推理的计算过程。即将 F(X) 视为新的 “世界” 和 研究对象,构造 F(F(X))

我们平时编写的计算系统是面向特定领域的(通常是面向现实建模), 系统中包含 用来描述领域中的实体实体间关系 的数据结构以及处理这些数据结构的规则。那么反射系统面向领域便是这个系统本身。

从上层概念往下走,很容易就能理解反射将为我们提供这样的能力:

计算机程序在运行时可以访问、检测和修改它本身状态或行为
—— 反射 (计算机科学) Wikipedia

很多编程语言可对程序本身进行构建(如何构建见下文),从而为程序员提供反射能力,即可在运行时访问和修改自身状态和行为:

// 运行时动态创建对象
Class<?> class1 = Class.forName("cn::lcy::dog");
Dog dogObj = (Dog) class1.newInstance();
// 运行时动态访问状态
Field dogNameField = class1.getDeclaredField("name");
// 运行时动态修改状态
dogNameField.set(dogObj, "鸡你太美");
// 运行时动态修改行为
Method method = class1.getMethod("signJumpRapAndBall");
method.invoke(dogObj);

例子中写的是字符串常量形式如 "cn::lcy::dog" ,但实际上 "cn::lcy::dog" 等可以是一个动态变化的变量,它可以由程序计算生成,可以从本地文件中读取,也可以通过网络从远程获取,这便是运行时动态的含义。

站在使用者的角度,通过上面例子应该可以对反射有一定的直观感受。

而站在实现者的角度,编程语言是如何实现程序反射的呢?

如果用一句话来总结反射实现的关键,笔者会概括为:获取系统元信息

元信息:即系统自描述信息,用于描述系统本身。举例来讲,即系统有哪些类?类中有哪些字段、哪些方法?字段属于什么类型、方法又有怎样的参数和返回值?....

对于上述例子的 Java 语言而言,其能够提供反射能力的关键是在编译阶段将程序的元信息编译进了 .class 文件,在程序运行时 JVM 将会把 .class 文件加载到 JVM 内存模型中的方法区。此后程序运行时将有能力获取关于自身的元信息。除了 Java 语言之外,JS、Python、GO、PHP 等各种语言也都在语言层面实现了程序反射。

而由于 C++ 在编译时并不会将类的元信息写进结果中,最终编译结果中只会包含变量、函数地址偏移、函数关系等,所以 C++ 自身无法获取元信息,那么自然无法提供反射能力。但这并不意味着使用 C++ 就无法应用反射技术,程序可以自己设计与实现程序元信息以及元信息与地址之间的映射。例如本文所要介绍的 ProtoBuf 反射实现。

ProtoBuf 反射技术简介

与其它语言提供的反射类似,ProtoBuf 能够为使用者提供了如下的反射能力:

/* 反射创建实例 */
auto descriptor = google::protobuf::DescriptorPool::generated_pool()->FindMessageTypeByName("Dog");
auto prototype = google::protobuf::MessageFactory::generated_factory()->GetPrototype(descriptor);
auto instance = prototype->New();

/* 反射相关接口 */
auto reflecter = instance.GetReflection();
auto field = descriptor->FindFieldByName("name");
reflecter->SetString(&instance, field, "鸡你太美") ;

// 获取属性的值.
std::cout<<reflecter->GetString(instance , field)<< std::endl ;
return 0 ;

利用上述 ProtoBuf 的反射能力,我们将能够实现许多强大的功能。例如各种 pb2json 库底层多是利用 ProtoBuf 的反射能力,实际上ProtoBuf 自身对编码结果反序列化并构建内存对象的过程用的也正是反射。

正如上一节提到的,反射的核心要点是:获取程序元信息。

ProtoBuf 自然也不会例外,那么 ProtoBuf 反射所需的元信息在哪?答案便是使用 ProtoBuf 的第一步就会接触到的:.proto 文件。

ProtoBuf 反射原理概述

我们在 深入 ProtoBuf - 简介 一文中介绍过使用 ProtoBuf 的第一步便是创建 .proto 文件,定义我们所需的数据结构。但很多人没有意识到,这个过程同时也是为 ProtoBuf 提供我们数据元信息的过程,这些元信息包括数据由哪些字段构成,字段又属于什么类型以及字段之间的组合关系等。

当然元信息也并非一定由 .proto 文件提供,它也可来自于网络或其它可能的输入,只要它满足 ProtoBuf Message 的定义语法即可。那么元信息的可能来源和处理就有:

  • .proto 文件
    • 使用 ProtoBuf 内置的工具 protoc 编译器编译,protoc 将 .proto 文件内容编码并写入生成的代码中(.pb.cc 文件)
    • 使用 ProtoBuf 提供的编译 API 在运行时手动(指编码)解析 .proto 文件内容。实际上 protoc 底层调用的也正是这个编译 API。
  • 非 .proto 文件
    • 从远程读取,如将数据与数据元信息一同进行 protobuf 编码并传输:
      message Req {
        optional string proto_file = 1;
        optional string data = 2;
      }
      
    • 从 Json 或其它格式数据中转换而来
    • ......

无论 .proto 文件来源于何处,我们都需要对其做进一步的处理,将其解析成内存对象,并构建其与实例的映射,同时也要计算每个字段的内存偏移。可总结出如下步骤:

  1. 提供 .proto (范指 ProtoBuf Message 语法描述的元信息)
  2. 解析 .proto 构建 FileDescriptor、FieldDescriptor 等,即 .proto 对应的内存模型(对象)
  3. 之后每创建一个实例,就将其存到相应的实例池中
  4. 将 Descriptor 和 instance 的映射维护到表中备查
  5. 通过 Descriptor 可查到相应的 instance,又由于了解 instance 中字段类型(FieldDescriptor),所以知道字段的内存偏移,那么就可以访问或修改字段的值

ProtoBuf 反射源码解析

反射源码有许多阅读路径,在上一节中列出了元信息的不同来源,不同来源元信息就会导致处理上有所不同。我们没有必要解读源码中所有的实现路径,这里只选择最直观也是我们接触最多的一种路径。

重看 ProtoBuf 反射技术简介 中的例子,可对这个例子一步步深入从而理解反射实现的原理(代码 4-1):

/* 反射创建实例 */
auto descriptor = google::protobuf::DescriptorPool::generated_pool()->FindMessageTypeByName("Dog");
auto prototype = google::protobuf::MessageFactory::generated_factory()->GetPrototype(descriptor);
auto instance = prototype->New();

/* 反射相关接口 */
auto reflecter = instance.GetReflection();
auto field = descriptor->FindFieldByName("name");
reflecter->SetString(&instance, field, "鸡你太美") ;

// 获取属性的值.
std::cout<<reflecter->GetString(instance , field)<< std::endl ;
return 0 ;

一、
代码 4-1 第一步我们通过 DescriptorPool 的 FindMessageTypeByName 获得了元信息 Descriptor。

DescriptorPool 为元信息池,对外提供了诸如 FindServiceByName、FindMessageTypeByName 等各类接口以便外部查询所需的元信息。当 DescriptorPool 不存在时需要查询的元信息时,将进一步到 DescriptorDatabase 中去查找。

DescriptorDatabase 可从硬编码或磁盘中查询对应名称的 .proto 文件内容,解析后返回查询需要的元信息。

DescriptorPool 相当于缓存了文件的 Descriptor(底层使用 Map),查询时将先到缓存中查询,如果未能找到再进一步到 DB 中(即 DescriptorDatabase)查询,此时可能需要从磁盘中读取文件内容,然后再解析成 Descriptor 返回,这里需要消耗一定的时间。

从上面的描述不难看出,DescriptorPool 和 DescriptorDatabase 通过缓存机制提高了反射运行效率,但这只是反射工程实现上的一种优化,我们更感兴趣的应该是 Descriptor 的来源。

DescriptorDatabase 从磁盘中读取 .proto 内容并解析成 Descriptor 这一来源很容易理解,但我们大多数时候并不会采用这种方式,反射时也不会去读取 .proto 文件。那么我们的 .proto 内容在哪?

实际上我们在使用 protoc 生成 xxx.pb.ccxxx.pb.h 文件时,其中不仅仅包含了读写数据的接口,还包含了 .proto 文件内容。阅读任意一个 xxx.pb.cc 的内容,你可以看到如下类似代码(代码 4-2):

static void AddDescriptorsImpl() {
  InitDefaults();

  // .proto 内容
  static const char descriptor[] GOOGLE_PROTOBUF_ATTRIBUTE_SECTION_VARIABLE(protodesc_cold) = {
      "\n\022single_int32.proto\"\035\n\010Example1\022\021\n\010int3"
      "2Val\030\232\005 \001(\005\" \n\010Example2\022\024\n\010int32Val\030\377\377\377\377"
      "\001 \003(\005b\006proto3"
  };

  // 注册 descriptor
  ::google::protobuf::DescriptorPool::InternalAddGeneratedFile(
      descriptor, 93);

  // 注册 instance
  ::google::protobuf::MessageFactory::InternalRegisterGeneratedFile(
    "single_int32.proto", &protobuf_RegisterTypes);
}

其中 descriptor 数组存储的便是 .proto 内容。这里当然不是简单的存储原始文本字符串,而是经过了 SerializeToString 序列化处理,而后将结果以硬编码的形式保存在 xxx.pb.cc 中,真是充分利用了自己的高效编码能力。

硬编码的 .proto 元信息内容将以懒加载的方式(被调用时才触发)被 DescriptorDatabase 加载、解析,并缓存到 DescriptorPool 中。

二、
代码 4-1 例子中的第二步是根据 MessageFactory 获得了一个实例。

MessageFactory 是实例工厂,对外提供了根据元信息 descriptor 获取相应实例的能力。

其实在代码 4-2 中已经涉及到该工厂,即:

// 注册对应 descriptor 的 instance 到 MessageFactory
// InternalRegisterGeneratedFile 函数内部,会将创建一个实例并做好 descriptor 与 instance 的映射
::google::protobuf::MessageFactory::InternalRegisterGeneratedFile(
    "single_int32.proto", &protobuf_RegisterTypes);

每次构建实例后,都将 descriptor 和 instance 维护到一个 _table 中,即映射表以便获取。后续所谓通过反射获得某个类的某个实例子,实际就是查表的过程。

三、
代码 4-1 例子中的第三步,就是对 instance 实例对象的属性进行读写。

实例对象的 reflection 里面存储了对象属性的偏移地址,而这些信息其实与 .proto 内容信息一样,在 protoc 编译时通过解析 proto 文件内容获得且记录在 xxx.pb.cc 中,阅读 xxx.pb.cc 代码,可看到如下类似代码:

const ::google::protobuf::uint32 TableStruct::offsets[] GOOGLE_PROTOBUF_ATTRIBUTE_SECTION_VARIABLE(protodesc_cold) = {
  ~0u,  // no _has_bits_
  // 将会计算实例与属性的内存偏移
  GOOGLE_PROTOBUF_GENERATED_MESSAGE_FIELD_OFFSET(::Example1, _internal_metadata_),
  ~0u,  // no _extensions_
  ~0u,  // no _oneof_case_
  ~0u,  // no _weak_field_map_
  GOOGLE_PROTOBUF_GENERATED_MESSAGE_FIELD_OFFSET(::Example1, int32val_),
  ~0u,  // no _has_bits_
  GOOGLE_PROTOBUF_GENERATED_MESSAGE_FIELD_OFFSET(::Example2, _internal_metadata_),
  ~0u,  // no _extensions_
  ~0u,  // no _oneof_case_
  ~0u,  // no _weak_field_map_
  GOOGLE_PROTOBUF_GENERATED_MESSAGE_FIELD_OFFSET(::Example2, int32val_),
};

有了属性的内存偏移,自然可以对属性进行读写操作,以例子中出现的 SetString 为例,其内部实现位于 generated_message_reflection.cc 中,核心代码如下:

// 获取属性内存地址指针,内部根据 __
const std::string* default_ptr =
    &DefaultRaw<ArenaStringPtr>(field).Get();

// DefaultRaw 底层调用:
// reinterpret_cast<const uint8*>
// (default_instance_) +
//     OffsetValue(offsets_[field->index()], field->type());

// .......

// assign 赋值
MutableField<ArenaStringPtr>(message, field)
    ->Mutable(default_ptr, GetArena(message))
    ->assign(std::move(value));

其他 SetInt32、SetInt64、SetBool 等等接口原理类似。

ProtoBuf 的源码十分庞大,本节只是解析了其中一条路径,但已经足够呈现 ProtoBuf 的反射原理。如果想了解更多工程细节可进一步阅读 ProtoBuf 源码

参考文献

Procedural reflection in programming languages
Google Developers. Protocol Buffers
一种自动反射消息类型的 Google Protobuf 网络传输方案
google protobuf 反射机制学习笔记

以上

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349