🚀 在 VS Code 中取得

嚴格 Null 檢查 Visual Studio Code

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

安全許可速度

快速行動很有趣。發布新功能、讓使用者開心並改善我們的程式碼庫都很有趣。但是,同時,發布錯誤百出的產品並不好玩。沒有人喜歡收到問題或在凌晨三點被叫醒處理事件。

雖然快速行動和發布穩定的程式碼通常被認為是互不相容的,但不應該是這樣。很多時候,使程式碼脆弱和錯誤百出的相同因素也會減慢開發速度。畢竟,如果我們總是擔心破壞東西,我們怎麼能快速行動呢?

在這篇文章中,我想分享 VS Code 團隊最近完成的一項重大工程工作:在我們的程式碼庫中啟用 TypeScript 的嚴格 null 檢查。我們相信這項工作將使我們能夠更快地行動並發布更穩定的產品。啟用嚴格 null 檢查的動機是將錯誤理解為不是孤立的事件,而是我們原始程式碼中更大危害的徵兆。以嚴格 null 檢查作為案例研究,我將討論是什麼促使我們進行這項工作、我們如何提出解決問題的漸進式方法,以及我們如何實作修復。這種識別和減少危害的通用方法可以應用於任何軟體專案。

範例

為了說明 VS Code 在啟用嚴格 null 檢查之前面臨的問題,讓我們考慮一個簡單的 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 程式碼庫中缺乏嚴格的 null 檢查。

要理解嚴格的 null 檢查,您必須記住 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 提供了一個名為嚴格 null 檢查的選項,該選項會將 undefinednull 視為不同的類型。使用嚴格 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 專案檔案,該檔案啟用了嚴格 null 檢查,最初由零個檔案組成。然後,我們有選擇地將個別檔案新增到此專案中,修正這些檔案中的嚴格 null 錯誤,然後簽入變更。只要我們新增的檔案沒有匯入或僅匯入其他已進行嚴格 null 檢查的檔案,我們每次迭代就只需要修正少量錯誤。

{
  "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 的嚴格 null 檢查子集。為了防止意外地回歸到已進行嚴格 null 檢查的檔案,我們新增了一個持續整合步驟,該步驟編譯了 tsconfig.strictNullChecks.json。這確保了回歸嚴格 null 檢查的簽入會破壞組建。

我們也整理了兩個簡單的腳本,以自動化與將檔案新增到嚴格 null 檢查專案相關的一些重複性工作。第一個腳本列印了符合嚴格 null 檢查條件的檔案列表。如果檔案僅匯入本身已進行嚴格 null 檢查的檔案,則該檔案被視為符合條件。第二個腳本嘗試自動將符合條件的檔案新增到嚴格 null 專案。如果新增檔案沒有導致編譯錯誤,則會將其提交到 tsconfig.strictNullChecks.json

我們也考慮過自動化一些嚴格 null 修正本身,但我們最終選擇反對這樣做。嚴格 null 錯誤通常是原始程式碼應該重構的良好信號。也許沒有充分的理由說明類型為什麼可以為 null。也許呼叫者應該處理 null 而不是實作者。手動審查和修正這些錯誤使我們有機會使我們的程式碼更好,而不是強行使其與嚴格 null 相容。

執行計畫

在接下來的幾個月中,我們逐漸擴大了嚴格 null 檢查檔案的數量。這通常是乏味的工作。大多數嚴格 null 錯誤都很簡單:只需新增 null 註解即可。對於其他錯誤,很難理解程式碼的意圖。值是有意保持未初始化,還是實際上存在程式設計錯誤?

一般來說,我們盡量避免在我們的主要程式碼庫中使用TypeScript 的非 null 斷言。我們在測試中更自由地使用它,理由是如果測試程式碼中缺乏 null 檢查會導致例外狀況,那麼測試無論如何都會失敗。

整個過程的一個令人沮喪的方面是,VS Code 程式碼庫中嚴格 null 錯誤的總數似乎從未減少。如果有的話,如果您使用啟用了嚴格 null 檢查來編譯所有 VS Code,我們所有的嚴格 null 工作實際上似乎都在導致錯誤總數上升!這是因為嚴格 null 修正通常具有連鎖效應。正確註解函數可以傳回 undefined 可能會為該函數的所有消費者引入嚴格 null 錯誤。與其擔心剩餘錯誤的總數,不如關注已進行嚴格 null 檢查的檔案數量,並努力確保我們永遠不會回歸這個總數。

同樣重要的是要注意,啟用嚴格 null 檢查並不能神奇地防止嚴格 null 相關的例外狀況永遠發生。例如,any 類型或錯誤的轉換可以輕鬆繞過嚴格 null 檢查

// 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% 的嚴格 null 錯誤—這將非常困難,甚至不可能—而是要防止絕大多數常見的嚴格 null 相關錯誤。這也是一個清理我們程式碼並使其更安全地進行重構的好機會。達到 95% 的目標對我們來說是可以接受的。

您可以在 GitHub 上找到我們完整的嚴格 null 檢查計畫及其執行情況在 GitHub 上。VS Code 團隊的所有成員以及許多外部貢獻者都參與了這項工作。作為這項工作的推動者,我進行了最多的嚴格 null 相關修正,但它只佔用了我約四分之一的工程時間。一路走來肯定有一些痛苦,包括對許多嚴格 null 回歸僅在簽入後才被持續整合捕獲感到惱火。嚴格 null 工作也引入了一些新錯誤。但是,考慮到變更的程式碼量,事情進行得非常順利。

最終為整個 VS Code 程式碼庫啟用嚴格 null 檢查的變更相當平淡無奇:它修正了一些程式碼錯誤,刪除了 tsconfig.strictNullChecks.json,並在我們的主要 tsconfig 中設定了 "strictNullChecks": true。缺乏戲劇性完全是按計畫進行的。有了它,VS Code 就通過了嚴格 null 檢查!

結論

當我告訴人們關於這個專案時,我聽到的一個常見問題是:它修正了多少個錯誤?我認為這個問題沒有真正的意義。對於 VS Code,我們從來沒有在修正與缺乏嚴格 null 檢查相關的錯誤方面遇到問題。通常它涉及新增一個條件,也許還有一個或兩個測試。但是我們不斷地一遍又一遍地看到相同類型的錯誤。修正這些錯誤不必要地減慢了我們的速度,這意味著我們無法完全信任我們的程式碼。我們程式碼庫中缺乏嚴格 null 檢查是一種危害,而錯誤只是這種危害的徵兆。透過啟用嚴格 null 檢查,除了為我們的程式碼庫和工作方式帶來許多其他好處之外,我們還做了大量工作來防止整類錯誤。

這篇文章的重點不是要成為在大型程式碼庫中啟用嚴格 null 檢查的教學課程。如果這個問題確實適用於您,希望您看到可以以理智的方式完成它,而無需任何魔法。(我將補充一點,如果您要啟動一個新的 TypeScript 專案,請為您未來的自己幫個忙,並以 "strict": true 作為預設值開始。)

我希望您從中學到的是,太過頻繁地,對錯誤的回應要么是新增測試,要么是責備。「當然,Bob 應該知道在存取該屬性之前檢查 undefined。」人們的本意是好的,但會犯錯。測試很有用,但也有成本,並且只測試我們編寫它們來測試的內容。

相反,當您遇到錯誤或減慢您速度的其他事情時,不要急於修正並繼續處理下一個問題,請停下來片刻,真正探索導致它的原因。它的根本原因是什麼?它揭示了哪些危害?例如,也許您的原始程式碼包含危險的程式設計模式,並且可以使用一些重構。然後努力以與其影響成比例的方式解決危害。您不需要重寫所有內容。做所需的最低限度的前期工作,並在有意義時自動化。減少危害,讓世界今天變得更好一點。

我們在 VS Code 中採用了這種嚴格 null 檢查方法,並將在未來應用於其他問題。我希望您也覺得它有用,無論您正在從事哪種類型的專案。

祝您編碼愉快,

Matt Bierner,VS Code 團隊成員 @mattbierner