严格的空值检查 Visual Studio Code
2019年5月23日,作者:Matt Bierner,@mattbierner
安全允许速度
快速行动很有趣。发布新功能、让用户满意、改进我们的代码库都很有趣。但同时,发布一个有缺陷的产品并不有趣。没有人喜欢收到问题或在凌晨三点被叫醒处理事故。
尽管快速行动和发布稳定的代码常常被视为不兼容,但这不应该是事实。很多时候,使代码脆弱和充满错误的因素也是拖慢开发速度的原因。毕竟,如果我们总是担心破坏东西,我们怎么能快速前进呢?
在这篇文章中,我想分享VS Code团队最近完成的一项重大工程:在我们的代码库中启用TypeScript的严格空值检查。我们相信这项工作将使我们能够更快地前进,并发布更稳定的产品。启用严格空值检查的动机是将错误理解为源代码中更大危险的症状,而不是孤立的事件。以严格空值检查为例,我将讨论我们工作的动机,我们如何提出逐步解决问题的方法,以及我们如何实施修复。这种识别和减少危险的一般方法可以应用于任何软件项目。
一个示例
为了说明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代码库中缺乏严格的空值检查。
要理解严格的空值检查,你必须记住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 提供了一个名为 strict null checking 的选项,它使得 undefined
和 null
被视为不同的类型。在使用严格空值检查时,任何可能为空的类型都必须进行相应的注解:
// With "strictNullCheck": true, all of these produce compile errors
getStatus(undefined); // Error
getStatus(null); // Error
getStatus({ id: undefined }); // Error
修复孤立的代码行或添加测试是一种被动的解决方案,只能修复那些特定的错误。启用严格的空值检查是一种主动的解决方案,不仅可以修复我们每月看到的报告中的错误,还可以防止这些类别的错误在未来发生。不再需要忘记检查可选属性是否有值。不再需要质疑函数是否可以返回空值。好处是显而易见的。
制定增量计划
问题在于,我们不能仅仅启用一个编译器标志,一切就会神奇地修复。VS Code 核心代码库有大约 1800 个 TypeScript 文件,包含超过五十万行代码。使用 "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
]
}
虽然这个计划看起来合理,但有一个问题是,在main分支工作的工程师通常不会编译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进行了严格的空值检查!
结论
当我向人们介绍这个项目时,一个常见的问题是:它修复了多少个bug?我认为这个问题并不真正有意义。对于VS Code来说,我们从未遇到过由于缺乏严格的空值检查而导致的bug修复问题。通常,这涉及到添加一个条件语句,可能还需要一两个测试。但我们一次又一次地看到相同类型的bug。修复这些bug不必要地拖慢了我们的进度,这意味着我们不能完全信任我们的代码。代码库中缺乏严格的空值检查是一个隐患,而这些bug只是这个隐患的一个症状。通过启用严格的空值检查,我们做了大量工作来预防一整类bug,同时也为我们的代码库和工作方式带来了许多其他好处。
这篇文章的目的并不是要成为关于在大型代码库中启用严格空值检查的教程。如果这个问题确实适用于你,希望你能看到,以一种合理的方式实现这一点是可能的,而不需要任何魔法。(我会补充说,如果你正在开始一个新的TypeScript项目,为了未来的自己着想,请以"strict": true
作为默认设置开始。)
我希望你能理解的是,很多时候,对错误的反应要么是添加测试,要么是责备。“当然,Bob应该知道在访问该属性之前检查是否未定义。”人们本意是好的,但会犯错误。测试是有用的,但也有成本,并且只测试我们编写它们来测试的内容。
相反,当你遇到一个错误或其他阻碍你前进的问题时,不要急于修复并转向下一个问题,而是停下来真正探索其原因。它的根本原因是什么?它揭示了哪些隐患?例如,也许你的源代码包含一个危险的编码模式,可能需要进行一些重构。然后,根据其影响程度来解决问题。你不需要重写所有内容。做最少的前期工作,并在有意义时进行自动化。减少隐患,让世界今天变得更好。
我们在VS Code中采用了严格的空值检查方法,并将在未来将其应用于其他问题。我希望无论你在进行什么类型的项目,你也会发现它有用。
编码愉快,
Matt Bierner, VS Code 团队成员 @mattbierner