C++实现ORM的杂谈

发表于2019-01-09
评论3 7.4k浏览

前提

研究的前提是用C++语言实现一个基于反射的ORM。

由于C++语言层面上不支持反射,所以我们没办法用C++的原生数据(class或struct)来做元数据,我们需要选择或实现一个合适元数据系统。

这个元数据系统需要有反射特性。

1)在运行时获取类型信息。元数据的对象需要在运行时获取到它本身的数据描述(describer)信息,这样ORM才能获取到数据的描述信息进行自动化处理。

2)在运行时动态创建对象。此处所述的动态创建对象不是指new操作符,而是以类似于工厂模式实现的通过类型名称来反射创建对象。

刚好Google的Protocol Buffer符合需求。

ORM

对象关系映射(Object Relational Mapping)。ORM是一种程序技术,用于实现面向对象编程语言里不同类型系统的数据之间的转换。从效果上说,它其实是创建了一个可在编程语言里使用的——“虚拟对象数据库”。通俗一点讲就是把存储操作自动化,有效地提高开发效率。

ORM的两种实现方式:

1)定义元数据->生成代码->直接使用。这种方式理解为用一种描述语言生成一个堆代码,包含了定义数据、操作数据、存取数据。

2)对象->类型信息->关联到数据库。这种方式是用反射来反向获取用户自定义的数据类型和类型描述,再对它进行关联数据库的操作,这种方式有点类似于框架在用户定义元数据之前就知道了元数据的定义,而去把它关联去存取数据库,这就是用反射来实现的神奇之处。

Protobuf

Google的Protocol Buffer本身是一种跨平台、跨语言的数据序列化协议。它的优点是性能、兼容性、扩展性、简单易用,缺点是官方支持的语言不多,但很多第三方实现(bug是逃不掉了)。

Protobuf同级别的库有很多,比如我公司现有框架下的sdp协议,由facebook研发目前在apache下的Thrift等。

Protobuf的实现使用继承化方式,这是什么意思呢?Protobuf的元数据在生成C++类的时候使用的是继承结构来实现的,也就是说所有C++的Protobuf对象都继承于google::protobuf::Message,对于Protobuf整个框架层来说所有用户定义的Protobuf对象都是一个多态的google::protobuf::Message对象,这样框架上都围绕Message来实现。

sdp和Thrift更类似一些,我把它们叫作模板类的序列化协议(后面会有解释),也都做了RPC。它们是没有继承体系的,所以底层的框架对用户定义的对象只能使用模板函数来统一他们的方法来达到框架内的实现(我把他们叫模板类的原因),这样实现会更直观一些,用Lex和Yacc之类的开源库就可以很快地实现对元数据的语法和词法分析,你只需要去实现生成目标语言就可以了。当然也有些弊端,如协议在项目中一般都是大量使用的(需要做接口化统一处理的地方都只能用模板函数来实现),这些模板函数在编译期间会推导出大量的实例化代码,编译速度会随着协议数量的增多越来越慢。

那么Protobuf为什么会使用继承化,自己实现对元数据的语法和词法分析呢?因为Google牛逼吗?我觉得是也不是,我认为这样设计是为了——反射。把语法和词法分析的结果用Descriptor来呈现出来,这样可以被Message基类获取到对类型的描述,也就是说框架的实现是可以先于用户定义的Protobuf元数据的,这就是反射,用这个反射就可以实现——自动化。

反射

本文所描述的是C++语言,C++语言本身并没有提供反射的特性,也就是说在C++程序运行时,我们可能无法获取一个C++对象的描述,无法修改一个C++对象的行为。但我们可以设计一个框架来获得反射的部分特性。

比如我们的ORM需要用反射来反向获取用户自定义的数据类型和类型描述,最好能通过类型描述来动态创建此类型的数据对象,这样我们需要:

1)运行时获取类型信息。

2)运行时动态构造对象。

Protobuf已经具有以上两点的反射特性,我们直接用起来就好了。

实现

除去我们需要的Protobuf反射特性,我们还需要元数据对应的存储结构(MySQL),我们需要扩展Protobuf的Compiler,让它可以实现--mysql_out,也就是把元数据转化为MySQL的建表sql语句。

然后就是我们需要利用Protobuf的反射拿到数据的Descriptor。

Descriptor *descriptor =
	google::protobuf::Message::GetDescriptor();
for (size_ti = 0; i < descriptor->field_count(); ++i) {
	FieldDescriptor *field_descriptor = descriptor->field(i);
	switch (field_descriptor->type()) {
		case ::google::protobuf::FieldDescriptor::TYPE_INT64:
		// TODO
		case ::google::protobuf::FieldDescriptor::TYPE_STRING:
		// TODO
		default:
		// TODO
	}
}

拿到类型信息,我们就可以结合需求拼出需要的sql语句,就可以把数据从C++对象映射到MySQL数据库中了。

除些之外还有一个潜在的需求,如果我们的对象与存储是在不同服务器上的,如对象在GameServer,存储单独做了一个MySQL代理,那么我们需要把对象序列化成二进制,然后通过TCP发给到MySQL代理服务器来处理。服务器接到一段二进制数据,如何知道这段数据是哪个类型呢?传统的方式可以想象下游戏的Server-Client协议,我们需要定义一个协议ID来表示这个二进制数据的协议类型,然后用switch-case或regist-handler的方式来把协议ID和处理函数做关联,也就是每个协议ID都需要一个函数来处理,这种方法在ORM上明显是重复劳动,无法自动化的。

Protobuf元数据在导出后有一个New虚函数方法,可以在Message基类上调用,用来动态创建一个对象,以这个为基础,我们可以把发送给MySQL代理数据包里的数据类型定义为Message::full_name(),它是一个string类型,这样不需要为每一个类型定义一个函数来处理二进制数据:

const google::protobuf::Descriptor *descriptor =
	DescriptorPool::generated_pool()->FindMessageTypeByName(type);
const google::protobuf::Message *prototype =
	MessageFactory::generated_factory()->GetPrototype(descriptor);
google::protobuf::Message *message = prototype->New();

这样就解决了ORM的关键问题,具体实现详细见:我Github的pb_mysql_orm项目

后续

目前为止,我们实现的是一个ORM框架的核心,实际上ORM框架还有许多事情要做。

元数据的版本升级。如果我们的元数据有所变动,导出的sql脚本如果没有版本更新的处理,那还是需要手动去操作(手动操作不仅仅指我们用命令之类的工具特化版本处理,也包括我们在程序层面上手动编写一段特定的代码来处理版本升级),这就不是自动化,也就不是完全的ORM框架。

Protobuf有一个FieldOptions的功能,也就是字段的选项形如:

optional bool lazy = 5 [default=false];

这个FieldOptions指Protobuf本身默认的FieldOptions,是一个默认值的选项。而这个FieldOptions我们是可以自定义的,我们只需要修改Protobuf源码目录下的descriptor.proto,就可以添加新的扩展选项,用来给compiler使用。在compiler我们可以在FieldDescriptor中获取这个选项。

试想下,元数据的版本升级、主键、索引、MySQL的引擎都可以通过这个FieldOptions在元数据中定义出来,可以在compiler中的generator(生成器)中使用这个选项来导出版本升级的sql脚本功能。

总结

本文主要叙述的是C++实现ORM,但核心其实是以Protobuf的反射特性实现了ORM,所以重点并不在于ORM,而是在于以ORM这个概念来说明反射特性和如何让程序实现自动化来达到更高的开发效率,所以本文的主题其实是反射带来的自动化。

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引