1. 简介
在本文中,我们将了解 JavaParser 库。我们将介绍它的功能、用途以及如何使用它。
2. JavaParser 是什么?
JavaParser 是一个用于处理 Java 源代码的开源库。它可以将 Java 源代码解析成抽象语法树(AST)。一旦完成这一步,我们就可以分析解析后的代码、对其进行操作,甚至编写新代码。
使用 JavaParser,我们可以解析最高版本为 Java 18 的 Java 源代码。这包括所有稳定语言特性,但可能不包括任何预览特性。
3. 依赖
在使用 JavaParser 之前,我们需要在构建文件中引入最新版本的依赖,撰写本文时最新版本为 3.25.10。
主要需要引入的依赖是 javaparser-core 。如果使用 Maven,可以在 pom.xml 文件中添加如下依赖:
<dependency>
<groupId>com.github.javaparser</groupId>
<artifactId>javaparser-core</artifactId>
<version>3.25.10</version>
</dependency>
如果是使用 Gradle,则可以在 build.gradle 文件中添加如下依赖:
implementation("com.github.javaparser:javaparser-core:3.25.10")
至此,我们就可以在应用中开始使用它了。
另外有两个额外的依赖可供选择。依赖 com.github.javaparser:javaparser-symbol-solver-core 提供了一种分析解析后的 AST 以查找 Java 元素及其声明之间关系的方法。依赖 com.github.javaparser:javaparser-core-serialization 则提供了一种将解析后的 AST 序列化为 JSON 以及从 JSON 反序列化的方法。
4. 解析 Java 代码
一旦在应用中设置好依赖,我们就可以开始使用它了。Java 代码的解析总是以 StaticJavaParser 类为起点。它提供了多种解析代码的机制,具体取决于我们解析的内容以及其来源。
4.1. 解析源文件
首先,我们将了解如何解析整个源文件。可以使用 StaticJavaParser.parse() 方法来实现。它有多个重载版本,允许我们以不同方式提供源代码——可以是直接的字符串、本地文件系统中的 File 、或者某些资源的 InputStream 或 Reader 。这些方式的工作原理相同,只是提供要解析代码的方式更便捷。
让我们看看它是如何工作的。这里,我们将尝试解析提供的源代码并生成一个 CompilationUnit 作为结果:
CompilationUnit parsed = StaticJavaParser.parse("class TestClass {}");
这代表了我们的 AST,可以让我们检查和操作解析后的代码。
4.2. 解析语句
单个语句位于我们可解析代码范围的另一端。我们可以通过 StaticJavaParser.parseStatement() 方法来解析它。与源文件不同,这个方法只有一个版本,它接受一个包含要解析语句的字符串。
该方法返回一个表示解析后语句的 Statement 对象:
Statement parsed = StaticJavaParser.parseStatement("final int answer = 42;");
4.3. 解析其他构造
JavaParser 还可以解析许多其他构造,涵盖了最高至 Java 18 的整个 Java 语言。每种构造都有一个专门的解析方法,并返回一个适当类型以表示解析后的代码。例如,可以使用 parseAnnotation() 解析注解,使用 parseImport() 解析导入语句,使用 parseBlock() 解析语句块等等。
在内部,JavaParser 对于解析代码的不同部分会使用完全相同的代码。例如,当使用 parseBlock() 解析一个块时,JavaParser 最终会调用与 parseStatement() 直接调用相同的代码。这意味着我们可以在相同代码子集上依赖这些不同的解析方法,它们的工作方式相同。
我们需要确切地知道要解析的代码类型,以便选择正确的解析方法。例如,使用 parseStatement() 方法解析类定义将会失败。
4.4. 解析有误的代码
如果解析失败,JavaParser 将抛出一个 ParseProblemException ,明确指出代码的问题所在。例如,如果我们尝试解析一个有误的类定义,那么我们将得到如下提示:
ParseProblemException parseProblemException = assertThrows(ParseProblemException.class,
() -> StaticJavaParser.parse("class TestClass"));
assertEquals(1, parseProblemException.getProblems().size());
assertEquals("Parse error. Found <EOF>, expected one of \"<\" \"extends\" \"implements\" \"permits\" \"{\"",
parseProblemException.getProblems().get(0).getMessage());
从这个错误信息中,我们可以看出问题是类定义不完整。在 Java 中,这样的语句必须后面跟一个 “ <“ (用于泛型定义)、 extends 或 implements 关键字,或者是一个 “ {“ (用于类体的开始)。
5. 分析已解析的代码
一旦解析了代码,我们就可以开始分析它以获取信息。这类似于在运行中的应用中进行反射,只是它针对的是解析后的源代码,而非当前运行的代码。
5.1. 访问已解析的元素
解析源代码后,我们可以查询 AST 以访问各个元素。具体方法取决于我们想要访问的元素以及我们解析的内容。
例如,如果我们将一个源文件解析为 CompilationUnit ,那么我们可以通过 getClassByName() 方法访问我们期望存在的类:
Optional<ClassOrInterfaceDeclaration> cls = compilationUnit.getClassByName("TestClass");
需要注意的是,这会返回一个 Optional 。使用 Optional 是因为我们不能保证这个类存在于当前的编译单元中。在其他情况下,我们可以保证某些元素的存在。例如,一个类总是有名字的,所以 ClassOrInterfaceDeclaration.getName() 无需返回 Optional 。
在每个阶段,我们只能直接访问当前处理对象的最外层元素。例如,如果从解析源文件得到一个 CompilationUnit ,那么我们可以访问包声明、导入语句和顶级类型,但无法访问这些类型中的成员。然而,一旦我们访问了其中一个类型,我们就可以访问其内部的成员。
5.2. 遍历已解析的元素
有时,我们可能不知道解析后的代码中确切包含哪些元素,或者我们只想处理所有特定类型的元素,而非仅仅一个。
每个 AST 类型都可以访问一系列适当的嵌套元素。具体操作取决于我们想要处理的内容。例如,我们可以从 CompilationUnit 中提取所有的导入语句:
NodeList<ImportDeclaration> imports = compilationUnit.getImports();
无需 Optional ,因为这保证会返回一个结果。不过,如果不存在导入语句,结果可能是一个空列表。
完成这一步后,我们可以将它当作普通集合来处理。NodeList 类型正确实现了 java.util.List ,因此我们可以像处理其他列表一样处理它。
5.3. 遍历整个 AST
除了从解析后的代码中提取特定类型的元素外,我们还可以遍历整个解析后的树。JavaParser 的所有 AST 类型都实现了访问者模式,允许我们使用自定义访问者访问解析源代码中的每一个元素:
compilationUnit.accept(visitor, arg);
我们可使用两种标准类型的访问者。它们都有针对每种可能 AST 类型的 visit() 方法,并且会传递一个在 accept() 调用中传入的状态参数。
最简单的是 VoidVisitor< A > 。它针对每种 AST 类型都有一个方法,且没有返回值。我们还有一个适配器类型 VoidVisitorAdapter ,它提供了一个标准实现,以确保整个树被正确调用。
我们只需实现感兴趣的方法——例如:
compilationUnit.accept(new VoidVisitorAdapter<Object>() {
@Override
public void visit(MethodDeclaration n, Object arg) {
super.visit(n, arg);
System.out.println("Method: " + n.getName());
}
}, null);
这将为源文件中的每个方法名输出一条日志消息,无论这些方法位于何处。由于会递归遍历整个树结构,因此这些方法可以位于顶级类、内部类,甚至是其他方法中的匿名类中。
另一种选择是 GenericVisitor<R, A> 。它的工作方式与 VoidVisitor 类似,只是它的 visit() 方法有一个返回值。在适配器类方面,这取决于我们如何收集每个方法的返回值。例如,GenericListVisitorAdaptor 会强制每个方法的返回类型为 List 并合并所有这些列表。
List<String> allMethods = compilationUnit.accept(new GenericListVisitorAdapter<String, Object>() {
@Override
public List<String> visit(MethodDeclaration n, Object arg) {
List<String> result = super.visit(n, arg);
result.add(n.getName().asString());
return result;
}
}, null);
这将返回一个包含整个树中所有方法名的列表。
6. 输出已解析的代码
除了解析和分析代码外,我们还可以将其再次输出为字符串。这可能有很多用途 —— 例如,如果我们只想提取和输出代码的特定部分。
实现这一目标的最简单方法是使用标准的 toString() 方法。所有 AST 类型都正确实现了该方法,并将生成格式化的代码。需要注意的是,生成的格式可能与我们解析代码时的格式不完全相同,但它仍然遵循相对标准的约定。
例如,如果我们解析以下代码:
package com.baeldung.javaparser;
import java.util.List;
class TestClass {
private List<String> doSomething() {}
private class Inner {
private String other() {}
}
}
格式化后的输出将是:
package com.baeldung.javaparser;
import java.util.List;
class TestClass {
private List<String> doSomething() {
}
private class Inner {
private String other() {
}
}
}
另一种用于格式化代码的方法是使用 DefaultPrettyPrinterVisitor 。这是一个标准的访问者类,用于处理格式化。这使我们能够配置输出格式的某些方面。例如,如果我们希望使用两个空格而不是四个空格进行缩进,可以编写如下代码:
DefaultPrinterConfiguration printerConfiguration = new DefaultPrinterConfiguration();
printerConfiguration.addOption(new DefaultConfigurationOption(DefaultPrinterConfiguration.ConfigOption.INDENTATION,
new Indentation(Indentation.IndentType.SPACES, 2)));
DefaultPrettyPrinterVisitor visitor = new DefaultPrettyPrinterVisitor(printerConfiguration);
compilationUnit.accept(visitor, null);
String formatted = visitor.toString();
7. 操作已解析的代码
一旦我们将代码解析为 AST,我们还可以对其进行修改。由于这只是一个 Java 对象模型,我们可以像处理其他对象模型一样处理它,并且 JavaParser 允许我们自由地更改它的大多数方面。
结合将 AST 输出为工作源代码的能力意味着我们可以操作解析后的代码、对其进行修改,并以某种形式提供输出。这可能对 IDE 插件、代码编译步骤等非常有用。
例如,如果我们想将代码中每个方法名都改为大写,可以执行以下操作:
compilationUnit.accept(new VoidVisitorAdapter<Object>() {
@Override
public void visit(MethodDeclaration n, Object arg) {
super.visit(n, arg);
String oldName = n.getName().asString();
n.setName(oldName.toUpperCase());
}
}, null);
这使用了一个简单的访问者来访问源树中的每个方法声明,并使用 setName() 方法为每个方法赋予新名称。新名称就是旧名称的大写形式。
完成这一步后,AST 就地更新。然后我们可以按需对其进行格式化,新格式化的代码将反映我们的更改。
8. 总结
我们在这里简要介绍了 JavaParser。我们展示了如何开始使用它以及可以利用它实现的一些功能。下次你需要操作 Java 代码时,为什么不尝试使用它呢?