0. Java Parser 基本信息
Java Parser 是当前应用的最火的 Java Parser 工具,当前 GitHub 上,该开源项目已经有 3.4k star,而且协议非常友好,可以应用到商用工具上面,因此使用率非常高。
Java Parser 是基于 JavaCC 做为 Java 语言词法解析的工具,支持 Java 语言生成 AST,在 AST 基础上进行类型推断分析,支持修改 AST 从而生成新的 Java 文件内容,支持从 Java 1.0 到 14 所有的版本的 AST 解析。
Java Parser 的组件架构(https://javaparser.org/code-style-architecture/)如下图所示:
图 0-1 Java Parser 组件架构
本文讨论 Java Parser 面向 Java 规范检查方向的一些内容,因此我们不关心 Java Parser 在修改 AST 和重新生成 Java 文件方面的作用,着重介绍在生成 AST、AST 遍历、解析、类型推断及应用。
说明:本文中所有的代码的完成代码,可以参考https://gitee.com/maijun/javaparser-test/tree/master。
1. Java Parser AST 生成及打印
1.1 Java Parser 生成 AST
Java Parser 转换生成 AST,有很多方法,主要支持两大类方式:
1) 输入为文件
输入为文件,即针对完整的文件为输入,生成整个文件的 AST(对于 Java 来说,如果是整个 Java 文件,AST 的根节点为 CompilationUnit),这种方式,可以完整保留源码的代码行信息,这样方便进行分析。对于文件为输入,即输入文件路径(多种写法,例如 String、File、Path、Resource 等,其实都是表示文件路径),同时可以指定文件的编码方式,下面的用例,指定的是 nio 的 Path 格式的路径。
// 1. 转换的是完整的Java文件
File base = new File("");
String relativePath = "test-case/javaparser-testcase/src/main/java/zmj/test/thread/MyThread.java";
String absolutePath = base.getCanonicalPath() + File.separator + relativePath;
ParseResult<CompilationUnit> result = new JavaParser().parse(Paths.get(absolutePath));
result.getResult().ifPresent(YamlPrinter::print);
复制代码
2) 输入为代码片段
输入为代码片段时,一定要是一种标准的符合一定格式的能够识别为语句或者表达式的代码片段,例如 int x 是一个 VariableDeclarationExpr,x = 3 是一个 AssignExpr,但是,如果代码片段写成 x + 3 = y,则不符合基本的 Java 的语法。
下面是一个基本的关于这种用法的例子(如果要看完整支持的功能,可以看 Java Parser 的 API 文档)。
// 2. 转换的为部分代码片段
ParseResult<VariableDeclarationExpr> exprResult = new JavaParser().parseVariableDeclarationExpr("int x = 3");
System.out.println(exprResult.getResult().get());
exprResult.getResult().ifPresent(YamlPrinter::print);
复制代码
事实上,我们可以将完整的 Java 文件内容都读到一个字符串里面,然后传递给 Java Parser,来获取 AST,即针对完整的 Java 文件,以代码片段的方式来解析获取 AST,这样也是可以的。但是我们不推荐这样做,因为直接传递文件,可以保留完整的代码行信息,但是传递的如果是代码片段,则代码行信息被丢失掉了。
1.2 Java Parser AST 打印
Java Parser AST 打印有多种方式,当前支持三种打印方式:Yaml、XML 和 Graphiz 可以识别的 dot 的图片生成的格式。
如下面的代码所示,我们针对一个简单的语句(x = 3 + 4),生成了 Yaml、XML 和 Graphiz dot 的输出:
public class AstPrinter {
public static void main(String[] args) {
ParseResult<AssignExpr> exprResult = new JavaParser().parseExpression("x = 3 + 4");
Optional<AssignExpr> expr = exprResult.getResult();
// 1. Yaml格式输出打印
expr.ifPresent(YamlPrinter::print);
// 2. XML格式输出打印
expr.ifPresent(XmlPrinter::print);
// 3. dot打印(可以通过Graphiz dot命令,将输出生成为图片格式,例如 dot -Tpng ast.dot > ast.png)
expr.ifPresent(e -> System.out.println(new DotPrinter(true).output(e)));
}
}
复制代码
1) Yaml 格式输出
输出的 Yaml 格式如下:
---
root(Type=AssignExpr):
operator: "ASSIGN"
target(Type=NameExpr):
name(Type=SimpleName):
identifier: "x"
value(Type=BinaryExpr):
operator: "PLUS"
left(Type=IntegerLiteralExpr):
value: "3"
right(Type=IntegerLiteralExpr):
value: "4"
...
复制代码
2) XML 格式输出
输出的 XML 格式如下(原始输出是只有一个字符串,这里我简单进行了处理,添加了换行和缩进):
<root type='AssignExpr' operator='ASSIGN'>
<target type='NameExpr'>
<name type='SimpleName' identifier='x'></name>
</target>
<value type='BinaryExpr' operator='PLUS'>
<left type='IntegerLiteralExpr' value='3'></left>
<right type='IntegerLiteralExpr' value='4'></right>
</value>
</root>
复制代码
3) Graphiz dot 输出
首先打印的内容如下:
digraph {
n0 [label="root (AssignExpr)"];
n1 [label="operator='ASSIGN'"];
n0 -> n1;
n2 [label="target (NameExpr)"];
n0 -> n2;
n3 [label="name (SimpleName)"];
n2 -> n3;
n4 [label="identifier='x'"];
n3 -> n4;
n5 [label="value (BinaryExpr)"];
n0 -> n5;
n6 [label="operator='PLUS'"];
n5 -> n6;
n7 [label="left (IntegerLiteralExpr)"];
n5 -> n7;
n8 [label="value='3'"];
n7 -> n8;
n9 [label="right (IntegerLiteralExpr)"];
n5 -> n9;
n10 [label="value='4'"];
n9 -> n10;
}
复制代码
将上面的内容写入 ast.dot 文件中,使用命令 dot -Tpng ast.dot > ast.png 即可得到一个图片,如下:
差不多算是一个 AST 的树状结构的一个样子了。
2. Java Parser AST 遍历
在 Java Parser 中,AST 的遍历采用的是访问者模式,在访问者模式的基础上,增加了一个简单的包装器(这里就不再介绍什么是访问者模式和包装器模式了,在另外一篇文章里面,有关于访问者模式的详细介绍)。下面代码中定义了两个非常简单的 visitor,其中一个访问函数调用表达式,打印所有的函数调用的代码,第二个访问所有的函数定义信息,将当前类中定义的函数都打印出来。
class TestVisitor extends GenericVisitorAdapter<Void, Void> {
@Override
public Void visit(MethodCallExpr n, Void arg) {
System.out.println("function call: " + n.toString());
return super.visit(n, arg);
}
@Override
public Void visit(MethodDeclaration n, Void arg) {
System.out.println("function declaration: " + n.getNameAsString());
return super.visit(n, arg);
}
}
复制代码
注意:一个非常重要的地方,每个 visit 方法写完,都需要调用 super.visit(n,arg); 因为在基类是一个包装器,实现了节点的遍历,如果不调用,当前节点处理完,程序就结束了。
3. Java Parser 类型推断
3.1 Symbol Solver
在 Java 中,所有的 Name 等都被称为 Symbol,包括变量、类型、方法等。一般提到类型推断,就是对 Symbol 的操作。所以我们这里提一下 Symbol Solver,因为所有的 Type Solver 都是针对特定的 Symbol 工作的(即 TypeSolver 需要封装在 SymbolSolver 中才能工作)。
3.2 Type Solver
Java Parser 类型推断有多种类型推断方式,如下表:
1) AarTypeSolver: 在 Android aar 文件中查找特定的类型;
2) ClassLoaderTypeSolver: 针对自定义的 ClassLoader 使用,可以处理由该 ClassLoader 加载的类,这种类型在静态代码分析中很少用到;
3) CombinedTypeSolver: 可以封装多个 Type Solver 一起使用
4) JarTypeSolver: 在 Jar 文件中,查找特定的类型
5) JavaParserTypeSolver: 在源码中查找特定的类型,只需要传递源码根路径即可
6) MemoryTypeSolver: 一般不需要使用,可以在测试中使用
7) ReflectionTypeSolver: 用来处理应用 classpath 里面的类,一般用于处理 JRE 中的类
下面介绍一个简单的例子(这个是一个简单的 maven 工程,但是为了测试 jar 的依赖,我先收集所有依赖的 jar 到项目下面的一个路径下,执行的命令为:mvn dependency:copy-dependencies -DoutputDirectory=lib)。
这一个普通的 Java 应用,需要处理的类型有三个来源:① 当前自己工程中定义;② 当前工程的第三方依赖;③ JRE 中的基本类型。对应上面的 JavaParserTypeSolver,JarTypeSolver 和 ReflectionTypeSolver,组合成一个 CombinedTypeSolver,代码如下:
public static CombinedTypeSolver generateTypeSolver(String sourcePath, String libPath) throws IOException {
CombinedTypeSolver solver = new CombinedTypeSolver();
// 1. JavaParserTypeSolver
solver.add(new JavaParserTypeSolver(sourcePath));
// 2. JarTypeSolver
FindFileVisitor findJarVisitor = new FindFileVisitor(".jar");
Files.walkFileTree(Paths.get(libPath), findJarVisitor);
for (String name : findJarVisitor.getFileNameList()) {
solver.add(new JarTypeSolver(name));
}
// 3. ReflectionTypeSolver
solver.add(new ReflectionTypeSolver());
return solver;
}
复制代码
然后构造的 TypeSolver 传递给 JavaParser:
TypeSolver typeSolver = generateTypeSolver(base.getCanonicalPath() + File.separator + sourcePath,
base.getCanonicalPath() + File.separator + libPath);
JavaSymbolSolver symbolSolver = new JavaSymbolSolver(typeSolver);
ParserConfiguration configuration = new ParserConfiguration();
configuration.setSymbolResolver(symbolSolver);
JavaParser parser = new JavaParser(configuration);
复制代码
下面介绍三类主要的类型处理内容:
1) 对变量(表达式)类型的推断
// 解析变量的类型定义
System.out.println("----> resolve variable type");
mce.getScope().ifPresent(expression -> {
ResolvedType resolvedType = expression.calculateResolvedType();
System.out.println(expression.toString() + " is a: " + resolvedType);
});
复制代码
用例的部分结果输出:
-------------------------------------
System.out.println("Hello World!")
----> resolve variable type
System.out is a: ReferenceType{java.io.PrintStream, typeParametersMap=TypeParametersMap{nameToValue={}}}
-------------------------------------
m.start()
----> resolve variable type
m is a: ReferenceType{zmj.test.thread.MyThread, typeParametersMap=TypeParametersMap{nameToValue={}}}
-------------------------------------
HashBasedTable.create()
----> resolve variable type
HashBasedTable is a: ReferenceType{com.google.common.collect.HashBasedTable, typeParametersMap=TypeParametersMap{nameToValue={com.google.common.collect.HashBasedTable.V=TypeVariable {JavassistTypeParameter{V}}, com.google.common.collect.HashBasedTable.R=TypeVariable {JavassistTypeParameter{R}}, com.google.common.collect.HashBasedTable.C=TypeVariable {JavassistTypeParameter{C}}}}}
-------------------------------------
tt.put("a", "b", "c")
----> resolve variable type
tt is a: ReferenceType{com.google.common.collect.Table, typeParametersMap=TypeParametersMap{nameToValue={com.google.common.collect.Table.C=ReferenceType{java.lang.String, typeParametersMap=TypeParametersMap{nameToValue={}}}, com.google.common.collect.Table.V=ReferenceType{java.lang.String, typeParametersMap=TypeParametersMap{nameToValue={}}}, com.google.common.collect.Table.R=ReferenceType{java.lang.String, typeParametersMap=TypeParametersMap{nameToValue={}}}}}}
复制代码
如上,可以计算诸如表达式(System.out)、变量(m,tt)、静态类(HashBasedTable)等的类型,而且支持第三方库的类型推断(HashBasedTable 为 google guava 库中的类型)。
2) 对函数签名的推断
// 解析函数调用的函数声明
System.out.println("----> resolve method declaration");
JavaParserFacade javaParserFacade = JavaParserFacade.get(typeSolver);
SymbolReference<ResolvedMethodDeclaration> resolvedMethodDeclarationSymbolReference = javaParserFacade.solve(mce);
System.out.println("is resolved: " + resolvedMethodDeclarationSymbolReference.isSolved());
System.out.println("resolved type" + resolvedMethodDeclarationSymbolReference.getCorrespondingDeclaration());
复制代码
如上,为了对函数签名进行推断,需要先构造出来一个 JavaParserFacade 对象,也是通过 typeSolver 构造出来的。上面的部分输出如下:
-------------------------------------
System.out.println("Hello World!")
----> resolve method declaration
is resolved: true
resolved typeReflectionMethodDeclaration{method=public void java.io.PrintStream.println(java.lang.String)}
-------------------------------------
m.start()
----> resolve method declaration
is resolved: true
resolved typeReflectionMethodDeclaration{method=public synchronized void java.lang.Thread.start()}
-------------------------------------
HashBasedTable.create()
----> resolve method declaration
is resolved: true
resolved typeJavassistMethodDeclaration{ctMethod=javassist.CtMethod@6c8156fd[public static create ()Lcom/google/common/collect/HashBasedTable;]}
-------------------------------------
tt.put("a", "b", "c")
----> resolve method declaration
is resolved: true
resolved typeJavassistMethodDeclaration{ctMethod=javassist.CtMethod@f9f9cbbc[public abstract put (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;]}
复制代码
如上所示,对函数签名推断得非常准确,包括静态方法和实例方法,抽象、函数形参类型、返回值类型、函数所在的类等,都推断得非常准确。
3) 对类继承关系推断
如下的代码:
// 解析类的继承关系
System.out.println("---- resolve class extend relation");
cu.findAll(ClassOrInterfaceDeclaration.class).forEach(cid -> {
System.out.println("current class: " + cid.getFullyQualifiedName().get());
NodeList<ClassOrInterfaceType> superTypes = cid.getExtendedTypes();
superTypes.forEach(type -> System.out.println("super class: " + type.resolve()));
});
复制代码
输出如下:
---- resolve class extend relation
current class: zmj.test.App
---- resolve class extend relation
current class: zmj.test.thread.MyThread
super class: ReferenceType{java.lang.Thread, typeParametersMap=TypeParametersMap{nameToValue={}}}
复制代码
当前只定义了两个类,一个是 App,没有定义父类(直接就是 Object),没有打印,MyThread 是一个线程,获取到了父类的类型。
4. Java Parser 简单应用
4.1 源码间依赖关系分析
如第 3.2 节中介绍的例子,可以非常方便地基于类型推断获取如下信息:
1) 当前类的父类或者父接口,并递归获取所有的父类和父接口;
2) 获取当前所调用函数的 signature(函数签名,包含可以唯一识别一个方法的所有信息,例如所在的类、名字、形参类型列表、返回值类型),从而拿到函数调用关系;
另外,再结合源码文件和 Java 类定义的对应关系,可以很方便地构造出工程的 CG 图,识别出源码之间的依赖关系分析。
4.2 Java 代码度量
实际上,基于 Java Parser 获得的 AST 信息,辅助类型推断特性,可以实现针对源代码的各种度量内容。可以参考https://www.exida.com/Blog/software-metrics-iso-26262-iec-61508, 查看常见的代码度量项。针对其中 3.1 所列出的所有的项都可以度量。
常见的一些度量项:
针对函数:圈复杂度、代码深度、各类代码行信息、调用函数数量、被调用次数、其他诸如入参个数…;
针对文件:总代码行、空行、注释行、非空非注释行、代码注释密度…;
针对工程:文件总数、代码行总数、平均代码行…
基于 Java Parser 可以很好地进行实现,而且大部分都不需要完整的依赖信息,可以方便地进行度量。
4.3 Java 规范支持检查
当前,针对各类规范支持,可以在不同的层面上进行支持,例如直接对源码检查、对解析得到的 Token 进行检查、对 AST 进行检查、在 CFG 和 CG 上进行数据流检查和各种形式化检查等。
基于 Java Parser,可以实现在 AST 及源码(可以直接读取源码信息)、Token(从 AST 的节点可以获取当前 AST Node 中的所有的 Token)相结合的所有检查。
例如,PMD 主要是在 AST 上进行的检查,CheckStyle 主要是在 Token 和 AST 上进行的检查,结合类型推断的支持,Java Parser 可以实现比 PMD 和 CheckStyle 更精确的检查。
评论