🚀 在 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 錯誤,將會增加大量的工程管理費用。而且您甚至從哪裡開始?從錯誤清單的頂部到底部依序處理?此外,分支中的變更不會對主要分支有所幫助,團隊中的大多數人仍然會在主要分支中工作。

我們想要一個計畫,可以立即開始逐步將嚴格 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
  ]
}

雖然這個計畫看起來合理,但一個問題是,在主要分支中工作的工程師通常不會編譯 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 檢查計畫及其執行情況。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。」人們出於好意,但會犯錯。測試很有用,但也有成本,而且只測試我們編寫要測試的內容。

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

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

快樂編碼,

Matt Bierner,VS Code 團隊成員 @mattbierner