不知不觉,一年的时间过去了,又该写几笔来回忆过去这一年了。。
原生AspectJ用法分析以及spring-aop原理分析
前言
前两天看了一些关于spring aop以及AspectJ的文章,但是总是感觉非常的乱,有的说spring aop跟aspectj相互独立,有的说spring aop依赖于aspectj,有的甚至直接把两者混为一谈。很多专门讲Aspectj的文章也只是搬运了AspectJ的语法,就那么一两点东西,讲来讲去也没有什么新意。甚至很多甚至都是面向IDE编程(教你怎么安装插件,点击菜单),对AspectJ的使用方式和工作原理都不去分析,离开了IDE的支持甚至连编译都不会了。我认为咱们这些码农平时习惯用IDE并没有问题,但是不仅要做到会用IDE,而且要做到超越IDE,这样才能站到更高一点的视角看出工具的本来面目而不是受工具的局限。
当然,我吐槽了这么多其实并不是想标新立异,只是想找一个写文章的理由。虽然从某种方面讲,可能也算是”茴香豆的X种写法“,但是既然我自己乐在其中,那么开心就好喽。
为什么用AspectJ
为什么用AspectJ,我的理解是两个字”方便“。我们知道面向切面编程(Aspect Oriented Programming)有诸多好处,但是在使用AspectJ之前我们一般是怎么编写切面的呢?我想一般来说应该是三种吧:静态代理,jdk动态代理,cglib动态代理。但是我们知道,静态代理的重用性太差,一个代理不能同事代理多种类;动态代理可以做到代理的重用,但是即使这样,他们调用起来还是比较麻烦,除了写切面代码以外,我们还需要将代理类耦合进被代理类的调用阶段,在创建被代理类的时候都要先创建代理类,再用代理类去创建被代理类,这就稍微有点麻烦了。比如我们想在现有的某个项目里统一新加入一些切面,这时候就需要创建切面并且侵入原有代码,在创建对象的时候添加代理,还是挺麻烦的。
说到底,这种麻烦出现的本质原因是,代理模式并没有做到切面与业务代码的解耦。虽然将切面的逻辑独立进了代理类,但是决定是否使用切面的权利仍然在业务代码中。这才导致了上面这种麻烦。
(当然,话不能说的这么绝对,如果有那种类似Spring的IoC容器,将类的创建都统一托管起来,我们只需要将切面用配置文件进行注册,容器会根据注册信息在创建bean的时候自动加上代理,这也是比较方便的。不过并不是所有框架都提供IoC机制的吧。。。)
既然代理模式这么麻烦,那么AspectJ又是通过什么方式来避免这个麻烦的呢?
我总结AspectJ提供了两套强大的机制:
第一套是切面语法。就是网上到处都是的那种所谓”AspectJ使用方法”,这套东西做到了将决定是否使用切面的权利还给了切面。在写切面的时候就可以决定哪些类的哪些方法会被代理,从而从逻辑上不需要侵入业务代码。由于这套语法实在是太有名,导致很多人都误以为AspectJ等于切面语法,其实不然。
第二套是织入工具。刚才讲到切面语法能够让切面从逻辑上与业务代码解耦,但是从操作上来讲,当JVM运行业务代码的时候,他甚至无从得知旁边还有个类想横插一刀。。。这个问题大概有两种解决思路,一种就是提供注册机制,通过额外的配置文件指明哪些类受到切面的影响,不过这还是需要干涉对象创建的过程;另外一种解决思路就是在编译期(或者类加载期)我们优先考虑一下切面代码,并将切面代码通过某种形式插入到业务代码中,这样业务代码不就知道自己被“切”了么?这种思路的一个实现就是aspectjweaver,就是这里的织入工具。
AspectJ究竟怎么用
一提起AspectJ,其实我感觉绝大多数人都会联想到Spring。毕竟,大多数人都是通过spring才接触到了AspectJ。可事实上Spring只是用到了AspectJ的冰山一角,局限于Spring恐怕是不能很好的理解AspectJ的,因此这一节我讲不涉及任何spring的东西,单看下AspectJ。
事实上AspectJ提供了两套对切面的描述方法,一种就是我们常见的基于java注解切面描述的方法,这种方法兼容java语法,写起来十分方便,不需要IDE的额外语法检测支持;另外一种是基于aspect文件的切面描述方法,这种语法本身并不是java语法,因此写的时候需要IDE的插件支持才能进行语法检查。
AspectJ相关jar包
AspectJ其实是eclipse基金会的一个项目,官网就在eclipse官网里。官网里提供了一个aspectJ.jar的下载链接,但其实这个链接只是一个安装包,把安装包里的东西解压后就是一个文档+脚本+jar包的程序包,其中比较重要的是如下部分:
1 | myths@pc:~/aspectj1.8$ tree bin/ lib/ |
当然,这些jar包并不总是需要从官网下载,很多情况下在maven等中心库中直接找会更方便。
这当中重点的文件是四个jar包中的前三个,bin文件夹中的脚本其实都是调用这些jar包的命令。
- aspectjrt.jar包主要是提供运行时的一些注解,静态方法等等东西,通常我们要使用aspectJ的时候都要使用这个包。
- aspectjtools.jar包主要是提供赫赫有名的ajc编译器,可以在编译期将将java文件或者class文件或者aspect文件定义的切面织入到业务代码中。通常这个东西会被封装进各种IDE插件或者自动化插件中。
- aspectjweaverjar包主要是提供了一个java agent用于在类加载期间织入切面(Load time weaving)。并且提供了对切面语法的相关处理等基础方法,供ajc使用或者供第三方开发使用。这个包一般我们不需要显式引用,除非需要使用LTW。
上面的说明其实也就指出了aspectJ的几种标准的使用方法(参考文档):
- 编译时织入,利用ajc编译器替代javac编译器,直接将源文件(java或者aspect文件)编译成class文件并将切面织入进代码。
- 编译后织入,利用ajc编译器向javac编译期编译后的class文件或jar文件织入切面代码。
- 加载时织入,不使用ajc编译器,利用aspectjweaver.jar工具,使用java agent代理在类加载期将切面织入进代码。
基于aspectj文件的AspectJ
这种说法比较蛋疼,其实我想说明的是这种不兼容javac的一种切面表示形式。比如当前我们有一个业务类App.java:
1 | public class App { |
我们希望对在say函数里加一个切面,那就创建一个AjAspectj.aj的文件:
1 | public aspect AjAspect { |
这样我们就能实现切面的功能。可这个aj文件的语法虽然跟java很类似,但是毕竟还是不能用javac来编译,如果我们要用这个的话就必须使用ajc编译器。使用的方法大概有这几种:
- 调用命令直接编译(直接使用ajc命令或者调用java -jar aspectjtools.jar)
- 使用IDE集成的ajc编译器编译
- 使用自动化构建工具的插件编译
其实2,3两点的本质都是使用aspectjtools.jar,最简单的调用方法如下:调用aspectjtools.jar包,指定aspectjrt的classpath,以及需要编译的路径,这样就会生成AjAspectj.aj以及App.java对应的class文件。我们反编译一下看看:1
2
3
4
5
6#!/usr/bin/env bash
ASPECTJ_TOOLS=/home/myths/.m2/repository/org/aspectj/aspectjtools/1.8.9/aspectjtools-1.8.9.jar
ASPECTJ_RT=/home/myths/.m2/repository/org/aspectj/aspectjrt/1.8.9/aspectjrt-1.8.9.jar
java -jar $ASPECTJ_TOOLS -cp $ASPECTJ_RT -sourceroots .
AjAspectj.class:App.class:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49import java.io.PrintStream;
import org.aspectj.lang.NoAspectBoundException;
public class AjAspect
{
private static Throwable ajc$initFailureCause;
public static final AjAspect ajc$perSingletonInstance;
public static AjAspect aspectOf()
{
if (ajc$perSingletonInstance == null) {
throw new NoAspectBoundException("AjAspect", ajc$initFailureCause);
}
return ajc$perSingletonInstance;
}
public static boolean hasAspect()
{
return ajc$perSingletonInstance != null;
}
private static void ajc$postClinit()
{
ajc$perSingletonInstance = new AjAspect();
}
static
{
try
{
}
catch (Throwable localThrowable)
{
ajc$initFailureCause = localThrowable;
}
}
public void ajc$before$AjAspect$1$682722c()
{
System.out.println("AjAspect before say");
}
public void ajc$after$AjAspect$2$682722c()
{
System.out.println("AjAspect after say");
}
}调用App.class,发现切面成功生效:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23import java.io.PrintStream;
public class App
{
public void say()
{
try
{
AjAspect.aspectOf().ajc$before$AjAspect$1$682722c();System.out.println("App say");
}
catch (Throwable localThrowable)
{
AjAspect.aspectOf().ajc$after$AjAspect$2$682722c();throw localThrowable;
}
AjAspect.aspectOf().ajc$after$AjAspect$2$682722c();
}
public static void main(String[] args)
{
App app = new App();
app.say();
}
}我们发现aj文件的确被编译成了一个单例类,并且生成了一些切面方法,这些方法被织入进了App类中的say方法体中,可以说是非常的暴力了。(这里顺便吐槽一波IntelliJ自带的反编译器真的很烂,还是jd-gui好用)。1
2
3
4$ java -cp ~/.m2/repository/org/aspectj/aspectjrt/1.8.9/aspectjrt-1.8.9.jar:. App
AjAspect before say
App say
AjAspect after say
不过,虽然事实上这种基于aj文件的切面描述方法比基于java注解的切面描述方法用起来要灵活的多,但是由于他无法摆脱ajc的支持,而且本身不兼容java语法导致难以统一编码规范,加上需要较多额外的学习成本,因此事实上很多项目还是不怎么用这种方式,更多的还是采用了兼容java语法的用注解定义切面的方式。
基于java注解的AspectJ
下面我们主要还是着力考虑下基于java注解的切面使用方法。
准备
先建一个普通的项目看看,老样子,从maven的maven-archetype-quickstart开始,pom.xml,pom文件里我们一般只需要加上aspetjrt的依赖即可。:
1 | <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
创建App.java文件:
1 | package com.mythsman.test; |
创建切面类AnnoAspect.java:
1 | package com.mythsman.test; |
当前项目结构应该是这样的:
1 | . |
其实就是创建了一个对App类进行切面的AnnoAspect类,这个类需要加上@Aspect注解用以声明这是一个切面,以及其他相关切面语法。接下来我们就来尝试下三种不同的编译方式。
编译时织入
编译时织入其实就是使用ajc来进行编译,暂时不使用自动化构建工具,我们先在项目根目录下手动写一个编译脚本compile.sh:
1 | #!/usr/bin/env bash |
调用aspectjtools.jar,在-cp里指明aspectjrt.jar的路径,-source 1.5指明支持java1.5以后的注解,-sourceroots指明编译的文件夹,-d指明输出路径。
这样就会生成AnnoAspect.class和App.class两个文件。
AnnoAspect.class:
1 | package com.mythsman.test; |
App.class
1 | package com.mythsman.test; |
我们发现ajc对AnnoAspect的处理方法与跟AjAspect的处理方法类似,都是将类声明成单例,并且识别AspectJ语法,将相关函数织入到App中。
运行(在项目根目录执行):
1 | $ java -cp ~/.m2/repository/org/aspectj/aspectjrt/1.8.9/aspectjrt-1.8.9.jar:src/main/java/ com.mythsman.test.App |
编译后织入
编译后织入其实就是在javac编译完成后,用ajc再去处理class文件得到新的、织入过切面的class文件。
仍然是上面的项目,我们先用javac编译一下:
1 | $ javac -cp ~/.m2/repository/org/aspectj/aspectjrt/1.8.9/aspectjrt-1.8.9.jar -d target/classes src/main/java/com/mythsman/test/*.java |
编译成功后生成了AnnoAspect.class以及App.class。显然,这两个class文件反编译后还是源文件的样子,并没有什么用,因此这时候执行App的main函数发现切面并没有生效。因此我们仍然需要用ajc来处理:
1 | !/usr/bin/env bash |
这样就把target/classes中原来的class文件替换成了织入后的class文件。反编译之后发现与采用编译期织入方法的结果基本相同。
加载时织入(LTW)
前两种织入方法都依赖于ajc的编译工具,LTW却通过java agent机制在内存中操作类文件,可以不需要ajc的支持做到动态织入。
不过,这里有一个挺有意思的问题,我们知道编译期一定会编译AnnoAspect类,那么这时候通过切面语法我们就可以找到他要处理的App类,这大概就是编译阶段织入的大概流程。但是如果在类加载期处理的话,当类加载到App类的时候,我们并不知道这个类需要被AnnoAspect处理。。。因此为了实现LTW,我们肯定要有个配置文件,来告诉类加载器,某某某切面需要优先考虑,他们很可能会影响其他的类。
为了实现LTW,我们需要在资源目录下配置META-INF/aop.xml文件,来告知类加载器我们当前注册的切面。
在上面的项目中,我们其实只需要创建src/main/resources/META-INF/aop.xml:
1 | <aspectj> |
这样,我们就可以先使用javac编译源文件,再使用java agent在运行时织入:
1 | #!/usr/bin/env bash |
运行结果:
1 | AnnoAspect before say |
当然,如果可以使用ajc的话,我们也可以通过-outxml参数来自动生成xml文件。
maven自动化构建
显然,自己写脚本还是比较麻烦的,如果用如maven这样的自动化构建工具的话就会方便很多,codehaus提供了一个ajc的编译插件aspectj-maven-plugin,我们只需要在build/plugins标签下加上这个插件的配置即可:
1 | <plugin> |
这个插件会绑定到编译期,采用的应该是编译后织入的方式,在maven-compiler-plugin处理完之后再工作的。
不要以为这个插件多厉害,说白了他其实就是对aspectjtools.jar的一个mojo封装而已,去看他的依赖树就会很清楚。
如何判断是织入还是代理
这个问题很有意思,也是非常容易被搞混的,尤其是在讨论spring aop的时候。我们知道spring里有很多基于动态代理的设计,而我们知道动态代理也可以被用作面向切面的编程,但是spring aop本身却支持aspectj的切面语法,而且spring-aop这个包也引用了aspectj,我们知道aspectj是通过织入的方式来实现aop的。。。那么spring aop究竟是通过织入还是代理来实现aop的呢?
没错就是动态代理
其实spring aop还是通过动态代理来实现aop的,即使不去看他的源码,我们也可以通过简单的实验来得到这个结论。
根据aspectj的使用方式,我们知道,如果要向代码中织入切面,那么我们要么采用ajc编译,要么使用aspectjweaver的agent代理。但是spring既没有依赖任何aspectjtools的相关jar包,虽然依赖了aspectjweaver这个包,但是并没有添加agent代理。当然,也存在一种可能就是spring利用aspectjweaver这个包自己实现了动态织入,但是从可复用的角度讲,spring真的会自己重新造轮子?如果真的重新造了那为啥不脱离aspectj彻底重新造,而是用一半造一半呢?
而且,我们知道用织入和用动态代理有一个很大的区别,如果使用织入的话,那么调业务对象的getClass()方法获得的类名就是这个类本身实现的类名;但是如果使用动态代理的话,调用getClass()方法获得的类名就是动态代理类的类名了。做一个简单的实验我们就可以发现,如果我们使用spring aop来对某一个service进行切面处理,那么调用getClass()方法获得的结果就是:
1 | com.mythsman.test.Myservice$$EnhancerBySpringCGLIB$$3afc9148 |
显然,虽然spring aop采用了aspectj语法来定义切面,但是在实现切面逻辑的时候还是采用CGLIB来进行动态代理的方法。
隐藏bug
看上去,使用动态代理似乎能完美实现aspectj的全部功能,但是动态代理在使用的时候有一个致命的缺点,对于新手来说,这个缺点很容易被当成是bug。比如如下代码:
1 | @Component |
假设TestAspect注解定义了一个切面,那么如果直接调用call方法,work方法是不会被代理的。这是因为call方法直接使用的是this对象的work方法,而不是代理后的对象的work方法,这一点尤其需要注意。解决方法如下:
1 | @Component |
必须手动将执行work的对象指定为使用代理的spring bean。
强行织入?
当然,如果我们想,我们也可以强行采用织入的方式,不过我们就不能将切面类注册为spring的bean,只能采用ajc插件编译或者java agent在类加载时织入。
参考资料
比较分析 Spring AOP 和 AspectJ 之间的差别
AOP之@AspectJ技术原理详解
AspectJ 编译时织入(Compile Time Weaving, CTW)
Mojohaus AspectJ-Maven-Plugin
Chapter 5. Load-Time Weaving
AspectJ documentation
静态代理、JDK与CGLIB动态代理、AOP+IoC
Lombok原理分析与功能实现
前言
这两天没什么重要的事情做,但是想着还要春招总觉得得学点什么才行,正巧想起来前几次面试的时候面试官总喜欢问一些框架的底层实现,但是我学东西比较倾向于用到啥学啥,因此在这些方面吃了很大的亏。而且其实很多框架也多而杂,代码起来费劲,无非就是几套设计模式套一套,用到的东西其实也就那么些,感觉没啥新意。刚这两天读”深入理解JVM”的时候突然想起来有个叫Lombok的东西以前一直不能理解他的实现原理,现在正好趁着闲暇的时间研究研究。
Lombok
代码
Lombok是一个开源项目,源代码托管在GITHUB/rzwitserloot,如果需要在maven里引用,只需要添加下依赖:
1 | <dependency> |
功能
那么Lombok是做什么的呢?其实很简单,一个最简单的例子就是它能够实现通过添加注解,能够自动生成一些方法。比如这样的类:
1 | @Getter |
我们用Lombok提供的@Getter来注解这个类,这个类在编译的时候就会变成:
1 | class Test{ |
当然Lombok也提供了很多其他的注解,这只是其中一个最典型的例子。其他的用法网上的资料已经很多了,这里就不啰嗦。
看上去是很方便的一个功能,尤其是在很多项目里有很多bean,每次都要手写或自动生成setter getter方法,搞得代码很长而且没有啥意义,因此这个对简化代码的强迫症们还是很有吸引力的。
但是,我们发现这个包跟一般的包有很大区别,绝大多数java包都工作在运行时,比如spring提供的那种注解,通过在运行时用反射来实现业务逻辑。Lombok这个东西工作却在编译期,在运行时是无法通过反射获取到这个注解的。
而且由于他相当于是在编译期对代码进行了修改,因此从直观上看,源代码甚至是语法有问题的。
一个更直接的体现就是,普通的包在引用之后一般的IDE都能够自动识别语法,但是Lombok的这些注解,一般的IDE都无法自动识别,比如我们上面的Test类,如果我们在其他地方这么调用了一下:
1 | Test test=new Test(); |
IDE的自动语法检查就会报错,说找不到这个getValue方法。因此如果要使用Lombok的话还需要配合安装相应的插件,防止IDE的自动检查报错。
因此,可以说这个东西的设计初衷比较美好,但是用起来比较麻烦,而且破坏了代码的完整性,很多项目组(包括我自己)都不高兴用。但是他的实现原理却还是比较好玩的,随便搜了搜发现网上最多也只提到了他修改了抽象语法树,虽说从感性上可以理解,但是还是想自己手敲一敲真正去实现一下。
原理
翻了翻现有的资料,再加上自己的一些猜想,Lombok的基本流程应该基本是这样:
- 定义编译期的注解
- 利用JSR269 api(Pluggable Annotation Processing API )创建编译期的注解处理器
- 利用tools.jar的javac api处理AST(抽象语法树)
- 将功能注册进jar包
看起来还是比较简单的,但是不得不说坑也不少,搞了两天才把流程搞通。。。
下面就根据这个流程自己实现一个有类似功能的Getter类。
手撸Getter
实验的目的是自定义一个针对类的Getter注解,它能够读取该类的成员方法并自动生成getter方法。
项目依赖
由于比较习惯用maven,我这里就用maven构建一下项目,修改下当前的pom.xml文件如下:
1 | <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
主要定义了下项目名,除了默认依赖的junit之外(其实并没有用),这里添加了tools.jar包。这个包实在jdk的lib下面,因此scope是system,由于${java.home}变量表示的是jre的位置,因此还要根据这个位置找到实际的tools.jar的路径并写在systemPath里。
由于防止在写代码的时候用到java8的一些语法,这里配置了下编译插件使其支持java8。
创建Getter注解
定义注解Getter.java:
1 | package com.mythsman.test; |
这里的Target我选择了ElementType.TYPE表示是对类的注解,Retention选择了RententionPolicy.SOURCE,表示这个注解只在编译期起作用,在运行时将不存在。这个比较简单,稍微复杂点的是对这个注解的处理机制。像spring那种注解是通过反射来获得注解对应的元素并实现业务逻辑,但是我们显然不希望在使用Lombok这种功能的时候还要编写其他的调用代码,况且用反射也获取不到编译期才存在的注解。
幸运的是Java早已支持了JSR269的规范,允许在编译时指定一个processor类来对编译阶段的注解进行干预,下面就来解决下这个处理器。
创建Getter注解的处理器
基本框架
自定义的处理器需要继承AbstractProcessor这个类,基本的框架大体应当如下:
1 | package com.mythsman.test; |
需要定义两个注解,一个表示该处理器需要处理的注解,另外一个表示该处理器支持的源码版本。然后需要着重实现两个方法,init跟process。init的主要用途是通过ProcessingEnvironment来获取编译阶段的一些环境信息;process主要是实现具体逻辑的地方,也就是对AST进行处理的地方。
具体怎么做呢?
init方法
首先我们要重写下init方法,从环境里提取一些关键的类:
1 |
|
我们提取了四个主要的类:
- Messager主要是用来在编译期打log用的
- JavacTrees提供了待处理的抽象语法树
- TreeMaker封装了创建AST节点的一些方法
- Names提供了创建标识符的方法
process方法
process方法的逻辑比较简单,但是由于这里的api对于我们来说比较陌生,因此写起来还是费了不少劲的:
1 | @Override |
步骤大概是下面这样:
- 利用roundEnv的getElementsAnnotatedWith方法过滤出被Getter这个注解标记的类,并存入set
- 遍历这个set里的每一个元素,并生成jCTree这个语法树
- 创建一个TreeTranslator,并重写其中的visitClassDef方法,这个方法处理遍历语法树得到的类定义部分jcClassDecl
- 创建一个jcVariableDeclList保存类的成员变量
- 遍历jcTree的所有成员(包括成员变量和成员函数和构造函数),过滤出其中的成员变量,并添加进jcVariableDeclList
- 将jcVariableDeclList的所有变量转换成需要添加的getter方法,并添加进jcClassDecl的成员中
- 调用默认的遍历方法遍历处理后的jcClassDecl
- 利用上面的TreeTranslator去处理jcTree
接下来再实现makeGetterMethodDecl方法:
1 | private JCTree.JCMethodDecl makeGetterMethodDecl(JCTree.JCVariableDecl jcVariableDecl) { |
逻辑就是读取变量的定义,并创建对应的Getter方法,并试图用驼峰命名法。
整体上难点还是集中在api的使用上,还有一些细微的注意点:
首先,messager的printMessage方法在打印log的时候会自动过滤重复的log信息。
其次,这里的list并不是java.util里面的list,而是一个自定义的list,这个list的用法比较坑爹,他采用的是这样的方式:
1 | package com.sun.tools.javac.util; |
挺有趣的,用这种叫cons而不是list的数据结构,添加元素的时候就把自己赋给自己的tail,新来的元素放进head。不过需要注意的是这个东西不支持链式调用,prepend之后还要将新值赋给自己。
而且这里在创建getter方法的时候还要把参数写全写对了,尤其是添加this指针的这种用法。
测试类
上面基本就是所有功能代码了,接下来我们要写一个类来测试一下(App.java):
1 | package com.mythsman.test; |
不过,先不要急着构建,构建了肯定会失败,因为这原则上应该是两个项目。Getter.java是注解类没问题,但是GetterProcessor.java是处理器,App.java需要在编译期调用这个处理器,因此这两个东西是不能一起编译的,正确的编译方法应该是类似下面这样,写成compile.sh脚本就是:
1 | #!/usr/bin/env bash |
其实是五个步骤:
- 创建保存class文件的文件夹
- 导入tools.jar,编译processor并输出
- 编译App.java,并使用javac的-processor参数指定编译阶段的处理器GetterProcessor
- 用javap显示编译后的App.class文件(非必须,方便看结果)
- 执行测试类
好了,进入项目的根目录,当前的目录结构应该是这样的:
1 | . |
调用compile.sh,输出如下:
1 | Note: value has been processed |
Note行就是在GetterProcessor类里通过messager打印的log,中间的是javap反编译的结果,最后一行表示测试调用成功。
Maven构建并打包
上面的测试部分其实是为了测试而测试,其实这应当是两个项目,一个是processor项目,这个项目应当被打成一个jar包,供调用者使用;另一个项目是app项目,这个项目是专门使用jar包的,他并不希望添加任何额外编译参数,就跟lombok的用法一样。
简单来说,就是我们希望把processor打成一个包,并且在使用时不需要添加额外参数。
那么如何在调用的时候不用加参数呢,其实我们知道java在编译的时候会去资源文件夹下读一个META-INF文件夹,这个文件夹下面除了MANIFEST.MF文件之外,还可以添加一个services文件夹,我们可以在这个文件夹下创建一个文件,文件名是javax.annotation.processing.Processor,文件内容是com.mythsman.test.GetterProcessor。
我们知道maven在编译前会先拷贝资源文件夹,然后当他在编译时候发现了资源文件夹下的META-INF/serivces文件夹时,他就会读取里面的文件,并将文件名所代表的接口用文件内容表示的类来实现。这就相当于做了-processor参数该做的事了。
当然这个文件我们并不希望调用者去写,而是希望在processor项目里集成,调用的时候能直接继承META-INF。
好了,我们先删除App.java和compile.sh,添加下META-INF文件夹,当前目录结构应该是这样的:
1 | . |
当然,我们还不能编译,因为processor项目并不需要把自己添加为processor(况且自己还没编译呢怎么调用自己)。。。完了,好像死循环了,自己在编译的时候不能添加services文件夹,但是又需要打的包里有services文件夹,这该怎么搞呢?
其实很简单,配置一下maven的插件就行,打开pom.xml,在project/build/标签里添加下面的配置:
1 | <build> |
我们知道maven构建的第一步就是调用maven-resources-plugin插件的resources命令,将resources文件夹复制到target/classes中,那么我们配置一下resources标签,过滤掉META-INF文件夹,这样在编译的时候就不会找到services的配置了。然后我们在打包前(prepare-package生命周期)再利用maven-resources-plugin插件的copy-resources命令把services文件夹重新拷贝过来不就好了么。
这样配置好了,就可以直接执行mvn clean install
打包提交到本地私服:
1 | myths@pc:~/Desktop/test$ mvn clean install |
可以看到这里的process-META作用生效。
调用jar包测试
重新创建一个测试项目app:
1 | . |
pom.xml:
1 | <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
App.java:
1 | package com.mythsman.test; |
编译并执行:
1 | mvn clean compile && java -cp target/classes com.mythsman.test.App |
最后就会在构建成功后打印”it works”。
参考资料
GITHUB/lombok
使用 lombok 简化 Java 代码
Java注解(3)-注解处理器
stackoverflow/How does lombok work?
【翻译】Javac骇客指南
Javac早期(编译期)
利用maven中resources插件的copy-resources目标进行资源copy和过滤
Java类加载原理与ClassLoader使用总结
前言
说来好笑,不知道怎么我就来搞Java了。虽说大学也码了三年多的代码,但是七七八八乱糟糟的东西搞得有点多,对Java的理解也只能算是hello world,这让我感觉非常慌,尤其是看到招聘网上的一堆JD都要求深入理解JVM,再对比下自己真是自惭形秽。这一两个月没什么事情,感觉是时候要补充点知识了。。。
双亲委派模型
类加载这个概念应该算是Java语言的一种创新,目的是为了将类的加载过程与虚拟机解耦,达到”通过类的全限定名来获取描述此类的二进制字节流“的目的。实现这个功能的代码模块就是类加载器。类加载器的基本模型就是大名鼎鼎的双亲委派模型(Parents Delegation Model)。听上去很牛掰,其实逻辑很简单,在需要加载一个类的时候,我们首先判断该类是否已被加载,如果没有就判断是否已被父加载器加载,如果还没有再调用自己的findClass方法尝试加载。基本的模型就是这样(盗图侵删):
实现起来也很简单,重点就是ClassLoader类的loadClass方法,源码如下:
1 | protected Class<?> loadClass(String name, boolean resolve) |
闲来无事再看一下findClass方法:
1 | protected Class<?> findClass(String name) throws ClassNotFoundException { |
突然感觉被逗了,怎么默认直接就抛了异常呢?其实是因为ClassLoader这个类是一个抽象类,实际在使用时候会写个子类,这个方法会按照需要被重写,来完成业务需要的加载过程。
自定义ClassLoader
在自定义ClassLoader的子类时候,我们常见的会有两种做法,一种是重写loadClass方法,另一种是重写findClass方法。其实这两种方法本质上差不多,毕竟loadClass也会调用findClass,但是从逻辑上讲我们最好不要直接修改loadClass的内部逻辑。
个人认为比较好的做法其实是只在findClass里重写自定义类的加载方法。
为啥说这种比较好呢,因为前面我也说道,loadClass这个方法是实现双亲委托模型逻辑的地方,擅自修改这个方法会导致模型被破坏,容易造成问题。因此我们最好是在双亲委托模型框架内进行小范围的改动,不破坏原有的稳定结构。同时,也避免了自己重写loadClass方法的过程中必须写双亲委托的重复代码,从代码的复用性来看,不直接修改这个方法始终是比较好的选择。
当然,如果是刻意要破坏双亲委托模型就另说。
破坏双亲委托模型
为什么要破坏双亲委托模型呢?
其实在某些情况下,我们可能需要加载两个不同的类,但是不巧的是这两个类的名字完全一样,这时候双亲委托模型就无法满足我们的要求了,我们就要重写loadClass方法破坏双亲委托模型,让同一个类名加载多次。当然,这里说的破坏只是局部意义上的破坏。
但是类名相同了,jvm怎么区别这两个类呢?显然,这并不会造成什么世界观的崩塌,其实类在jvm里并不仅是通过类名来限定的,他还属于加载他的ClassLoader。由不同ClassLoader加载的类其实是互不影响的。
做一个实验。
我们先写两个类:
1 | package com.mythsman.test; |
1 | package com.mythsman.test; |
两个类名字一样,唯一的区别是方法的实现不一样。我们先分别编译,然后把生成的class文件重命名为Hello.class.1和Hello.class.2。
我们的目的是希望能在测试类里分别创建这两个类的实例。
接着我们新建一个测试类com.mythsman.test.Main,在主函数里创建两个自定义的ClassLoader:
1 | ClassLoader classLoader1=new ClassLoader() { |
这两个ClassLoader的用途就是分别关联Hello类的两种不同字节码,我们需要读取字节码文件并通过defineClass方法加载成class。注意我们重载的是loadClass方法,如果是重载findClass方法那么由于loadClass方法的双亲委托处理机制,第二个ClassLoader的findClass方法其实并不会被调用。
那我们怎么生成实例呢?显然我们不能直接用类名来引用(名称冲突),那就只能用反射了:
1 | Object helloV1=classLoader1.loadClass("com.mythsman.test.Hello").newInstance(); |
输出:
1 | This is from Hello v1 |
OK,这样就算是完成了两次加载,但是还有几个注意点需要关注下。
两个类的关系是什么
显然这两个类并不是同一个类,但是他们的名字一样,那么类似isinstance of之类的操作符结果是什么样的呢:
1 | System.out.println("class:"+helloV1.getClass()); |
输出:
1 | class:class com.mythsman.test.Hello |
他们的类名的确是一样的,但是类的hashcode不一样,也就意味着这两个本质不是一个类,而且他们的类加载器也不同(其实就是Main的两个内部类)。
这两个类加载器跟系统的三层类加载器是什么关系
以第一个自定义的类加载器为例:
1 | System.out.println(classLoader1.getParent().getParent().getParent()); |
输出:
1 | null |
我们可以看到,第四行就是这个自定义的ClassLoader,他的父亲是AppClassLoader,爷爷是ExtClassLoader,太爷爷是null,其实就是用C写的BootStrapClassLoader。而当前系统的ClassLoader就是这个AppClassLoader。
当然,这里说的父子关系并不是继承关系,而是组合关系,子ClassLoader保存了父ClassLoader的一个引用(parent)。
有没有不用反射的更优雅的调用方法
显然,每次都用反射来调用还是太蠢了,难道就没有更方便的类似用类名引用的方法么?当然是有的,前面之所以不能直接用类名引用是因为原生类的类加载器是systemClassLoader,而从class文件创建的类的类加载器是自定义的classLoader,这两个类本质不一样,因此才不能互相强制转换,如果硬要强制转换就会报ClassCastException。那么,如果我们提取一个父类,父类由systemClassLoader加载,而子类由自定义classLoader加载,然后强制转换的时候转换成父类不就好了么?
做个试验,创建一个父类Father,其实就是提取了个抽象方法:
1 | package com.mythsman.test; |
然后修改一下Hello类:
1 | package com.mythsman.test; |
然后将Hello类手动编译,并把class文件放到其他地方。重新修改这个类,将”say outside”改成”say inside”。
再修改下主函数:
1 | package com.mythsman.test; |
这样我们就可以看到输出是:
1 | say outside |
笔记:NPM版本号自增,自动化发布NPM包
不可不知的Mac OS X专用命令行工具(持续更新中)
OS X
的终端下通用很多 Unix
的工具和脚本。如果从 Linux
迁移到 OS X
会发现很多熟悉的命令和脚本工具,其实并没有任何区别。
但是 OS X
也提供了很多其他系统所没有的特别的命令行工具。我们推荐 8
个这类的工具,希望有助于提高在 Mac
的命令行环境下的效率。
LLT工作总结与Gherkin语法解析器简单应用
前言
这几天产品线这里要搞LLT(Low level Test)重点工作,保障版本的高质量发布。工作当然包括一系列的规范、培训、编码、检视,不过具体看下来主要还是提取了下面的一些度量要点:
0. 保证LLT运行不挂(废话)
- 清零无效LLT代码
- 保证LLT对代码的覆盖率
- 保证LLT对需求的覆盖率
清零无效LLT代码,意思是指通过一些检查工具,检查出LLT代码中没有使用断言的测试,或者是那种假装使用了断言的测试代码(“assert(true);”)。显然,这两种情况下写的LLT代码永远无法告警,因此是没有任何意义的。
保证LLT对代码的覆盖率很简单,就是通过测量测试代码对业务代码的覆盖率,保证软件的质量。虽然代码覆盖率并不能够绝对代表测试的充分程度,但是在排除恶意提高覆盖率的情况下,也可以作为度量LLT代码质量的一个参考。
保证LLT对需求的覆盖率这一点是一个比较小众的概念,这主要是对BDD(Behaviour Driven Development)实践执行效果的一个辅助度量方式。我们知道BDD的要点在于将功能需求作为测试的方案,测试代码围绕着需求展开(而不是函数)。这样一方面写完测试代码就相当于写完了测试文档,任何人都可以非常清晰的理解LLT代码的实际目的是什么;另一方面也可以很好的从需求的层面保障新需求经过了完备的LLT测试。那么为了度量LLT对需求的保障程度,就需要将需求进行编号,然后与LLT对应,以度量需求的覆盖率以及需求的平均用例数。
吐槽
LLT的初衷是将代码错误拦截在软件生命周期的较早的阶段,减少后期处理bug的代价。但是,凡事都是要辩证的来看,既然LLT跟BDD的好处有点那么多,为什么不是所有的产品都采用了这一套流程呢?显然,这样的一套流程不可避免的会带来很多额外的工作量,软件度量这件事情本身就是值得商榷的,如果不采用硬性的指标规定,管理者无法切实有效的进行管理,开发人员也没有动力去遵守;而采用硬性的指标规定,又势必容易导致一刀切,而事实上很多指标在某些情况下没有达成的必要,如果偏要达成,则容易造成很大的资源浪费。
我在推进这项工作的时候也经常发现下面这些远离我们初衷的现象:
- 写LLT代码本身也容易引起额外的bug,增加了整体代码的维护难度。
- 对“无效LLT代码”的定位不准,容易造成诊断错误,简单的检查工具无法识别特殊场景下的确有用的LLT代码。
- 有时候纯粹是为了覆盖率而“补”用例,用例本身并不能测出漏洞反而浪费时间。
- 很多需求本身不涉及LLT代码(比如涉及配置或者一些静态文件需求),这样的话统计LLT对需求的覆盖率这件事本身可能就没有什么实际意义。
- 很多时候当LLT代码量庞大时,会极大地延长代码的编译构建时间,拖慢项目进度。
其实LLT的根本目的就是为了节约成本,节约时间(提前发现问题,减少回溯成本),可如果一味地为了做好LLT而投入了过多的时间和成本,那我觉得可能就是有点舍本逐末了。
不过谁在乎呢?公司舍得花钱,领导需要政绩,码农需要活干,那就干呗。
工作
我这边的具体工作大概就是写一个扫描Cucumber测试文件的检查工具,并且对接公司内部的需求设计平台,统计出LLT代码与需求的关联度并做可视化展示。这个工作的难点大概就是解析Cucumber文件了。Cucumber大概是当前比较流行的BDD框架了,虽然这个东西并不是很新,但是当前很多大型软件公司也在用。这个东西的好处自不必说,网上的各种推荐跟教程都有很多。不过作为一个靠谱的码农不在迫不得已的情况下还是尽量不要学二手的知识比较好,而且要尽量保持视野的开阔,能不造轮子就不造轮子。这不,仔细研究一下就知道,Cucumber用例文件的语法解析器什么的都是开源的,代码下下来捣鼓捣鼓就好了,完全没有必要自己从0开始造轮子。
Gherkin语法
Cucumber工具采用的他自己定义的语法—Gherkin。这个其实很简单,官网上解释的很详细。比如下面的文件就描述了两个测试场景。
1 | Feature: |
那我们怎么将他跟需求对应起来呢?
我们在需求设计和分析阶段的时候会把用户需求进行逐步细分和下发。一个典型的例子就是从用户描述的初始需求,拆分为工程领域的系统需求,再细分到各个子模块,由具体开发人员当成一个个小的用户故事来开发。到了这一层面,每一个需求就会对应一个需求单号,我们就通过这个来需求进行识别。
有了需求单号,我们就可以通过在Cucumber工具定义的feature文件里以标签的形式加进来:
1 | @ST.SR.IR.XXX.YYY.ZZZ |
gherkin语法支持在多个地方添加@标签。这个标签本来是用作“开关”,方便程序员在执行时选择执行,不过我们现在拿来对接需求单号也未尝不可,毕竟每一个用例都可以对应多个标签,二者互不影响。
文件解析
下面就牵涉到具体的文件解析,我们需要从feature文件里提取出标签,并对应上他所标注的用例。
Gherkin本身提供了将文件解析成抽象语法树(AST)以及JSON(Pickle)的功能,AST本身功能强大,但是稍微复杂一点,JSON更好理解,而且一般来说解析成类似下面的JSON也就够用了。
1 | { |
可以看到,每一组用例就是一个pickle,里面清晰的表明了Cucumber语句的位置,标签,路径等等信息。
官网文档中写的不是很详细,毕竟给Cucumber做二次开发的人也不多。文档中给了各个语言
的底层接口,不过比较简略,用起来也不是很方便。于是我就看了下CLI工具的实现,用JAVA简单摸索了一下。
具体实现
首先是安装依赖,我习惯用maven,最新的版本号可以参考这里的,不过我当前用的是一个稍老的稳定版本:
1 | <dependency> |
测试用feature文件:
1 | @a |
读取feature文件并解析:
1 | public class Main { |
主要是下面的步骤:
- 读取代码文件。
- 扫描出feature文件。
- 创建SourceEvents,其实就是feature文件的集合。
- 创建GherkinEvents,其实是选择解析的模式,是否包含源码,是否包含AST树,是否包含Pickle,我们当然只选择Pickle。
- 用GherkinEvents去遍历SourceEvents获得PickleEvents,其实到这一层就已经解析到了每一组用例了。
- 最后选择需要显示的信息即可。Gherkin默认采用Gson来处理JSON数据。
输出结果样例:
1 | [{"location":{"line":1,"column":1},"name":"@a"},{"location":{"line":3,"column":3},"name":"@b"},{"location":{"line":3,"column":6},"name":"@c"}] |
围绕生成Pickle类的关键类图如下:
有了这套流程,我们就可以很方便的获得每组用例所对应的标签,然后加以统计分析了。