OpenRewrite 自动化迁移:LST、Recipe 与工程级回滚
编译能过的批量迁移,为什么仍然不可信
一个 Java 平台团队要统一废弃 API。第一轮脚本只替换方法名,漏掉了静态 import;第二轮 AST 工具改对了调用,却重排了整份文件,评审者无法从几千行格式噪声里看出真正变化;第三轮在开发机成功,进入多模块仓库后因为私服依赖无法解析,部分源码没有类型信息,规则悄悄少改了一批文件。
OpenRewrite 的价值不只是“能改 Java”。它把源码、构建文件和配置解析为可打印回原文本的 Lossless Semantic Tree,Recipe 在树上搜索、扫描或变换,插件再把变化输出成 patch 或写回工作树。真正决定迁移可信度的是四件事:构建模型是否完整、类型归因是否成立、Recipe 与插件版本是否固定、第二次执行是否保持无新增 diff。
先用项目自己的构建入口启用插件
OpenRewrite 不是一台需要长期运行的服务。对单仓库迁移,最直接的安装方式是在现有 Maven 或 Gradle 构建中启用插件,并通过项目 wrapper 执行。开始前确认工作树干净、JDK 与项目基线一致、私有制品库可访问;运行身份只需要源码读取、临时目录写入和依赖下载权限,预览阶段不需要 push token。
插件和 Recipe 制品会持续发布。不要在共享迁移中使用 latest.release、动态版本或未锁定的 snapshot。下面采用官方 quickstart 给出的 Maven 插件 6.44.0 与 Gradle 插件 7.37.0 作为一组明确示例基线;接入现有项目时,应按目标 Recipe 的 usage 页面选择兼容版本,整组回归后再由依赖锁定或内部制品仓库保存。OpenRewrite 官方还发布 rewrite-recipe-bom 对齐 recipe 模块;使用多个 org.openrewrite.recipe 制品时应固定 BOM,并让依赖锁或内部镜像记录最终解析版本,不能只锁插件而让 recipe 传递依赖漂移。
Maven 项目在 pom.xml 的 <build><plugins> 中加入:
<plugin>
<groupId>org.openrewrite.maven</groupId>
<artifactId>rewrite-maven-plugin</artifactId>
<version>6.44.0</version>
<configuration>
<activeRecipes>
<recipe>org.openrewrite.java.OrderImports</recipe>
</activeRecipes>
<failOnInvalidActiveRecipes>true</failOnInvalidActiveRecipes>
</configuration>
</plugin>随后使用 wrapper 发现与预览:
./mvnw rewrite:discover -Ddetail=true
./mvnw rewrite:dryRun
find . -path '*/target/site/rewrite/rewrite.patch' -type f -print
git status --short预期 discover 能列出激活的 org.openrewrite.java.OrderImports;存在待修改源码时,dryRun 会在各 Maven 模块的 target/site/rewrite/rewrite.patch 产生候选,没有变化时则可能不生成 patch。源码工作树不因预览而改变。failOnInvalidActiveRecipes 让错误 Recipe 名称或缺少必填参数显式失败,避免“配置没生效”被误判成零变化。
Gradle Groovy DSL 的对应配置是:
plugins {
id 'java'
id 'org.openrewrite.rewrite' version '7.37.0'
}
repositories {
mavenCentral()
}
rewrite {
activeRecipe('org.openrewrite.java.OrderImports')
failOnDryRunResults = false
throwOnParseFailures = true
}团队也可以把这个固定值集中到受版本控制的插件管理配置中,但不能改成环境里的浮动字符串。执行:
./gradlew rewriteDiscover
./gradlew rewriteDryRun
find . -path '*/build/reports/rewrite/rewrite.patch' -type f -print
git status --short存在待修改源码时,预期 patch 位于 build/reports/rewrite/rewrite.patch;没有变化时同样可能不生成 patch,源码仍未写盘。多模块项目先确认插件应用在哪个 project、Recipe 依赖在哪个 configuration、根目录 rewrite.yml 怎样被各模块解析;不要只在根模块看到任务存在,就推断所有源码集都已进入解析。
LST 为什么能保留格式,又能区分同名调用
传统 AST 通常关注编译器需要的语法结构,空格、注释和原始格式可能被丢弃。OpenRewrite 的 LST 在树节点上保留前后空白、注释与标记,并为支持的语言附加语义信息;打印时可以重建接近原文件的文本,Recipe 插入新代码时还能参考局部样式。
对 Java 来说,类型归因把一个源码里的短名字连接到方法、字段、类和依赖中的类型身份。logger.info(...) 的表面形状相同,归因后才能区分接收者是 SLF4J、Logback 还是业务类。这个能力来自解析器看到的 classpath、source set、JDK 和构建模型,不是看到一段文本就自动出现。缺依赖、生成源码未生成、annotation processor 行为不完整或模块模型错误时,树可以局部存在,类型信息却可能缺失。
一次本地执行中,LST 驻留在进程内存:从磁盘建立树,Recipe 变换树,变化再打印回文本;进程结束后不会把这棵树作为下一次运行的隐式缓存。第二次运行会从当前磁盘状态重新建树。这也是幂等验证有意义的原因:它检验 Recipe 面对自己的输出时是否还会继续改变状态。
Recipe 不是一条替换规则
Recipe 是可组合的迁移单元。搜索 Recipe 可以给命中位置加标记;普通变换 Recipe 通过 visitor 修改树;Scanning Recipe 先跨文件收集事实,再在后续阶段生成或修改文件。声明式 rewrite.yml 把多个 Recipe 与参数组合成组织自己的迁移名:
---
type: specs.openrewrite.org/v1beta/recipe
name: com.example.NormalizeImports
displayName: Normalize imports
recipeList:
- org.openrewrite.java.RemoveUnusedImports
- org.openrewrite.java.OrderImports再把 com.example.NormalizeImports 放进 Maven 的 <activeRecipes> 或 Gradle 的 activeRecipe(...)。顺序可能改变结果:前一条 Recipe 可以删除、移动或生成后一条要消费的节点。组合 Recipe 必须像代码一样有 fixture,不能把一串名称当作无状态清单。
外部 Recipe 不会因为写进 activeRecipes 就自动可用。Maven 要把 Recipe artifact 放在 rewrite 插件的 <dependencies> 中;Gradle 要加入 rewrite(...) configuration。artifact 版本、插件版本、核心库版本和目标 JDK 之间需要兼容基线。以每个 Recipe 页面给出的坐标和配置为准,不要凭包名猜依赖,也不要在迁移中途只升级其中一层。
rewrite:discover 与 rewriteDiscover 只列出执行 classpath 上可发现的 Recipe,不证明某个 catalog 条目已经安装,也不授予超出原许可证的权利。Recipe catalog 的每个条目都给出来源、坐标、usage 与许可证;引入前应把 artifact、版本、许可证标识和源码地址写入依赖审查记录。OpenRewrite 核心、Maven/Gradle 插件和许多基础 Recipe 使用 Apache-2.0,但 catalog 同时包含 Apache-2.0、Moderne Source Available License 和专有 Recipe。内部改造自有代码、对外提供迁移服务、把 Recipe 嵌入商业产品是不同使用场景,必须逐制品核对 OpenRewrite licensing 与仓库许可证,不能用“OpenRewrite 是开源的”替代这一步。
正向实验:从 patch 到写盘,再证明幂等
选择一个已经能用 wrapper 构建的 Java 小项目,在类中故意放入乱序且未使用的 import:
package com.example;
import java.util.Set;
import java.util.ArrayList;
import java.util.List;
public class Names {
private final List<String> values = new ArrayList<>();
}启用上面的 com.example.NormalizeImports。Maven 执行:
./mvnw -DskipTests compile
./mvnw rewrite:dryRun
git apply --check target/site/rewrite/rewrite.patch
git apply --stat target/site/rewrite/rewrite.patch
git status --shortGradle 则执行:
./gradlew classes
./gradlew rewriteDryRun
git apply --check build/reports/rewrite/rewrite.patch
git apply --stat build/reports/rewrite/rewrite.patch
git status --short预期 patch 删除未使用的 Set,并按激活样式整理其余 import;工作树仍没有源码修改。先能编译再 dry run,是为了证明构建模型与依赖基线可用,但它不能替代解析失败检查,因为某些非主 source set 或资源文件可能仍被跳过。
确认 patch 后执行写盘:
# Maven
./mvnw rewrite:run
# Gradle 二选一,不要在同一工作树重复混跑两个入口
./gradlew rewriteRun随后运行仓库原有验证并保存证据:
git diff --check
./mvnw verify # Maven 项目
# 或 ./gradlew check # Gradle 项目
git diff --stat最后先删除上一轮 target/site/rewrite 或 build/reports/rewrite 报告目录,再执行同一个 dry run。预期不再产生新的源码候选,也不再生成非空 patch。幂等判据不是“第二次命令退出零”,而是同一源码状态、wrapper、JDK、依赖锁、插件、Recipe、参数与样式下没有新增 diff;任一输入发生变化都应记录为新实验,不能与上一轮混算。
反向实验:让 classpath 缺失暴露出来
类型敏感 Recipe 最危险的失败不是报错,而是因为归因缺失而少命中。可以在隔离分支稳定制造证据:让一个源码引用测试制品中的 com.example.audit.AuditLogger,然后临时移除该依赖或让测试仓库不可访问,再运行 compile 与 dry run。
预期 Maven/Gradle 依赖解析或编译先失败;若插件继续处理部分源码,日志中可能出现解析或类型相关警告。Gradle 将 throwOnParseFailures = true。当前 Maven 插件没有同名的解析失败严格开关,不能照抄 Gradle 配置;应单独激活核心 Recipe org.openrewrite.FindParseFailures,以 -Drewrite.exportDatatables=true 运行并检查 ParseFailures data table,存在记录就由 CI 包装层使批次失败。这个 Recipe 只报告带 ParseExceptionResult marker 的解析失败;它不能证明每个本应归因的 JavaType 都完整,因此类型敏感迁移还要用正反 fixture 证明目标方法、重载、继承与同名非目标调用的命中边界。接入时仍要在 Maven plugin reference、解析失败诊断 Recipe 或 Gradle plugin reference 核对目标版本。
另一个更直接的反例是加入语法错误:
package com.example;
public class Broken {
void run( {
}Gradle 的 throwOnParseFailures 应让任务非零退出,并给出 Broken.java 的解析失败证据。Maven 侧则必须由 FindParseFailures 的导出结果和外层门禁把同一证据转成失败;仅看 rewrite:dryRun 退出码不够。若任务仍成功且只改了其他文件,这个批次不能进入写盘;它只能说明“可解析子集生成了候选”,不能证明整个仓库完成迁移。修复语法、补齐生成源码与 classpath 后,从干净工作树重新建立 patch。
不要用 broad exclusion 掩盖失败。exclusions 适合明确不属于迁移输入的生成目录、vendor 或超大制品;每个排除模式都应对应 owner 与理由。Gradle 的 sizeThresholdMb、Maven 的文件排除和 plain-text mask 会直接改变语料,默认值升级后也可能变化,必须进入版本回归。
从演示 Recipe 进入真实项目迁移
真实迁移通常要让源码与构建文件协同变化。例如测试框架升级既要改注解、断言与生命周期方法,也可能要改 Maven dependency、Gradle configuration、测试插件或运行平台。优先选择官方目录中已经把这些动作组合起来的迁移 Recipe,并先阅读它实际包含的子 Recipe、参数、依赖坐标和许可证。
接入顺序从小样本开始:先在一个结构典型的模块运行 discover 与 dry run,确认源码、pom.xml 或 build.gradle 的变化属于同一迁移;再加入多模块、生成源码、Kotlin/Groovy 混合构建和私服依赖样本。patch 评审要区分“应该变化却没变”的漏改与“无关文件发生格式变化”的噪声。只有替换数量而没有构建文件差异、编译测试和反例,不足以证明工程迁移完成。
项目可把组合 Recipe 放在独立的 rewrite.yml,把插件和 Recipe artifact 版本放在版本目录或受控属性中。CI 的常态门禁使用 dry run 并让“发现待应用变化”非零退出:Maven 配置 failOnDryRunResults=true,Gradle 配置同名字段并让 check 依赖 rewriteDryRun。迁移执行仍在临时分支完成,由评审者决定何时运行 run;CI job 不应自行修改并 push 受保护分支。
解析失败要按第一证据分型
依赖无法下载时,先看 Maven/Gradle resolution 日志、仓库 URL、代理、证书与凭证作用域;不要先调 Recipe。主源码能编译而测试源码解析失败,检查 source set、test fixture 与插件应用位置。生成类型缺失时,确认 OpenRewrite 运行前是否执行了必要代码生成,以及生成目录是否被错误排除。JDK API 找不到时,对齐 wrapper、toolchain、JAVA_HOME 与 CI 镜像。
Recipe discover 找不到名称,通常是 Recipe artifact 没进入插件 classpath、坐标版本错误、配置文件路径不对或 YAML 缩进失败。Recipe 能发现但没有变化,先验证目标类型是否完成归因,再核对参数和扫描语料。变化过多则检查组合 Recipe、active style、构建脚本解析开关与格式化子 Recipe;不要在几千行 diff 上继续叠加迁移。
内存溢出或运行极慢时,记录模块数、源文件数、总字节、最大文件、解析阶段耗时、Recipe 阶段耗时、峰值 heap 和 patch 大小。先拆模块、排除已确认的生成物、修正超大文件,再按插件文档设置 JVM 内存。无限加 heap 可能只是把不受控语料和错误 classpath 的代价推迟到 CI。
清理与回滚要覆盖源码、构建和报告
dry run 只产生报告时,清理 target/site/rewrite 或 build/reports/rewrite 即可;删除前确认路径位于当前项目构建目录。run 写盘后先保存 patch、任务日志和验证结果,再用 Git 恢复本次涉及的受控文件。未提交实验可执行:
git diff --name-only
git restore --worktree -- pom.xml build.gradle build.gradle.kts settings.gradle settings.gradle.kts src命令中的路径必须按实际项目选择,不能把不存在或无关目录机械加入。若迁移已经形成共享提交,用反向提交撤销源码、构建依赖、插件配置和生成物;已经发布到制品库的版本、执行过的数据迁移或外部 API 副作用不由 git revert 自动消失,需要各自的退出方案。
迁移完成后可以保留 dry run 作为持续门禁,也可以移除一次性插件配置。移除时同步删除 Recipe 依赖、rewrite.yml、CI 任务、报告制品和临时私服凭证,再运行原生构建证明项目不再隐式依赖迁移工具。不要删除用于审计的已合并 diff 与必要日志,保留期由代码与安全策略决定。
权限、凭证与敏感数据
本地 OpenRewrite OSS 进程拥有当前构建用户的文件和网络权限。预览身份只需读取源码、写构建目录并从允许的制品库下载插件与 Recipe;写盘身份需要工作树写权限,但仍不需要仓库 push 权限。把预览、改写、提交拆成不同步骤,可以防止一个解析器进程同时持有源码写权与组织级代码宿主凭证。
私有 Maven/Gradle 仓库凭证放在 CI secret、settings.xml server、Gradle credentials provider 或组织认可的密钥注入层中,不得写进 rewrite.yml、pom.xml、gradle.properties 提交文件和命令历史。Recipe 可能扫描配置、SQL、YAML 与源码,导出的 patch、data table 和日志都可能含内部包名、路径、代码片段或配置值;这些制品需要访问控制、脱敏与保留期限。
自定义 Recipe 是在构建进程中执行的代码,权限等同于插件进程。只从受信制品库解析,固定坐标与校验,审查传递依赖和许可证。不要在拥有生产云凭证的 runner 上执行来源不明的 Recipe,也不要让迁移 job 访问与构建无关的生产网络。
容量、成本与长期治理
OpenRewrite 的主要本地成本是依赖解析、LST 内存、类型归因、Recipe 遍历、patch 存储和完整构建。多模块仓库还会重复承担项目模型与 classpath 成本。容量预算至少记录源码文件数、总字节、模块与 source set 数、依赖下载量、解析失败数、归因警告、峰值 heap、dry run 时长、变更文件数和 diff 行数。
门禁不使用脱离仓库规模的万能秒数。更可靠的不变量是:解析失败为零或全部进入经批准的排除清单;同一输入重复 dry run 的候选稳定;写盘后第二次运行无新增 diff;迁移分支的编译、测试与静态检查不比基线缺项;报告与缓存不会随轮次无界增长。大仓按模块与 Recipe 分批,限制并发,避免一批迁移同时打爆私服、CI runner 和评审队列。
长期治理为插件、核心库、Recipe artifact 和 JDK 建立兼容矩阵。每次升级都重跑类型归因缺失、语法错误、多模块、生成源码、源码与构建文件协同变化、格式保持和幂等 fixture。Recipe owner 负责参数、fixture 与变更说明;平台 owner 负责制品镜像、runner 容量和凭证;业务 owner 负责语义验收。三种责任不能压缩成“工具跑绿了”。
OpenRewrite OSS 与商业平台怎样选
OpenRewrite OSS 提供 Apache-2.0 核心框架、LST、Recipe API、Maven/Gradle 本地执行入口和一部分开源 Recipe,适合单仓或由团队自己编排的迁移。完整 catalog 不是单一许可证的软件包;source-available 或 proprietary Recipe 仍受各自条款约束。OSS 插件也不会凭一次本地命令自动获得组织级仓库发现、跨仓调度、结果汇总、权限同步、执行器治理和批量 PR 生命周期。
Moderne 提供围绕 LST 的商业平台与 CLI 能力,但套餐、部署方式、支持规模、数据驻留、代码宿主集成和具体功能会变化。采购或接入时应在 Moderne 文档 与官方定价入口重新核对,并完成安全、网络、权限、审计和数据保留评审。不要把商业平台能力写成 OpenRewrite OSS 的默认承诺,也不要反过来把开源 Recipe 与本地插件说成必须购买平台才能使用。
选型取决于组织规模。一个仓库、少量模块、现有 CI 足以编排时,固定版本的 OSS 插件通常成本最低;几十到上千仓库需要统一发现、调度、审计与持续迁移时,平台化能力可能降低自建编排成本,但会引入订阅、代码索引或上传、凭证、网络和供应商治理责任。评估时比较总成本,而不是只比较“能否运行同一个 Recipe”。
当迁移依赖类型身份、跨文件关系、构建文件协同变化与格式保持时,OpenRewrite 比文本模板更合适;只改简单、无类型含义的通用结构时,轻量结构工具会更快。无论选择哪一层,最终可信证据都相同:输入完整,失败可见,patch 可审查,构建测试通过,重复执行不再变化,源码、依赖和外部副作用都能按计划撤回。LST 生命周期与 Recipe 类型可继续查阅 Lossless Semantic Trees 和 Types of recipes。
