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 的反应以及因无法快速行动而产生的沮丧感也都是症状。当我们开始讨论这些症状的根本原因时,我们发现了一些常见的:
- 未能捕获简单的编程错误,例如访问
null
或undefined
上的属性。 - 接口规范不足。哪些参数可以是
undefined
或null
,哪些函数可能返回undefined
或null
?函数实现者通常与调用者有不同的假设。 - 类型怪异之处。
undefined
与null
。undefined
与false
。undefined
与空字符串。 - 感觉我们无法信任代码,也无法安全地重构它。
识别根本原因是一个很好的第一步,但我们想深入探讨。在所有这些情况下,到底是什么危害导致了一个善意的工程师最初引入了 bug?我们很快识别出了所有这些问题共同的一个明显危害:VS Code 代码库中缺乏严格的空值检查。
要理解严格空值检查,你必须记住 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 提供了一个名为严格空值检查的选项,该选项会使 undefined
和 null
被视为不同的类型。使用严格空值检查时,任何可能为空的类型都必须进行相应的标注
// 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 个严格空值错误而从代码库中拉出分支,会增加巨大的工程开销。而且,你从何开始呢?从上到下逐一处理错误列表吗?此外,分支中的更改对主线开发也无益,团队的大部分成员仍会在主线上工作。
我们想要一个能立即开始,逐步为团队所有工程师带来严格空值检查好处的计划。这样,我们就可以将工作分解为可管理的更改,每次小改动都能使代码变得更安全一点。
为此,我们创建了一个新的 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
]
}
虽然这个计划看起来合理,但一个问题是,在主线上工作的工程师通常不会编译 VS Code 的严格空值检查子集。为了防止对已严格空值检查的文件意外引入回归,我们添加了一个持续集成步骤,该步骤会编译 tsconfig.strictNullChecks.json
。这确保了如果提交的代码导致严格空值检查出现回归,构建就会失败。
我们还编写了两个简单的脚本,以自动化一些与向严格空值检查项目添加文件相关的重复任务。第一个脚本打印出符合严格空值检查条件的文件列表。如果一个文件只导入了本身已经过严格空值检查的文件,则认为它符合条件。第二个脚本尝试自动将符合条件的文件添加到严格空值项目中。如果添加文件没有导致编译错误,则将其提交到 tsconfig.strictNullChecks.json
。
我们还考虑过自动化一些严格空值修复本身的工作,但最终我们放弃了。严格空值错误通常是一个很好的信号,表明源代码应该进行重构。也许一个类型本来就不应该为空。也许调用者而不是实现者应该处理 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 的反应要么是添加测试,要么是归咎于人。“当然,Bob 在访问该属性之前应该知道检查 undefined。”人们本意是好的,但总会犯错。测试固然有用,但也需要成本,而且只测试我们编写它们所要测试的内容。
相反,当你遇到 bug 或其他让你效率低下的问题时,不要急于修复然后转到下一个问题,而是停下来花点时间真正探究其原因。它的根本原因是什么?它揭示了哪些危害?例如,你的源代码可能包含一个危险的编码模式,需要进行一些重构。然后,以与其影响成比例的方式来解决危害。你不需要重写所有东西。做最少的前期工作,并在有意义的时候进行自动化。减少危害,今天就开始让世界一点点变得更好。
我们对 VS Code 的严格空值检查采用了这种方法,未来也会将其应用于其他问题。我希望无论你正在从事何种类型的项目,都能发现它同样有用。
编程愉快,
Matt Bierner,VS Code 团队成员 @mattbierner