自 2020 年以来,Meta 公司的 Android 开发就一直以 Kotlin 为先,这一编程语言也在公司内部得到了开发人员们的普遍好评和支持。
然而,早期采用并不涉及遗留代码转换。对于大部分其他企业,常见的作法仅仅是直接用 Kotlin 编写一切新代码,同时继续保留原有 Java 代码。或者也可以更进一步,只转译最重要的原有代码文件。但相反,Meta 认为充分发挥 Kotlin 价值的唯一方式就是全力以赴推进转译,而这也意味着必须构建自有基础设施以大规模实现转译自动化。
为此,Meta 的工程师们在几年前就决定用 Kotlin 重写约千万行本已完好、运行顺畅的 Java 代码。另外,Meta 还必须解决转译以外的各种问题,例如构建速度慢以及 linter 测试不足等。
为了最大限度提高开发人员的生产力与 null 安全性,Meta 的目标是转译几乎所有正在积极开发的代码,以及依赖项图谱中的全部核心代码。可以想见,这涉及 Meta 的大部分代码,累计达到数千万行之巨,其中涉及不少极其复杂的文件。
直观来讲,如果想要最大限度提高生产力,Meta 必须转译其积极开发的代码部分。除此之外,能够提供增量 null 安全优势的部分也在需要转译的范畴之内。简而言之,任何遗留的 Java 代码都可能引发 null 混乱,特别是不具备 null 安全保障的部分。如果既涉及 null 安全、又属于依赖项图谱的核心,自然成为转译工作的重中之重。
Meta 还希望尽量减少混合代码库的缺点。只要还拥有大量 Java 代码,Meta 就必须继续支持并行工具链。另外一个备受诟病的问题就是构建速度较慢:Kotlin 的编译速度本身就不及 Java 编译,但同时编译二者才最是影响性能。
与业内的大多数企业一样,Meta 的迁移之旅同样是从反复单击 Intellij IDE 中的功能按钮开始。这项操作将触发 Intellij 的翻译工具,即大家熟悉的 J2K。但 Meta 工程师们很快发现,这种方法无法扩展至规模庞大的代码库:为了转译 Meta 的 Android 代码库,工程师们必须先点击按钮、然后等待几分钟后才能恢复运行,而这样的操作几乎要重复达十万次。
考虑到这一点,Meta 开始着手推动转换过程自动化,并尽量减少对于开发人员日常工作的干扰。结果就是,Meta 围绕 J2K 构建了一款名为 Kotlinator 的工具,其具体操作现在包含以下六个阶段:
迈向无头 J2K
第一步是创建一个能够在远程设备上运行的无头版 J2K——考虑到 J2K 与 Intellij IDE 其余部分的紧密耦合,这项工作并不容易。
Meta 的工程师们尝试了几种方法,包括使用类似于 Intellij 测试环境的设置运行 J2K 等。但在与 JetBrains 的 J2K 专家 Ilya Kirillov 交谈之后,他们最终决定采用更类似于无头检查的方法。为了实现这种方法,工程师们开发出一款 Intellij 插件,其中包含一个用于扩展 ApplicationStarter 的类,并直接调用 JavaToKotlinConverter 类——IDE 中的转换按钮同样会引用该类。
除了不会阻塞开发人员的本地 IDE 之外,无头方法还使得一次性转译多个文件成为可能,同时消除了一系列有用但却耗时的步骤,例如接下来详述的“构建与错误修复”流程。尽管总体转换时间变长(典型的远程转换现在需要大约 30 分钟才能运行完成),但开发人员投入的时间却大幅缩短。
当然,无头转换带来了另一个难题:如果开发人员不亲自点击按钮,那么该由谁来决定转译什么、如何审核以及能否发布?
答案其实很简单:Meta 拥有一套内部系统,允许开发人员设置一项实质上属于 cron 作业的任务,其会根据用户定义的选择标准每天生成一批 diff(Meta 版的 pull request)。该系统还能帮助选择相关度最高的审阅者,以确保测试及其他验证顺利通过,而后经人工批准再发布 diff。Meta 还为开发人员提供一套 Web UI,用于触发针对特定文件或者模块的远程转换;在后台,整个运行过程与 cron 作业完全相同。
至于选择转译的内容和时间,除了优先考虑正在积极开发的文件之外,Meta 不强制规定任何顺序。目前的 Kotlinator 足够复杂,可以处理外部文件中所需要的大多数兼容性变更(例如将 Kotlin 依赖项的 foo.getName() 引用更改为 foo.name),因此无需根据依赖项图谱对转译做出排序。
添加自定义的转换前与转换后处理步骤
由于 Meta 拥有的代码库规模极大且须使用自定义框架,原始 J2K 生成的绝大多数转换 diff 都无法构建。
为了解决这个问题,Meta 在转换过程中添加了两个自定义阶段,即预处理与后处理。这两个阶段均包含数十个步骤,具体涵盖接收正在转译的文件、执行文件分析(有时也须分析文件的依赖项及从属项),并在必要时执行 Java 到 Java 或者 Kotlin 到 Kotlin 的转换。Meta 的部分后处理转换方法已经开源(https://github.com/fbsamples/kotlin_ast_tools)。
这些自定义的转译步骤建立在内部元编程工具之上,该工具利用到了 Jetbrains 的 Java 以及 Kotlin PSI 库。与大多数元编程工具不同,其并不属于编译器插件,因此可以分析两种语言中的损坏代码,而且运行速度极快。这种速度优势对于后处理阶段尤其重要,因为其通常会在存在编译错误的代码上运行,执行各类需要配合类型信息的分析。对于一部分涉及依赖项的后处理步骤,往往需要解析数千个无法构建的 Java 及 Kotlin 文件中的符号。例如,Meta 的一个后处理步骤通过检查其 Kotlin 实现程序并将重写的 getter 函数更新为重写属性以协助完成接口转换。
但这款工具的速度和灵活性也存在一定缺点,导致其有时无法提供关于类型信息的答案,特别是难以解析第三方库中定义的符号。在这种情况下,该工具会直接放弃,避免在不确定的情况下仍然执行转换。尽管其生成的 Kotlin 代码可能仍无法构建,但余下的修复工作量对于工程师们来说已经完全在可接受范围之内(只是较为枯燥)。
Meta 最初添加这些自定义阶段的目的,在于帮助开发人员减少工作量。但随着时间推移,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 的自定义符号解析虽然快速且灵活,但在精确性上仍不及 J2K,这一点在解析第三方库中定义的符号时体现尤其明显。对一系列预处理及后处理步骤进行移植以利用 J2K 扩展点,不仅有助于提升其准确性,也允许工程师们使用 Intellij 提供的更为复杂的静态分析工具。
降低开源与协作难度。Meta 的某些自定义步骤由于高度针对 Android 而无法被纳入 J2K,但其功能本身往往同样符合其他公司的需求。遗憾的是,其中大多数都依赖于 Meta 自定义的符号解析机制。通过将这些步骤移植到依赖 J2K 的符号解析当中,Meta 将这部分成果进行了开源,此举也使其能从社区的共同努力中获益。
但更重要的是 null 安全!
为了在避免到处产生 null 指针异常(NPE)的前提下实现代码转译,Meta 首先需要保证 null 安全(所谓 null 安全,是指由 Nullsafe 或者 NullAway 等静态分析器对代码进行检查)。Null 安全虽然不足以彻底消除发生 NPE 的可能性,但至少是个很好的起点。可遗憾的是,代码的 null 安全一直是个说起来容易、做起来难的大麻烦。
即使是 null 安全 Java 代码,偶尔也会抛出 NPE。
其实经常接触 null 安全 Java 代码的朋友都清楚,虽然其可靠性要远超原始 Java 代码,但触发 NPE 的几率仍然不低。遗憾的是,静态分析只在 100% 代码覆盖率下才能让人彻底安心,而这样的覆盖率在任何与服务器及第三方库交互的大规模移动代码库中都不现实。
下面就是一个看似无害的典型变更案例,其很有可能触发 NPE:
假设有十几个依赖项均须调用 MyNullsafeJava::doThing。单一非 null 安全依赖项可能传入一个 null 参数(例如 MyNullsafeJava().doThing(null)),这时如果在 doThing 的主体中插入取消引用,则会导致 NPE。
当然,虽然无法通过百分之百的 null 安全覆盖率彻底消除 Java 中的 NPE,但仍可以大大降低其发生频率。在上面的例子中,当只有一个非 null 安全依赖项时,触发 NPE 的情况往往相当罕见。但如果有多个传递依赖项均未经 null 安全,或者其中某个更加核心的依赖节点不具备 null 安全,则 NPE 风险会高得多。
Null 安全 Java 与 Kotlin 之间的最大区别在于,在跨语言边界的 Kotlin 字节码中存在运行时验证机制。这种验证虽不可见但却功能强大,因为其允许开发人员信任自己正在修改或者调用的任何代码中所声明的可空性注释。
让我们重新回到之前的 MyNullsafeClass.java 示例,在将其转译为 Kotlin 之后,则会得到类似以下形式的内容:
现在,在 doThing 主体开头的字节码中存在一个不可见的 checkNotNull(s),意味着我们可以安全添加对 s 的取消引用。因为如果 s 可空,则此代码将崩溃。大家可以想见,这种确定性将使得开发更加顺畅且安全。
在静态分析层次上也存在一些差异:在并发方面,Kotlin 编译器会强制执行比 Nullsafe 略为严格的一组 null 安全规则。具体来讲,Kotlin 编译器可能会因为取消对另一线程中设置为 null 的类级属性的引用而抛出错误。这个差异对 Meta 来说并不重要,但其确实会导致 null 安全代码的转译过程中出现更多问题。
也没那么简单。相信大家都清楚,歧义的由多转少对应着大量成本。对于像 MyNullsafeClass 这样的情况,尽管在转译为 Kotlin 之后能够大大降低开发难度,但相应的也必然有人要承担起初始风险——即为那些确切不可为 null 的参数有效插入非 null 断言。而承担风险的“人”,只能是最终负责 Kotlin 转换工作的开发人员或者机器人。
我们当然可以采取一些措施为尽量减少转换过程中引入新 NPE 的风险,其中最简单的办法就是在转换参数及返回类型时偏向“更可空”。仍然以 MyNullsafeClass 为例,Kotlinator 会使用上下文线索(在此例中,doThing 主体中没有任何取消引用)来推断 String s 是否应被转换为 s:String?。
在审查转换差异时,Meta 要求开发人员高度关注的变更之一,就是在预先存在的取消引用之外添加!!。 有趣的是,Meta 所担心的并不是像 foo!!.name 这样的表达式,因为其在 Kotlin 中引发崩溃的可能性并不比 Java 这边更高。相反,像 someMethodDefinedInJava(foo!!) 这样的表达式倒更值得关注,因为 someMethodDefinedInJava 可能只是在其参数上缺少 @Nullable,盲目添加!! 反而会不必要地引发 NPE。
为了避免在转换过程中添加不必要!! 之类的问题,Meta 运行有十几种互补的代码模块。它们会认真检查代码库,查找可能缺失的 @Nullable 参数、返回类型及成员变量。在整个代码库中(即使是那些永远不可能转译的 Java 文件中)实现更准确的可空性不仅有助于提高安全水平,同时也能大大改善转换成功率,这一点对于转译项目的收尾部分显得尤其重要。
当然,Java 代码中剩余的最后一点 null 安全问题往往会长期存在,其解决难度也是最高的。以往解决这些问题的尝试主要是依赖静态分析,因此 Meta 决定借鉴 Kotlin 编译器的基本思路,即创建一款 Java 编译器插件来帮助收集运行时可空性数据。这款插件允许 Meta 收集一切接收 / 返回 null 值,且未被注释为 null 值的返回类型及参数数据。无论其属于来自 Java/Kotlin 互操作还是在本地级别注释错误的类,Meta 都可以借此确定最终事实来源并使用 codemods 对相应注释进行修复。
除了 null 安全风险之外,还有其他几十种可能性都会威胁到转换过程中的代码完整性。在交付了超过 4 万次转换之后,Meta 已经从这段艰难的旅程中吸取到诸多教训,目前也设置了多层验证加以预防。下面分享几项重点举措:
截至目前,Meta 的 Android Java 代码中已经有超过半数被成功转译为 Kotlin(在少数情况下也可能被直接删除)。但这只是其中比较简单的一半!真正艰难的部分仍在后面,而且越来越棘手。
Meta 希望通过添加并改进自定义步骤以及为 J2K 项目做出贡献以最终实现更多完全自动化的转换流程。更重要的是,Meta 还希望通过对 Kotlinator 的改进顺利且安全地交付余下几千个半自动转换流程。
Kotlin 未进谷歌“四大语言”之列
Kotlin 在 Android 开发中占有重要地位。目前 Android 上 80% 的应用都使用 Kotlin。
Kotlin 的开发与 Java 密不可分。JetBrains 于 2010 年开始开发 Kotlin,以摆脱内部对 Java 的过度依赖。Kotlin 的早期开发者之一 Dmitry Yeremov早在 2011 年就指出,从 Java 到 Kotlin 的转变将逐步进行。
获得 Google 等巨头的官方支持是让 Kotlin 像 Java 一样受欢迎(甚至更受欢迎)的重要一步。2017 年,当谷歌宣布 Kotlin 将作为 Android 官方语言时,现场观众欢呼雀跃。
Kotlin 在 Google 的编程生态系统中占有重要地位,主要应用于 Android 应用开发。不过根据 Analytics Insight 数据,谷歌最重要和使用最广泛的顶级编程语言则是 Python、C++、Java 和 Golang。
Python 是 Web 开发、数据科学、人工智能和自动化等各种应用的理想选择,其活跃的社区确保了持续的改进和支持。Python 的多功能性使开发人员能够快速制作原型并实施解决方案,使其在学术研究和行业应用中都具有很高的价值。谷歌在各种项目中对 Python 的支持,包括 TensorFlow 等机器学习框架,进一步巩固了其作为顶级编程语言的地位。
C++ 主要用途是大多数现代浏览器的核心 Chromium,例如 Chrome、Mozilla、Brave 等。Java 可以创建复杂的 Web 应用程序,用于 Gmail 功能、Stadia 以及更重要的 Google Document Suite 中。Golang 主要用于基于 Web 的应用程序,包括 Google Cloud 微服务、地图管理、Gmail 服务等,几乎每个微服务都可能使用 Golang 语言。
但实际上,Kotlin 在 Android 之外也越来越受欢迎,Kotlin 现在用于后端开发、全栈开发、Web 应用程序,甚至数据科学。
得益于 Ktor 和 Spring Boot(完全支持 Kotlin)等框架,Kotlin 在后端开发中的应用越来越广泛。该语言的简单性和富有表现力的语法使其成为构建 RESTful API 和微服务的理想选择。
Kotlin 既可用于服务器端开发,也可用于客户端开发,从而减少了对堆栈内多种语言的需求。借助 Kotlin/JS,开发人员可以编写编译为 JavaScript 的 Kotlin 代码,从而实现全栈 Kotlin 应用程序。
Kotlin Multiplatform 则允许跨平台共享代码,使开发人员更容易使用单一代码库维护 Android、iOS 和 Web 应用程序。今年 5 月谷歌宣布将在安卓平台支持 JetBrains 旗下 Kotlin Multiplatform 技术。
尽管 Kotlin 在某些方面已经取代了 Java,但它并没有在各个方面都超越这门旧语言。例如,Java 的 IDE 体验仍然被认为是更优,其文档更为详尽。与 Rust 一样,Kotlin 用户也抱怨其编译时间过长。
但正如一位经验丰富的 Java 开发人员所说,“尽管我过去很喜欢 Java,但在经历了这样一个实用且富有成效的语言之后,现在我看到了新的可能性。”
参考链接:
https://www.analyticsinsight.net/programming/googles-top-programming-languages-in-2024
https://www.analyticsinsight.net/software-developers/future-of-kotlin-what-developers-need-to-know
https://engineering.fb.com/2024/12/18/android/translating-java-to-kotlin-at-scale/
本文来自微信公众号“InfoQ”,整理:核子可乐 褚杏娟,36氪经授权发布。
钛媒体APP 2024-12-20
雷科技 2024-12-20
雷科技 2024-12-20
雷科技 2024-12-20
36氪 2024-12-20
IT之家 2024-12-20
IT之家 2024-12-20