严格空值检查的案例研究

2020-12-18 16:10:14

在Figma,我们相信通过不断发现错误类型并以系统的方式解决它们,不断投资于产品质量和开发人员生产力。几个月前,我们的工程团队完成了两项工作:为前端TypeScript代码库打开strictNullChecks编译器标志。

使用严格的空检查编写的代码在不同的类型检查规则下运行,并且与未编写的代码相比,其风格明显不同。转换我们的代码库以使用它是一项艰巨的任务,我们认为这是代码迁移中有趣的案例研究。

在此博客文章中,我们希望分享在不减慢并发产品开发速度的情况下,如何逐步执行此迁移。

在许多流行的类型化编程语言(例如TypeScript,Java等)中,大多数或所有变量都允许具有空值,这表明不存在更具体的值。

// // strictNullChecks:off interface {x:number,y:number} var v:= {x:1,y:2} //✅这是允许的v = null //✅这也是函数长度(v:) {//如果不确定,我们可能需要检查是否为空。如果(v){返回。 sqrt(v。x * v。x + v。y * v。y)} else {//返回一些默认值? //调用length(null)甚至意味着什么? }}

我们大多数人都习惯了这一点。但是,这不是编程语言的基本属性。这是1965年Tony Hoare故意设计的选择,后来他称其为十亿美元的错误。

实际上,有些语言会以不同的方式执行此操作,它们会执行编译时规则,以确保只有明确声明为可空值时该值才能为空。自8.0版起,示例包括Rust,OCaml,Haskell和C#。 TypeScript程序可以使用strictNullChecks编译器选项选择加入更严格的编译时空检查。启用此选项后,TypeScript会执行控制流分析,以确保代码不会以不安全的方式使用可能为null的值。

// // strictNullChecks:接口{x:number,y:number}上var vector:= {x:1,y:2} //✅允许使用vector = null //❌错误! `vector`不能为null //✅允许使用这些变量var nullableVector:| null = {x:1,y:2} nullableVector =空函数长度(v:){//✅编译器保证这是安全的返回。 sqrt(v.x * v.x + v.y * v.y))} //明确指出`null`是受支持的参数函数someHelperFunction(v:| null):number {console。 log(v。x)//❌编译错误!如果(v){// could v可以为null,在此块中,v的类型为Vector,//不是Vector | null`,因为if语句//消除了||空的情况。 TypeScript很聪明!返回长度(v)}否则{返回defaultValue}}

通过启用严格的空检查,我们可以消除整个错误类别(例如,无法访问未定义的.name)。在非严格代码中,这是最常见的错误类型,并且我们的历史数据表明,在进入生产之前,我们的一些高严重性事件在进行严格的空检查后会被捕获。

严格的空检查还可以提高代码质量。维护代码涉及到有关代码的问题,例如“此时是否在代码中加载了文件信息?”在类型系统中表达的这些问题通常归结为“此值可以为空吗?”如果答案是正确的类型,则代码将更易于维护。

尽管今天TypeScript社区建议在启用严格的空检查的情况下启动新项目,但是当第一次实施严格的空检查时,Figma在TypeScript 2.0之前开始使用TypeScript。即使升级后,我们也编写了新代码,这些代码不一定会与启用的设置一起编译。我们的情况并不是唯一的:增量添加类型的JavaScript代码库通常也没有启用严格的空检查,因为JavaScript本质上是非严格的。

将此类代码库迁移到严格的null检查是一项挑战。仅将其打开会产生数千个错误。当我们开始该项目时,我们约1162个TypeScript文件的前端代码库产生了4000多个错误,并且在许多情况下,修复其中一个将揭示更多的错误。显然,尽管这种迁移不像用另一种体系结构或语言进行完全重写那样剧烈,但它需要类似的思考过程。有几种方法可以做到:

我们本可以要求所有工程师停止从事其当前项目以参与迁移。

我们认为这不是一个正确的决定:迁移中涉及的工作是可以并行化的,但不能并行化,因此让每个人停止工作并不是时间的最佳利用。从战略上讲,这也是一个问题,因为当时我们的产品工作对于维持业务发展势头至关重要。最后,大型公司获得的多个团队之间进行协调的后勤工作变得不那么实际。

另一种方法是修复由于在一段时间内启用严格的空检查而没有实际启用严格的空检查(例如,仅在CI期间运行它们)的错误。

这是破坏性最小的方法。缺点是,如果不执行严格的null检查,新代码往往会引入新的错误。在成倍增长的初创企业中,可能会有很多新代码:在完成迁移所需的时间里,我们的代码库从37.6万行增加到46.4万行。如果可以比引入错误的速度更快地解决问题,那么“ w鼠”策略仍然是合理的,但是拥有“进度条”甚至偶尔向后移动的乐趣也不是一件容易的事。

我们采取的方法是通过将文件添加到允许列表来一次严格对文件进行一次空检查。

在讨论这种方法的详细信息之前,我们想在此指出适当之处! VS Code团队努力进行严格的空值检查,这是我们的灵感所在,该代码库的大小与我们类似。我们从他们构建的工具开始,然后扩展它以更好地解决我们的代码库特有的问题。

该方法的核心是两次编译代码库:一次是针对所有未启用严格空检查的文件,一次是针对已知已成功启用严格空检查的文件列表。

// tsconfig.strictNullChecks.json {" extends" :" ./ tsconfig.json" ," compilerOptions" :{" strictNullChecks" :true}," files" :[...在这里添加文件...],}

一个主要的观察结果是,编译TypeScript文件需要编译其所有依赖项(导入)。这意味着,与--noImplicitAny之类的其他类似lint的标志不同,仅当文件的依赖项也严格时才对null进行严格检查才有意义。因此,仅当文件的依赖性在允许列表中之后,才能将文件添加到允许列表。知道了,这是将文件添加到允许列表的过程:

我们以代码库作为一个非循环树开始,其中节点是文件,边缘是依赖项(我们将在后面讨论循环):

列出当前严格进行空检查的所有候选文件。这些文件的依赖关系已全部在允许列表中。我们编写了一个脚本来生成这些候选人的列表。为了评估将候选者转换为严格的空检查的影响,该列表按依赖于候选者的文件数进行排序。

-[]`" ./ figma_app / lib / library_helpers.ts" `—由>开启** 158 **个文件(直接导入23个文件)-[]`" ./ figma_app / reducers / org_user_test.ts" `—由>开启** 127个文件(18个直接导入)-[]`" ./ figma_app / reducers / picker.ts" `—由>开启** 84 **个文件(18个直接导入)-[]`" ./ figma_app / views / fullscreen / multilevel_dropdown / multilevel_dropdown.tsx" `—由>开启** 83 **个文件(8个直接导入)-[]`" ./ shared / auth / internal / auth_middleware.ts" `—由>开启** 55 **个文件(4个直接导入)...

选择一个候选文件并将其添加到tsconfig.strictNullChecks.json的[]部分。运行TypeScript,并修复所有新的编译错误。

将某些文件添加到允许列表后,新文件将有资格成为候选文件。

对于我们的实际代码库,我们制作了一个依赖关系图可视化程序,这对于提前计划(确定文件的优先级)和可视化进度都非常有帮助。看起来像这样:

我们已经开源了在此使用的工具。这是一组使用TypeScript Compiler API进行依赖关系分析的脚本,我们发现它易于使用。我们从VS Code团队的构建开始,然后将其改编为我们的代码库。

首先,我们必须处理代码库中的怪癖,而这些怪癖会妨碍静态分析。例如,该工具无法解决桶输入问题,这是一种TypeScript功能,可以将来自多个不同文件的导出捆绑在一起。我们仅在少数几个地方使用了它,这对于迁移和删除它们的代码库都是最简单的。

该工具的主要补充是能够处理依赖周期,这在项目开始时在我们的代码库中很常见。依赖周期使逐步执行迁移变得更加困难:除非您同时添加周期中的所有文件,否则不可能将周期中的单个文件添加到允许列表中。就依赖关系图而言,我们必须将每个循环都视为一个紧密连接的组件,它实际上成为了依赖关系图中的单个节点。

我们拥有的最大周期是500多个文件的怪物-几乎是我们代码库的一半!除了解除严格的空值检查迁移之外,破坏这一整体还可以改善代码库的体系结构。为此,我们针对了导致循环依赖性的常见模式。例如:

Figma使用Redux,因此我们有模型,动作和简化器。以前,我们已经在与reducers相同的文件中定义了模型。通过将模型移动到它们自己的文件中,不再需要从reducer文件中导入动作文件,从而打破了循环。

用户界面中的所有模式都处于同一周期。它们全部由模态渲染器导入modal.ts中,但是每个模态也从modal.ts中导入常量。将常量移动到其自己的文件中打破了循环。

解决这些根本问题后,减少了数百个文件的周期。合并它们具有挑战性,因为它们接触了数百个文件,并且经常与其他人的提交冲突。在一种情况下,我们使用jscodeshift来自动执行重构,这使得重新设置&在最新的主服务器上重新生成整个重构。

一小部分工程师完成了“发现”环境的初始阶段(设置工具并删除了像循环这样的障碍物)。在那之后,为整个团队做出了良好的设置。我们举办了两个为期三天的冲刺,其中来自Figma的不同团队的成员对严格的null检查文件做出了贡献,尤其是在他们的专业领域。总共有30位工程师在各个时间点上为这项工作做出了贡献。这是让更多人参与进来的一种好方法,而不会破坏其他团队。

迁移的一个重要方面是建立有关花费多少精力来迁移每个单独文件的指导。尽管仅通过更改类型注释就可以修复某些错误,但许多其他错误则需要重构代码以证明可为空性(或缺少空性)。

例如,考虑使用堆栈的这种常见方式,该方式在严格的null检查下将无法编译:

//堆栈:Array< number>如果(堆栈。长度> 0){常量v =堆栈。 pop()//类型:数字|未定义的控制台。 log(v + 1)//错误:对象可能未定义}

通过检查显然是正确的,但需要非null断言!或以其他方式改写它:

这就引出了一个问题:我们应该告诉团队在迁移时避免重构,还是在可能的时候重构?

为了避免重构,需要通过添加|使许多类型注释可为空。每当很难证明值不能为null时,就返回null。反过来,这需要添加许多非空断言或许多if语句。前者仍然使代码容易出错。当if陈述变得多余时,后者可能会误导未来的读者。无论哪种方式,它都使类型注释难以信任,从而降低了严格的空检查的值。

另一方面,在迁移时进行重构可能会使整个过程花费更长的时间。通常,工程师不仅需要理解代码,而且还需要相应的产品领域来了解是否打算实现可空性。它还有导致回归的风险。

在Figma,我们鼓励团队在可能的情况下进行重构。我们相信额外的投资是值得的,以确保将来的维护者可以信任我们的类型注释。尽管迁移确实引入了一些回归,但我们的工程师是全职员工,并且正在对其进行修复。最后,我们假设,如果代码在迁移过程中中断,则必须已经很难推理(关于可空性),并且有可能在以后进行回归。

但是“尽可能”部分也很重要。最终,目标是完成迁移,并且处于部分迁移状态会带来不利影响-导致编译器的两个实例吞噬CPU是其中之一。因此,尽管我们建议进行重构,但我们也选择给予团队很大的酌处权,并让人们在需要很长时间的情况下采取捷径。

当然,权衡会因情况而异。例如,在具有志愿者贡献者的开源项目中,或在具有大量旧代码的系统中,处理回归可能会麻烦得多。

一旦大多数文件通过了严格的空检查,剩下的工作就变得难以并行化。那时,我们使用排除选项代替文件,从允许列表方法改为拒绝列表方法,以便新文件在严格模式下自动编译。在这一点上,一些工程师能够转换剩余的文件并完成项目。

就捕获错误而言,我们可以说空错误不再显示在我们的错误信息中心中。但是,我们想再次强调:尽管难以衡量,但最大的好处可能是我们代码的可读性增强。 TypeScript在推断可空性方面最麻烦的部分通常是人类难以推理的部分,并且从重构中受益最大。

综上所述,这种迁移并非通往目标的直路。在一段时间内,活动很少,每个人(包括项目的驱动程序)都忙于其他任务。幸运的是,渐进方法的好处恰恰在于它提供了规划方面的灵活性。在一开始就能够取得小的进展有助于建立动力,然后最终完成迁移。

因此,尽管大多数人可能不需要执行此精确迁移,但我们希望这已成为大规模代码更改的有用示例!

如果您觉得这种工作有趣,请进一步了解团队和Figma,我们正在招聘!