OpenRewrite 学习笔记(三):重构配方 Recipe 与访问者 Visitor
今天这篇我们来学习配方 Recipe 和访问者 Visitor,之所以一起介绍这两个是因为在 Recipe 的设计中使用了 访问者模式(Visitor Pattern)。
访问者模式是一种将算法与对象结构分开的软件设计模式。得益于这种分离,在将新操作添加到现有对象结构中时,无需修改对象结构。它是面向对象编程和软件工程中遵循开放/封闭原则的一种方法。
– 维基百科
配方 Recipes
配方(Recipe)是一组可对 无损语义树(LST) 执行搜索和重构操作的逻辑集合。配方既可以代表一个独立的微小变更,也可以与其他配方组合,形成实现更复杂目标(如框架迁移)的大型改造方案。
OpenRewrite 提供了一个托管环境,用于发现、实例化和配置配方。在实现搜索或重构操作时,配方会委托给访问者(Visitor),由其负责抽象语法树(LST)的遍历和操作。
在第一篇的快速上手中,我们使用了配方 ChangeMethodName 将方法 hello 名改为 gretting。
扫描配方 Scanning Recipe
这是一个特别的 Recipe。如果一个 Recipe 需要生成新的源文件或者需要在进行更改前查看所有源文件,那它必须是 ScanningRecpie
。
在实现层面 ScanningRecipe
扩展了 Recipe
,添加两个关键对象:accumulator
和 scanner
。
accumulator
是由 Recipe 自身来定义的数据结构,用于存储 Recipe 运行时所需的任何信息。scanner
是一个用数据来填充accumulator
的Visitor
#visitor
除了两个对象外,ScanningRecipe
还提供了方法定义 getScanner()
这个方法需要子类来实现,返回值也是一个访问者 Visitor(在后面我们会介绍)。
以 配方 CreateEmptyJavaClass 为例。从命名上来看,不难猜出配方 CreateEmptyJavaClass
用于创建一个空的 Java 类:只有类声明,没有类体。在创建类文件之前,需要确保没有同名的类文件存在,这个检查是在扫描阶段完成。
执行流水线
执行流水线用于指示如何对一组源文件应用配方,并实际执行代码转换任务。
当你调用某个配方的 run()
方法,并为其提供流水线中收集的源文件时,即可启动转换过程。
在流水线中,顶级配方(即启动执行流水线的配方)以及与其链接的所有后续配方都会参与执行。配方是可组合的,你可以通过将多个配方嵌套起来,为执行流水线提供额外的转换逻辑。
每个配方在执行时会依次经历以下步骤:
- 调用
validate()
对自身配置进行校验。默认情况下,如果校验失败,该配方将被跳过。 - 如果该配方关联了访问者(Visitor),则会将通过流水线传入的所有源文件交由这些访问者处理。在这一阶段,处理操作可以以并行方式进行,从而加速对 LST 的遍历与修改。
- 如果该配方还链接有其他配方,流水线将以相同的方式对这些后续配方执行上述步骤,直到所有嵌套的配方执行完毕。
执行上下文(Execution Context)
执行上下文是一种在多个 Recipe 及其访问者之间共享状态的机制。
在启动执行流水线(即调用 run()
方法)时,需要提供一个执行上下文。run()
方法有多个重载版本,如果未显式传入上下文,则会在内部自动创建一个默认上下文。
执行周期
在执行流水线中,一个 Recipe 对源文件的修改可能会触发另一个 Recipe 对同一文件进行进一步操作。因此,执行流水线可能会重复多次运行整个 Recipe 链,直到有一次执行没有引入任何新的修改,或者达到最大循环次数(默认值为 3)为止。
通过这种方式,Recipe 可以对其他 Recipe 所做的修改做出响应,从而实现渐进式的代码重构。
举例来说,假设执行流水线中有两个 Recipe:
- 第一个 Recipe 对 LST 进行空白区域的格式化。
- 第二个 Recipe 则负责向代码中添加新内容。
在执行时,流水线首先运行第一个 Recipe,对代码格式进行调整;完成后再次运行第二个 Recipe 添加新代码。由于流水线检测到代码发生了变化,它会再次重复整个过程,确保修改后的代码始终以期望的形式呈现。
理想情况下,Recipe 应该在一个执行周期内完成它的所有变更,而不应依赖多个周期来完成工作。 如果确实需要分多个周期进行,则必须通过 Recipe.causesAnotherCycle()
方法返回 true
来触发额外的执行周期。
执行结果
流水线成功执行后会生产 Result
实例集合。每个结果代表对特定源文件所做的更改,并提供对以下信息的访问:
方法 | 描述 |
---|---|
getBefore() |
原始 SourceFile ,如果更改表示新文件,则为 null。 |
getAfter() |
修改后的 SourceFile ,如果更改表示文件删除,则为 null。 |
getRecipesThatMadeChanges() |
对源文件进行更改的 Recipe 名称。 |
diff() /diff(Path) |
git 风格的 diff(带有可选路径以相对化输出中的文件路径) |
如果 getBefore()
和 getAfter()
都不为空,并且二者的文件路径不同,说明文件发生了移动;
有兴趣的可以查看 Maven 插件 recipe-maven-plugin 中的 AbstractRewriteBaseRunMojo.ResultsContainer 对结果的分类,以及 AbstractRewriteRunMojo 中对结果的处理。
在 recipe-maven-plugin 的输出结果,还可以预估各个操作相比手动处理所能节省的时间。
访问者 Visitor
在访问者模式(Visitor Pattern)中,主要有三个角色:
- 被处理的对象结构(LST,无损语义树)
- 发起处理的客户端应用(Recipe)
- 执行处理逻辑的访问者(Visitor)
OpenRewrite 提供的 visitElement
方法默认采用深度优先的方式对树进行遍历。比如一个类中定义了多个方法,在配方执行时,会遍历完整个方法树中所有的语句后,才会遍历下一个方法树。
由于代码(及其对应的 LST)通常是深度嵌套的,完整地遍历整棵 LST 树非常重要,否则就可能错过需要修改的 LST 元素。因此,在每个具体实现中,都应调用 super.visitElement
来确保对整个 LST 进行完整的深度遍历。
关键组件
Tree
访问者总是接受并返回扩展 Tree
的参数类型,比如 J
、Xml
、Yaml
等。每个 Tree
都有:
- 唯一的 ID
accept()
方法,充当特定语言访问者的回调- 通过几个重载的
print()
方法将自身转换为源码的可读形式 - 包含了提供有关 LST 元数据的标记
Tree
是所有 LST 的实现接口。Java 中的语法相关的 LST 如 J.ClassDeclaration
、J.MethodInvocation
、J.WhileLoop
都实现了接口 J
,而 Tree
是 J
的父接口。
TreeVisitor
OpenRewrite 的所有访问者都扩展了抽象类 TreeVisitor<T extends Tree, P>
。正是此类提供了驱动访问者的多态导航、光标定位和生命周期的通用参数化 visit(T, P)
方法。
参数化类型 T
表示访问者将在其上导航和转换的 LST 类型。第二个参数 P
是一个附加的共享上下文,当访问者导航给定的 LST 时,该上下文会传递给所有访问方法(有关详细信息,请参阅 在访问者之间共享数据)。
每个 TreeVisitor 类型,都要对关注的 LST 有完全的了解,即包含该 LST 类型的“导航”结构。
例如,对于一个 Java 源文件来说,每个 .java
文件都是一个编译单元,首先被解析成 J.CompilationUnit
,也是该语法树的根节点。作为树的访问入口,在 Java 的 特定 TreeVisitor JavaVisitor.visitCompilationUnit()
方法中,包含了 Java 源文件语法树的“导航”结构:
- 访问包声明
- 访问导入语句
- 访问类声明(在一个 Java 源文件可能会有多个类定义,但只有一个 public 类)
同样在访问类声明时,可以获取到整个类的树结构进行深度优先遍历,访问完一个类之后才会访问下一个。
Cursor
游标 Cursor 用于在遍历 LST 时跟踪访问者在 LST 中的位置。Cursor 有三个关键对象:
- parent 指向其父节点,根节点的游标的 parent 为空
- value 当前 LST
- message
Map
结构,用于存储数据。
通过 parent 对象,我们可以对树进行回溯,直至根节点,或者符合条件的节点。比如我们在访问方法声明时可以通过该对象查找当前方法所在的源文件地址。
在官方文档的 示例 中,新的配方用于为编译单元中的顶级类标记为 final。此时,需要排除掉嵌套类。通过 getCursor().getParentOrThrow().firstEnclosing(J.ClassDeclaration.class)
来查找当前类声明的封闭类,如果找不到这说明当前类是顶级类。
通过 message 对象,可以同一个 Visitor 的不同访问方式中传递数据。比如在访问类声明时,如果检测到类是内部类,则在其父类游标的 message 标记含有内部类。在访问类的方法时,可以通过查看所在类是否存在内部类而进行特殊的处理。
快速上手:自定义配方
看过上面关于配方和访问者的内容之后,我们尝试写个简单的配方:为指定的类添加注解。
首先这个配方会有几个参数:要添加注解的类、要添加的注解,以及注解的属性(可选)。
创建一个名为 AddAnnotation 类,添加配方参数,名字以及说明等必须字段和方法。
@EqualsAndHashCode(callSuper = false)
@Value
public class AddAnnotation extends Recipe {
@Override
public @NlsRewrite.DisplayName String getDisplayName() {
return "Add an annotation";
}
@Override
public @NlsRewrite.Description String getDescription() {
return "Add an annotation to specified class.";
}
@Option(displayName = "Annotation full qualified name", description = "The full qualified name of the annotation to add")
String annotationFullQualifiedName;
@Option(displayName = "Class full qualified name", description = "The full qualified name of the class to add the annotation to")
String classFullQualifiedName;
@Nullable
@Option(displayName = "Attributes", description = "The attributes of the annotation to add")
Map<String, Object> attributes;
@TestOnly
public AddAnnotation() {
this.annotationFullQualifiedName = "";
this.classFullQualifiedName = "";
this.attributes = null;
}
public AddAnnotation(String annotationFullQualifiedName, String classFullQualifiedName, Map<String, Object> attributes) {
this.annotationFullQualifiedName = annotationFullQualifiedName;
this.classFullQualifiedName = classFullQualifiedName;
this.attributes = attributes;
}
}
接下来就是主要逻辑的部分,添加访问者。这个配方中,我们需要在类上添加注解,因此重写 JavaIsoVisitor 的 visitClassDeclaration 方法来访问类声明。
首先检查是否是指定的类,以及类声明中是否已经存在指定的注解。如果不存在,通过 JavaTemplate
编写代码片段来添加指定的注解。。
在 OpenRewrite 的 最佳实践 中提到,应该避免手动编码的来创建 LST,而应该使用 JavaTemplate 来构造对象。比如这里,我们不会使用 J.Annotation 来构造注解。
@Override
public TreeVisitor<?, ExecutionContext> getVisitor() {
return new JavaIsoVisitor<ExecutionContext>() {
@Override
public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration classDecl, ExecutionContext executionContext) {
if (!classDecl.getType().getFullyQualifiedName().equals(classFullQualifiedName)) {
return classDecl;
}
boolean hasAnnotation = classDecl.getLeadingAnnotations().stream()
.anyMatch(annotation -> annotation.getAnnotationType().getType() != null
&& annotation.getAnnotationType().getType().toString().equals(annotationFullQualifiedName));
if (!hasAnnotation) {
maybeAddImport(annotationFullQualifiedName);
String annotationSimpleName = annotationFullQualifiedName.substring(annotationFullQualifiedName.lastIndexOf(".") + 1);
String attrStr = "";
if (attributes != null && !attributes.isEmpty()) {
attrStr = attributes.entrySet().stream().map(entry -> entry.getKey() + " = \"" + entry.getValue() + "\"")
.reduce((s1, s2) -> s1 + ", " + s2)
.map(s -> "(" + s + ")").orElse("");
}
classDecl = JavaTemplate.builder(
"@" + annotationSimpleName + "#{}"
)
.javaParser(JavaParser.fromJavaVersion()
.classpath("spring-web")
)
.imports(annotationFullQualifiedName)
.contextSensitive()
.build()
.apply(getCursor(), classDecl.getCoordinates().addAnnotation(Comparator.comparing(J.Annotation::getSimpleName)), attrStr);
}
return classDecl;
}
};
}
逻辑比较简单易懂,除了 JavaTemplate。JavaTemplate 是一个非常强大且使用的工具,后面会单独整理一篇笔记来介绍。
编写完配方后,参考 文档 来写个单元测试用例 AddAnnotationTest。
class AddAnnotationTest implements RewriteTest {
@Override
public void defaults(RecipeSpec spec) {
spec.recipe(new AddAnnotation("org.springframework.web.bind.annotation.RequestMapping", "com.atbug.demo.Controller", Map.of("path", "/api")));
}
@Test
void addAnnotationToController() {
rewriteRun(java("""
package com.atbug.demo;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class Controller {
}
""", """
package com.atbug.demo;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RequestMapping(path = "/api")
@RestController
public class Controller {
}
"""));
}
@Test
void doNotAddAnnotationIfAlreadyPresent() {
rewriteRun(java("""
package com.atbug.demo;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RequestMapping(path = "/api")
@RestController
public class Controller {
}
"""));
}
@Test
void doNotAddAnnotationToNonSpecifiedClass() {
rewriteRun(java("""
package com.atbug.demo;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class AnotherController {
}
"""));
}
}
总结
本篇文章深入介绍了 OpenRewrite 的两个核心概念——配方(Recipe)与访问者(Visitor),并通过实际示例展示了如何快速上手编写一个简单的配方。
在实际开发中,配方为代码重构与迁移提供了高层次的逻辑组织,而访问者则负责对代码的无损语义树(LST)进行遍历与精确的操作。二者的结合,使得我们能够以更灵活、可扩展的方式对大规模代码库进行批量修改和改进。
在此基础上,本文还通过示例演示了使用配方与访问者为指定类添加注解的过程,让读者对如何快速定制与应用 OpenRewrite 的能力有了直观的理解。