Java元编程及其应用

时间:2022-03-24 14:16:50

首先,我们且不说元编程是什么,他能做什么.我们先来谈谈生产力.

同样是实现一个投票系统,一个是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文件

程序员世界有个挠痒痒定理

当一个东西令你觉得痒了,那么很有可能,这个东西也令其他程序员痒了,而且github上面也许已经有了现成的项目可以借鉴.

比如 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元编程及其应用

如图所示,Java的编译过程分为三步

  1. Parse & Enter: 这一步主要负责将Java的源代码解析成抽象语法树(AST)
  2. Annotation Processing: 这一步就会执行用户定义的AnnotationProcessing逻辑,生成新的代码/资源,然后重复执行过程1,直到没有新的源代码生成
  3. Analyse & Generate: 这一步才是真正的生成字节码的过程

这个编译过程中,我们可以扩展的是,第二部,我们可以自己实现一个javax.annotation.processing.Processor类,然后将这个类告诉编译器,然后编译器就会在编译源代码的时候,调用接口的process逻辑,我们就可以在这里生成新的源文件与资源文件!

遗憾的是,编译器并没有显示的API提供给我们,允许我们修改已有class的抽象语法树,也就是说,我们无法在通过正规途径编译时给一个类增加成员;这里强调了正规途径是因为确认是存在一些非正规途径,可以让我们去修改这棵树. lombok就是这么做

lombok是做什么的?

lombok允许我们通过简易的注解,来自动生成我们模型的getter,setter,constructor,toString等常用方法,可以让我们的模型代码更加干净.

了解了上述的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文件

这种组合索引字段,生成方法名的方式比较粗暴,比如如果有N个@Index字段,对应的selectByXXX方法就会有2**N,大部分场景下,这个N都不会超过3个,比如订单表,就是orderno,商品表,就是itemid

由于annotation是编译器的扩展,这一点体验比较好,一旦我们定义好了模型(比如Order.class),然后编译模型,我们就可以在代码其它地方,就可以直接引用OrderDAO这个对象类(这个类是生成的哦),可以回顾一下Java的编译过程.

实践

实践中,虽然生成的DAO可以覆盖我们大部分的用例,但是并不能覆盖所有我们的需求场景,因此,我们推荐将生成的DAO统一叫做BasicDAO,这样有些个性化的需求,我们仍然可以同自己书写SQL的方式来自定义,这样在解决重复冗余的前提下,也能很好的适应复杂的业务场景.

总结

Java本身是一门静态语言,程序从源代码,到运行的程序,中间会经历很多的环节.

这些环节都可以作为我们元编程的切入点,不同的环节,可以发挥不同的威力,使用得当,可以帮助我们提供生产力的同时,也能很好优化我们的代码性能

参考文档