天天看點

為何需要關心二進制相容性?

語義化版本号

一般來說,版本号推薦采用 Semantic Versioning 中推薦的格式(major.minor.patch ),通過語義化的版本格式,清晰地傳達庫作者對于各個版本之間的相容性保證語義。以 Akka 為例,在 2.3.x 版本以前,使用的是 epoch.major.minor 的格式,而從 2.4.0 版本開始,版本号的格式也切換為了更通用、更易了解的 major.minor.patch 格式。庫的使用者,通過版本号則能夠清晰的識别出 Akka 在各個版本之間提供的向後相容性保障,比如:

為何需要關心二進制相容性?

同一個 major 版本下各個 minor 和 patch 版本之間,我們保持了向後二進制相容性(backwards binary compatibility),除非特别說明,本文後續所有的相容性,都指向後相容性。向後的二進制相容性指:一個具備向後相容性的新版本Jar,可以直接替換掉其對應的舊版本,而不引發任何問題,這裡主要指對公開程式設計接口的二進制相容性保障。

保障 minor 和 patch 版本的二進制相容性也不一定就意味着開發疊代就束縛了手腳。我們可以通過顯式的标注:ApiMayChange 、 Internal 來清晰的傳達語義。

有意義的版本号清晰的傳達了語義,簡化了日常開發工作中的包管理工作。在版本進行更新和收斂過程中,希望可以提供清晰的變更曆史(changelog)。而在兩個二進制不相容版本之間,希望可以提供清晰的遷移指引(migration guide)。

什麼是二進制相容性

得益于完善的自動化釋出工具鍊,我們進行庫的釋出也是非常友善的。而友善快捷的開發支援并不意味着我們可以快速疊代而忽略庫其他方面的要素,比如在編寫和釋出二方包時對二進制相容性的考慮。接下來将會讨論:

·二進制相容性為何會導緻應用運作時錯誤

·如何通過良好的設計、流程以及通過工具來盡可能地保持版本間的二進制相容性

·如何評估評估潛在的二進制相容性問題

受限于本文的篇幅,本文将不會讨論:

·序列化的相容性

·網絡協定的相容性

·JVM上的同一種程式設計語言的不同版本之間的二進制相容性

·JVM上的不同程式設計語言不同版本之間的互操作性

推薦閱讀:

  1. 感興趣的同學可以擴充學習 Java 各版本之間的相容性相關資料
  2. Groovy、Kotlin 、Scala 等 JVM 程式設計在不同的 Java 版本上的編譯産物差異

▐ JVM 的執行流程

JVM 上的程式設計語言在通過編譯器編譯後,會将我們的代碼轉換為平台無關的 JVM 位元組碼,并存儲在 .class 檔案中, .class 檔案打包在 Jar 檔案中進行分發。

推薦:可以通過 jd-gui 或則 recaf 等工具來解讀 .class 檔案,進一步加深了解。

當我們的代碼依賴于某個庫時,在編譯産物中,結果位元組碼 引用 (references) 了對應庫類/成員的位元組碼。在應用運作并引用到了對應的類/成員 被時,JVM 将會使用 Classloader 結合 類/成員的簽名,進而加載對應的位元組碼。這時如果沒有找到對應的類/成員,就會報錯,最直覺的感受就是應用啟動失敗,或則在運作過程中出現錯誤。一般來說,在更新相關依賴時,都需要仔細閱讀對應版本的變更曆史和相容性聲明,并通過人工和自動化回歸測試等方式來進行保障。

為了避免這些錯誤,應用執行過程中所需位元組碼的相關庫都需要正确提供。如果确定不會用到某些執行路徑上的相關位元組碼,則可以通過通過排除相關依賴,對最終産物的大小進行縮減。因為 JVM 對位元組碼進行懶加載的緣故,經常在應用運作過程中我們才陸續發現相關的二進制相容性問題,對應的異常基本都是 java.lang.LinkageError 及其子類,比如: AbstractMethodError 、 NoSuchMethodError 、 NoSuchFieldError 、 NoClassDefFoundError 等。

為何需要關心二進制相容性?

GraalVM、Scala-Native、Scala-JS 等可靜态連結,報錯的時機不一樣,這裡不展開讨論。但本質上也是連結錯誤。

感興趣的讀者可以搜尋:Native Image Compatibility and Optimization Guide

建構工具的依賴管理和仲裁

在應用的過程中如果用到了某個類,JVM 将通過 Classloader 從 classpath 加載對應的類,并使用其第一個比對的類。如果比對到的和我們實際代碼正确執行需要有出入,就會導緻各種運作時錯誤或則行為改變。為了避免上面的問題,業界早已有各種基于 Classloader 的隔離方案,比如 OSGI 标準、螞蟻的 sofa 容器等。建構工具無論是 Gradle 、Maven 還是 SBT 都提供了預設的版本驅逐/仲裁規則。其中Gradle和SBT的預設規則是使用最新的版本,而Maven的仲裁規則稍微複雜一些,簡單認為是:版本管理聲明優先、先聲明優先、最短路徑優先。

可以通過 arthas 檢視對應的類被加載的版本等資訊

目前使用最廣泛的還是 Maven,如果遇到了版本沖突等問題,大家可能最簡單的想法就是:更新到相關依賴庫的最新版本。然而,很多情況下,因為二進制不相容,導緻并沒有這樣簡單直接的解法,下面将會展開講一下為什麼可能會陷入這樣的困境。

▐ 源碼相容性和二進制相容性

源碼相容性

源碼相容的兩個庫可以互相替換,不會新增加任何的編譯錯誤和行為改變(不相容的行為改變可能會引發對應的源碼修改)。比如從 akka-actor 的 2.6.10 版本 更新到 2.6.11 版本 不會引入任何的編譯錯誤、也不會引起語義改變,那麼我們可以說 akka-actor 的 2.6.11 版本對其 2.6.10 版本是源碼相容的。

說明:這裡的兩個版本都使用Java 1.8 進行編譯, 并且使用了相同的 Scala版本。

二進制相容性

二進制相容的兩個庫可以互相替換,而不會引發任何的連結錯誤(LinkageError)。比如 某個人對内提供的包在 1.3.13 到 1.3.14 的兩個版本之間隻是修改了某個方法的内部實作(邏輯優化)。此時我們說 1.3.13 和 1.3.14 這兩個版本之間是二進制相容的。

源碼相容和二進制相容的關系

通常來說,破壞源碼相容性也會破壞二進制相容性,而破壞二進制相容性,則不一定破壞了破壞源碼相容性。比如在原來的某個方法基礎上增加了一個參數,那麼這個時候我同時破壞了源碼相容性和二進制相容性。而如果我在不修改方法簽名的情況下,在之前的版本使用Java 6編譯,在新的版本實作中,我在内部實作利用了部分Java 8的特性和文法并使用Java 8 進行編譯和釋出。那麼這個時候新的版本沒有破壞源碼相容性,卻破壞了二進制相容性;此時在JVM 6 上運作會報錯。

▐ 向前和向後相容性

廣義的相容性分為向前相容(Forward Compatible)和向後相容(Backward Compatible),分别是是站在同一個庫的不同版本來說的。

·向後相容:在需要用到某個庫時,可以使用新版本的某個庫替換其舊版本,而不會引發問題,這也是我們通常的關注的。比如 guava-jre 的 20.0 版本可以替換 guava-jre 的 19.0 版本(沒有使用非相容方法)。我們在讨論某個庫的新版本不相容的時候,通常指其沒有提供向後相容性。例如:Guava 的向後相容性說明

·向前相容:在需要使用某個庫時,可以使用舊版本的某個庫替換其新版本,而不會引發問題。通常我們需要使用SchemaRegistery等來達到目的(比如舊版本還可以讀取新版本寫入的資料),向前相容使我們提供的用戶端庫可以獨立伺服器端進行多版本演進。還有一些則是為了生态平滑更新做出的努力:Forward Compatibility for the Scala 3 Transition

為何需要關心二進制相容性?

在上圖中,産物 A 依賴了庫 B ;如果可以使用 B V1 代替 B V2 ,則說 B V1 是向前相容的;如果可以使用 B V3 代替 B V2 ,則說 B V3 是向後相容的。

二進制相容性的重要性

正常的情況下,如果應用更新/引入一個庫在啟動、回歸測試中都沒問題,那麼這次更新是簡單的,如果啟動失敗或則運作錯誤,則就需要開發同學進一步介入,開始漫長的排包、修複、測試過程。

·針對二進制不相容的庫,開發同學需要把不相容的版本進行剔除;這個過程比較耗時、容易出錯。借助于MavenHelper 等依賴分析工具可以節省一部分心力。

·而某些二進制不相容問題比較複雜,處理起來需要相關依賴上下遊進行整體更新,耗時耗力。

為何需要關心二進制相容性?

上圖中,應用 A 同時依賴了庫 B 和 庫 C;而庫 B V1 依賴庫 D V1 , 但是這些方法在 D V2 中被删除了,而在 C V2 中又開始使用了僅存在于 D V2 的一些方法。這個時候, D V2 相對于 D V1 不具備向後二進制相容性。作為應用 A來說,就陷入了困境,無論排除掉庫的 V1 還是 V2 版本,都會引發錯誤。唯一的辦法是讓 B 遷移到 依賴 D V2 ;或 B 和 C 都更新到的另一個共同相容版本進行編譯釋出,然後 A 再更新。

為何需要關心二進制相容性?

有時候,新功能開發需要引入一個新的依賴,但是新的依賴又會引入二進制不相容性,這個時候開發新功能,就會破壞舊功能;保留舊功能,就不好開發新功能。通常把這種情況叫做“依賴地獄”(dependency hell)。如上圖,如果我們需要引入一個新的功能需要用到庫 F ,而 F 依賴了庫 D V3 , 如果此時 D V3 相對于 D V2 不是二進制相容的,那麼我們就必須要 更新 B V2 和 C V2 ,使它們依賴于 D V3 進行開發,進而才可以安全地引入 F 。

由上可見,保持一個二方包在各個 minor 和 patch 版本之間的相容性對于一個普通的開發同學的幸福感來說是多麼的重要。通過盡可能地保持二進制相容性,庫的消費者在遇到依賴沖突時,可以排除掉舊版本的包,直接使用新版本的包,而不會陷入進退維谷的境地。

如何保持二進制相容性

保持二進制相容性,除了依賴工具來進行檢查,還有一些明顯可以推斷出會引發二進制相容性問題的。對于在 JVM 上的一些語言,可能會在保持了源碼相容性的情況下損壞二進制相容性,這裡不作為展開,下面都描述的是使用 Java 時需要注意的點。

1. 保持較低的 Java 目标版本

在同一個大版本的多個小版本之間,盡可能地使用相同的 source 或則 target 進行釋出庫。比如針對于 Java 1.6 或則 Java 1.8 釋出,在整個過程中不要輕易地修改。通過克制和顯式地聲明庫編寫時使用的語言特性和目标版本,降低庫消費者的使用成本。如果需要使用更新的語言特性,則需要使用新的 major 版本進行重新釋出,或則多針對多個版本進行差異化編譯。

2. 完整的方法廢棄過程

保持相容性的多個版本間,對于廢棄的方法或字段不要輕易地進行删除或修改,使用 @Deprecated 顯式地标注廢棄的原因和替代,給出清晰的指引,并在變更日志中指出。保留2個 minor 版本之後,則可以通過流程卡點、公告等方式來告知相關方,版本的收斂情況則可以通過各種工具來檢視。

3. 不要輕易修改成員可見性

對于公開提供的 API 方法、字段需要盡可能地克制,保持相容性的多個版本間,預設内部實作的方法不要對外進行公開,如果需要對可見性進行封閉,則需要使用上面描述的廢棄過程來進行逐漸收斂。改變可見性後将會破壞源碼相容性和二進制相容性。如果不得不公開,則可以通過顯式的标注該成員僅供内部使用來進行聲明。

4. 不要輕易修改方法的簽名

在保持相容性的多個版本間,盡可能地使用複合參數對象,比如 QueryMessageRequest 來代替多個參數的方法重載,進而友善地保持向後相容性。如果已經采用了多參數的方式,則可以遷移到新的複合參數的方法上,比如都代理到 private queryMessage0(final QueryMessageRequest request) 。并通過 2 中描述的過程來對以前已經暴露的方法進行廢棄。直接修改方法簽名将會破壞二進制相容性和源碼相容性。

5. 不要輕易删除類的公開成員

在保持相容性的多個版本間,不要删除一個類的方法和字段,如果要删除則需要經過 2 中描述的過程進行廢棄。直接删除方法簽名将會破壞二進制相容性和源碼相容行。

6. 修改二進制相容性後可以選擇更新包名

在 major 的版本更新中,我們可以修改包名,比如從 org.apache.commons.lang2 改為 org.apache.commons.lang3 ,通過這樣的方式可以很好的避免 2個版本之間的二進制相容性問題。不過包名看起來相對來說會怪一點。與此同時,RxJava 3 在版本釋出的過程中也采用了類似的 package 路徑隔離方式。

7. 其他會引發相容性問題的修改

為何需要關心二進制相容性?

8. (推薦)使用工具來進行檢查

在釋出二方包的過程中,推薦使用各種編譯插件來檢查二進制相容性,Java 、Kotlin 和 Scala 生态都有對應的二進制相容性檢查工具。以最近測試過的:japicmp 插件為例,如果我進行了不安全的修改,在打包的時候會非常清晰地給出指引,并生成對應的報告。

配置好目前的版本,以及舊版本之後,japicmp 會在打包過程中給出清晰的訓示。例如:

為何需要關心二進制相容性?
為何需要關心二進制相容性?

推薦閱讀

Revapi is a tool for API analysis and change tracking.

japicmp is a tool to compare two versions of a jar archive:

japi-compliance-checker — a tool for checking backward binary and source-level compatibility of a Java library API.

binary-compatibility-validator ——The tool allows to dump binary API of a Kotlin library

小結

在本文中,我們分享了什麼是二進制相容性,不保持二進制相容性可能帶來的問題是什麼、保持二進制相容性對于使用者側的來說可以帶來什麼好處,以及如何通過工具來避免常見的二進制相容性破壞。如果所有的庫作者都盡可能地保持二進制相容性,通過版本号來顯化的傳遞潛在的二進制相容性問題,并在庫的變更記錄和更新指引中顯式給出指導,那麼作為庫的使用方将會更少、更加容易的處理依賴相關的問題。

參考連結:

·

https://semver.org/lang/zh-CN/ https://github.com/java-decompiler/jd-gui https://www.graalvm.org/reference-manual/native-image/Limitations/#features-incompatible-with-closed-world-optimization https://github.com/alibaba/arthas https://github.com/google/guava/wiki/Compatibility#backward-compatibility https://www.scala-lang.org/blog/2020/11/19/scala-3-forward-compat.html#forward-compatibility https://siom79.github.io/japicmp/ https://revapi.org/revapi-site/index.html https://github.com/siom79/japicmp https://github.com/lvc/japi-compliance-checker

·di

https://github.com/Kotlin/binar