尝试以扩展 VS Code 中的代理模式!

在 Visual Studio Code 中进行严格的 null 检查

2019 年 5 月 23 日,由 Matt Bierner (@mattbierner) 发布

安全保障速度

快速前进很有趣。发布新功能、让用户满意、改进我们的代码库,这些都很有趣。但是,同时,发布一个有缺陷的产品就没那么有趣了。没有人喜欢收到问题报告,或者在凌晨三点因为事故被叫醒。

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

在这篇文章中,我想分享一个 VS Code 团队最近完成的重大工程项目:在我们的代码库中启用 TypeScript 的严格 null 检查。我们相信这项工作将使我们能够更快地前进,并发布更稳定的产品。启用严格 null 检查的动机是,我们将 bug 不再看作孤立事件,而是看作我们源代码中更大隐患的症状。我将以严格 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 调用的更改都必须由一名高级工程师批准。这应该能彻底杜绝这些讨厌的 bug……

也许这次我们又过了几天才发生下一次崩溃。甚至可能过了几个月。但是,除非我们的代码再也不被修改,否则总会有下一次。如果不是在这个特定的函数里,那也会在我们代码库的其他地方。

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

识别隐患

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

我们意识到,我们需要开始以一种新的方式来理解我们的 bug,不是将它们视为孤立的事件,而是视为更大问题的症状/信号。我们对这些 bug 的反应以及我们对无法快速前进的沮丧感也是症状。当我们开始讨论这些症状的根本原因时,我们发现了一些常见的因素。

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

识别根本原因是一个很好的第一步,但我们想更深入地探究。在所有这些案例中,是什么样的隐患让一个善意的工程师一开始就引入了 bug?我们很快就发现了一个贯穿所有这些问题的明显隐患: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

修复孤立的代码行或添加测试是一种被动的解决方案,只能修复那些特定的 bug。启用严格 null 检查则是一种主动的解决方案,它不仅能修复我们每个月都会看到的那些 bug 报告,还能防止这类 bug 在未来再次发生。再也不用担心忘记检查可选属性是否有值。再也不用怀疑一个函数是否可能返回 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 检查会导致异常,那么测试无论如何都会失败。

整个过程中一个令人沮丧的方面是,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 的工作也引入了一些新的 bug。然而,考虑到修改的代码量,事情进展得非常顺利。

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

结论

当我向人们讲述这个项目时,我听到的一个常见问题是:那么,它到底修复了多少个 bug?我认为这个问题其实没有太大意义。在 VS Code 中,我们从来不愁修复与缺乏严格 null 检查相关的 bug。通常这只需要添加一个条件判断和一两个测试。但我们一次又一次地看到同类型的 bug 出现。修复这些 bug 拖慢了我们的速度,也意味着我们无法完全信任我们的代码。我们代码库中缺乏严格 null 检查是一个隐患,而这些 bug 只是这个隐患的症状。通过启用严格 null 检查,我们做了大量工作来防止整整一类 bug 的出现,此外还为我们的代码库和工作方式带来了许多其他好处。

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

我希望你能从中领悟到的是,很多时候,对 bug 的反应要么是添加测试,要么是归咎于人。“鲍勃当然应该知道在访问那个属性之前要检查 undefined。”人们的本意是好的,但总会犯错。测试很有用,但也有成本,而且只测试我们为它们编写的内容。

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

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

编程愉快,

Matt Bierner,VS Code 团队成员 @mattbierner