架构实现利器:反射

版权声明:本文由韩伟原创文章,转载请注明出处: 
文章原文链接:https://www.qcloud.com/community/article/246

来源:腾云阁 https://www.qcloud.com/community

 

作者介绍:韩伟,1999年大学实习期加入初创期的网易,成为第30号员工,8年间从程序员开始,历任项目经理、产品总监。2007年后创业4年,开发过视频直播社区,及多款页游产品。2011年后就职于腾讯游戏研发部公共技术中心架构规划组,专注于通用游戏技术底层的研发。

通用型软件框架的难题

假设我们希望开发一套通用型的软件框架,这个框架允许用户自定义大量不同的情况下的回调函数(方法),用来实现丰富多彩的业务逻辑功能,例如一个游戏脚本引擎,那么,其中一个实现方式,就是使用观察者模式,以事件的方式来驱动整个框架。用户通过定义各个事件的响应函数,来组织和实现业务逻辑。而框架也提供了自定义事件及其响应函数的入口。在一些实现代码中,我们可能会发现有大量的“注册事件”的代码,或者是使用一个巨大的switch…case…对事件函数进行分发调用。譬如我们想做一个服务器端的基本进程框架,这个框架让用户只需要填写一些回调函数,就能成为一个稳定持续运行的后台服务进程。其中一个部分,就是需要定义程序启动事件,以便用户自定义程序启动要做的事情。那么我们可以定义一个”Init”的字符串来代表这个事件,在一个事件响应函数的回调哈希表里面,记录上”Init”pfunInit()。又或者是用一个常量宏INIT=12来表示此事件,在程序的主循环处,利用switch…case…来检查代表每个事件的类型编码,如果发现是和INIT宏相等的,就调用case INIT下面的代码(往往是一个单独的函数,如pfunINit())

维护长长的“注册事件”代码和长长的switch…case…都一样的让人昏昏欲睡,同时容易让人错漏百出。这些代码往往还带有大量的“常量”,因为用来作为回调函数的key的数据,往往都是一些自定义的常量。这些常量的同步维护,也往往让人筋疲力尽。这些长长的代码清单,经常还都需要由多个开发者一起来使用,自然就很容易发生你错改了我的,我覆盖了你的这一类问题。这些问题非常的“低级”,但是要找起来却一点都不容易。

[游戏的按键控制代码/JS]

难道我们的框架代码中,就一定会充斥着长长的字符串常量,或者整数常量吗?答案是否定的,因为很多编程语言,都提供能反射的功能。在编译型语言如C/C++里面,也可以利用代码生成技术,模拟出类似反射的能力。

什么是反射

要想知道什么是反射,我们可以先来看一个观察者模式的例子。假设我们在编写一个GUI的程序:在一个窗体上安放了一个按钮,此按钮的名字叫“ButtonA”,当这个按钮按下的时候,我们希望有一个我们自己写的函数被调用。根据观察者模式的设计,这个按钮被用户按下后,程序底层应该能监测到这个事情,然后在进程内部产生一个“事件”,这个“事件”对象往往会带有这个信息:被按下的按钮名字。如果我们用以前的注册事件的方法来编码,我们必须要在按钮被按下之前,比如程序初始化的时候,就向观察者对象注册这样一个回调函数:RegisterEvent(“ButtonA”, ONCLICK, myOnClick) —— ButtonA被按下的事件—myOnClick()。这里的函数myOnClick()就是我们想处理ButtonA被按下的事件的响应函数。但是,我们可以用另外一个更省事的方法来解决:我们把myOnClick()函数的名字改成ButtonA_OnClick(),然后观察者在发生“ButtonA”被按下的事件后,自动去找有没有叫“ButtonA_OnClick”这个名字的函数,如果找到的话,就调用这个函数。——显然这种做法无需预先手工去注册回调函数,而是仅仅根据函数名字的约定,简单的来决定要调用什么函数。一般来说,我们认为程序运行的过程中,这些函数名字、类名字、属性名字都不起什么重要的作用,以至于我们还会用一些“混淆器”软件来处理源代码,把这些自定义的名字都弄的乱七八糟,也不影响程序的运行。然而,如果我们使用反射的技术,程序就可以在运行时,实时的用一些常量,来检索并且获得源代码中,函数、类、属性名字所对应的实体,并且还能调用这些东西。

[在Java里通过字符串类名反射构建一个对象]

反射这种功能,在编译型的C语言程序中,几乎是不可使用的,因为C语言源代码中的名字“常量”,都被分离成“符号表”,然后在链接的过程中从二进制可执行程序中去掉了。虽然动态链接库会保留部分类似反射的能力,但是也仅仅限于动态链接库的接口函数。在C++中,由于编译器支持RTTI(运行时类型检测),我们可以通过typeof()操作符获得任何一个对象的类型信息,但我们还是不能实施用一个常量在运行时直接调用一个函数或对象的操作。不过,如果我们使用IDL(接口定义语言)来用程序生成C++的源代码,倒是可以把对象构造器函数、成员函数等等的名字常量,作为一个Map的key存放起来,对应把这些函数作为value放入Map,这样实现类似反射的功能(前提是要反射的对象都需要用IDL来描述,否则就要自己手工写一堆注册名字—函数的代码)。

如果我们使用基于虚拟机的语言,比如C#或者JAVA,又或者脚本语言,如python, Lua, JavaScript这些,都非常适合使用反射功能。由于虚拟机在运行时是能完全掌控所有代码的“符号表”,所以使用语言系统提供的一些API,就能很方便的通过任何一个字符串常量,查找这个常量对应(在源代码中)的类、方法、成员属性等等。

反射的配置功能

在我们懂得反射的用法后,我们就可以发现,源代码不再是“数据结构+算法”这么简单的东西。我们可以利用源代码作为数据本身的载体。一个最简单的例子,就是XML的解析:我们可以定义一个和XML文件对应的类,这个类的成员属性的名字,和需要解析的XML文件结构中的字段名一致。当我们在解析对应的XML文档的时候,就可以通过XML内容中的字段名,找到对应类成员属性对象,然后把XML字段值赋值进去。而这个过程中,只要我们按照XML文档的结构来定义类,就能很方便的把XML文档内的数据,赋值到一个类对象里面,这对于编写冗长的解析、赋值代码来说,能介绍不少的代码篇幅。这种做法也许不是非常高效,因为反射查找本身需要额外的CPU消耗,但是,如果解析XML这个步骤不是“关键路径”,这点性能损失对比大段的类似代码,还是很值得的。

反射用于配置的另外一个功能,是把类名、方法名放在配置文件里面,作为程序功能的配置项。以前我们如果想要利用配置文件,来定制一个程序的行为,必须要在源代码中编写一段switch…case,来把行为函数和配置文件中的配置值对应起来。这对于频繁修改、增加这些可配置行为的框架来说,是一个非常难以维护的工作。但是,如果我们利用反射,就可以直接在配置文件中写入对应行为的类名或方法名,这样框架就可以通过这些常量名字,在运行时找到进程空间中对应的类、对象、方法,从而直接调用他们以生效。这方面最常见的场景,有Tomcat这一类web容器,它们往往把一个个对应不同URL处理的servlet对象的类名,写入到配置文件中。或者如Spring框架,把互相依赖的各个对象的类名,都用配置文件管理起来,在运行时根据这样的配置文件,实时的反射出对应的类和对象,建立按配置要求的对象关系来。


[Spring通过XML来配置对象的关系]

从代码维护的角度来看,类、成员、方法的名字,被程序以外的一些“配置文件”所管理和知道,是有一定风险的。因为我们常常不把配置文件看成是源代码那么重要的东西,错漏也没有编译器或者IDE协助,所以一些难以调试的BUG往往是从这些位置产生的。不过作为一种大大节省框架代码的技术,还是受到广泛欢迎。而上文所说的问题,现在渐渐由另外一种技术“元数据”(或者叫注解、特性),把配置文件和源代码合并起来,这样就能大大改善上述的问题。

反射的通信功能

我们在编写通信功能的程序时,传统的思路是要定义协议,也就是定义协议头部,协议包长度,协议包字段等等。在一个比较复杂的网络服务程序中,这样的协议很容易就有几十上百个。维护代码的程序员想要搞明白别人定义的如此众多的协议,实际上是不太容易的。我们很容易想到,能不能使用对象模型来代替通信协议的定义呢?答案是可以的。但是,使用对象模型又有一个新的问题:对象是一个在运行时的内存结构,如何把对象中的数据,通过网络接收和发送呢?最简单的做法,就是使用memcpy(),Linux提供了这个功能强大的API,可以让任何内存中的数据变成一段字节数组,然后我们就能直接通过网络发送了。但是,如果我们的对象不是一个简单的结构体(事实上简单的结构体也有问题),而是一个对象,这个对象里面可能存在指针类型的成员,这样的拷贝就不可能顾及到这些指针指向的数据了。而且,如果收发两端的程序,并不是同一种语言(操作系统、平台),这样的内存结构数据可能毫无意义,比如把一个C++的对象内存直接拷贝给JAVA程序,肯定无法直接使用。所以,我们想要用对象结构来定义通信协议,我们需要一个把对象转换成通用的字节数组的方法,这就是“序列化/反序列化”的能力。在这里我不打算说太多关于序列化的内容,我只想说,当这些对象具备序列化能力后,就能成为通信数据的载体。问题是,如果我们收到了一段对象序列化的数据,如何构建出对应数据的对象呢?答案就是使用反射,反射机能能从数据中获得对象类的名字,然后通过这个名字构造出对象来,然后从数据中继续获得余下成员的数据,一一复制到这个对象身上。由此看,只要我们有反射功能,我们可以让使用者,简单的构造一个对象,然后整个把这个对象发送给网络的另外一端,对方也能直接收到一个对象,这样在编写通信程序的时候,只要按照业务需求定义对象即可。对于阅读代码的程序员来说,不用在脑子装一根叫“编码、解码”的弦,只要“无脑”的定义、处理对象即可。

在通信程序中,有种叫命令模式的设计模式非常常见,它脱胎于传统的基于命令字的网络处理方式:解析出命令字通过switch…case调用对应的处理函数。命令模式下的通信程序往往很简单,就是定义一个类型,这个类型的成员属性(通信协议)是可以随便定义的,只要再定一个Process()方法即可——这个方法的内容,就是收到此类型对象,应该如何处理的容器。由于我们利用反射可以在网络另外一段重建这个对象,所以我们也可以调用这个预定义的Process()方法,这个方法由于和协议对象类定义在一起,所以它是知道所有的成员定义的,这样这个处理方法,就无需好像以前的程序那样,费劲的通过强制类型转换,来得到具体的数据内容。在命令模式的通信程序实现过程里,反射是至关重要的一环,因为当我们收到一个数据包时,必须要从数据包中得到其对应的对象的类名,然后建立这个类所对应的对象。一旦这个对象建立后,我们可以调用其反序列化函数,让对象的内容和数据包中一致,最后调用其Process()方法,就大功告成了。这种设计,可以用不同的语言,定义同结构的类对象,用来在不同的语言平台程序之间通讯,而无需定义很复杂的协议定义规范。一些强大的对象数据工具,比如Google Protocol Buffer和Apache Thrift,直接可以用一个通用的IDL语言,生成各种语言的类定义源代码,就更方便了。

[Thrift、PB的自动序列化/反序列化的类型字段]

反射的编辑器功能

在我刚刚接触Delphi这款IDE的时候,我惊叹于它那便利的功能:可以对任何一个控件对象进行图形化的编辑。虽然我们可以用初始化的代码,来对任何一个对象进行修改,但是直接在IDE界面修改这些属性,还是非常方便的。甚至我会通过这些属性界面,来猜测和学习一款控件的用法。像这类功能,往往背后就需要反射的力量(当然delphi可能不是使用反射,而是利用组件模版等技术实现)。当我们自己开发一个这样的程序,我们必须要把一些对象、类的内部结构读取出来,然后才能以另外的途径展示出来。

[delphi上用界面设置ADO数据库控件的属性]

在JAVA中,JavaBean就是一个著名的利用反射来使用的“对象约定”:只要你编写的JAVA类型,其成员是类似setXXX()或者getXXX()的,很多框架都会自动识别和处理这些成员函数,从而实现诸如自动更新成员数据,自动关联界面内容等功能。另外一个类似的例子是JMX,这个JAVA的通用监控标准接口,可以把你定义的类对象解析出来,成员属性的值可以变成统计图线、可修改的表格项,方法变成按钮。在游戏开发领域,反射还广泛的用于,把图形美术资源和程序代码结合的目的:比如Flash Builder就可以通过反射,把一个Flash动画对象,绑定到一个MovieClip类型上,从而获得一个既具备美术效果,又能让用户自定义行为的对象。Unity3D在绑定了3D的游戏对象和脚本组件后,对于脚本中的Start()/Update()函数调用,也是通过反射进行的,这样开发者就不必要把脚本的类型,死死的和某个基类绑定到一块,而且这些反射调用的函数,还是可以有不同的返回值(不同的函数原型),从而实现协程或者非协程的调用。


[在flash编辑器里,对一个动画指定关联的自定义类]

反射由于可以把源代码中的信息提取出来,和其他的数据结合,让源代码的能力大大的提升,所以在开发工具方面,具有非常重要的地位。我们不再需要通过写代码,一遍遍的把源代码的数据和外部结构做对接,而是简单的开发一个反射能力框架,就能让我们实现某种源代码的“约定”,从而实现各种丰富的快捷开发能力。

反射的最佳搭档:元数据

在反射的使用过程中,我们往往会发现,源代码直接作为数据,还是会有一些问题。譬如我们的源代码可能会根据一些非业务因数做修改,改名、改参数类型是在重构的时候非常常见的。所以我们往往还是离不开配置文件,把源代码里的名字写到配置里面,然后框架再根据配置来运行。一个比较典型的例子就是Hibernate,这一款著名的ORM框架,能让你的源代码类型和数据库、表结构关联起来。按理说利用反射,我们可以直接建立一些和数据库表、字段名字同名的对象,就能直接关联了,但是我们的源代码如果需要修改这些名字,再去改数据库的内容,就显得太麻烦了。所以我们要编写很多配置文件,来关联什么表对应什么类,什么字段对应哪个属性……这些配置文件往往和使用数据库的表数量一样多,任何的修改都还要记得对应这些配置的修改,我们被迫同时维护:数据库结构、配置文件、源代码这三个东西。然而,如果我们的平台是支持“元数据”的话,问题就很好解决了。因为我们可以在源代码里面直接写配置文件项目。我们在源代码的类名前面,用类似注释的方式,标注这个类对应数据库的哪个表;在属性名前面,用注释标注对应的字段、默认值等等。这样我们只需要维护两个东西:数据库结构、源代码。这大大的减轻的项目的复杂程度。

我接触的最早最著名的元数据,是用来同步修改API文档的JavaDoc技术,这个技术让更新文档不再成为一个苦力活。由于可以在源代码的注释里面编写文档,所以在修改代码的同时也可以同时更新文档。更重要的是,javadoc标记自然的把源代码中的“名字表”和相关注释自动对应起来了,要知道,这种对应如果人工来做,可是要费相当大的功夫。在javadoc的教育下,我对于java的注解、C#的attribute(特性)都觉得非常亲切。以前那些需要登记大量类名、方法名的配置,统统都可以直接记录在源代码里面了。而一些和美术资源关联的客户端代码,也可以通过源代码的特殊标记,连接上正确的图形资源。


能让这些源代码里面的“元数据”生效的重要技术,其实就是反射。由于我们的元数据处理程序,一般都需要和源代码里面的类、方法名字对应起来,所以都要使用反射的方法。而这种反射,又为我们任意增加“元数据”提供了强大的机制。

反射给软件开发带来的改变

我们曾经相信:数据结构+算法=程序。但是从今天的软件产业来看,固然还是有很多专事计算的软件在被开发着,然而我们接触到更多的软件,都是所谓“信息管理系统”类的软件。这类软件要处理的并非是复杂的计算任务,而是对各种各样现实世界中的信息,增删查改是这些信息处理最通俗的描述。我们在处理这些信息的时候,如果还是把程序的载体源代码,仅仅看成是编译过程中不可缺少的一环而已,那么我们就必须额外处理大量的数据形式:数据库、配置文件、IDE配置……然而,在面向对象的风潮之下,源代码完全可以作为一种“树状”的数据承载方式。面向对象定义的类、成员、方法,就是一个个现实世界中的实体映像,他们所包含的结构和常量,往往直接可以成为系统中的数据源头。在MUD文字游戏中,几乎整个游戏世界,都是以源代码常量的形式编写的,这不但没有成为维护的难题,反而让真个游戏的开发变得更轻松,因为程序员还是最习惯于面对源代码去工作。

反射这种特性,能把源代码中的所有数据,包括“名字符号表”,都提供给开发者去使用,让软件开发过程,从单纯的算法实现过程,变成一个综合的信息管理的过程。这个做法看起来似乎不够专业,但是在编程已经不算“高科技”的年代,这种技术能帮助大量的开发者,以某种“约定”的方式去编写源代码,从而自动获得框架的强大支持。——制造这种允许“约定”方式运行源代码的框架,正式新的框架应该拥有的特点,因为人类的创造时间,不应该被浪费在大量的重复而类似的工作之上啊!

 

posted @ 2016-12-10 19:49  偶素浅小浅  阅读(476)  评论(0编辑  收藏  举报