在软件开发领域,语言的选择对于提高开发效率和代码质量非常重要。今天在 Meta 官方博客看到了一篇介绍他们大规模将 Java 代码成功转译为 Kotlin 的文章。文章中,Meta 表示将把所有 Java 代码都转为 Kotlin。其实早在 2020 年时,Meta 就将 Kotlin 作为他们 Android 开发的主要语言,为了充分利用 Kotlin 的优势,开始将整个 Android 代码库从 Java 向 Kotlin 转译,这是一个庞大的工程,到现在完成有一半了,在这个过程中,Meta 克服了种种困难,积累了很多宝贵经验,下面是这篇文章的译文。
自 2020 年以来,Kotlin 就一直是 Meta Android 开发的首选语言,且深受工程师们的喜爱。Meta 在早期采用 Kotlin 时并未涉及将遗留代码转译成 Kotlin。与许多公司一样,Meta 选择了直接用 Kotlin 编写新代码,同时保留现有的 Java 代码。在某些情况下,他们也只是对最关键的原有 Java 代码文件进行了转译。
但随后,Meta 认识到,要充分利用 Kotlin 的优势,唯一的途径是进行全面的代码转译。这就意味着不仅需要构建自己的基础设施,以自动化大规模的代码转译,还需要投入更多的资源和精力来确保这一转型的顺利进行。因此,早在几年前,Meta 的工程师们就决定将大约一千万行功能正常的 Java 代码重写为 Kotlin 代码。
一.转译多少才够?
为了最大化开发者生产力和 null 安全性,Meta 的目标是转译几乎所有正在开发的代码,以及依赖项图谱中的所有核心代码。这几乎涵盖了 Meta 的所有代码,有几千万行,其中还包括一些最复杂的文件。如果希望最大化生产力的提升,Meta 就必须转译他们正在全力开发的代码。而且能够提供增量 null 安全优势的部分也需要转译。因为任何剩余的 Java 代码都可能引发 null 混乱,特别是当它不具备 null 安全保障,还处在依赖图谱的核心位置时。
Meta 还希望尽量减少混合代码库带来的缺点。只要还有大量的 Java 代码,Meta 就需要继续支持并行的工具链。另外还有一个问题是构建速度慢:Kotlin 的编译速度比 Java 慢,同时编译两者时速度就更慢了。
二.Meta 是怎么做的?
像业内大多数公司一样,Meta 起初通过在 Intellij IDE 中反复点击一个按钮,逐步进行迁移。这个按钮会触发 Intellij 的转译工具,通常被称为 J2K。很快,Meta 就发现这种方法无法扩展至规模庞大的代码库:为了转译 Meta 的 Android 代码库,工程师们将不得不先点击这个按钮,再等待几分钟,然后在点按钮,再等待,这样重复操作十多万次。
这样的操作,简直让人崩溃,所以 Meta 开始着手自动化转换,并尽量减少对开发人员日常工作的干扰。最后,Meta 围绕 J2K 构建了一个名为 Kotlinator 的工具。它包含六个阶段:
“深度” 构建:构建即将转译的代码,帮助 IDE 解析所有符号,尤其是在涉及第三方依赖或生成代码时。
预处理:这个阶段建立在 Meta 自定义的工具 Editus 上,包含大约 50 个步骤,涉及空指针安全性、J2K 的临时解决方案、支持自定义 DI 框架的更改等。
无头 J2K:就是大家熟悉的 J2K,但它是为服务器优化的版本!
后处理:与预处理类似,但增加了约 150 个步骤,处理 Android 特有的更改、更多的空指针安全性修复,并使生成的 Kotlin 代码更加符合惯用法。
Linter 测试:运行 Linter 测试并自动修复,能够以一种既适用于转换 diff 又适用于常规 diff 的方式实现长期修复。
基于构建错误的修复:最后,Kotlinator 根据构建错误进行更多修复。在转译后的代码构建失败后,会解析错误并进行进一步的修复
下面,我们看看 Meta 的一些实战经验。
使用无头 J2K
第一步是创建一个无头版本的 J2K,使其能够在远程机器上运行 —— 鉴于 J2K 与 Intellij IDE 其他部分的紧密耦合,这并非易事。Meta 的工程师们考虑了几种方法,包括使用类似 Intellij 测试环境的设置来运行 J2K,但在与 JetBrains 的 J2K 专家 Ilya Kirillov 交流后,他们最终选择了更像无头检查的方法。为了实现这一方法,Meta 创建了一个 Intellij 插件,插件中有一个扩展自 ApplicationStarter 类的类,直接调用 JavaToKotlinConverter 类,该类也在 IDE 的转换按钮中被引用。
除了不会阻塞开发者本地的 IDE,无头方法还允许一次性转译多个文件,并解锁了许多有用但耗时的步骤,例如下面详细描述的 “构建并修复错误” 过程。总体来说,转换时间变得更长(典型的远程转换现在需要约 30 分钟才能完成),但开发者所花费的时间大大减少。当然,使用无头版本还带来了另一个难题:如果开发者不自己点击按钮,那么由谁来决定转译哪些文件,并且如何进行审核和发布呢?答案出乎意料地简单:Meta 拥有一个内部系统,允许开发者设置类似 cron 作业的定时任务,根据用户定义的选择标准生成每日的差异文件(即拉取请求)。
该系统还会帮助选择相关的审阅者,确保测试和其他验证通过,并在人工审核通过后发布差异文件。Meta 还为开发者提供了一个 Web 界面,用于触发特定文件或模块的远程转换;其幕后执行过程与 cron 作业相同。
至于如何选择转译的内容和时机,除了优先转译那些正在积极开发的文件外,Meta 并不强制要求遵循特定顺序。目前,Kotlinator 已经足够成熟,可以处理外部文件中大多数需要的兼容性变更(例如,将 Kotlin 依赖项中的 foo.getName() 改为 foo.name),因此无需根据依赖图来安排转译顺序。
添加自定义的转换前后步骤
由于 Meta 代码库的规模和使用的自定义框架,原生 J2K 生成的大多数转换差异无法构建。为了解决这个问题,Meta 在转换过程中增加了两个自定义阶段:预处理和后处理。这两个阶段包含几十个步骤,分析正在转译的文件(有时还会分析它的依赖和被依赖项),并在需要时执行 Java->Java 或 Kotlin->Kotlin 的转换。
这些自定义转译步骤建立在一个内部的元编程工具之上,利用 Jetbrains 的 PSI 库来处理 Java 和 Kotlin。与大多数元编程工具不同,它并非编译器插件,因此能够迅速分析跨两种语言的错误代码。特别是在后处理阶段,它常常需要在包含编译错误的代码上运行,并进行类型信息分析。
一些处理依赖项的后处理步骤可能需要在数千个无法构建的 Java 和 Kotlin 文件中解析符号。例如,Meta 的一个后处理步骤通过检查 Kotlin 的实现者,帮助转换接口并将重写的 getter 函数更新为重写的属性,像下面的例子一样:
interface JustConverted {
val name: String // I used to be a method called `getName`
}
class ConvertedAWhileAgo : JustConverted {
override fun getName(): String = "JustConvertedImpl" // 堆代码 duidaima.com
}
class ConvertedAWhileAgo : JustConverted {
override val name: String = "JustConvertedImpl"
}
这种工具的快速性和灵活性带来的缺点是,它不能总是提供类型信息的答案,尤其是在符号定义在第三方库中时。在这种情况下,它会快速并明显地退出,避免在不确信的情况下执行转换。生成的 Kotlin 代码可能无法构建,但通常人类可以轻松识别需要修复的问题(尽管修复过程可能会有些繁琐)。
Meta 最初添加这些自定义阶段是为了减少开发者的工作量,但随着时间推移,他们也利用这些自定义阶段减少了开发者的错误。
与普遍看法不同,Meta 工程师们发现将复杂的转换交给机器人处理往往更加安全。即使某些修复不完全必要,Meta 仍然将其自动化为后处理的一部分,目的是最小化人为(即容易出错)干预的可能性。一个例子是简化长链中的 null 检查:生成的 Kotlin 代码在正确性上并没有改变,但它减少了善意开发者意外丢失取反操作的风险。
利用构建错误
在进行转换过程中,Meta 的工程师们注意到,在最后阶段,花费了大量时间根据编译器的错误信息反复构建和修复代码。理论上,可以在自定义后处理阶段解决很多这些问题,但这将要求重新实现 Kotlin 编译器中已经包含的许多复杂逻辑。因此,Meta 在 Kotlinator 中添加了一个新的最终步骤,利用编译器的错误信息,像人类一样进行修复。与后处理类似,这些修复通过元编程进行,能够分析无法构建的代码。
自定义工具的局限性
在预处理、后处理和构建后阶段之间,Kotlinator 包含了超过 200 个自定义步骤。不幸的是,一些转换问题仅通过增加更多步骤无法解决。起初,Meta 将 J2K 当作一个黑箱来处理 —— 尽管它是开源的 —— 因为它的代码复杂且没有积极开发;深入研究并提交 PR 看起来不值得。到了 2024 年初,JetBrains 开始致力于使 J2K 与新的 Kotlin 编译器 K2 兼容。Meta 抓住这个机会,与 JetBrains 一起改进 J2K,并解决了困扰多年的问题,比如丢失的 override 关键字。
与 JetBrains 合作还让 Meta 有机会在 J2K 中插入 hook,允许像 Meta 这样的客户端在转换前后直接在 IDE 中运行自己的自定义步骤。尽管 Meta 已经编写了大量自定义处理步骤,但这一做法还是带来了两个主要好处:
改进符号解析:Meta 的自定义符号解析快速且灵活,但准确性不如 J2K,特别是在解析定义在第三方库中的符号时。将一些预处理和后处理步骤移植过来,借助 J2K 的扩展点将使这些步骤更加准确,并能使用 Intellij 更加复杂的静态分析工具。
方便开源与协作:Meta 的一些自定义步骤是针对 Android 特定的,无法直接纳入 J2K,但依然可能对其他公司有用。遗憾的是,它们大多依赖于 Meta 自定义的符号解析。将这些步骤移植为依赖 J2K 的符号解析,使 Meta 有机会将它们开源,并从社区中获益。
三.最重要的是 null 安全!
为了在转译代码时避免到处都是 null 指针异常(NPE),首先需要确保代码是 null 安全的(“null 安全” 指的是通过静态分析工具,如 Nullsafe 或 NullAway 检查的代码)。尽管 null 安全并不足以完全消除 NPE 的可能性,但它是一个很好的起点。不幸的是,确保代码是 null 安全的并不容易。
即使是 null 安全的 Java 也偶尔会抛出 NPE。
任何在 null 安全的 Java 代码中工作过的人都知道,尽管它比普通 Java 代码更可靠,但仍然容易发生 NPE。不幸的是,静态分析只有在代码覆盖率达到 100% 时才是完全有效的,而在任何与服务器和第三方库交互的大型移动代码库中,实现 100% 的代码覆盖都是不现实的。
下面是一个看似无害的更改,它有可能触发 NPE:
@Nullsafe
public class MyNullsafeClass {
void doThing(String s) {
// can we safely add this dereference?
// s.length;
}
}
假设有十几个依赖者调用 MyNullsafeJava::doThing。一个非 null 安全的依赖者可能会传入一个 null 参数(例如,MyNullsafeJava().doThing(null)),如果在 doThing 方法体内插入了解引用操作,就会导致 NPE。当然,虽然通过 null 安全覆盖无法完全消除 Java 中的 NPE,但可以大大减少它们的发生频率。在上面的例子中,当只有一个非 null 安全的依赖者时,发生 NPE 的可能性是存在的,但相对较少。如果多个传递的依赖者缺乏 null 安全,或者某个更核心的依赖节点缺乏 null 安全,则 NPE 的风险将大大增加。
Kotlin 的不同之处
null 安全的 Java 和 Kotlin 之间最大的区别是在 Kotlin 字节码的跨语言边界上存在运行时验证。这种验证是隐形的,但非常强大,因为它使得开发者能够信任他们所修改或调用的任何代码中声明的 null 安全注解。回到之前的例子,MyNullsafeClass.java,并将其转译成 Kotlin,得到的代码如下:
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 是 null,这段代码早就会崩溃。正如你想象的那样,这种确定性让开发过程更加顺畅、安全。在静态分析层面,也存在一些差异:Kotlin 编译器在并发性方面执行的 null 安全规则比 Nullsafe 更严格。更具体地说,当类级别的属性可能在另一个线程中被设置为 null 时,Kotlin 编译器会抛出错误。这个差异对 Meta 来说并不是特别重要,但它确实导致了在转译 null 安全代码时,出现比人们预期更多的问题。
把所有代码都转译成 Kotlin 吧!
先别急。大家都知道,歧义由多到少也需要成本的。以 MyNullsafeClass 为例,虽然转译为 Kotlin 后开发会更加容易,但开发者或机器人必须承担最初的风险,即有效地为其可能并非真的不可为 null 的参数 s 插入一个非 null 断言。这个 “承当风险的人” 就是最终提交 Kotlin 转换的开发者或机器人。为了尽量减少在转换过程中引入新的 NPE 的风险,可以采取一些措施,其中最简单的做法就是在转译参数和返回类型时 “更宽松地假设可能为 null”。以 MyNullsafeClass 为例,Kotlinator 会通过上下文线索(在这个例子中是 doThing 方法体中没有任何解引用操作)推断出 String s 应该转译为 s:String?。
Meta 要求开发者在审查转换差异时仔细检查的一个变化是,在已有解引用的情况下添加 !!。有趣的是,他们并不担心像 foo!!.name 这样的表达式,因为在 Kotlin 中它崩溃的可能性并不会比在 Java 中更大。然而,像 someMethodDefinedInJava(foo!!) 这样的表达式却更让人担心,因为 someMethodDefinedInJava 可能只是缺少了对其参数的 @Nullable 注解,因此添加 !! 将引入一个完全不必要的 NPE。
为了避免在转换过程中引入不必要的 !!,Meta 运行了十多个互补的 codemods,这些工具会在代码库中查找可能缺少 @Nullable 的参数、返回类型和成员变量。更精确的 null 安全性注解(即使是在可能永远不会转换的 Java 文件中)不仅更安全,还能促进更成功的转换,特别是当接近项目的最后阶段时。
当然,Java 代码中最后剩下的 null 安全问题通常是因为它们非常难以解决。以前解决这些问题的尝试主要依赖静态分析,因此 Meta 决定借鉴 Kotlin 编译器的思路,创建一个 Java 编译器插件,帮助收集运行时的 null 安全数据。
这个插件允许收集所有接收或返回 null 值的返回类型和参数的数据,并且这些参数没有相应的注解。无论这些问题是来自 Java/Kotlin 互操作性,还是来自本地层次上注解错误的类,都可以确定最终的事实来源,并使用 codemods 最终修复这些注解。
四.导致代码受损的其他可能性
除了可能回退的 null 安全性风险外,还有数十种其他方式可能在转换过程中导致代码损坏。在完成超过 40,000 次转换的过程中,Meta 以艰难的方式学到了很多这些问题,并且现在有多个验证层来防止它们的发生。以下是最常见的几种:
将初始化与 getter 混淆:
// Incorrect!
val name: String = getCurrentUser().name
// Correct
val name: String
get() = getCurrentUser().name
可空布尔值:
// Original
if (foo != null && !foo.isEnabled) println("Foo is not null and disabled")
// Incorrect!
if (foo?.isEnabled != true) println("Foo is not null and disabled")
// Correct
if (foo?.isEnabled == false) println("Foo is not null and disabled")
五.未来展望
到目前为止,Meta 的 Android Java 代码已经有超过一半被转译成了 Kotlin(在少数情况下也可能被直接删除)。但这只是简单的一部分!真正有趣的部分在还后面,它相当复杂。
Meta 希望通过增加和改进自定义步骤,以及为 J2K 贡献代码,解锁成千上万的完全自动化转换。同时,更希望通过对 Kotlinator 的改进顺利且安全地完成剩下的几千个半自动化转换。