编程知识 cdmana.com

基于语法树的 Java 代码自动化插桩

代码插桩是测试和定位问题的常用手段,通过在代码对应位置插入相应的代码(“桩”),来打印或收集我们所需要的数据。

自动化插桩,也就是在代码的特定位置,自动的插入我们需要的一行或几行代码。通常我们会在编译后的代码上进行插桩,这样做好处就是避免了对源码的侵入,一定程度上屏蔽了开发者不同的代码风格。这里,我们主要介绍另一种在源码上插桩的方式,如果不考虑对代码的侵入性,那么在源码上直接插桩会更加直观,也就更加容易把控和调试,具有更高的灵活性,而且本文将要介绍的这种方法也无须考虑不同的代码风格。


插桩需求

以一段结构比较简单的Java代码为例,假设我们有一个后缀名为".java"的源码文件,需要在里面的每个方法的开头插入一行代码打印当前方法的名字,在代码执行时,让我们能够知道哪个方法被调用过,以便绘制整个工程的调用关系图。文件中的源码如下:
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;
}
}
}

显然,我们不可能直接插桩,因为那样即便是你能准确的定位到每个方法的第一行,仍然不具有通用性,同样的代码换一个写法,或者增加一些复杂的代码结构,再或者换个书写习惯,不仅无法识别代码第一行,即便是再开发,也非常复杂。接下来我们换个思路进行插桩。


Java代码组成

在这里我们需要探究的是Java代码是如何构成的,或者说一个后缀名为".java"的文件里,都包含了怎样的语句和结构。例如在上述代码中,可以很直观的看到,它包含:
  • 包名

  • 类名

  • 类里面三个属性

  • 类里面一个构造方法及其包含的语句

  • 类里面三个普通方法及其包含的语句


    按照它们的包含关系,可以画出这样的一个树形关系图: 


从根节点开始一层一层的将所有代码都包含进来。完整且清晰的表示了这部分代码和它们之间的关系。

抽象语法树

上面这棵"树"是一种非常直观的方式,但也说明了代码是可以抽象成树的形式表示的。接下来我们以更细的粒度再绘制这棵树。


      抽象语法树(AST)是源码语法结构的一种抽象表示,它以树状的形式表现代码的结构。实际上Eclipse已经提供了源码的AST表示,以帮助开发者更加完整、清晰的分析代码的结构和关系。
      在这里我们要实现的是自动化插桩,也就是说我们需要实时分析代码结构,然后在正确的位置插入准备好的代码,并且保证插桩后的代码能够被编译、执行。


JavaParser

我们使用JavaParser来对源码进行处理,它是一个比较通用的代码分析工具。通过JavaParser的解析,我们将会得到一个".java"文件的抽象语法树。参考如下步骤:

1)引用
      创建项目工程后,引入JavaParser,Maven或Gradle均可:
  • 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属性:


可以看出抽象语法树中包含了源码的全部信息,在这棵树上,我们能够准确的定位到任何我们需要识别的代码结构。


遍历语法树

回忆一下我们的需求:在每个方法的第一行插入打印日志的语句。所以我们首先需要找到“每个方法”。对于抽象语法树来说也就是找到所有的方法节点,很自然的我们想到遍历这棵树。
      JavaParser工具提供了遍历这棵树的方法,需要我们创建一个VoidVisitorAdapter对象,并按需求实现里面的visit(SomeType, Object)方法。那么我们可以遍历语法树后,如何识别哪个节点代表的是方法,哪个节点代表某个语句或者属性呢?前文的断点截图,如果细心看过的话,一定会注意到在这棵树上,不同的代码结构,其节点有着不同的类型,比如:
代码结构 节点类型
类属性变量 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"文件中,并覆盖原有数据,自动化插桩完成。


版权声明
本文为[AggrxTech]所创,转载请带上原文链接,感谢
https://toutiao.io/k/cc5vsk2

Scroll to Top