现已推出!阅读 10 月份的新功能和修复。

严格空值检查 Visual Studio Code

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

安全允许速度

快速行动很有趣。发布新功能、让用户满意以及改进代码库很有趣。但是,与此同时,发布有错误的产品并不有趣。没有人喜欢收到问题或在凌晨三点被唤醒处理事件。

尽管快速行动和发布稳定代码通常被认为是不兼容的,但这不应该是这种情况。许多时候,导致代码脆弱和有错误的因素也是导致开发速度变慢的原因。毕竟,如果我们总是担心破坏东西,我们怎么能快速行动呢?

在这篇文章中,我想分享 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 的调用的更改都必须得到高级工程师的批准。这应该会永久性地阻止这些讨厌的错误...

也许这次我们能够在下次崩溃前多坚持几天。甚至可能是几个月。但是,除非我们的代码不再被更改,否则总会出现错误。如果不是在这个特定的函数中,那么就会出现在代码库的其他地方。

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

识别危害

虽然上面例子中的错误可能看起来很明显,但我们在开发 VS Code 时遇到了相同类型的问题。每次迭代,我们都会修复与意外 undefined 相关的错误。我们会添加测试。我们会发誓要成为更好的工程师。这些都是传统的应对措施,但到了下一轮迭代,问题又会重新出现。这不仅导致一些用户对 VS Code 的体验不佳,而且这些错误以及我们对它们的应对措施也减缓了我们在开发新功能或更改现有源代码时的速度。

我们意识到,我们需要从新的角度开始理解我们的错误,不要将它们视为孤立事件,而是视为更大问题的症状/信号。我们对这些错误的应对措施以及我们无法快速行动的沮丧也是症状。当我们开始讨论这些症状的根本原因时,我们发现了一些常见的原因。

  • 未能捕获简单的编程错误,例如访问 nullundefined 上的属性。
  • 接口定义不足。哪些参数可以是 undefinednull,以及哪些函数可能返回 undefinednull?通常,函数的实现者所做的假设与调用者不同。
  • 类型奇异性。undefined vs nullundefined vs falseundefined vs 空字符串。
  • 感觉我们无法信任代码或安全地重构它。

识别根本原因是一个很好的第一步,但我们希望更深入地了解。在所有这些情况下,危害是什么,这些危害使一位好心的工程师能够在第一时间引入错误?我们很快发现,所有这些问题中都存在着一个明显的共同危害:VS Code 代码库中缺乏严格的空值检查。

要了解严格空值检查,您必须记住,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 提供了一个名为 严格空值检查 的选项,该选项使 undefinednull 被视为不同的类型。使用严格空值检查时,任何可能为空的类型都必须进行注释。

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

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

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

制定增量计划

问题在于,我们不能仅仅启用一个编译器标志,然后一切都会神奇地解决。VS Code 的核心代码库包含大约 1800 个 TypeScript 文件,包含超过 50 万行代码。使用 "strictNullChecks": true 编译它会产生大约 4500 个错误。糟糕!

此外,VS Code 是由一个小型核心团队开发的,我们喜欢快速行动。从代码中分支出来以修复这 4500 个严格空值错误会增加大量的工程开销。而且,你从哪里开始呢?从头到尾遍历错误列表?此外,分支中的更改不会帮助主分支,大多数团队成员仍然会在主分支上工作。

我们想要一个计划,该计划可以从一开始就将严格空值检查的优势逐渐带给团队中的所有工程师。这样,我们可以将工作分解成可管理的更改,每次小的更改都会使代码更安全一些。

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

{
  "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 子集。为了防止意外回归到已经经过严格空值检查的文件,我们添加了一个持续集成步骤来编译 tsconfig.strictNullChecks.json。这确保了回归严格空值检查的签入会破坏构建。

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

我们也考虑过自动化一些严格空检查修复,但最终还是放弃了。严格空错误通常是一个很好的信号,表明源代码应该重构。也许没有很好的理由让类型可为空。也许调用者应该处理空值,而不是实现者。手动审查和修复这些错误给了我们一个机会来改进代码,而不是强行使其兼容严格空检查。

执行计划

在接下来的几个月里,我们逐渐增加了严格空检查文件的数量。这通常是一项繁琐的工作。大多数严格空错误都很简单:只需添加空注释即可。对于其他错误,则难以理解代码的意图。某个值是故意保持未初始化,还是存在实际的编程错误?

总的来说,我们尽量避免在主代码库中使用 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 团队的所有成员以及许多外部贡献者都参与了这项工作。作为这项工作的推动者,我做了大多数与严格空相关的修复,但这只占了我工程时间的大约四分之一。当然,过程中也有一些痛苦,包括一些恼人的问题,例如,许多严格空回归只有在提交后才被持续集成发现。严格空工作也引入了一些新错误。但是,考虑到代码更改的数量,一切进展非常顺利。

最终 启用整个 VS Code 代码库的严格空检查的变更相当平淡:它修复了一些代码错误,删除了 tsconfig.strictNullChecks.json,并在我们的主 tsconfig 中设置了 "strictNullChecks": true。缺乏戏剧性正是我们计划中的。就这样,VS Code 实现了严格空检查!

结论

我经常听到的一个问题是:那么它修复了多少错误呢?我认为这个问题并没有实际意义。对于 VS Code,我们从来没有遇到过与缺乏严格空检查相关的错误修复问题。通常情况下,这涉及添加一个条件,也许还有几个测试。但是我们不断看到相同类型的错误一遍又一遍地出现。修复这些错误毫无必要地拖慢了我们的速度,这意味着我们不能完全信任我们的代码。代码库中缺乏严格空检查是一个隐患,而错误只是这个隐患的症状。通过启用严格空检查,我们在防止一整类错误方面做了大量工作,此外还为我们的代码库和工作方式带来了许多其他好处。

这篇文章的重点不是关于在大型代码库中启用严格空检查的教程。如果这个问题确实适用于你,希望你看到,这可以通过一种理智的方式完成,无需任何魔法。(我需要补充一点,如果你正在开始一个新的 TypeScript 项目,为了你的将来着想,请从 "strict": true 作为默认值开始。)

我希望你能明白的是,在很多时候,对错误的反应要么是添加测试,要么是责怪。“当然,Bob 应该知道在访问该属性之前检查是否存在 undefined。”人们的出发点是好的,但也会犯错误。测试是有用的,但也有一定的成本,而且只测试我们编写的代码。

相反,当你遇到错误或其他阻碍你工作的事情时,与其匆忙修复并继续下一个问题,不如花点时间真正探索导致错误的原因。根本原因是什么?它揭示了什么隐患?例如,你的源代码可能包含一种危险的编码模式,需要进行一些重构。然后,以与其影响相称的方式解决隐患。你不需要重写所有内容。只需完成所需的最低限度的前期工作,并在有意义的情况下进行自动化。减少隐患,使世界今天变得更加美好。

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

祝你编码愉快,

Matt Bierner,VS Code 团队成员 @mattbierner