天天看點

在 Meta 上将 null-safety 改造到 Java(譯文)

作者:閃念基因
  • 我們開發了一種名為 Nullsafe 的新靜态分析工具,Meta 使用它來檢測 Java 代碼中的 NullPointerException (NPE) 錯誤。
  • 與遺留代碼的互操作性和逐漸部署模型是 Nullsafe 廣泛采用的關鍵,并使我們能夠在數百萬行代碼庫中的其他 null 不安全語言的上下文中恢複一些 null 安全屬性。
  • Nullsafe 幫助顯着減少了 NPE 錯誤的總數并提高了開發人員的工作效率。這顯示了靜态分析在大規模解決現實世界問題中的價值。

Null 取消引用是 Java 中常見的程式設計錯誤類型。在 Android 上,NullPointerException (NPE) 錯誤是Google Play 上應用程式崩潰的最大原因。由于 Java 不提供表達和檢查空不變量的工具,開發人員不得不依靠測試和動态分析來提高代碼的可靠性。這些技術是必不可少的,但在信号發送時間和覆寫範圍方面有其自身的局限性。

2019 年,我們啟動了一個名為0NPE的項目,目标是在我們的應用程式中解決這一挑戰,并通過靜态分析顯着提高 Java 代碼的空安全性。

在兩年的時間裡,我們開發了 Nullsafe,這是一種用于檢測 Java 中 NPE 錯誤的靜态分析器,将其內建到核心開發人員工作流程中,并進行了大規模代碼轉換,使數百萬行 Java 代碼符合 Nullsafe 标準。

在 Meta 上将 null-safety 改造到 Java(譯文)

圖 1:随時間變化的空安全代碼百分比(大約)。

以Meta 最大的 Android 應用程式之一Instagram為例,我們觀察到在 18 個月的代碼轉換期間,生産 NPE 崩潰減少了 27%。此外,NPE 不再是導緻 alpha 和 beta 通道崩潰的主要原因,這直接反映了開發人員體驗和開發速度的改善。

null的問題

空指針因導緻程式錯誤而臭名昭著。即使是像下面這樣的一小段代碼,也可能會以多種方式出錯:

清單 1:錯誤的 getParentName方法

Path getParentName(Path path) {
  return path.getParent().getFileName();
}           
  1. getParent ()可能會産生null并在getParentName(…)中本地引發NullPointerException 。
  2. getFileName ()可能會傳回null ,這可能會進一步傳播并在其他地方導緻崩潰。

前者相對容易發現和調試,但後者可能具有挑戰性——尤其是随着代碼庫的增長和發展。

在像上面這樣的玩具示例中,找出值的空值和發現潛在問題很容易,但在數百萬行代碼的規模上變得非常困難。然後每天添加數千個代碼更改使得無法手動確定沒有任何單個更改會導緻其他元件中的NullPointerException 。結果,使用者遭受崩潰,應用程式開發人員需要花費過多的精力來跟蹤值的無效性。

然而,問題不在于null值本身,而是 API 中缺乏明确的 nullness 資訊以及缺乏驗證代碼是否正确處理 nullness 的工具。

Java 和 nullness

為了應對這些挑戰,Java 8 引入了java.util.Optional<T>類。但它的性能影響和遺留 API 相容性問題意味着Optional不能用作可空引用的通用替代品。

同時,注釋已成功用作語言擴充點。特别是,将@Nullable和@NotNull等注釋添加到正常可為 null 的引用類型是一種可行的方式來擴充具有顯式 nullness 的 Java 類型,同時避免Optional的缺點。但是,這種方法需要外部檢查器。

清單 1中代碼的注釋版本可能如下所示:

清單 2:正确且帶注釋的getParentName方法

// (2)                          (1)
@Nullable Path getParentName(Path path) {
  Path parent = path.getParent(); // (3)
  return parent != null ? parent.getFileName() : null;
            // (4)
}           

與空安全但未注釋的版本相比,此代碼在傳回類型上添加了一個注釋。這裡有幾點值得注意:

  1. 未注釋的類型被認為是不可空的。此約定大大減少了注釋負擔,但僅适用于第一方代碼。
  2. 傳回類型被标記為@Nullable,因為該方法可以傳回null 。
  3. 局部變量parent沒有注釋,因為它的空性必須由靜态分析檢查器推斷出來。這進一步減少了注釋負擔。
  4. 檢查一個值是否為null将其類型細化為在相應的分支中不可為 null。這稱為流敏感類型,它允許以慣用的方式編寫代碼并僅在真正需要的地方處理空值。
注釋為 nullness 的代碼可以靜态檢查 null 安全性。分析器可以保護代碼庫免受回歸,并允許開發人員自信地更快地移動。

Kotlin 和 nullness

Kotlin是一種現代程式設計語言,旨在與 Java 進行互操作。在 Kotlin 中,類型中的 nullness 是顯式的,編譯器會檢查代碼是否正确處理了 nullness,進而為開發人員提供即時回報。

我們認識到這些優勢,事實上,在 Meta 中大量使用 Kotlin。但我們也認識到這樣一個事實,即有許多業務關鍵型 Java 代碼不能——有時也不應該——在一夜之間遷移到 Kotlin。

Java 和 Kotlin 這兩種語言必須共存,這意味着仍然需要針對 Java 的空安全解決方案。

大規模無效檢查的靜态分析

Meta 成功建構了其他靜态分析工具,例如Infer、Hack和Flow并将它們應用于現實世界的代碼庫,這讓我們相信我們可以為 Java 建構一個 nullness 檢查器,它是:

  1. 符合人體工程學:了解代碼中的控制流,不需要開發人員竭盡全力使他們的代碼符合要求,并增加最少的注釋負擔。
  2. 可擴充:能夠從數百行代碼擴充到數百萬行。
  3. 與 Kotlin 相容:實作無縫互操作性。
回想起來,實作靜态分析檢查器本身可能是比較容易的部分。真正的努力是将此檢查器與開發基礎設施內建,與開發人員社群合作,然後使數百萬行生産 Java 代碼成為 null-safe。

我們将第一個版本的 Java 空值檢查器作為 Infer 的一部分來實作,它是一個很好的基礎。後來,我們轉向了基于編譯器的基礎架構。與編譯器更緊密地內建使我們能夠提高分析的準确性并簡化與開發工具的內建。

分析器的第二個版本稱為 Nullsafe,我們将在下面介紹它。

引擎蓋下的空檢查

Java 編譯器 API 是通過JSR-199引入的。該 API 允許通路已編譯程式的編譯器内部表示,并允許在編譯過程的不同階段添加自定義功能。我們使用此 API 通過運作 Nullsafe 分析的額外通道來擴充 Java 的類型檢查,然後收集并報告空性錯誤。

分析中使用的兩個主要資料結構是抽象文法樹 (AST) 和控制流圖 (CFG)。有關示例,請參見清單 3 以及圖 2 和圖 3。

  • AST 表示源代碼的句法結構,沒有标點符号等多餘細節。我們通過編譯器 API 獲得程式的 AST,以及類型和注釋資訊。
  • CFG 是一段代碼的流程圖:用箭頭連接配接的指令塊表示控制流的變化。我們正在使用Dataflow庫為給定的 AST 建構 CFG。

分析本身分為兩個階段:

  1. 類型推斷階段負責找出各種代碼片段的無效性,回答如下問題:此方法調用能否在程式點 X 處傳回null ?這個變量在程式點 Y可以為空嗎?
  2. 類型檢查階段負責驗證代碼沒有做任何不安全的事情,例如取消引用可為 null 的值或在不期望的地方傳遞可為 null 的參數。

清單 3:示例getOrDefault方法

String getOrDefault(@Nullable String str, String defaultValue) {
  if (str == null) { return defaultValue; }
  return str;
}           
在 Meta 上将 null-safety 改造到 Java(譯文)

圖 2:清單 3 中代碼的 CFG。

在 Meta 上将 null-safety 改造到 Java(譯文)

圖 3:清單 3 中代碼的 AST

類型推斷階段

Nullsafe 根據代碼的 CFG 進行類型推斷。推理的結果是在不同的程式點從表達式到空擴充類型的映射。

state = expression x program point → nullness – 擴充類型

推理引擎周遊CFG并根據分析規則執行每條指令。對于清單 3中的程式,它看起來像這樣:

  1. 我們從<entry>點 的映射開始:{str → @Nullable String, defaultValue → String}。
  2. 當我們執行比較str == null時,控制流分裂,我們産生兩個映射:THEN: { str → @Nullable String, defaultValue → String } 。否則:{ str → String , defaultValue → String } 。
  3. 當控制流加入時,推理引擎需要生成一個映射,該映射過度逼近兩個分支中的狀态。如果我們在一個分支中有@Nullable String而在另一個分支中有String ,則過度近似的類型将是@Nullable String 。
在 Meta 上将 null-safety 改造到 Java(譯文)

圖 4:帶有分析結果的 CFG

使用 CFG 進行推理的主要好處是它允許我們使分析流敏感,這對于像這樣的分析在實踐中有用是至關重要的。

上面的示例示範了一個非常常見的情況,其中根據控制流改進了值的空值。為了适應現實世界的編碼模式,Nullsafe 支援更進階的功能,從我們使用 SAT 求解的契約和複雜不變量到過程間對象初始化分析。但是,對這些功能的讨論超出了本文的範圍。

類型檢查階段

Nullsafe 根據程式的 AST 進行類型檢查。通過周遊 AST,我們可以将源代碼中指定的資訊與推理步驟的結果進行比較。

在清單 3 的示例中,當我們通路return str節點時,我們擷取str表達式的推斷類型(恰好是String ),并檢查該類型是否與聲明為String的方法的傳回類型相容。

在 Meta 上将 null-safety 改造到 Java(譯文)

圖 5:在 AST 周遊期間檢查類型。

當我們看到對應于對​象取消引用的 AST 節點時,我們檢查接收器的推斷類型是否排除了null 。以類似的方式處理隐式拆箱。對于方法調用節點,我們檢查參數的推斷類型是否與方法的聲明類型相容。等等。

總的來說,類型檢查階段比類型推斷階段簡單得多。這裡的一個重要方面是錯誤呈現,我們需要使用上下文來增加類型錯誤,例如類型跟蹤、代碼來源和潛在的快速修複。

支援仿制藥的挑戰

上面給出的 nullness 分析示例僅涵蓋了所謂的根 nullness,或值本身的 nullness。泛型為語言增加了一個全新的表現力次元,同樣,可以擴充空值分析以支援泛型和參數化類,進而進一步提高 API 的表現力和精度。

支援泛型顯然是一件好事。但額外的表現力是有代價的。特别是,類型推斷變得更加複雜。

考慮一個參數化類Map<K, List<Pair<V1, V2>>> 。在非通用nullness 檢查器的情況下,隻有根 nullness 可以推斷:

// NON-GENERIC CASE
   ␣ Map<K, List<Pair<V1, V2>>
// ^
// \--- Only the root nullness needs to be inferred
           

一般情況需要在已經很複雜的流量敏感分析之上填補更多空白:

// GENERIC CASE
   ␣ Map<␣ K, ␣ List<␣ Pair<␣ V1, ␣ V2>>
// ^     ^    ^      ^      ^      ^
// \-----|----|------|------|------|--- All these need to be inferred           

這還不是全部。分析推斷出的泛型類型必須嚴格遵循Java 本身推斷出的類型的形狀,以避免僞造錯誤。例如,考慮以下代碼片段:

interface Animal {}
class Cat implements Animal {}
class Dog implements Animal {}

void targetType(@Nullable Cat catMaybe) {
  List<@Nullable Animal> animalsMaybe = List.of(catMaybe);
}           

List.<T>of(T…)是一種通用方法,孤立地List.of(catMaybe)的類型可以推斷為List<@Nullable Cat> 。這會産生問題,因為 Java 中的泛型是不變的,這意味着List<Animal>與List<Cat>不相容并且指派會産生錯誤。

此代碼類型檢查的原因是 Java 編譯器知道指派目标的類型,并使用此資訊來調整類型推理引擎在指派上下文(或相關方法參數)中的工作方式。此功能稱為目标類型,雖然它改進了使用泛型的人體工程學,但它不能很好地與我們之前描述的那種基于前向 CFG 的分析一起使用,并且需要格外小心處理。

除了上述之外,Java 編譯器本身也存在缺陷(例如this),需要在 Nullsafe 和其他使用類型注釋的靜态分析工具中采取各種變通辦法。

盡管存在這些挑戰,我們仍然看到支援仿制藥的巨大價值。尤其是:

  • 改進的人體工程學。如果不支援泛型,開發人員就無法以空感覺的方式定義和使用某些 API:從集合和功能接口到流。他們被迫繞過無效檢查器,這會損害可靠性并強化壞習慣。我們在代碼庫中發現許多地方缺少 null-safe 泛型導緻脆弱的代碼和錯誤。
  • 更安全的 Kotlin 互操作性。Meta 是 Kotlin 的重度使用者,支援泛型的 nullness 分析縮小了兩種語言之間的差距,顯着提高了互操作的安全性和異構代碼庫中的開發體驗。

處理遺留和第三方代碼

從概念上講,Nullsafe 執行的靜态分析向 Java 添加了一組新的語義規則,試圖将 null-safety 改進為其他 null-unsafe 語言。理想情況是所有代碼都遵循這些規則,在這種情況下,分析器提出的診斷是相關且可操作的。現實情況是,有很多對新規則一無所知的 null 安全代碼,而且還有更多的 null 不安全代碼。對此類遺留代碼或什至調用遺留元件的新代碼運作分析會産生過多的噪音,這會增加摩擦并破壞分析器的價值。

為了在 Nullsafe 中處理這個問題,我們将代碼分為三層:

  • 第 1 層:Nullsafe 相容代碼。這包括标記為@Nullsafe并檢查沒有錯誤的第一方代碼。這還包括已知良好的帶注釋的第三方代碼或我們為其添加了無效性模型的第三方代碼。
  • 第 2 層:不符合 Nullsafe 的第一方代碼。這是編寫的内部代碼,沒有考慮明确的 nullness 跟蹤。Nullsafe 樂觀地檢查了這段代碼。
  • 第 3 層:未經審查的第三方代碼。這是 Nullsafe 一無所知的第三方代碼。使用此類代碼時,會悲觀地檢查用途,并敦促開發人員添加适當的空性模型。

這個分層系統的重要方面是,當 Nullsafe 類型檢查調用 Tier Y代碼的 Tier X代碼時,它使用 Tier Y的規則。尤其是:

  1. 從第 1 層到第 2 層的調用被樂觀地檢查,
  2. 悲觀地檢查從第 1 層到第 3 層的調用,
  3. 根據第 1 層元件的無效性檢查從第 2 層到第 1 層的調用。

這裡有兩點值得注意:

  1. 根據 A 點,第 1 層代碼可能具有不安全的依賴項或不安全地使用的安全依賴項。這種不健全是我們在代碼庫中簡化和逐漸推出和采用 Nullsafe 所必須付出的代價。我們嘗試了其他方法,但額外的摩擦使它們極難擴充。好消息是,随着更多的第 2 層代碼遷移到第 1 層代碼,這一點變得不再那麼令人擔憂。
  2. 對第三方代碼的悲觀處理(B 點)增加了無效檢查器采用的額外阻力。但根據我們的經驗,成本并不高,而第 1 層和第 3 層代碼互操作性的安全性提高是真實的。
在 Meta 上将 null-safety 改造到 Java(譯文)

圖 6:三層空值安全規則。

部署、自動化和采用

單獨的空值檢查器不足以産生真正的影響。檢查器的效果與符合此檢查器的代碼量成正比。是以,遷移政策、開發人員采用和防止回歸成為主要關注點。

我們發現三個要點對我們的計劃取得成功至關重要:

  1. 快速修複非常有用。代碼庫充滿了瑣碎的空安全違規。教授靜态分析不僅可以檢查錯誤,還可以提出快速修複,可以涵蓋很多領域,并為開發人員提供進行有意義的修複的空間。
  2. 開發人員的采用是關鍵。這意味着檢查器和相關工具應該與主要開發工具很好地內建:建構工具、IDE、CLI 和 CI。但更重要的是,應用程式和靜态分析開發人員之間應該有一個有效的回報循環。
  3. 資料和名額對于保持勢頭很重要。了解您所在的位置、您取得的進展以及接下來要解決的最佳問題确實有助于促進遷移。

長期可靠性影響

例如,檢視 Instagram Android 應用程式 18 個月的可靠性資料:

  • 應用代碼中符合 Nullsafe 的部分從 3% 增長到 90%。
  • 所有釋出管道中NullPointerException (NPE) 錯誤的相對數量顯着減少(參見圖 7)。特别是在生産中,NPE 的數量減少了 27%。

此資料針對其他類型的崩潰進行了驗證,并顯示了應用程式的可靠性和空安全性方面的真正改進。

同時,個别産品團隊還報告說,在解決了 Nullsafe 報告的無效錯誤後,NPE 崩潰的數量顯着減少。

生産 NPE 的下降因團隊而異,改善幅度 從 35% 到 80%不等。

結果的一個特别有趣的方面是alpha 通道中 NPE 的急劇下降。這直接反映了使用和依賴 nullness 檢查器帶來的開發人員工作效率的提高。

我們的北極星目标和理想場景是完全消除 NPE。然而,現實世界的可靠性是複雜的,并且有更多因素在起作用:

  • 實際上,仍然存在不安全的空代碼,這導緻了大部分頂級 NPE 崩潰。但現在我們所處的位置是,有針對性的零安全改進可以産生重大而持久的影響。
  • 崩潰的數量并不是衡量可靠性改進的最佳名額,因為進入生産環境的一個錯誤可能會變得非常嚴重,并單槍匹馬地扭曲結果。一個更好的名額可能是每個版本新的獨特崩潰的數量,我們看到了n倍的改進。
  • 并非所有 NPE 崩潰都是由應用程式代碼中的錯誤單獨引起的。用戶端和伺服器之間的不比對是生産問題的另一個主要來源,需要通過其他方式解決。
  • 靜态分析本身具有局限性和不合理的假設,進而導緻某些錯誤進入生産環境。

重要的是要注意,這是數百名工程師使用 Nullsafe來提高其代碼安全性以及其他可靠性舉措的效果的總和,是以我們不能将改進僅歸因于 Nullsafe 的使用。然而,根據過去幾年的報告和我們自己的觀察,我們相信 Nullsafe 在減少與 NPE 相關的崩潰方面發揮了重要作用。

在 Meta 上将 null-safety 改造到 Java(譯文)

圖 7:按釋出管道劃分的 NPE 崩潰百分比。

超越元

上面列出的問題幾乎不是 Meta 特有的。意想不到的null -dereferences 在不同的公司引起了無數的問題。像 C# 這樣的語言演變成在它們的類型系統中具有顯式的 nullness,而其他語言,如 Kotlin,從一開始就有它。

談到 Java,從JSR-305開始,曾多次嘗試添加空性,但沒有一次取得廣泛成功。目前,有許多很棒的 Java 靜态分析工具可以檢查 nullness,包括 CheckerFramework、SpotBugs、ErrorProne 和 NullAway 等。特别是,Uber通過使用 NullAway 檢查器使他們的 Android 代碼庫 null-safe走上了同樣的道路。但最終,所有檢查員都以不同且微妙地不相容的方式執行無效性分析。缺乏具有精确語義的标準注解限制了整個行業對 Java 靜态分析的使用。

這個問題正是JSpecify 工作組旨在解決的問題。JSpecify 始于 2019 年,是代表谷歌、JetBrains、優步、甲骨文等公司的個人之間的合作。自 2019 年底以來,Meta 也已成為 JSpecify 的一部分。

盡管nullness 标準尚未最終确定,但規範本身和工具方面已經取得了很大進展,很快就會有更多令人興奮的公告釋出。參與 JSpecify 也影響了我們在 Meta 對 Java 的 nullness 和我們自己的代碼庫演變的看法。

作者:Artem Pianykh、Ilya Zorin、Dmitry Lyubarskiy

出處:https://engineering.fb.com/2022/11/22/developer-tools/meta-java-nullsafe/

繼續閱讀