参加你附近的 ,了解 VS Code 中的 AI 辅助开发。

在 Visual Studio Code 中启用严格的 null 检查

2019 年 5 月 23 日,作者:Matt Bierner,@mattbierner

安全保障速度

快速行动是充满乐趣的。发布新功能、让用户满意、改进我们的代码库都很有趣。但同时,发布一个有缺陷的产品却毫无乐趣可言。没有人喜欢收到问题报告,或者在凌晨三点因为事故被叫醒。

尽管快速行动和发布稳定代码常常被认为是相互矛盾的,但事实本不该如此。很多时候,正是那些让代码变得脆弱和充满缺陷的因素,也同样拖慢了开发速度。毕竟,如果我们总是担心会破坏东西,又怎么能快速前进呢?

在这篇文章中,我想分享 VS Code 团队最近完成的一项重大工程:在我们的代码库中启用 TypeScript 的严格 null 检查。我们相信这项工作将使我们能够更快地行动,并发布更稳定的产品。启用严格 null 检查的动机是,我们开始将缺陷不视为孤立事件,而是代码库中更大风险的症状。我将以严格 null 检查为案例,讨论我们这项工作的动机、我们如何制定一个增量方法来解决问题,以及我们如何着手实施修复。这种识别和减少风险的通用方法可以应用于任何软件项目。

一个例子

为了说明 VS Code 在启用严格 null 检查之前所面临的问题,让我们来看一个简单的 TypeScript 库。如果你对 TypeScript 不熟悉,别担心,具体细节并不重要。这个虚构的例子只是为了说明我们在 VS Code 代码库中遇到的那类问题,并提一下应对这类问题的一些传统方法。

我们的示例库包含一个名为 getStatus 的函数,该函数从一个假设网站的后端获取指定用户的状态。

export interface User {
  readonly id: string;
}

/**
 * Get the status of a user
 */
export async function getStatus(user: User): Promise<string> {
  const id = user.id;
  const result = await fetch(`/api/v0/${id}/status`);
  const json = await result.json();
  return json.status;
}

看起来很合理。发布吧!

但是在部署新代码后,我们发现崩溃事件激增。从调用栈来看,崩溃似乎发生在我们的 getStatus 函数中。糟了!

再往前追溯一下,似乎我们的一位同事在调用 getStatus(undefined),错误地试图获取当前用户的状态。这在代码尝试访问 undefined.id 时导致了异常。一个简单的错误。既然我们知道了原因,就来修复它吧!

于是我们更新了调用代码,更新了 getStatus 以处理 undefined,并在文档注释中添加了一个有用的警告。

/**
 * Get the status of a user
 *
 * Don't call this with undefined or null!
 */
export async function getStatus(user: User): Promise<string> {
  if (!user) {
    return '';
  }
  const id = user.id;
  const result = await fetch(`/api/v0/${id}/status`);
  const json = await result.json();
  return json.status;
}

因为我们是真正的工程师,所以我们还写了一个测试。

it('should return empty status for undefined user', async () => {
  assert.equals(getStatus(undefined), '');
});

太棒了!再也没有崩溃了。而且我们的测试覆盖率也回到了 100%!我们的代码现在一定是完美的了。

几天过去了,然后:砰!有人在我们的日志中发现了一些奇怪的东西,有大量请求发往 /api/v0/undefined/status。这个用户名有点奇怪……

于是我们再次调查,再次修复代码,添加更多测试。或许还给那个调用 getStatus({ id: undefined }) 的人发一封不那么友好的邮件。

/**
 * Get the status of a user
 *
 * !!!
 * WARNING: Don't call this with undefined or null, or with a user without an id
 * !!!
 */
export async function getStatus(user: User): Promise<string> {
  if (!user) {
    return '';
  }
  const id = user.id;
  if (typeof id !== 'string') {
    return '';
  }
  const result = await fetch(`/api/v0/${id}/status`);
  const json = await result.json();
  return json.status;
}

完美。但是,为了以防万一,我们要求所有引入 getStatus 调用的变更都必须由高级工程师批准。这应该能永久性地杜绝这些讨厌的缺陷……

也许这次我们在几天后才遇到下一次崩溃。甚至可能几个月。但是,除非我们的代码永远不再改变,否则总会有的。如果不是在这个特定的函数里,那也会在我们代码库的其他地方。

更糟糕的是,现在每次变更都需要:防御性地检查 undefined、修改或添加新测试,以及获得团队的批准。这是怎么回事?我们都在尽自己的一份力,但缺陷依然存在!一定有更好的方法。

识别风险

虽然上面例子中的缺陷看起来很明显,但我们在开发 VS Code 时也遇到了同样类型的问题。每一次迭代,我们都会修复与意外的 undefined 相关的缺陷。我们也会添加测试。我们也会发誓要做更好的工程师。这些都是传统的方法,然而在下一次迭代中,同样的事情又会发生。这不仅导致一些用户的 VS Code 体验不佳,这些缺陷和我们对它们的反应也减慢了我们开发新功能或修改现有源代码的速度。

我们意识到需要用一种新的方式来理解我们的缺陷,不应将其视为孤立事件,而应视为更大问题的症状/信号。我们对这些缺陷的反应以及无法快速前进的挫败感也是症状。当我们开始讨论这些症状的根本原因时,我们发现了一些共同点:

  • 未能捕捉到简单的编程错误,例如在 nullundefined 上访问属性。
  • 接口定义不明确。哪些参数可以是 undefinednull?哪些函数可能返回 undefinednull?通常函数的实现者和调用者有着不同的假设。
  • 类型上的怪异之处。undefinednullundefinedfalseundefined 与空字符串。
  • 感觉我们无法信任代码或安全地重构它。

识别根本原因是很好的第一步,但我们想更深入一些。在所有这些案例中,是什么风险让一个善意的工程师在一开始就引入了这个缺陷?我们很快就发现了一个贯穿所有这些问题的明显风险:VS Code 代码库中缺少严格的 null 检查。

要理解严格的 null 检查,你必须记住 TypeScript 的目标是为 JavaScript 添加类型。TypeScript 继承自 JavaScript 的一个后果是,默认情况下,TypeScript 允许将 undefinednull 用于任何值。

// Without strict null checking, all of these calls are valid

getStatus(undefined); // Ok
getStatus(null); // Ok
getStatus({ id: undefined }); // Ok

虽然这种灵活性使得从 JavaScript 迁移到 TypeScript 更简单,但我们那个假设网站的示例库表明,这也是一个风险。这个风险也是我们在开发 VS Code 时发现的四个根本原因(以及其他许多原因)的核心。

但值得庆幸的是,TypeScript 提供了一个名为严格 null 检查的选项,它会使 undefinednull 被视为不同的类型。当使用严格 null 检查时,任何可能为 null 的类型都必须如此注解。

// With "strictNullCheck": true, all of these produce compile errors

getStatus(undefined); // Error
getStatus(null); // Error
getStatus({ id: undefined }); // Error

修复孤立的代码行或添加测试是一种被动的解决方案,只能修复那些特定的缺陷。启用严格 null 检查是一种主动的解决方案,它不仅能修复我们每个月都看到的那些被报告的缺陷,还能防止这类缺陷在未来再次发生。再也不用忘记检查可选属性是否有值。再也不用质疑一个函数是否可能返回 null。好处是显而易见的。

制定一个增量计划

问题在于,我们不能简单地启用一个编译器标志,然后一切就神奇地修复了。VS Code 核心代码库有大约 1800 个 TypeScript 文件,超过 50 万行代码。用 "strictNullChecks": true 编译它会产生大约 4500 个错误。天哪!

此外,VS Code 由一个小核心团队组成,我们喜欢快速行动。创建一个分支来修复那 4500 个严格 null 错误会增加巨大的工程开销。而且你该从哪里开始呢?从上到下地过一遍错误列表吗?此外,分支中的更改对主干(main)没有帮助,而团队的大部分人仍将在主干上工作。

我们想要一个能立即为团队所有工程师带来严格 null 检查好处的增量计划。这样,我们就可以把工作分解成可管理的变更,每个小变更都让代码更安全一点。

为此,我们创建了一个名为 tsconfig.strictNullChecks.json 的新 TypeScript 项目文件,它启用了严格 null 检查,最初包含零个文件。然后我们有选择地将单个文件添加到这个项目中,修复这些文件中的严格 null 错误,然后提交更改。只要我们添加的文件要么没有导入,要么只导入其他已经过严格 null 检查的文件,我们每次迭代只需要修复少量错误。

{
  "extends": "./tsconfig.base.json", // Shared configuration with our main `tsconfig.json`
  "compilerOptions": {
    "noEmit": true, // Don't output any javascript
    "strictNullChecks": true
  },
  "files": [
    // Slowly growing list of strict null check files goes here
  ]
}

虽然这个计划看起来合理,但一个问题是,在主干上工作的工程师通常不会编译 VS Code 的严格 null 检查子集。为了防止已通过严格 null 检查的文件意外地出现倒退,我们增加了一个持续集成步骤,该步骤会编译 tsconfig.strictNullChecks.json。这确保了导致严格 null 检查倒退的提交会破坏构建。

我们还编写了两个简单的脚本来自动化一些与向严格 null 检查项目添加文件相关的重复性任务。第一个脚本打印出符合严格 null 检查条件的文件列表。一个文件如果只导入自身已经过严格 null 检查的文件,就被认为是符合条件的。第二个脚本尝试自动将符合条件的文件添加到严格 null 项目中。如果添加文件没有导致编译错误,那么它就会被提交到 tsconfig.strictNullChecks.json 中。

我们也曾考虑过自动化一些严格 null 修复本身,但最终我们决定不这样做。严格 null 错误通常是一个很好的信号,表明源代码应该被重构。也许一个类型并没有很好的理由可以为 null。也许调用者应该处理 null 而不是实现者。手动审查和修复这些错误给了我们一个让代码变得更好的机会,而不是强行让它兼容严格 null。

执行计划

在接下来的几个月里,我们慢慢地增加了经过严格 null 检查的文件数量。这通常是乏味的工作。大多数严格 null 错误都很简单:只需添加 null 注解。但对于其他一些错误,很难理解代码的意图。一个值是故意未初始化的,还是真的存在编程错误?

总的来说,我们尽量避免在主代码库中使用 TypeScript 的非 null 断言。我们在测试中会更自由地使用它,理由是如果测试代码中缺少 null 检查会导致异常,那么测试无论如何都会失败。

整个过程有一个令人沮丧的方面是,VS Code 代码库中严格 null 错误的总数似乎从未减少。甚至可以说,如果你用严格 null 检查来编译整个 VS Code,我们所有的严格 null 工作似乎反而导致了错误总数的增加!这是因为严格 null 的修复常常有级联效应。正确地注解一个函数可以返回 undefined,可能会为该函数的所有消费者引入严格 null 错误。我们没有去担心剩余错误的总数,而是专注于已经通过严格 null 检查的文件数量,并努力确保这个总数永远不会倒退。

同样重要的是要注意,启用严格 null 检查并不能神奇地防止所有与严格 null 相关的异常发生。例如,any 类型或错误的类型转换可以轻易绕过严格 null 检查。

// strictNullCheck: true

function double(x: number): number {
  return x * 2;
}

double(undefined as any); // not an error

访问数组中越界的元素也是如此。

// strictNullCheck: true

function double(x: number): number {
  return x * 2;
}

const arr = [1, 2, 3];

double(arr[5]); // not an error

此外,除非你还启用了 TypeScript 的严格属性初始化,否则如果你访问一个尚未初始化的成员,编译器也不会报错。

// strictNullCheck: true

class Value {
  public x: number;

  public setValue(x: number) {
    this.x = x;
  }

  public double(): number {
    return this.x * 2; // not an error even though `x` will be `undefined` if `setValue` has not been called yet
  }
}

这项工作的重点从来不是消除 VS Code 中 100% 的严格 null 错误——这即使不是不可能,也是极其困难的——而是为了防止绝大多数常见的与严格 null 相关的错误。这也是一个清理代码并使其更易于安全重构的好机会。对我们来说,达到 95% 的目标是可以接受的。

你可以在 GitHub 上找到我们整个严格 null 检查计划及其执行过程。VS Code 团队的所有成员以及许多外部贡献者都参与了这项工作。作为这项工作的推动者,我做了最多的严格 null 相关修复,但这只占用了我大约四分之一的工程时间。在此过程中当然有一些痛苦,包括一些恼人的情况,即许多严格 null 的倒退只有在提交后才被持续集成捕捉到。严格 null 的工作也引入了一些新的缺陷。然而,考虑到更改的代码量,事情进展得非常顺利。

那个最终为整个 VS Code 代码库启用严格 null 检查的变更相当平淡无奇:它修复了几个代码错误,删除了 tsconfig.strictNullChecks.json,并在我们的主 tsconfig 文件中设置了 "strictNullChecks": true。这种波澜不惊正是我们计划好的。就这样,VS Code 实现了严格 null 检查!

结论

在向人们讲述这个项目时,我听到的一个常见问题是:那么,它修复了多少个缺陷?我认为这个问题其实没有多大意义。在 VS Code 中,我们从来没有在修复因缺少严格 null 检查而导致的缺陷上遇到困难。通常这只需要加一个条件判断,或许再加一两个测试。但是我们一次又一次地看到同类型的缺陷。修复这些缺陷不必要地拖慢了我们的速度,也意味着我们无法完全信任我们的代码。我们代码库中缺少严格 null 检查是一个风险,而那些缺陷只是这个风险的症状。通过启用严格 null 检查,我们做了大量的工作来防止一整类缺陷的发生,此外还为我们的代码库和工作方式带来了许多其他好处。

这篇文章的目的不是要成为一个关于如何在大型代码库中启用严格 null 检查的教程。如果这个问题确实适用于你,希望你看到了在没有任何魔法的情况下,以一种理智的方式做到这一点是可能的。(我要补充一点,如果你正在开始一个新的 TypeScript 项目,为了你未来的自己着想,请默认从 "strict": true 开始。)

我希望你从中得到的启示是,很多时候,对一个缺陷的反应要么是添加测试,要么是相互指责。“鲍勃当然应该知道在访问那个属性之前要检查 undefined。”人们的本意是好的,但总会犯错。测试很有用,但也有成本,并且只测试我们为它们编写的内容。

相反,当你遇到一个缺陷或其他拖慢你速度的事情时,不要急于修复并转向下一个问题,而是停下来真正探索它产生的原因。它的根本原因是什么?它揭示了哪些风险?例如,也许你的源代码中包含一种危险的编码模式,需要进行一些重构。然后,根据其影响程度,努力去解决这个风险。你不需要重写所有东西。做最少的前期工作,并在有意义的时候进行自动化。减少风险,让世界在今天就变得更好一点。

我们在 VS Code 的严格 null 检查中采取了这种方法,并将在未来将其应用于其他问题。我希望你也能发现它很有用,无论你正在从事何种类型的项目。

编程愉快,

Matt Bierner,VS Code 团队成员 @mattbierner