在 VS Code 中试试

Visual Studio Code 的严格空值检查

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

安全保障速度

快速前进很有趣。发布新功能、让用户满意并改进我们的代码库是件乐事。但与此同时,发布一个有 bug 的产品却不好玩。没人喜欢收到问题报告,也不喜欢半夜三点被叫醒处理事故。

尽管快速前进和发布稳定代码常常被认为是不相容的,但事实并非如此。很多时候,导致代码脆弱和出现 bug 的因素,也正是拖慢开发速度的原因。毕竟,如果我们总是担心会破坏东西,又怎能快速前进呢?

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

一个例子

为了说明在启用严格空值检查之前 VS Code 所面临的问题,让我们考虑一个简单的 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?函数实现者常常在与调用者不同的一套假设下工作。
  • 类型怪异之处。undefined vs nullundefined vs falseundefined vs 空字符串。
  • 感觉我们无法信任代码,也无法安全地重构它。

确定根本原因是一个很好的第一步,但我们想深入挖掘。在所有这些情况下,是什么样的风险让一个好心的工程师最初引入了 bug?我们很快就找到了所有这些问题共同的一个明显风险:VS Code 代码库中缺乏严格的空值检查。

要理解严格空值检查,必须记住 TypeScript 的目标是为 JavaScript 添加类型。TypeScript 的 JavaScript 遗留特性导致默认情况下允许将 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 提供了一个名为严格空值检查的选项,它使得 undefinednull 被视为不同的类型。使用严格空值检查时,任何可能为空的类型都必须进行标注。

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

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

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

制定增量计划

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

此外,VS Code 由一个小型核心团队组成,我们喜欢快速前进。如果分支代码来修复那 4500 个严格空值错误,会增加巨大的工程开销。而且从哪里开始呢?从头到尾检查错误列表吗?此外,分支中的更改对 main 分支没有帮助,而团队的大部分成员仍然在 main 分支上工作。

我们想要一个能够立即开始、逐步为团队中所有工程师带来严格空值检查好处的计划。这样,我们可以将工作分解成可管理的更改,每次小的更改都会使代码更安全一点。

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

{
  "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
  ]
}

尽管这个计划看起来很合理,但一个问题是,在 main 分支上工作的工程师通常不会编译通过严格空值检查的 VS Code 子集。为了防止意外回退到已经通过严格空值检查的文件,我们添加了一个持续集成步骤,该步骤会编译 tsconfig.strictNullChecks.json。这确保了如果提交导致严格空值检查出现回退,就会中断构建。

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

我们也考虑过自动化部分严格空值修复本身,但最终放弃了。严格空值错误往往是源码应该重构的良好信号。也许某个类型可空并没有充分的理由。也许应该由调用者而不是实现者来处理 null。手动审查和修复这些错误给了我们一个改进代码的机会,而不是强行使其符合严格空值要求。

执行计划

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

通常,我们尽量避免在主代码库中使用TypeScript 的非空断言。我们在测试中更自由地使用了它,因为我们认为如果在测试代码中缺乏空值检查会导致异常,那么测试无论如何都会失败。

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

还需要注意的是,启用严格空值检查并不能神奇地完全防止严格空值相关的异常发生。例如,any 类型或错误的类型转换很容易绕过严格空值检查。

// 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% 的严格空值错误——这即使不是不可能,也非常困难——而是为了防止绝大多数常见的严格空值相关错误。这也是一个清理代码并使其更安全地重构的好机会。达到 95% 的目标对我们来说是可以接受的。

您可以在 GitHub 上找到我们整个严格空值检查计划及其执行情况。VS Code 团队的所有成员以及许多外部贡献者都参与了这项工作。作为这项工作的推动者,我进行了最多的严格空值相关修复,但这只占我工程时间的四分之一左右。过程中肯定有一些痛苦,包括许多严格空值回退只能在提交后通过持续集成捕获到,这令人有些恼火。严格空值的工作也引入了一些新的 bug。然而,考虑到代码的修改量,整个过程进行得异常顺利。

最终为整个 VS Code 代码库启用严格空值检查的更改反而显得波澜不惊:它修复了少数几个代码错误,删除了 tsconfig.strictNullChecks.json 文件,并在主 tsconfig 文件中设置了 "strictNullChecks": true。这种平淡如水正是计划好的。至此,VS Code 完成了严格空值检查!

结论

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

本文的目的不是要提供一个关于如何在大型代码库中启用严格空值检查的教程。如果这个问题对你有帮助,希望你看到这可以通过合理的方式完成,并不需要任何魔法。(我想补充一点,如果你正在开始一个新的 TypeScript 项目,请帮未来的自己一个忙,默认就使用 "strict": true。)

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

相反,当你遇到 bug 或其他拖慢你速度的问题时,不要急于修复然后继续处理下一个问题,而是停下来仔细探究是什么原因造成的。它的根本原因是什么?它揭示了什么风险?例如,你的源码可能包含危险的编码模式,可以进行一些重构。然后,以与其影响成比例的方式来解决风险。你不需要重写所有东西。只做最少量的必要前期工作,并在合理时进行自动化。减少风险,让世界今天变得更好一点。

我们在 VS Code 的严格空值检查中采用了这种方法,将来也会将其应用于其他问题。无论你正在处理哪种类型的项目,希望你也能觉得它有用。

愉快地编码,

Matt Bierner,VS Code 团队成员 @mattbierner