Lombok经常用,但是你知道它的原理是什么吗?(二)

4,744 阅读13分钟

在上一篇Lombok经常用,但是你知道它的原理是什么吗?简单介绍了注解处理器,是用来处理编译期的注解的一个工具,我们只是自己生成了一些代码,但是和Lombok却不一样,因为Lombok是在原有类的基础上增加了一些类,你那么Lombok是如何做到修改原有类的内容呢?接下来我们就再进一步了解Lombok的原理。

Javac原理

既然我们是在编译期对类进行操作了,那么我们就需要了解在Java中Javac到底对程序做了什么。Javac对代码编译的过程其实就是用Java来写的,我们可以查看其源码对其简单的分析,如何下载源码,Debug源码这里我就不进行分析了,推荐一篇文章写的挺好的。Javac 源码调试教程

编译过程大致分为了三个阶段

  • 解析与填充符号表
  • 注解处理
  • 分析与字节码生成

这三个阶段的交互过程如下图所示。

解析与填充符号表

这一步骤是两个步骤,包括了解析和填充符号,其中解析是分为词法分析语法分析两个步骤。

词法分析和语法分析

词法分析就是将源代码的字符流转变为Java中的标记(Token)集合,单个字符是程序编写过程中最小的元素,而标记(Token)则是编译过程中最小的元素,关键字、变量名、字面量、运算符都可以成为标记(Token)。比如在Java中int a = b+2,这段代码则表示了6个标记Token,分别是int、a、=、b、+、2。虽然关键字int是由三个字符构成的,但是它只是一个Token,不可以再拆分了。

语法分析是根据Token序列构造抽象对象树的过程,抽象语法树(Abstract syntax tree),是一种用来描述代码语法结构的树形表示方法,语法树的每一个节点都代表着程序代码中的一个语法结构,例如包、类型、修饰符、运算符、接口、返回值甚至是代码注释都是一个语法结构。

语法分析分析出来的树结构是由JCTree来表示的,我们可以看一下它的子类有哪些。

我们自己建一个类,可以观察它在编译过程中用树结构表示是一种怎样的结构。

 1public class HelloJvm {
2
3    private String a;
4    private String b;
5
6    public static void main(String[] args{
7        int c = 1+2;
8        System.out.println(c);
9        print();
10    }
11
12    private static void print(){
13
14    }
15}

大家注意我划红线的地方,可以看到这些都是JCTree的子类。我们可以知道编译期的树是以JCCompilationUnit为根节点,然后作为类的构成元素例如方法、私有变量、class类,这些都是作为树的构成一种。

填充符号表

填充符号表和我们的Lombok原理关联不大,这里了解即可。

完成了语法分析和词法分析以后,下一步就是填充符号表的过程,符号表是由一组符号地址和符号信息构成的表格,可以将它想象成哈希表中的K-V值对的形式(符号表不一定是哈希表实现,可以使有序符号表,树状符号表、栈结构符号表等)。符号表中所登记的信息在编译的不同阶段都要用到,在语义分析中,符号表所登记的内容将用于语义检查(如检查一个名字的使用和原先的说明是否一致)和产生中间代码。在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的依据。

注解处理器

第一步的解析和填充符号表完成以后,接下来就是我们的重头戏注解处理器了。因为在这一步就是Lombok实现原理的关键。

在JDK1.5之后,Java语言提供了对注解的支持,这些注解与普通的Java代码一样,是在运行期间发挥作用的。在JDK1.6中实现了对JSR-269的规范,提供了一组插入式注解处理器的标准API在编译期间对注解进行处理,我们可以把它看作是一组编译器的插件,在这些插件里面,可以读取,修改,添加抽象语法树中的任意元素。

如果这些插件在处理注解期间对语法树进行了修改,那么编译器将回到解析及填充符号表的过程重新处理,直到所有的插入式注解处理器都没有了再对语法树进行修改为止。每一次循环成为一个Round。

有了编译器注解处理的标准API后,我们的代码才有可能干涉编译器的行为,由于语法树中的任意元素,甚至包括代码注释都可以在插件之中访问到,所以通过插入式注解处理器实现的插件在功能上有很大的发挥空间。只要有足够多的创意,程序员可以使用插入式注解处理器来实现许多原本只能在编码中完成的事情。

语义分析与字节码生成

语法分析之后,编译器获得了程序代码的抽象语法树表示,语法树能表示一个结构正确的源程序的抽象,但是无法保证源程序是符合逻辑的。而语义分析的主要任务就是对结构上正确的源程序进行上下文有关性质的审查,如进行类型检查。

比如我们有以下代码

1int a = 1;
2boolean b = false;
3char c = 2;

下面我们有可能出现如下运算

1int d = b+c;

其实上面的代码在结构上能构成准确的语法树,但是在语义上下面的运算是错误的。所以如果运行的话就会出现编译不通过,无法编译。

自己实现一个简单的Lombok

上面我们了解了javac的过程,那么我们直接来自己写一个简单的在已有类中添加代码的小工具,我们就只生成set方法。首先写一个自定义的注解类。

1@Retention(RetentionPolicy.SOURCE) // 注解只在源码中保留
2@Target(ElementType.TYPE) // 用于修饰类
3public @interface MySetter {
4}

然后写对于此注解类的注解处理器类

 1@SupportedSourceVersion(SourceVersion.RELEASE_8)
2@SupportedAnnotationTypes("aboutjava.annotion.MySetter")
3public class MySetterProcessor extends AbstractProcessor {
4
5    private Messager messager;
6    private JavacTrees javacTrees;
7    private TreeMaker treeMaker;
8    private Names names;
9
10    /**
11     * @Description: 1. Message 主要是用来在编译时期打log用的
12     *              2. JavacTrees 提供了待处理的抽象语法树
13     *              3. TreeMaker 封装了创建AST节点的一些方法
14     *              4. Names 提供了创建标识符的方法
15     */

16    @Override
17    public synchronized void init(ProcessingEnvironment processingEnv) {
18        super.init(processingEnv);
19        this.messager = processingEnv.getMessager();
20        this.javacTrees = JavacTrees.instance(processingEnv);
21        Context context = ((JavacProcessingEnvironment)processingEnv).getContext();
22        this.treeMaker = TreeMaker.instance(context);
23        this.names = Names.instance(context);
24    }
25
26    @Override
27    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
28        return false;
29    }
30}

此处我们注意我们在init方法中获得一些编译阶段的一些环境信息。我们从环境中提取出一些关键的类,描述如下。

  • JavacTrees:提供了待处理的抽象语法树
  • TreeMaker:封装了操作AST抽象语法树的一些方法
  • Names:提供了创建标识符的方法
  • Messager:主要是在编译器打日志用的

然后接下来我们利用所提供的工具类对已存在的AST抽象语法树进行修改。主要的修改逻辑存在于process方法中,如果返回是true的话,那么javac过程会再次重新从解析与填充符号表处开始进行。process方法的逻辑主要如下

 1@Override
2    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
3        Set<? extends Element> elementsAnnotatedWith = roundEnv.getElementsAnnotatedWith(MySetter.class);
4        elementsAnnotatedWith.forEach(e->{
5            JCTree tree = javacTrees.getTree(e);
6            tree.accept(new TreeTranslator(){
7                @Override
8                public void visitClassDef(JCTree.JCClassDecl jcClassDecl) {
9                    List<JCTree.JCVariableDecl> jcVariableDeclList = List.nil();
10                    // 在抽象树中找出所有的变量
11                    for (JCTree jcTree : jcClassDecl.defs){
12                        if (jcTree.getKind().equals(Tree.Kind.VARIABLE)){
13                            JCTree.JCVariableDecl jcVariableDecl = (JCTree.JCVariableDecl) jcTree;
14                            jcVariableDeclList = jcVariableDeclList.append(jcVariableDecl);
15                        }
16                    }
17                    // 对于变量进行生成方法的操作
18                    jcVariableDeclList.forEach(jcVariableDecl -> {
19                        messager.printMessage(Diagnostic.Kind.NOTE,jcVariableDecl.getName()+"has been processed");
20                        jcClassDecl.defs = jcClassDecl.defs.prepend(makeSetterMethodDecl(jcVariableDecl));
21                    });
22                    super.visitClassDef(jcClassDecl);
23                }
24            });
25        });
26        return true;
27    }

其实看起来比较难,原理比较简单,主要是我们对于API的不熟悉所以看起来不好懂,但是主要意思就是如下

  1. 找到@MySetter注解所标注的类,获得其语法树
  2. 遍历其语法树,找到其参数节点
  3. 自己建一个方法节点,并添加到语法树中

用图表示的话,我们建了一个测试类TestMySetter,我们知道其语法树的大致结构如下图所示。

那么我们的目标就是将其语法树变成下图所示,因为最终生成字节码是根据语法树来生成的,所以我们在语法树中添加了方法的节点,那么在生成字节码的时候就会生成对应方法的字节码。

其中生成方法节点的代码如下

 1private JCTree.JCMethodDecl makeSetterMethodDecl(JCTree.JCVariableDecl jcVariableDecl){
2
3    ListBuffer<JCTree.JCStatement> statements = new ListBuffer<>();
4    // 生成表达式 例如 this.a = a;
5    JCTree.JCExpressionStatement aThis = makeAssignment(treeMaker.Select(treeMaker.Ident(names.fromString("this")), jcVariableDecl.getName()), treeMaker.Ident(jcVariableDecl.getName()));
6    statements.append(aThis);
7    JCTree.JCBlock block = treeMaker.Block(0, statements.toList());
8
9    // 生成入参
10    JCTree.JCVariableDecl param = treeMaker.VarDef(treeMaker.Modifiers(Flags.PARAMETER), jcVariableDecl.getName(), jcVariableDecl.vartype, null);
11    List<JCTree.JCVariableDecl> parameters = List.of(param);
12
13    // 生成返回对象
14    JCTree.JCExpression methodType = treeMaker.Type(new Type.JCVoidType());
15    return treeMaker.MethodDef(treeMaker.Modifiers(Flags.PUBLIC),getNewMethodName(jcVariableDecl.getName()),methodType,List.nil(),parameters,List.nil(),block,null);
16
17}
18
19private Name getNewMethodName(Name name){
20    String s = name.toString();
21    return names.fromString("set"+s.substring(0,1).toUpperCase()+s.substring(1,name.length()));
22}
23
24private JCTree.JCExpressionStatement makeAssignment(JCTree.JCExpression lhs, JCTree.JCExpression rhs) {
25    return treeMaker.Exec(
26            treeMaker.Assign(
27                    lhs,
28                    rhs
29            )
30    );
31}

最后我们执行下面三个命令

1javac -cp $JAVA_HOME/lib/tools.jar aboutjava/annotion/MySetter* -d
2javac -processor aboutjava.annotion.MySetterProcessor aboutjava/annotion//TestMySetter.java
3javap -p aboutjava/annotion/TestMySetter.class

可以看到输出的内容如下

1Compiled from "TestMySetter.java"
2public class aboutjava.annotion.TestMySetter {
3  private java.lang.String name;
4  public void setName(java.lang.String);
5  public aboutjava.annotion.TestMySetter();
6}

可以看到字节码中已经生成了我们需要的setName方法。

代码地址

总结

到目前为止大概将Lombok的原理讲明白了,其实就是对于抽象语法树的各种操作。其实大家还可以利用编译期做许多的事情,例如代码规范的检查之类的。这里我只写了关于set方法的创建,大家有兴趣的可以自己写代码自己试一下关于Lombok的get方法的创建。

有感兴趣的可以关注一下我新建的公众号,搜索[程序猿的百宝袋]。或者直接扫下面的码也行。

参考