Java 元编程及其应用
首先,我们且不说元编程是什么,他能做什么.我们先来谈谈生产力.
同样是实现一个投票系统,一个是python程序员,基于django-framework,用了半小时就搭建了一个完整系统,另外一个是标准的SSM(Spring-SpringMVC-Mybatis)Java程序员,用了半天,才把环境刚刚搭好.
可以说,社区内,成功的web框架中基本没有不强依赖元编程技术的,框架做的工作越多,应用编写就越轻松.
那什么是元编程
试想以下,如果那些原本需要我们手动编写的代码,可以自动生成,我们是不是又更多的时间来做更加有意义的事情?有些框架之所以开发效率高,其原因也是因为框架层面,把大量的需要重复编写的代码,采用元编程的方式给自动生成了.
如果我们的应用框架有这样一种能力,那么可以省掉我们大部分的重复工作.
比如经常被Java程序员诟病的大段大段的setter/getter/toString/hashCode/equals方法,这些方法其实在模型字段定义好了之后,这些方法其实基本上就已经标准化了,比如常用的IDE(eclipse,IDEA)都支持自动生成这些方法,这样挺好,可以省掉我们好多精力. 但是这样做的还不够好,当我们尝试去理解一个模型的时候,视线里有大量这些的冗余方法,会增加我们对于模型理解的负担. lombok给出了一个解决方案通过注解的方法,来自动为模型生成setter/getter/toString/hashCode方法,使我们的代码精简了很多.
比如另外一个Java程序员诟病的地方,用mybatis访问数据库,即使我们的对数据库的操作仅仅是简单的增删查改,我们也需要对每一个操作的定义sql,我们需要编写
- 领域模型对象
- DAO的interface
- mybatis的mapper文件
程序员世界有个挠痒痒定理
比如 mybatis generator就可以根据数据库结构自动生成上面这些文件, 他大大减少了初次搭建项目的负担.
但是文件生成了,我么就得维护,我们会往里面加其它东西,比如加字段,增加其它操作. 这样当数据库的表结构有变动之后,我们就要维护所有涉及到的文件,这个工作量其实也不小. 有没有更好的方法?本文后面会提出一种解决方案.
Java元编程的几种姿势
反射(reflection)
自省
我们要生成代码,我至少得知道我们现有的代码长什么样子吧?
正如,我们要化妆(给自己化妆,亦或是给别人化妆)我们至少得看得清楚我们的容貌,别人的容貌吧.
reflection
这个名字起得真有意思,把程序的自省比喻成照镜子,对着这个镜子,程序就知道,哟,
- 这是一个
Class
- 这个
Class
有几个Field
- 这个
Field
是什么类型的 - 这个
Field
是否static
,是否是final
的 - 这个
Class
还有几个Method
- 这个
Method
的返回类型是什么 - 这个
Method
的参数列表类型什么 - 每个参数有什么注解
- …
反射的API除了提供了以上的读
能力之外,还提供了一个动态代理的功能.
动态代理
所谓动态代理,它的动态其实是相对于静态代理而言的.在静态代理里面,代理对象与被代理对象的类型都实现了同样的接口,这样当客户端持有一个接口对象的时候,就可以用代理的对象来替换这个真实对象,同时这个代理对象就像在扮演真实对象的秘书,很多需要真实对象处理的东西,其实都是这个代理做的.大部分场景下,他会直接把问题转给真实对象处理,同时,他还做了其它事情
- 比如记录一下日志啊
- 比如
选择性拒绝
啊(我们老板太忙,这个请求我替我们老板拒绝了) - 甚至还可以通过请求其它服务,来伪造结果(mock)
所有的这些代理工作的实现,都是在写代码的时候,手动实现好的. 明显,这很不元编程
动态代理的神奇之处在于,本来老板
是没有秘书
的,只是突然决定要请一个秘书
,就临时变了一个秘书出来
,老板
能做的事情,他都能做(Proxy.newProxyInstance()
需要传一个接口列表,这个新生成的类,就会实现这些接口)
有了这种变化能力
,我们不仅仅可以动态变出AA
的代理AAProxy
,而且还能动态变出BB
的代理BBProxy
,甚至更多. 看出区别了吗?
如果有10个需要代理的类,在静态代理中,我们就需要编写10个代理类;而在动态代理中,我们可以仅需要编写一个实现了java.lang.reflect.InvocationHandler
接口的类即可.
甚至更夸张的是,本来公司没老板
(被代理类),现在决定要一个老板
,我们描述一下这个老板需要什么能力(实现的接口
),就能动态的变一个类似于老板的东西(代理对象
),而这个东西,还挺像个老板的(实现了老板的接口,并且能够符合人们预期工作)
就像retrofit这个项目实现的一样,通过一个接口,以及这个接口上的注解,就能动态生成一个符合预期的,http接口的Java SDK.(代码就不贴了,有兴趣自己到官网参观).我之前,也借鉴这种模式,写了一个公司内部http接口的生成器. 这种编码方式,更加干净,更加直观.
其它使用动态代理技术的项目
- Spring的基于接口的AOP
- dubbo reference对象的生成
- …
字节码增强(bytecode enhancement)
我们知道,Java的类是编译成字节码存在class文件中的,类的加载,其实就是字节码被读取,生成Class类的过程.
我们是否能够通过某种途径,改变这个字节码呢?
要回答这个问题,我们可以先反问一句,我们是否有改变一个已经加载了的Class
的需求呢?还真有,比如我们想给一个类的某些标记了@Log
注解的方法进行打日志记录,我们想统计一个标记了@Perf
注解的方法的执行时间. 如果我们无法改变一个类,那么我们就必须在每个类里面加类似的代码,这显然不环保. 由于这是个强需求,如果Java不允许修改意见加载的类,那么Java无疑会被实现了这些feature其它技术所淘汰,基于这个反向推理,由于Java现在还那么火,所以可以推测,Java应该支持这种feature.
加载时
为了实现上面这种需求,Java5就推出了java.lang.instrument
并且在jdk6进一步加强.
要实现一个类的转换,我们需要执行如下步骤:
- 就像我们编写Java程序入口
main
方法一样,我们通过编写一个public static void premain(String agentArgs, Instrumentation inst);
方法 - 然后再方法体里面注册一个
java.lang.instrument.ClassFileTransformer
- 然后实现这个transformer
- 然后将整个程序打包,并且在
META-INF/MANIFEST.MF
注明实现了premain方法的类名 - 最终在程序启动的时候,
java -javaagent:myagent.jar
JVM就会加载myagent.jar中的META-INF/MANIFEST.MF
, 读取Premain-Class
的值,并且加载我们的Premain-class
类,然后在main方法执行之前,执行这个方法,由于我们在方法体重注册了transformer,这样后续一旦有类在加载之前,都会先执行我们的transformer的transform方法,进行字节码增强.
在java.lang.instrument.ClassFileTransformer
的接口有一个方法
byte[] transform(
ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer)
throws IllegalClassFormatException;
我们可以利用一些字节码增强的类库,对传入的字节码数组进行解析,然后修改,然后序列化成字节码,作为方法结果返回
常用的字节码增强类库
- ASM
- cglib
- javassist
其中javassist因为API易于使用,且项目一直活跃,所以推荐使用.
运行时
Java也可以在类已经加载到内存中的情况,对类进行修改,不过这个修改有个限制,只能修改方法体的实现,不能对类的结构进行修改.
类似的eclipse以及IDEA的动态加载,就是这个原理.
Annotation Processing
运行时或者加载时的字节码增强,虽然牛逼,但是其有个致命性短板,它增加的方法,无法在编译时被代码感知,也就是说,我们在运行时给MyObj
类增加的方法getSomeThing(Param param)
,无法在其它源代码中,通过myObj.getSomeThing(param)
这种方式进行调用,而只能通过反射的方式进行调用,这无疑丑陋了很多.也许Java也是考虑到这种需求,才发明了Annotation Processing
这种编译过程
Java编译过程
如图所示,Java的编译过程分为三步
- Parse & Enter: 这一步主要负责将Java的源代码解析成抽象语法树(AST)
- Annotation Processing: 这一步就会执行用户定义的AnnotationProcessing逻辑,生成
新的代码
/资源,然后重复执行过程1,直到没有新的源代码生成 - Analyse & Generate: 这一步才是真正的生成字节码的过程
这个编译过程中,我们可以扩展的是,第二部,我们可以自己实现一个javax.annotation.processing.Processor
类,然后将这个类告诉编译器,然后编译器就会在编译源代码的时候,调用接口的process逻辑,我们就可以在这里生成新的源文件与资源文件!
遗憾的是,编译器并没有显示的API提供给我们,允许我们修改已有class的抽象语法树,也就是说,我们无法在通过正规途径
在编译时
给一个类增加成员;这里强调了正规途径
是因为确认是存在一些非正规途径,可以让我们去修改这棵树. lombok就是这么做的
了解了上述的Java的编译过程,我们其实就可以想想,是否可以通过代码生成的方式,来去掉我们平时诟病,却一直难以根除的痛?
基于Annotation Processing的MybatisDAO & mapper文件自动生成
分析
对于一个model而已,常用的操作包括以下几种
- insert(model)
- selectByXXX(model)
- countByXXX(model)
- updateByXXXAndYYY(model)
- deleteByXXX(model)
如果仅仅提供model,是不是就足以生成对应的DAO接口申明以及对应mapper配置?
- 表名: 简单点,可以直接根据模型名来推断,也可以通过注解增加方法,来允许自定义表名
- insert/update的字段列表: 直接去模型的字段列表即可
- select/update/delete的时候,我们是需要知道我们根据什么字段进行过滤,这个信息我们是需要告诉
Processor
的,因为我们可以考虑增加一个注解@Index
来告诉Processor
,这些字段是索引字段,可以根据这些字段进行过滤
基于上面分析,我们有了以下大致思路
- 我们首先定义一个
@DAO
,用于标记我们的模型class - 然后定义一个
@Index
,用于标记模型的字段 - 然后定义一个
DAOGeneratorProcessor
继承自AbstractProcessor
,并且申明支持DAO
- process方法的实现中,我们会分析模型的语法书,提取出类名,字段列表
- 找出标记了
@Index
的字段列表,然后对涉及到过滤的方法生成所有的组合,比如- selectByOrderAndSellerId
- selectBySellerId
- selectByOrderNo
- 生成对应的接口声明,以及mapper文件
由于annotation是编译器的扩展,这一点体验比较好,一旦我们定义好了模型(比如Order.class),然后编译模型,我们就可以在代码其它地方,就可以直接引用OrderDAO
这个对象类(这个类是生成的哦),可以回顾一下Java的编译过程.
实践
实践中,虽然生成的DAO可以覆盖我们大部分的用例,但是并不能覆盖所有我们的需求场景,因此,我们推荐将生成的DAO统一叫做BasicDAO,这样有些个性化的需求,我们仍然可以同自己书写SQL的方式来自定义,这样在解决重复冗余的前提下,也能很好的适应复杂的业务场景.
总结
Java本身是一门静态语言,程序从源代码,到运行的程序,中间会经历很多的环节.
这些环节都可以作为我们元编程的切入点,不同的环节,可以发挥不同的威力,使用得当,可以帮助我们提供生产力的同时,也能很好优化我们的代码性能。
========== End