JavaParser 简介

在本文中,我们将了解JavaParser库。我们将了解它是什么、我们可以用它做什么以及如何使用它。

什么是JavaParser?
JavaParser 是一个用于处理 Java 源代码的开源库。它允许我们将 Java 源代码解析为抽象语法树(AST)。完成此操作后,我们可以分析解析的代码、操作它,甚至编写新代码。

使用 JavaParser,我们可以解析用 Java 编写的源代码,最高可达 Java 18。这包括所有稳定语言功能,但可能不包括任何预览功能。

依赖关系
在使用 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 进行序列化的方法。

解析Java代码
一旦我们在应用程序中设置了依赖项,我们就可以开始了。Java 代码的解析始终从StaticJavaParser类开始。这为我们提供了几种不同的解析代码的机制,具体取决于我们解析的内容以及它的来源。

1.解析源文件
我们首先要分析的是整个源文件。我们可以使用StaticJavaParser.parse()方法来做到这一点。几种重载的替代方案允许我们以不同的方式提供源代码——直接作为字符串、作为本地文件系统上的文件、或者作为某些资源的输入流或读取器。所有这些都以相同的方式工作,并且只是提供要解析的代码的便捷方法。

让我们看看它的实际效果。在这里,我们将尝试解析提供的源代码并生成一个CompilationUnit作为结果:

CompilationUnit parsed = StaticJavaParser.parse("class TestClass {}");

这代表了我们的 AST,让我们可以检查和操作解析后的代码。

2.解析语句
各个语句位于我们可以解析的代码范围的另一端。我们使用StaticJavaParser.parseStatement()方法来完成此操作。与源文件不同,只有一个版本,它采用包含要解析的语句的单个字符串。

此方法返回一个Statement对象,该对象表示已解析的语句:

Statement parsed = StaticJavaParser.parseStatement("final int answer = 42;");

3.解析其他结构
JavaParser 还可以解析许多其他构造,涵盖直至 Java 18 的整个 Java 语言。每个构造都有一个单独的专用解析方法,并返回表示解析代码的适当类型。例如,我们可以使用parseAnnotation()来解析注释,parseImport()来解析导入语句,parseBlock()来解析语句块,等等。

在内部,JavaParser 将使用完全相同的代码来解析代码的各个部分。例如,当使用parseBlock()解析块时,JavaParser 最终将得到与parseStatement()直接调用的代码相同的代码。这意味着我们可以依赖这些不同的解析方法,对相同的代码子集发挥相同的作用。

我们确实需要确切地知道我们正在解析什么类型的代码,以便选择正确的解析方法。例如,使用parseStatement()方法解析类定义将会失败。

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. 分析解析代码
一旦我们解析了一些代码,我们就可以开始分析它并从中学习。这类似于正在运行的应用程序中的反射,仅针对已解析的源代码而不是当前正在运行的代码。

1.访问已解析的元素
一旦我们解析了一些源代码,我们就可以查询 AST 来访问各个元素。我们具体如何做到这一点取决于我们想要访问的元素和我们解析的内容。

例如,如果我们已将源文件解析为 CompilationUnit ,那么我们可以使用getClassByName()访问我们期望存在的类:

Optional<ClassOrInterfaceDeclaration> cls = compilationUnit.getClassByName("TestClass");

请注意,这会返回一个Optional<ClassOrInterfaceDeclaration>。使用可选是因为我们不能保证该类型存在于该编译单元中。在其他情况下,我们也许能够保证元素的存在。例如,类总是有一个名称,因此ClassOrInterfaceDeclaration.getName()不需要返回Optional。

在每个阶段,我们只能直接访问当前正在使用的最外层的元素。例如,如果我们通过解析源文件获得了CompilationUnit ,那么我们可以访问包声明、导入语句和顶级类型,但无法访问这些类型中的成员。但是,一旦我们访问其中一种类型,我们就可以访问其中的成员。

2.迭代解析的元素
在某些情况下,我们可能不确切知道解析的代码中存在哪些元素,或者我们只是想使用某种类型的所有元素而不是仅使用一个。

我们的每个 AST 类型都可以访问整个范围的适当嵌套元素。具体如何工作取决于我们想要处理什么。例如,我们可以使用以下命令从CompilationUnit中提取所有导入语句:

NodeList<ImportDeclaration> imports = compilationUnit.getImports();
不需要Optional,因为这保证返回结果。但是,如果不存在导入,则此结果可能是空列表。

完成此操作后,我们可以将其视为任何集合。 NodeList类型正确实现了java.util.List ,因此我们可以像任何其他列表一样使用它。

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<R>并将所有这些列表合并在一起:

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 插件、代码编译步骤等非常有用。

只要我们能够访问适当的 AST 元素,就可以以任何方式使用它——无论是直接访问它们、使用访问者迭代还是任何有意义的方式。

例如,如果我们想将一段代码中的每个方法名称都大写,那么我们可以这样做:

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 就会就地更新。然后我们可以按照自己的意愿对其进行格式化,新格式化的代码将反映我们的更改。