代码插桩是测试和定位问题的常用手段,通过在代码对应位置插入相应的代码(“桩”),来打印或收集我们所需要的数据。
自动化插桩,也就是在代码的特定位置,自动的插入我们需要的一行或几行代码。通常我们会在编译后的代码上进行插桩,这样做好处就是避免了对源码的侵入,一定程度上屏蔽了开发者不同的代码风格。这里,我们主要介绍另一种在源码上插桩的方式,如果不考虑对代码的侵入性,那么在源码上直接插桩会更加直观,也就更加容易把控和调试,具有更高的灵活性,而且本文将要介绍的这种方法也无须考虑不同的代码风格。
package com.ast.pkg;
public class ASTDemo {
int intData = 0;
double floatData = 0;
String strData = "";
//construction
public ASTDemo() {
this.intData = 123;
this.floatData = 3.14;
this.strData = "It's been a long time.";
}
public void setIntData(int data) {
this.intData = data;
}
public String getStrData() {
return this.strData;
}
public void methodDemo(String param1, String param2) {
if (null == param1 || null == param2) {
return;
}
if (param1.length() > param2.length()) {
strData = strData + param1;
} else {
strData = strData + param2;
}
}
}
显然,我们不可能直接插桩,因为那样即便是你能准确的定位到每个方法的第一行,仍然不具有通用性,同样的代码换一个写法,或者增加一些复杂的代码结构,再或者换个书写习惯,不仅无法识别代码第一行,即便是再开发,也非常复杂。接下来我们换个思路进行插桩。
包名
类名
类里面三个属性
类里面一个构造方法及其包含的语句
类里面三个普通方法及其包含的语句
按照它们的包含关系,可以画出这样的一个树形关系图:
上面这棵"树"是一种非常直观的方式,但也说明了代码是可以抽象成树的形式表示的。接下来我们以更细的粒度再绘制这棵树。
抽象语法树(AST)是源码语法结构的一种抽象表示,它以树状的形式表现代码的结构。实际上Eclipse已经提供了源码的AST表示,以帮助开发者更加完整、清晰的分析代码的结构和关系。
在这里我们要实现的是自动化插桩,也就是说我们需要实时分析代码结构,然后在正确的位置插入准备好的代码,并且保证插桩后的代码能够被编译、执行。
Maven
<dependency>
<groupId>com.github.javaparser</groupId>
<artifactId>javaparser-core-serialization</artifactId>
<version>3.6.5</version>
</dependency>
<dependency>
<groupId>com.github.javaparser</groupId>
<artifactId>javaparser-core</artifactId>
<version>3.6.5</version>
</dependency>
Gradle
implementation 'com.github.javaparser:javaparser-symbol-solver-core:3.6.5'
implementation 'com.github.javaparser:javaparser-core:3.6.5'
注意引用版本不要低于3.6.4,否则会出现各种疑难杂问题。
2)解析Java源码
依赖JavaParser工具,我们只需要传入".java"文件的输入流,就可以完成对源码的解析。
String javaFilePath = "ASTDemo.java";
FileInputStream in = null;
try {
in = new FileInputStream(javaFilePath);
CompilationUnit compilationUnit = JavaParser.parse(in);
} catch (FileNotFoundException e) {
e.printStackTrace();
} finally {
if (null != in) {
try {
in.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
JavaParser中的parse方法,会依据源码生成代码树,并以CompilationUnit类型返回。CompilationUnit类位于com.github.javaparser.ast包下面,正常引入JavaParser就可以使用。通过打断点的形式,我们观察一下该对象的构成:
对于树形结构,我们最容易理解的属性就是childNode了,在根节点compilationUnit上有两个子节点,一个是所属包名,一个是ASTDemo类,类节点下又包含七个子节点,除了类名节点外,另外6个节点分别代表了三个类属性变量,一个构造方法和三个普通方法。
具体到某一个方法,以methodDemo(String, String)为例,它有5个子节点:
分别是方法名,两个参数,返回值和方法体,我们都知道,该方法体内的代码为:
if (null == param1 || null == param2) {
return;
}
if (param1.length() > param2.length()) {
strData = strData + param1;
} else {
strData = strData + param2;
}
继续向内跟踪,方法体节点下有两个子节点,分别代表了两个if
以此类推,细化到某一语句时,仍然是类似结构,例如赋值语句strData = strData + param1;
以该语句为一个根节点包含两个子节点,分别是赋值符的左边和右边:
"="作为一种“赋值(ASSIGN)”操作,保存在根节点的operator属性中:
同理对于"="右侧,有两个节点strData和param2,以及标识PLUS操作的oprator属性:
可以看出抽象语法树中包含了源码的全部信息,在这棵树上,我们能够准确的定位到任何我们需要识别的代码结构。
代码结构 | 节点类型 |
---|---|
类属性变量 | FieldDeclaration |
构造方法 | ConstructorDeclaration |
普通方法 | MethodDeclaration |
分支判断 | IfStmt |
赋值语句 | AssignExpr |
方法调用 | MethodCallExpr |
VoidVisitorAdapter的visit方法对于每一种类型都有定义,我们这里需要识别的是构造方法和普通方法,所以实现ConstructorDeclaration和MethodDeclaration两种类型即可。
VoidVisitorAdapter<Object> adapter = new VoidVisitorAdapter<Object>() {
public void visit(MethodDeclaration methodDeclaration, Object obj) {
......
}
public void visit(ConstructureDeclaration methodDeclaration, Object obj) {
......
}
};
visit方法中分别是遇到对应类型节点时的处理逻辑。所以我们需要在这里进行插桩。
System.out.println(...)
,这种语句的类型是ExpressionStmt,所以我们先创建一个节点:
String pileContent = "System.out.println(...)";
ExpressionStmt expressionStmt = new ExpressionStmt();
expressionStmt.setExpression(pileContent);
接下来,我们要把这个节点挂到正确的位置上去,要求是每个方法代码的第一行,转换到语法树上也就是要在方法体节点的第一个子节点前增加这个节点:
methodDeclaration.getBody().get().getStatements().add(0, expressionStmt);
这里需要特别说明的是,如果是子类的构造方法,因为调用
super()需要在第一行进行,所以可以判断如果构造方法中第一行是super方法,那么这个节点要插在第二个位置,否则编译时会报错。
我们可以通过调用adapter的visit方法,传入之前生成的语法树对象,开始遍历:
adapter.visit(compilationUnit, null);
经过遍历后,每一个方法节点下面都插入了预设的代码。
最后,调用CompilationUnit的toString()方法将代码树转成源码,打印到相同位置的".java"文件中,并覆盖原有数据,自动化插桩完成。