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 文件

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

当一个东西令你觉得痒了, 那么很有可能, 这个东西也令其他程序员痒了, 而且 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 编译过程

compile process

如图所示,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 个, 比如订单表, 就是 order_no, 商品表, 就是 item_id

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

实践

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

总结

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

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

参考文档