Visual Studio Code 中的嚴格空值檢查
2019 年 5 月 23 日 by 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?通常函式的實現者與呼叫者所依據的假設集不同。 - 型別怪異之處。
undefinedvsnull。undefinedvsfalse。undefinedvs 空字串。 - 感覺我們無法信任程式碼或安全地重構它。
識別根本原因是一個很好的第一步,但我們想更深入。在所有這些情況下,是什麼隱患讓一個好心的工程師首先引入了錯誤?我們很快就識別出了所有這些問題的共同 glaring 隱患: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
修復孤立的程式碼行或新增測試是一種被動解決方案,只修復了那些特定的錯誤。啟用嚴格空值檢查是一種主動解決方案,它不僅會修復我們每月報告的錯誤,而且還會防止這些整類錯誤在未來發生。不再忘記檢查可選屬性是否有值。不再質疑函式是否可以返回 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 的非空斷言。我們在測試中更自由地使用它,理由是如果測試程式碼中缺乏 null 檢查會導致異常,那麼測試也會失敗。
整個過程中令人沮喪的一個方面是,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 作為預設值開始。)
我希望你明白的是,太多時候,對錯誤的反應要麼是新增測試,要麼是歸咎於人。“鮑勃當然應該知道在訪問該屬性之前檢查 undefined。”人們是善意的,但會犯錯誤。測試是有用的,但也有成本,並且只測試我們編寫它們來測試的內容。
相反,當你遇到一個錯誤或其它減緩你速度的事情時,不要急於修復並轉到下一個問題,停下來片刻,真正探究是什麼導致了它。它的根本原因是什麼?它揭示了哪些隱患?例如,也許你的原始碼包含危險的編碼模式,並且可以進行一些重構。然後以與隱患影響成比例的方式解決隱患。你不必重寫所有內容。做最少量的預先工作,並在有意義時自動化。減少隱患,讓世界今天變得更好一點。
我們對 VS Code 的嚴格空值檢查採用了這種方法,將來也會將其應用於其他問題。我希望你覺得它有用,無論你正在做什麼型別的專案。
程式設計愉快,
Matt Bierner, VS Code 團隊成員 @mattbierner