严格的 Null 值检查 Visual Studio Code

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 值检查时,任何可能为空的类型都必须如此注释

// 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 值错误将增加大量的工程开销。而且你从哪里开始呢?从上到下浏览错误列表?此外,分支中的更改无助于主分支,团队的大部分成员仍将在主分支上工作。

我们想要一个计划,该计划可以立即开始逐步为团队中的所有工程师带来严格 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 注释。对于其他人来说,很难理解代码的意图。一个值是有意保持未初始化还是实际上存在编程错误?

总的来说,我们尽量避免在我们的主代码库中使用 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 开始。)

我希望您能从中学到的是,在很多时候,对错误的反应要么是添加测试,要么是责备。“当然,Bob 应该知道在访问该属性之前检查 undefined。” 人们本意是好的,但会犯错误。测试很有用,但也有成本,并且只测试我们编写测试的内容。

相反,当您遇到错误或减慢您速度的其他问题时,不要急于修复并继续处理下一个问题,而是停下来片刻,真正探索导致该问题的原因。它的根本原因是什么?它揭示了哪些风险?例如,也许您的源代码包含危险的编码模式,并且可以使用一些重构。然后努力以与其影响成比例的方式解决风险。您不需要重写所有内容。完成所需的最少量的预先工作,并在有意义时进行自动化。减少风险,让世界在今天变得更好。

我们对严格 Null 值检查 VS Code 采用了这种方法,并将在未来将其应用于其他问题。我希望您也觉得它有用,无论您正在从事哪种类型的项目。

编码愉快,

Matt Bierner,VS Code 团队成员 @mattbierner