參加你附近的 ,瞭解 VS Code 中的 AI 輔助開發。

在 Visual Studio Code 中啟用嚴格的 null 檢查

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)沒有幫助,而團隊的大部分人仍將在主幹上工作。

我們想要一個能立即為團隊所有工程師帶來嚴格 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 開始。)

我希望你從中得到的啟示是,很多時候,對一個缺陷的反應要麼是新增測試,要麼是相互指責。“鮑勃當然應該知道在訪問那個屬性之前要檢查 undefined。”人們的本意是好的,但總會犯錯。測試很有用,但也有成本,並且只測試我們為它們編寫的內容。

相反,當你遇到一個缺陷或其他拖慢你速度的事情時,不要急於修復並轉向下一個問題,而是停下來真正探索它產生的原因。它的根本原因是什麼?它揭示了哪些風險?例如,也許你的原始碼中包含一種危險的編碼模式,需要進行一些重構。然後,根據其影響程度,努力去解決這個風險。你不需要重寫所有東西。做最少的前期工作,並在有意義的時候進行自動化。減少風險,讓世界在今天就變得更好一點。

我們在 VS Code 的嚴格 null 檢查中採取了這種方法,並將在未來將其應用於其他問題。我希望你也能發現它很有用,無論你正在從事何種型別的專案。

程式設計愉快,

Matt Bierner,VS Code 團隊成員 @mattbierner