基于语法树的 Java 代码自动化插桩
自动化插桩 ,也就是在代码的特定位置,自动的插入我们需要的一行或几行代码。 通常我们会在编译后的代码上进行插桩,这样做好处就是避免了对源码的侵入,一定程度上屏蔽了开发者不同的代码风格。 这里,我们主要介绍另一种在源码上插桩的方式,如果不考虑对代码的侵入性,那么在源码上直接插桩会更加直观,也就更加容易把控和调试,具有更高的灵活性,而且本文将要介绍的这种方法也无须考虑不同的代码风格。
以一段结构比较简单的Java代码为例,假设我们有一个后缀名为\”.java\”的源码文件,需要在里面的每个方法的开头插入一行代码打印当前方法的名字,在代码执行时,让我们能够知道哪个方法被调用过,以便绘制整个工程的调用关系图。文件中的源码如下:
显然,我们不可能直接插桩,因为那样即便是你能准确的定位到每个方法的第一行,仍然不具有通用性,同样的代码换一个写法,或者增加一些复杂的代码结构,再或者换个书写习惯,不仅无法识别代码第一行,即便是再开发,也非常复杂。接下来我们换个思路进行插桩。
在这里我们需要探究的是Java代码是如何构成的,或者说一个后缀名为\”.java\”的文件里,都包含了怎样的语句和结构。例如在上述代码中,可以很直观的看到,它包含:
- 包名
- 类名
- 类里面三个属性
- 类里面一个构造方法及其包含的语句
- 类里面三个普通方法及其包含的语句按照它们的包含关系,可以画出这样的一个树形关系图:
从根节点开始一层一层的将所有代码都包含进来。完整且清晰的表示了这部分代码和它们之间的关系。
上面这棵\”树\”是一种非常直观的方式,但也说明了代码是可以抽象成树的形式表示的。接下来我们以更细的粒度再绘制这棵树。
抽象语法树(AST)是源码语法结构的一种抽象表示,它以树状的形式表现代码的结构。实际上Eclipse已经提供了源码的AST表示,以帮助开发者更加完整、清晰的分析代码的结构和关系。
在这里我们要实现的是自动化插桩,也就是说我们需要实时分析代码结构,然后在正确的位置插入准备好的代码,并且保证插桩后的代码能够被编译、执行。
我们使用JavaParser来对源码进行处理,它是一个比较通用的代码分析工具。通过JavaParser的解析,我们将会得到一个\”.java\”文件的抽象语法树。参考如下步骤:
1)引用
创建项目工程后,引入JavaParser,Maven或Gradle均可:
- Maven
- Gradle
注意引用版本不要低于3.6.4,否则会出现各种疑难杂问题。
2)解析Java源码
依赖JavaParser工具,我们只需要传入\”.java\”文件的输入流,就可以完成对源码的解析。
JavaParser中的parse方法,会依据源码生成代码树,并以CompilationUnit类型返回。CompilationUnit类位于com.github.javaparser.ast包下面,正常引入JavaParser就可以使用。通过打断点的形式,我们观察一下该对象的构成:
对于树形结构,我们最容易理解的属性就是childNode了,在根节点compilationUnit上有两个子节点,一个是所属包名,一个是ASTDemo类,类节点下又包含七个子节点,除了类名节点外,另外6个节点分别代表了三个类属性变量,一个构造方法和三个普通方法。
具体到某一个方法,以methodDemo(String, String)为例,它有5个子节点:
分别是方法名,两个参数,返回值和方法体,我们都知道,该方法体内的代码为:
继续向内跟踪,方法体节点下有两个子节点,分别代表了两个if
以此类推,细化到某一语句时,仍然是类似结构,例如赋值语句 strData = strData + param1; 以该语句为一个根节点包含两个子节点,分别是赋值符的左边和右边:
\”=\”作为一种“赋值(ASSIGN)”操作,保存在根节点的operator属性中:
同理对于\”=\”右侧,有两个节点strData和param2,以及标识PLUS操作的oprator属性:
可以看出抽象语法树中包含了源码的全部信息,在这棵树上,我们能够准确的定位到任何我们需要识别的代码结构。
回忆一下我们的需求:在每个方法的第一行插入打印日志的语句。所以我们首先需要找到“每个方法”。对于抽象语法树来说也就是找到所有的方法节点,很自然的我们想到遍历这棵树。
VoidVisitorAdapter的visit方法对于每一种类型都有定义,我们这里需要识别的是构造方法和普通方法,所以实现ConstructorDeclaration和MethodDeclaration两种类型即可。
visit方法中分别是遇到对应类型节点时的处理逻辑。所以我们需要在这里进行插桩。
当前的代码是树形结构,要想添加一行代码,我们需要创建一个节点。为了尽量简单,我们插入就插入一条简单的执行语句 System.out.println(…) ,这种语句的类型是ExpressionStmt,所以我们先创建一个节点:
接下来,我们要把这个节点挂到正确的位置上去,要求是每个方法代码的第一行,转换到语法树上也就是要在方法体节点的第一个子节点前增加这个节点:
这里需要特别说明的是,如果是子类的构造方法,因为调用
super()需要在第一行进行,所以可以判断如果构造方法中第一行是super方法,那么这个节点要插在第二个位置,否则编译时会报错。
我们可以通过调用adapter的visit方法,传入之前生成的语法树对象,开始遍历:
经过遍历后,每一个方法节点下面都插入了预设的代码。
最后,调用CompilationUnit的toString()方法将代码树转成源码,打印到相同位置的\”.java\”文件中,并覆盖原有数据,自动化插桩完成。
Java 注解底层原理
注解是 Java 1.5 版本加入的新特性。所有 Java 注解首先是一个接口。从定义注解的角度我们可以看出, 有关键字interface ,还有方法定义 String[] value()。default 字段 后面紧跟返回的默认常量。都实现了 java.lang.annotation.Annotation 接口 Class<? extends Annotation> annotationType() 返回的是 注解的接口类型 Class。 我在研究反射的时候意外发现他其实就是接口的事实,之前我还以为 Java 1.5 只是引入了一个新概念,其实就是一个类而已。通过原有的技术实现。也可以把这种接口叫做 Annotation Interface 举例:
我可以直接实现 Component 这个接口,覆盖实现接口方法。
Eclipse 提示不建议这样做。但不表示不行。上面直接证明了它就是接口。拥有类的所有特性。到这里它的原理也不难想到。肯定用来什么技术实现了这个注解接口。下文实现原理着重讲解。这个结论很重要,我认为对理解该特性(注解)有很大帮助。下文大多讨论都是基于该结论。
用上文提到的注解 @Component 做例子。
上面的例子展示了将我们自定义的注解标注到了类上面,同时我们指定了 value 的值 userService。当然这个值是个数组。因为 String[] value() 返回值是数组。 上面的写法是标准写法还可以简写成:@Component(\”userService\”)。解释:Java 规定当这个方法名是 value 的时候括号里可以省略不写 value = , 仅仅填入值。如果方法返回值是数组且值只有一个,大括号 {} 也可以省略。 还有可以从上面的例子看出 @Autowired 注解标注在构造器上,看到这里我觉得它还可以使用在方法上,属性上(成员变量),甚至是局部变量上。是不是每个注解都可以随便标注在任何地方呢?这个说法不正确。应该是每个注解都具有这样的能力,但是要取决于 Java 的机制,Java规定使用 @java.lang.annotation.Target 标注注解的使用范围。上面的例子中 Component 被 @Target({ ElementType.TYPE, ElementType.METHOD }) 标注。实际上还可以定义其他位置: 取决于 枚举类ElementType :
超出了指定的范围编译器在编译期间就会报错。注意 ElementType 不能重复。
Component 还有一个 @Retention 注解它表明了这个注解存在的范围, 取决于设置的 java.lang.annotation.RetentionPolicy枚举类。RetentionPolicy 枚举类有 3 个值 SOURCE,CLASS,RUNTIME。
- SOURCE 注解元数据只存在 Java 源文件,生成的字节码文件(.class文件)没有。
- CLASS 注解元数据不仅存在源文件还存在字节码文件,只是运行时读取不到。
- RUNTIME 在 CLASS 基础上能在运行时通过反射获取。 一个正常的注解不能少了上面的两个注解 @Target, @Retention
读取之前我们不得不提到一个接口 java.lang.reflect.AnnotatedElement 该接口提供了访问注解的能力。凡是实现了该接口的实现类都可以访问对应注解实例。
从上图我们可以看到可以从类,方法,构造器,属性等地方获取注解。所以我可以在上面的例子中获取 @Component 实例。当然也可以说成:获取注解接口Component 的实现类实例对象。
当然只要得到了对应的 AnnotatedElement 实例对象就可以得到对应位置的注解。
注解获取本身就是得到类信息,这种在运行时获取类信息的就是反射技术。Java 默认实现了一套反射机制,放在 java.lang.reflect 包下。一个 Java 程序,反射技术贯穿整个应用。在第一次加载类到虚拟机的时候就用 ClassLoader 生成了 Class 对象。动态代理 java.lang.reflect.Proxy 用字节码技术动态生成接口子类使用InvocationHandler 处理方法调用。刚好在实现注解的功能的时候就用到了 Java 自己的动态代理, 用 sun.reflect.annotation.AnnotationInvocationHandler 处理注解里面的方法调用。返回对应的元数据。
Java 通过 AnnotationParser 在运行时解析字节码文件(.class文件)
最重要的来了,使用动态代理实现 Map<String, Object> memberValues 到注解对象的转换。
本文作者及来源:Renderbus瑞云渲染农场https://www.renderbus.com
文章为作者独立观点不代本网立场,未经允许不得转载。