Visual Studio Code 的严格 Null 检查
2019 年 5 月 23 日,作者:Matt Bierner,@mattbierner
安全保障速度
快速行动很有趣。发布新功能、让用户满意和改进我们的代码库都很有趣。但是,与此同时,发布有缺陷的产品并不有趣。没有人喜欢收到问题或在凌晨三点因事故被叫醒。
虽然快速行动和发布稳定的代码通常被认为是不兼容的,但事实并非如此。很多时候,使代码脆弱和有缺陷的相同因素也会减慢开发速度。毕竟,如果我们总是担心会破坏某些东西,我们如何才能快速行动?
在这篇文章中,我想分享 VS Code 团队最近完成的一项重大工程工作:在我们的代码库中启用 TypeScript 的严格的 Null 检查。我们相信这项工作将使我们能够更快地行动并发布更稳定的产品。启用严格的 Null 检查的动机是将错误理解为不是孤立的事件,而是我们源代码中更大危害的症状。以严格的 Null 检查作为一个案例研究,我将讨论我们工作的动机,我们如何提出解决问题的渐进式方法,以及我们如何实施修复。这种识别和减少危险的通用方法可以应用于任何软件项目。
一个例子
为了说明在启用严格的 Null 检查之前 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 的体验不佳,这些错误以及我们对它们的响应也在我们开发新功能或更改现有源代码时减慢了我们的速度。
我们意识到我们需要开始以新的方式理解我们的错误,不是将其视为孤立的事件,而是作为更大问题的症状/信号。我们对这些错误的响应以及我们无法快速行动的挫败感也是症状。当我们开始讨论这些症状的根本原因时,我们发现了一些常见的根本原因
- 未能捕捉到简单的编程错误,例如访问
null
或undefined
上的属性。 - 接口规范不足。哪些参数可以是
undefined
或null
,哪些函数可能返回undefined
或null
?通常,函数的实现者所依据的假设与调用者不同。 - 类型怪异。
undefined
与null
。undefined
与false
。undefined
与空字符串。 - 感觉我们无法信任代码或安全地重构它。
识别根本原因是一个好的第一步,但我们希望更深入地了解。在所有这些情况下,是什么危险导致一个善意的工程师首先引入了错误?我们很快就发现了一个在所有这些问题中都存在的明显危险:VS Code 代码库中缺乏严格的 Null 检查。
要理解严格的 Null 检查,您必须记住 TypeScript 的目标是向 JavaScript 添加类型。TypeScript 的 JavaScript 遗留问题的一个后果是,默认情况下,TypeScript 允许将 undefined
和 null
用于任何值
// 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 检查的选项,该选项会将 undefined
和 null
视为不同的类型。使用严格的 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 错误会增加大量的工程开销。而且你从哪里开始呢?从上到下浏览错误列表?此外,分支中的更改不会帮助到 main,团队中的大多数人仍在 main 上工作。
我们希望有一个计划,可以立即将严格的 Null 检查的好处逐步带给团队中的所有工程师。这样,我们可以将工作分解为可管理的更改,每个小的更改都会使代码更安全一些。
为了实现这一点,我们创建了一个名为 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