1000万行Java代码变Kotlin!Meta如何用“偷懒”秘籍省下10万次转换按钮点击?

Meta 多年来一直致力于将其整个 Android 代码库从 Java 转换为 Kotlin 语言。如今,拥有世界上规模最大 Android 代码库之一的 Meta,已经将这项工作完成过半,并仍在持续推进中。

近日,Meta 分享了在实现自动过渡到 Kotlin 过程中所做的权衡决策,其中包括一些看似简单但实际上颇具挑战的转换方法,以及如何与其他公司合作以处理数百种复杂的边缘案例。

作者 | Meta 工程团队      编译 | 苏宓
出品 | CSDN(ID:CSDNnews)

以下为译文 :

自 2020 年以来,Meta 的 Android 开发就已经以 Kotlin 为优先了,开发团队一直表示,他们更喜欢 Kotlin 这门语言。

采用 Kotlin 语言其实并不一定意味着要对源代码进行全部改写。Meta 本可以选择用 Kotlin 编写所有新代码,同时保留现有的 Java 代码不变,就像许多其他公司所做的那样。也可以采取更进一步的行动,譬如仅改写最重要的文件代码。

然而,Meta 决定,只有彻底转向 Kotlin,才能充分发挥其价值,即使这意味着需要构建自己的基础设施以实现大规模自动化转换。因此,几年前,Meta 的工程师决定用 Kotlin 重写 1000 万行运行良好的 Java 代码。

当然,挑战不仅仅局限于代码转换,还包括解决构建速度慢和缺乏足够的代码检查工具等问题。

图片

转换多少代码才算足够?

为了最大限度地提高开发者的工作效率和代码的安全性,Meta 的目标是将几乎所有正在开发的代码,以及依赖关系图中核心的代码都转译为 Kotlin。毫不意外,这几乎涵盖了其团队的大部分代码,总计数千万行,其中包括一些最复杂的文件。

从直觉上来说,如果我们想最大限度地提高生产率,就应该优先转译正在开发的代码。但为什么要转换所有的代码,Meta 认为 Kotlin 可以带来额外的空值安全性收益,也许这一点在很多人来看来可能不太显而易见。

简短的答案是:任何剩余的 Java 代码都可能成为引发空值混乱的源头,尤其是当这些代码本身不具备空值安全性时,更不用说它们是依赖图中的核心部分。

我们也希望尽量避免混合代码库的缺点。只要代码库中还有大量 Java 代码,我们就不得不继续支持并行的工具链。此外,还有一个备受诟病的问题是构建速度慢:编译 Kotlin 的速度比编译 Java 慢,但同时编译二者则是最慢的。

图片

我们是如何开始转换数千万行的 Java 代码的?

和业界大多数人一样,我们最初开始迁移代码时主要是反复点击 Intellij IDE 中的按钮。这一按钮会触发 Intellij 的转换工具,即广为人知的 J2K。

然而,很快我们发现这种方式无法应对我们规模庞大的代码库:要完成 Android 代码库的转译,我们需要点击该按钮近 10 万次,而每次运行需要花费几分钟。

考虑到这一点,我们开始着手转换过程的自动化,并尽可能减少对开发者日常工作的干扰。最终,我们基于 J2K 构建了一个名为 Kotlinator 的工具。它分为六个阶段:

1. “深度”构建:在转译之前构建代码,帮助 IDE 解析所有符号,特别是在涉及第三方依赖或生成代码时。

2. 预处理:这一阶段基于我们自定义工具 Editus 构建,包括约 50 个步骤,如空值处理、J2K 问题修复、支持自定义依赖注入框架的调整等。

3. 无界面 J2K:这是我们熟悉的 J2K,但更适合服务器运行环境。

4. 后处理:这一阶段与预处理架构类似,包括约 150 个步骤,用于 Android 特定调整、额外的空值处理以及让 Kotlin 代码更符合惯例的修改。

5. 代码检查器:运行代码检查器并自动修复问题,使转换后的差异(diffs)和后续日常代码差异都受益。

6. 基于构建错误的修复:Kotlinator 根据构建错误进一步修复问题,例如添加缺失的导入或插入 !!。

我们将在下文中详细介绍一些最有趣的阶段。

J2K 的无界面化

第一步是创建一个可以在远程机器上运行的无界面 J2K,这并不容易,因为 J2K 与 Intellij IDE 的其余部分紧密耦合。我们曾考虑过多种方法,包括使用类似于 Intellij 测试环境的设置运行 J2K,但在与 JetBrains 的 J2K 专家 Ilya Kirillov 讨论后,我们最终采用了类似无界面检查的方案。

为实现这一方法,我们创建了一个 Intellij 插件,其中包含一个扩展 ApplicationStarter 的类,并直接调用 IDE 转换按钮引用的 JavaToKotlinConverter 类。

无界面化不仅避免了阻塞开发者的本地 IDE,还允许我们一次性转译多个文件,并支持一些耗时但有帮助的步骤,例如“构建并修复错误”的流程。虽然总体转换时间增加了(典型的远程转换现在需要约 30 分钟),但开发者投入的时间大幅减少。

当然,无界面化也带来了新的问题:如果开发者不再亲自点击按钮,谁来决定转译哪些文件,又如何审核和发布呢?答案其实很简单:Meta 内部有一个系统,允许开发者设置类似 cron 的任务,根据用户定义的选择条件,每天生成一批 diff(类似于 pull request)。该系统还能选择相关的审核人员,确保测试及其他验证通过后,由人工批准并发布。此外,我们还提供了一个 Web 界面,供开发者触发特定文件或模块的远程转换;该界面背后运行的正是与 cron 任务相同的流程。

至于选择译的顺序,我们没有强制规定,仅优先处理正在开发的文件。目前,Kotlinator 已足够智能,可以处理外部文件所需的大部分兼容性更改(例如将 Kotlin 依赖的 foo.getName() 修改为 foo.name),因此无需按依赖图排序进行转换。

添加自定义的预处理和后处理步骤

由于代码库的规模和使用的自定义框架,原生 J2K 生成的大部分转换差异(diffs)无法通过构建。为了解决这个问题,我们在转换流程中增加了两个自定义阶段:预处理和后处理。这两个阶段包含数十个步骤,接收正在转换的文件,分析其内容(有时还会分析其依赖项和被依赖项),并在必要时执行 Java->Java 或 Kotlin->Kotlin 的转换。一些后处理的转换步骤已经开源(https://github.com/fbsamples/kotlin_ast_tools)。

这些自定义的转换步骤基于我们内部的元编程工具构建,该工具利用了 JetBrains 的 Java 和 Kotlin 的 PSI 库。与大多数元编程工具不同,这个工具并不是一个编译器插件,因此可以快速分析两种语言中存在语法错误的代码。这在后处理阶段尤其有用,因为此阶段经常需要对带有编译错误的代码进行分析,同时执行需要类型信息的操作。一些处理被依赖项的后处理步骤可能需要解析几千个无法构建的 Java 和 Kotlin 文件中的符号。例如,我们的一个后处理步骤用于帮助转换接口:它会检查接口的 Kotlin 实现类,并将被覆盖的 getter 方法更新为覆盖属性,如以下示例所示。

interface JustConverted {  val name: String // I used to be a method called `getName`}
class ConvertedAWhileAgo : JustConverted {  override fun getName(): String = "JustConvertedImpl"}
class ConvertedAWhileAgo : JustConverted {  override val name: String = "JustConvertedImpl"}

这个工具虽然速度快、灵活性强,但在处理类型信息时有一定局限性,尤其是当符号定义在第三方库中时。在这种情况下,工具会迅速且明显地退出操作,以避免错误地执行转换。虽然生成的 Kotlin 代码可能无法直接通过构建,但对应的修复通常对开发人员来说很明显(尽管可能有点繁琐)。

最初我们引入这些自定义阶段是为了减少开发人员的工作量,但随着时间推移,它们也帮助我们降低了人为操作的不可靠性。与普遍看法相反,我们发现让机器人处理最复杂的转换通常更安全。在后处理阶段,我们甚至自动化了一些并非严格必要的修复,以减少人为(即更容易出错)干预的机会。例如,压缩冗长的空值检查链虽然不会让代码变得更正确,但能降低开发者无意间丢失逻辑否定符号的风险。

利用构建错误

在转换过程中,我们发现自己经常需要在最后阶段反复构建代码并根据编译器的错误消息进行修复。理论上,我们可以在自定义后处理阶段解决许多此类问题,但这样做需要重新实现大量已经嵌入 Kotlin 编译器的复杂逻辑。

于是,我们为 Kotlinator 添加了一个新的最终步骤,利用编译器的错误消息,像开发者一样进行修复。类似于后处理,这些修复依赖于一种能够分析不可构建代码的元编程工具。

自定义工具的局限性

Kotlinator 的预处理、后处理和后构建阶段总共包含了 200 多个自定义步骤。但即便如此,有些转换问题依然无法通过增加更多步骤来解决。

起初,我们将 J2K 当作一个黑盒处理,尽管它是开源的。这是因为它的代码复杂且未被积极开发,深入研究并提交改进请求似乎得不偿失。然而,这种情况在 2024 年初发生了改变,当时 JetBrains 开始使 J2K 兼容新的 Kotlin 编译器 K2。我们抓住这个机会与 JetBrains 合作,解决了困扰我们多年的问题,例如消失的 override 关键字。

与 JetBrains 的合作还让我们得以在 J2K 中插入钩子,让像 Meta 这样的客户端能够在 IDE 中直接运行自定义的转换步骤。这可能听起来奇怪,考虑到我们已经编写了大量的自定义处理步骤,但这样做有两个主要好处:

1. 改进符号解析

我们的自定义符号解析快速灵活,但在解析第三方库中的符号时不如 J2K 精确。将一些预处理和后处理步骤移植到 J2K 的扩展点中,可以使这些步骤更准确,并允许我们使用 Intellij 的更复杂的静态分析工具。

2. 更易开源和协作

虽然我们的一些自定义步骤过于针对 Android,无法直接整合到 J2K 中,但它们可能对其他公司有用。不幸的是,目前大多数步骤依赖于我们的自定义符号解析。如果将这些步骤移植到 J2K 的符号解析之上,我们就能选择将它们开源,从而受益于社区的集体努力。

图片

但首先,解决空值安全!

为了在转换代码时避免产生大量空指针异常(NPE),我们需要确保代码首先是空值安全的(即通过了像 Nullsafe 或 NullAway 这样的静态分析器的检查)。尽管空值安全无法完全消除 NPE 的可能性,但它是一个非常好的起点。不幸的是,实现代码的空值安全并不容易。

即使是空值安全的 Java 代码有时也会抛出 NPE

任何使用过空值安全 Java 代码的人都知道,尽管它比普通 Java 代码更可靠,但仍然可能抛出 NPE。不幸的是,静态分析只有在代码覆盖率达到 100% 时才完全有效,而这在任何与服务器和第三方库交互的大型移动代码库中都是不现实的。

以下是一个看似无害但可能引发 NPE 的典型示例:

MyNullsafeClass.java

@Nullsafepublic class MyNullsafeClass {

void doThing(String s) { // can we safely add this dereference? // s.length; }}

假设有十几个依赖调用 MyNullsafeJava::doThing。其中某个非空安全的依赖可能会传入一个空参数(例如,MyNullsafeJava().doThing(null)),如果 doThing 方法体中插入了对参数的解引用,就会导致 NPE。

虽然我们无法通过空安全覆盖完全消除 Java 中的 NPE,但可以大幅降低它们的发生频率。在上述例子中,如果只有一个非空安全的依赖,NPE 的风险较低。然而,如果有多个传递性依赖缺乏空安全,或者某个核心依赖节点不安全,那么 NPE 的风险将显著提高。

Kotlin 的不同之处

Kotlin 和空安全的 Java 之间最大的区别在于,Kotlin 的字节码在语言边界上有运行时校验。这种校验是不可见但非常强大的,因为它允许开发者在他们修改或调用的任何代码中信赖声明的空值注解。

回到之前的例子,当将 MyNullsafeClass.java 转换为 Kotlin 时,我们得到类似这样的代码:

MyNullsafeClass.kt

class MyNullsafeClass {

fun doThing(s: String) { // there's an invisible `checkNotNull(s)` here in the bytecode // so adding this dereference is now risk-free! // s.length }}

在 doThing 方法体的开头,字节码中会插入一个隐式的 checkNotNull(s)。因此,可以安全地对 s 进行解引用,因为如果 s 是可空的,代码早就因为校验失败而崩溃。这种确定性使开发过程更加流畅和安全。

Kotlin 编译器在并发处理时对空安全规则的要求比 Nullsafe 稍严格。具体而言,Kotlin 编译器会对可能在其他线程中被设置为 null 的类级属性的解引用抛出错误。这种差异对我们来说并不算太重要,但确实导致了在将空安全代码转换为 Kotlin 时比预期更多地出现 !! 运算符。

把一切转换为 Kotlin?

没那么简单。从更模糊的语义转换为更精确的语义并不是免费的。例如,在处理 MyNullsafeClass 这样的情况时,虽然 Kotlin 转换后开发更容易,但某人必须承担初步插入非空断言的风险,而这个人可能是执行 Kotlin 转换的开发者或机器人。

我们可以采取一些措施来尽量减少转换过程中引入新 NPE 的风险,其中最简单的方法是在转换参数和返回类型时偏向“更可空”。对于 MyNullsafeClass,Kotlinator 会根据上下文线索(例如 doThing 方法体中没有解引用)推断 String s 应翻译为 s: String?。

在审查转换差异时,我们最关注的一项变化是新增的 !! 是否超出了已有解引用之外。令人意外的是,我们并不担心诸如 foo!!.name 这样的表达式,因为它在 Kotlin 中发生崩溃的可能性并不比在 Java 中更高。然而,像 someMethodDefinedInJava(foo!!) 这样的表达式就令人担忧,因为 someMethodDefinedInJava 可能只是缺少一个 @Nullable 注解,添加 !! 会引入完全不必要的 NPE。

为了避免在转换过程中错误添加 !!,我们运行了十多个代码修改工具,扫描代码库中可能缺少 @Nullable 注解的参数、返回类型和成员变量。提高整个代码库的空值精确性——即使是我们可能永远不会转换的 Java 文件——不仅更安全,也有助于更成功的转换,尤其是当我们接近这个项目的最后阶段时。

当然,Java 代码中剩余的空安全问题往往是因为它们很难解决。此前的解决方案主要依赖静态分析,因此我们借鉴了 Kotlin 编译器的思路,创建了一个 Java 编译器插件,用于收集运行时空值数据。这个插件可以收集所有返回类型和参数的空值信息,识别哪些未正确注解的地方。无论这些问题源自 Java/Kotlin 的互操作还是本地注解错误,我们都可以找到最终的真相,并通过代码修改工具最终修正注解。

图片

其他可能破坏代码的方式

除了空安全的回退风险外,转换过程中还有许多其他可能破坏代码的方法。在完成超过 40,000 次转换的过程中,我们经历了许多教训,现在有多层验证来防止这些问题。以下是几个常见问题的例子:

混淆初始化与 getter

// Incorrect!val name: String = getCurrentUser().name

// Correctval name: String get() = getCurrentUser().name

可空布尔值

// Originalif (foo != null && !foo.isEnabled) println("Foo is not null and disabled")
// Incorrect!if (foo?.isEnabled != true) println("Foo is not null and disabled")
// Correctif (foo?.isEnabled == false) println("Foo is not null and disabled")

图片

有趣的部分

到目前为止,Meta 的 Android Java 代码中已有超过一半被转换为 Kotlin。但这只是容易的部分,真正有趣的部分还在后面。我们希望通过添加和改进自定义步骤以及为 J2K 做出贡献,解锁数千次完全自动化的转换。同时,借助其他 Kotlinator 的改进,我们希望平稳安全地完成数千次半自动化的转换。

我们面临的许多问题也困扰着其他正在转换 Android 代码库的公司。如果你有类似的经历,我们欢迎你利用我们的修复方案并分享你们的解决方案。