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。
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,这样也是可以的。但是我们不推荐这样做,因为直接传递文件,可以保留完整的代码行信息,但是传递的如果是代码片段,则代码行信息被丢失掉了。
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的树状结构的一个样子了。
在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); 因为在基类是一个包装器,实现了节点的遍历,如果不调用,当前节点处理完,程序就结束了。
在Java中,所有的Name等都被称为Symbol,包括变量、类型、方法等。一般提到类型推断,就是对Symbol的操作。所以我们这里提一下Symbol Solver,因为所有的Type Solver都是针对特定的Symbol工作的(即TypeSolver需要封装在SymbolSolver中才能工作)。
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是一个线程,获取到了父类的类型。
如第3.2节中介绍的例子,可以非常方便地基于类型推断获取如下信息:
1) 当前类的父类或者父接口,并递归获取所有的父类和父接口;
2) 获取当前所调用函数的signature(函数签名,包含可以唯一识别一个方法的所有信息,例如所在的类、名字、形参类型列表、返回值类型),从而拿到函数调用关系;
另外,再结合源码文件和Java类定义的对应关系,可以很方便地构造出工程的CG图,识别出源码之间的依赖关系分析。
实际上,基于Java Parser获得的AST信息,辅助类型推断特性,可以实现针对源代码的各种度量内容。可以参考https://www.exida.com/Blog/software-metrics-iso-26262-iec-61508, 查看常见的代码度量项。针对其中3.1所列出的所有的项都可以度量。
常见的一些度量项:
针对函数:圈复杂度、代码深度、各类代码行信息、调用函数数量、被调用次数、其他诸如入参个数…;
针对文件:总代码行、空行、注释行、非空非注释行、代码注释密度…;
针对工程:文件总数、代码行总数、平均代码行…
基于Java Parser可以很好地进行实现,而且大部分都不需要完整的依赖信息,可以方便地进行度量。
当前,针对各类规范支持,可以在不同的层面上进行支持,例如直接对源码检查、对解析得到的Token进行检查、对AST进行检查、在CFG和CG上进行数据流检查和各种形式化检查等。
基于Java Parser,可以实现在AST及源码(可以直接读取源码信息)、Token(从AST的节点可以获取当前AST Node中的所有的Token)相结合的所有检查。
例如,PMD主要是在AST上进行的检查,CheckStyle主要是在Token和AST上进行的检查,结合类型推断的支持,Java Parser可以实现比PMD和CheckStyle更精确的检查。
领取专属 10元无门槛券
私享最新 技术干货