函式庫作者的二進位相容性

語言

作者:Jacob Wang

簡介

多樣化且全面的函式庫對任何高效能的軟體生態系統都很重要。雖然開發和散布 Scala 函式庫很簡單,但好的函式庫作者不只是撰寫程式碼並發布它而已。

在本指南中,我們將探討二進位相容性這個重要主題

  • 二進位相容性如何導致應用程式的生產失敗
  • 如何避免破壞二進位相容性
  • 如何推理和傳達程式碼變更的影響

在開始之前,讓我們了解一下程式碼是如何在 Java 虛擬機器 (JVM) 上編譯和執行的。

JVM 執行模型

Scala 編譯成一個與平台無關的格式,稱為JVM 位元組碼,並儲存在 .class 檔案中。這些類別檔案會整理在 JAR 檔案中以供分發。

當某些程式碼依賴於函式庫時,其編譯的位元組碼會參照函式庫的位元組碼。函式庫的位元組碼會由其類別/方法簽章參照,並在執行期間由 JVM 類別載入器延遲載入。如果找不到與簽章相符的類別或方法,則會擲回例外。

此執行模型的結果

  • 由於函式庫的位元組碼僅被參照,而不是合併到其使用者的位元組碼中,因此在啟動應用程式時,我們需要提供依賴樹中使用的每個函式庫的 JAR
  • 由於延遲載入,遺失的類別/方法問題可能只會在應用程式執行一段時間後浮現。

類別載入失敗的常見例外包括 InvocationTargetExceptionClassNotFoundExceptionMethodNotFoundExceptionAbstractMethodError

讓我們用一個範例來說明這一點

考慮一個應用程式 App,它依賴於 A,而 A 本身又依賴於函式庫 C。在啟動應用程式時,我們需要提供 AppAC 的所有類別檔案(類似於 java -cp App.jar:A.jar:C.jar:. MainClass)。如果我們沒有提供 C.jar,或者我們提供的 C.jar 不包含 A 呼叫的一些類別/方法,則當我們的程式碼嘗試呼叫遺失的類別/方法時,我們將會得到類別載入例外。

這些就是我們所謂的連結錯誤 – 發生於編譯的位元組碼參照在執行期間無法解析的名稱時。

Scala.js 和 Scala Native 呢?

與 JVM 類似,Scala.js 和 Scala Native 各自有等同於 .class 檔案的 .sjsir 檔案和 .nir 檔案。與 .class 檔案類似,它們分佈在 .jar 中,並在最後連結在一起。

然而,與 JVM 相反,Scala.js 和 Scala Native 在連結時間連結它們各自的 IR 檔案,因此是熱切的,而不是在執行期間懶惰地連結。無法正確連結整個程式會導致在嘗試呼叫 fastOptJS/fullOptJSnativeLink 時報告連結錯誤。

此外,連結錯誤時機的不同,這些模型極為類似。除非另有說明,否則本指南的內容同樣適用於 JVM、Scala.js 和 Scala Native。

在我們探討如何避免二進位不相容錯誤之前,讓我們先建立一些我們將在指南的其餘部分使用的關鍵術語。

什麼是驅逐、來源相容性和二進位相容性?

驅逐

當在執行期間需要一個類別時,JVM 類別載入器會從類別路徑載入第一個匹配的類別檔案(任何其他匹配的類別檔案都會被忽略)。因此,在類別路徑中擁有同一個函式庫的多個版本通常是不理想的

  • 需要擷取並綑綁多個函式庫版本,而實際上只使用一個
  • 如果類別檔案的順序改變,則會有意外的執行期間行為

因此,sbt 和 Gradle 等建置工具會在解析要使用於編譯和封裝的 JAR 時選擇一個版本,並驅逐其餘版本。預設情況下,它們會選擇每個函式庫的最新版本,但如果需要,可以指定另一個版本。

來源相容性

兩個程式庫版本彼此來源相容,如果將其中一個換成另一個不會產生任何編譯錯誤或意外的行為變更(語意錯誤)。
例如,如果我們可以將依賴項的 v1.0.0 升級到 v1.1.0,並且在沒有任何編譯錯誤或語意錯誤的情況下重新編譯我們的程式碼,則 v1.1.0v1.0.0 來源相容。

二進位相容性

兩個程式庫版本彼此二進位相容,如果這些版本的已編譯位元組碼可以互換而不會導致連結錯誤。

來源和二進位相容性之間的關係

雖然破壞來源相容性通常也會導致二進位不相容,但它們實際上是正交的 - 破壞其中一個並不表示破壞另一個。

向前和向後相容性

當我們描述程式庫版本的相容性時,有兩個「方向」

向後相容表示較新的程式庫版本可以在預期較舊版本的環境中使用。在討論二進位和來源相容性時,這是常見且暗示的方向。

向前相容表示較舊的程式庫可以在預期較新版本的環境中使用。向前相容性通常不適用於程式庫。

讓我們來看一個範例,其中程式庫 A v1.0.0 與程式庫 C v1.1.0 一起編譯。

Forward and Backward Compatibility

C v1.1.0 向前二進位相容v1.0.0,如果我們可以在執行時期使用 v1.0.0 的 JAR,而不是 v1.1.0 的 JAR,而不會產生任何連結錯誤。

C v1.2.0 向後二進位相容v1.1.0,如果我們可以在執行時期使用 v1.2.0 的 JAR,而不是 v1.1.0 的 JAR,而不會產生任何連結錯誤。

為什麼二進位相容性很重要

二進位相容性很重要,因為破壞二進位相容性會對軟體周圍的生態系產生不良後果。

  • 最終使用者必須在其所有相依樹中遞迴更新版本,以便它們二進位相容。這個過程很耗時且容易出錯,而且它可能會改變最終程式的語意。
  • 函式庫作者需要更新其函式庫相依項,以避免「落後」並為其使用者造成相依地獄。頻繁的二進位中斷會增加維護函式庫所需的工作。

函式庫中持續的二進位相容性中斷,特別是其他函式庫使用的函式庫,對我們的生態系有害,因為它們需要最終使用者和相依函式庫的維護人員花費時間和精力來解決。

讓我們來看一個二進位不相容可能會造成悲傷和挫折的範例

「相依地獄」的範例

我們的應用程式 App 相依於函式庫 AB。函式庫 AB 都相依於函式庫 C。最初,函式庫 AB 都相依於 C v1.0.0

Initial dependency graph

稍後,我們看到 B v1.1.0 已可用,並在我們的建置中升級其版本。我們的程式碼編譯且看似運作良好,因此我們將其推送到生產環境並回家吃晚餐。

不幸的是,在凌晨 2 點,我們收到客戶的緊急來電,表示我們的應用程式損壞了!檢視記錄檔後,您發現 A 中的某些程式碼擲出許多 NoSuchMethodError

Binary incompatibility after upgrading

我們為何會收到 NoSuchMethodError?請記住,A v1.0.0 是使用 C v1.0.0 編譯的,因此呼叫 C v1.0.0 中可用的方法。
雖然 B v1.1.0App 已使用 C v2.0.0 重新編譯,但 A v1.0.0 的位元組碼並未變更 - 它仍呼叫現已在 C v2.0.0 中不存在的方法!

只有確保所選的 C 版本與相依性樹中所有其他已移除的 C 版本二進位相容,才能解決此情況。在本例中,我們需要一個新的 A 版本,它相依於 C v2.0.0(或任何其他與 C v2.0.0 二進位相容的未來 C 版本)。

現在,假設 App 較為複雜,且有許多相依性本身相依於 C(直接或傳遞相依),因此升級任何相依性變得極為困難,因為它現在會拉入與相依性樹中其他 C 版本不相容的 C 版本!

在以下範例中,我們無法升級至 D v1.1.1,因為它會傳遞拉入 C v2.0.0,這會因為二進位相容性而導致中斷。無法在不中斷任何情況下升級任何套件,這通常稱為相依性地獄

Dependency Hell

我們身為程式庫作者,如何才能讓我們的使用者免於執行時期錯誤和相依性地獄?

  • 使用遷移管理員 (MiMa) 來在釋出新程式庫版本前,找出意外的二進位相容性中斷。
  • 透過仔細設計和演進您的程式庫介面,避免中斷二進位相容性
  • 透過版本控管清楚地傳達二進位相容性中斷

MiMa - 檢查與先前程式庫版本相較的二進位相容性

MiMa 是一個用於診斷不同程式庫版本之間二進位相容性的工具。
它的運作方式是比較兩個提供的 JAR 的類別檔,並回報任何發現的二進位相容性中斷。交換 JAR 的輸入順序,可以偵測向後和向前的二進位相容性中斷。

透過將 MiMa 的 sbt 外掛程式 整合到您的 sbt 建置中,您可以輕鬆檢查是否意外地引入了二進位相容性變更。有關如何使用 sbt 外掛程式的詳細說明,請參閱連結。

我們強烈建議每個程式庫作者將 MiMa 整合到他們的持續整合和發行工作流程中。

由於 Scala 的語言功能(例如隱含和命名參數),使用 Scala 來偵測向後原始碼相容性很困難。檢查向後原始碼相容性的最佳近似值是執行向前和向後二進位相容性檢查,因為這可以偵測到大多數原始碼不相容變更的案例。例如,新增/移除公開類別成員是原始碼不相容變更,而且會透過向前 + 向後二進位相容性檢查來偵測到。

在不中斷二進位相容性的情況下,演進程式碼

二進位相容性中斷通常可以透過仔細使用某些 Scala 功能,以及修改程式碼時可以套用的某些技術來避免。

例如,這些語言功能的使用是函式庫版本中二進位相容性中斷的常見來源

  • 方法或類別的預設參數值
  • 案例類別

您可以在 二進位相容性程式碼範例和說明 中找到詳細說明、可執行範例和維護二進位相容性的秘訣。

再次,我們建議使用 MiMa 再次檢查您在變更後是否中斷了二進位相容性。

以向後相容的方式變更案例類別定義

有時,您會希望變更案例類別的定義(新增和/或移除欄位),同時仍與案例類別的現有用法保持向後相容性,亦即不中斷所謂的二進位相容性。您應該問自己的第一個問題是「您是否需要案例類別?」(與一般類別相反,一般類別在二進位相容性方面較容易演化)。使用案例類別的一個好理由是,當您需要 equalshashCode 的結構化實作時。

為達成此目的,請遵循此模式

  • 將主要建構函式設為私人(這也會將類別的 copy 方法設為私人)
  • 在伴隨物件中定義一個私人 unapply 函式(請注意,這樣做會讓案例類別失去在比對運算式中用作萃取器的能力)
  • 對於所有欄位,在案例類別上定義 withXXX 方法,以建立一個新的執行個體,並變更相關欄位(您可以使用私人 copy 方法來實作這些方法)
  • 透過在伴隨物件中定義 apply 方法來建立一個公開建構函式(它可以使用私人建構函式)
  • 在 Scala 2 中,您必須新增編譯器選項 -Xsource:3

範例

// Mark the primary constructor as private
case class Person private (name: String, age: Int) {
  // Create withXxx methods for every field, implemented by using the (private) copy method
  def withName(name: String): Person = copy(name = name)
  def withAge(age: Int): Person = copy(age = age)
}

object Person {
  // Create a public constructor (which uses the private primary constructor)
  def apply(name: String, age: Int) = new Person(name, age)
  // Make the extractor private
  private def unapply(p: Person): Some[Person] = Some(p)
}
// Mark the primary constructor as private
case class Person private (name: String, age: Int):
  // Create withXxx methods for every field, implemented by using the (private) copy method
  def withName(name: String): Person = copy(name = name)
  def withAge(age: Int): Person = copy(age = age)

object Person:
  // Create a public constructor (which uses the private primary constructor)
  def apply(name: String, age: Int): Person = new Person(name, age)
  // Make the extractor private
  private def unapply(p: Person) = p

此類別可以在函式庫中發布,並以如下方式使用

// Create a new instance
val alice = Person("Alice", 42)
// Transform an instance
println(alice.withAge(alice.age + 1)) // Person(Alice, 43)

如果您嘗試在 match 表達式中將 Person 用作萃取器,它會失敗並顯示類似「無法存取方法 unapply 作為 Person.type 的成員」的訊息。您可以將其用作類型化樣式

alice match {
  case person: Person => person.name
}
alice match
  case person: Person => person.name

稍後,您可以修改原始案例類別定義,例如新增一個選用的 address 欄位。您

  • 新增一個新欄位 address 和一個自訂的 withAddress 方法,
  • 更新共用物件中的公開 apply 方法,以初始化所有欄位,
  • 指示 MiMa 忽略 類別建構函式的變更。此步驟是必要的,因為 MiMa 尚未忽略私人類別建構函式簽章的變更(請參閱 #738)。
case class Person private (name: String, age: Int, address: Option[String]) {
  ...
  def withAddress(address: Option[String]) = copy(address = address)
}

object Person {
  // Update the public constructor to also initialize the address field
  def apply(name: String, age: Int): Person = new Person(name, age, None)
}
case class Person private (name: String, age: Int, address: Option[String]):
  ...
  def withAddress(address: Option[String]) = copy(address = address)

object Person:
  // Update the public constructor to also initialize the address field
  def apply(name: String, age: Int): Person = new Person(name, age, None)

而且,在您的建置定義中

import com.typesafe.tools.mima.core._
mimaBinaryIssueFilters += ProblemFilters.exclude[DirectMissingMethodProblem]("Person.this")

否則,MiMa 會失敗並顯示類似「Person 類別中的方法 this(java.lang.String,Int)Unit 在目前版本中沒有對應項」的錯誤。

請注意,一種替代方案是新增回先前的建構函式簽章作為次要建構函式,而不是新增 MiMa 排除篩選器

case class Person private (name: String, age: Int, address: Option[String]):
  ...
  // Add back the former primary constructor signature
  private[Person] def this(name: String, age: Int) = this(name, age, None)

原始使用者可以使用案例類別 Person,就像以前一樣,所有在這個變更之前就存在的 method 都會在這個變更後保持不變,因此與現有使用方式的相容性得以維持。

新欄位 address 可以如下使用

// The public constructor sets the address to None by default.
// To set the address, we call withAddress:
val bob = Person("Bob", 21).withAddress(Some("Atlantic ocean"))
println(bob.address)

一個沒有遵循此樣式的常規案例類別會中斷其使用,因為新增一個新欄位會變更一些方法(其他人可能會使用這些方法),例如 copy 或建構函式本身。

選擇性地,你也可以在伴隨物件中加入 apply 方法的重載,以便在一次呼叫中初始化更多欄位。在我們的範例中,我們可以加入一個重載,它也會初始化 address 欄位

object Person {
  // Original public constructor
  def apply(name: String, age: Int): Person = new Person(name, age, None)
  // Additional constructor that also sets the address
  def apply(name: String, age: Int, address: String): Person =
    new Person(name, age, Some(address))
}
object Person:
  // Original public constructor
  def apply(name: String, age: Int): Person = new Person(name, age, None)
  // Additional constructor that also sets the address
  def apply(name: String, age: Int, address: String): Person =
    new Person(name, age, Some(address))

版本控制架構 - 傳達相容性中斷

函式庫作者使用版本控制架構,以便向其使用者傳達函式庫版本之間的相容性保證。例如 語意化版本控制 (SemVer) 等版本控制架構,讓使用者可以輕鬆地推論更新函式庫的影響,而不需要閱讀詳細的版本說明。

在下一個章節中,我們將概述一個基於語意化版本控制的版本控制架構,我們強烈建議你將其採用於你的函式庫。下列列出的規則除了語意化版本控制 v2.0.0 之外

給定一個版本號碼 MAJOR.MINOR.PATCH,你必須增加

  1. MAJOR 版本,如果後向二進位相容性中斷,
  2. MINOR 版本,如果後向原始碼相容性中斷,以及
  3. PATCH 版本,表示既沒有二進位也不相容性

根據 SemVer,修補版本應僅包含修正錯誤行為的錯誤修正,因此方法/類別中的主要行為變更應導致次要版本升級。

  • 當主要版本為 0 時,次要版本升級可能包含原始碼和二進位中斷

一些範例

  • v1.0.0 -> v2.0.0二進位不相容。最終使用者和函式庫維護者需要更新其所有相依圖形,以移除對 v1.0.0 的所有相依性。
  • v1.0.0 -> v1.1.0二進位相容。類別路徑可以安全地包含 v1.0.0v1.1.0。最終使用者可能需要修正引進的次要原始碼中斷變更
  • v1.0.0 -> v1.0.1原始碼和二進位相容。這是一個安全的升級,不會引進二進位或原始碼不相容性。
  • v0.4.0 -> v0.5.0二進位不相容。最終使用者和函式庫維護人員需要更新所有依賴圖表,以移除對 v0.4.0 的所有依賴性。
  • v0.4.0 -> v0.4.1二進位相容。類別路徑可以安全地包含 v0.4.0v0.4.1。最終使用者可能需要修正引進的次要原始碼中斷變更

Scala 生態系統中的許多函式庫都採用了這種版本編號方案。以下是一些範例:AkkaCatsScala.js

結論

為什麼二進位相容性如此重要,以至於我們建議使用主要版本號碼來追蹤它?

從我們上述的 範例,我們學到了兩個重要的教訓

  • 二進位不相容的版本通常會導致依賴性地獄,讓您的使用者無法更新任何函式庫,而不會中斷他們的應用程式。
  • 如果新的函式庫版本是二進位相容但原始碼不相容,使用者可以修正編譯錯誤,而他們的應用程式應該可以正常運作。

因此,如果可能,應避免二進位不相容的版本,並在發生時明確記錄,保證使用主要版本號碼。您的函式庫使用者可以享受簡單的版本升級,並在因二進位不相容的版本而需要調整依賴性樹中的函式庫版本時獲得明確的警告。

如果我們遵循本指南中列出的所有建議,我們作為一個社群可以花更少時間解開依賴性地獄,並花更多時間建立酷炫的東西!

此頁面的貢獻者