Visual Studio Code 的严格空值检查 (Strict null checking)

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

安全性成就速度

快速迭代是令人愉悦的。发布新功能、让用户满意并改进代码库是一件乐事。但与此同时,发布带有漏洞的产品却并不愉快。没人喜欢处理工单,或者在凌晨三点因为事故被叫醒。

尽管“快速迭代”和“发布稳定的代码”常被认为不可兼得,但事实并非如此。很多时候,导致代码脆弱和产生漏洞的因素,恰恰是拖慢开发速度的原因。毕竟,如果我们总是担心会破坏现有的功能,又怎能快速前进呢?

在这篇文章中,我想分享 VS Code 团队最近完成的一项重大工程任务:在我们的代码库中启用 TypeScript 的 严格空值检查 (strict null checking)。我们相信,这项工作不仅能让我们行动更快,还能交付更稳定的产品。启用严格空值检查的初衷,是将漏洞视为源代码中更大隐患的征兆,而非孤立事件。以严格空值检查为例,我将讨论推动我们工作的动机,我们是如何构思出一种增量式方案来解决该问题的,以及我们是如何实施修复的。这种识别并减少隐患的通用方法,可以应用于任何软件项目。

一个例子

为了说明 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?函数实现者和调用者往往基于不同的假设工作。
  • 类型混淆。undefinednullundefinedfalseundefined 与空字符串。
  • 感觉无法信任代码,也不敢安全地进行重构。

识别根本原因只是第一步,我们想挖掘得更深。在所有这些案例中,到底是什么样的隐患导致本意良好的工程师引入了漏洞?我们很快发现了一个贯穿所有问题的明显隐患: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 提供了一个名为 严格空值检查 (strict null checking) 的选项,它将 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 中。

我们也考虑过自动化修复部分严格空值错误,但最终决定放弃。严格空值错误通常是一个很好的信号,提示源代码应该进行重构。也许一个类型之所以可为空并没有正当理由;也许调用方应该处理 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 团队的所有成员以及许多外部贡献者都参与了这项工作。作为这项工作的推动者,我完成了大部分相关修复,但这只占用了我约四分之一的工程时间。过程中确实存在一些痛点,包括很多严格空值回归问题只有在提交后才会被持续集成捕获的烦恼。这项工作也引入了一些新漏洞。然而,考虑到修改的代码量,整个过程进行得相当顺利。

最终为整个 VS Code 代码库启用严格空值检查的那次更改相当平淡:它修复了最后几个代码错误,删除了 tsconfig.strictNullChecks.json,并在我们的主 tsconfig 中设置了 "strictNullChecks": true。这种波澜不惊正是我们所预期的。至此,VS Code 完成了严格空值检查迁移!

结论

当人们听到这个项目时,经常问的一个问题是:它修复了多少漏洞?我认为这个问题本身意义不大。对于 VS Code,我们在修复与缺乏严格空值检查相关的漏洞方面从未遇到问题。通常只需添加一个条件判断,或许再加一两个测试。但我们却不断地看到同类漏洞反复出现。修复这些漏洞不必要地拖慢了我们的速度,也意味着我们无法完全信任代码。代码库中缺乏严格空值检查是一个隐患,而漏洞仅仅是这种隐患的症状。通过启用严格空值检查,我们不仅预防了整类漏洞,还为我们的代码库和工作方式带来了许多其他益处。

这篇文章的目的不是作为一个在大型代码库中启用严格空值检查的教程。如果这个问题确实困扰着你,希望你能看到,以一种理性的方式并不费力地解决它是可能的。(我要补充的是,如果你正在启动一个新的 TypeScript 项目,为了未来的自己,请默认使用 "strict": true。)

我希望你从中学到的是,人们对于漏洞的反应往往过于简单,要么是添加测试,要么是归咎于人。“Bob 当然应该知道在访问该属性前检查 undefined。”人们初衷虽好,但仍会犯错。测试固然有用,但也有成本,而且只能测试我们编写的内容。

相反,当你遇到漏洞或阻碍进度的问题时,不要急于修复后就进入下一个议题,请停下来认真探究其原因。根本原因是什么?它揭示了什么隐患?例如,你的源代码可能包含某种危险的编码模式,需要重构。然后,以与影响程度相符的方式去解决这个隐患。你不需要重写一切,只需完成所需的最少预备工作,并在有意义的地方进行自动化。减少隐患,让世界在今天变得更美好一点。

我们在 VS Code 中采用了这种方法处理严格空值检查,未来也会将其应用于其他问题。无论你从事什么类型的项目,我都希望这种方法对你有所帮助。

编程愉快,

Matt Bierner,VS Code 团队成员 @mattbierner

© . This site is unofficial and not affiliated with Microsoft.