JavaParser入门:以编程方式分析Java代码

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 代码时,为什么不尝试使用它呢?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值