版本的故事(四)版本號有多重要

上一篇談了怎樣給版本取個好名字,也就是版本號。說明了「語義化版本」的命名規範,也說明了這一種命名規範在依賴管理中發揮的重要作用。今天繼續談語義化版本號,說明一下這種命名方式的重要性,以及對研發和運維過程的影響。

開發人員每一次向下游工序交付一個版本,都必須為這個版本編一個號碼,這就是版本號(也稱作版本 ID)。如果每次交付都是同樣的版本號,隨着時間的推進就會產生很多版本號相同、但是功能不一樣的二進制包,在這種情況下部署,你可以想像會遇到多少驚奇。對於每一次生成二進制包,都應該分配一個唯一標識,對於審計來說,這是非常重要的。最顯而易見的方法,是使用二進制包的散列值作為唯一標識,以便可以驗證生成二進制包的源代碼是否正確。當你不確定某個環境到底部署了哪個版本的時候,可以使用文件的 MD5 碼找出版本庫中對應的版本。一些二進制產物管理平台可以自動提取散列碼。但是以散列碼作為標識有一些缺點,很明顯的一個缺點是散列碼太長,與他人溝通的時候很難記得住(你很難告訴同事「把 customer 組件從 ea3304ea2fff21dd1e501795c43c48ff 版本替換成 81b134b598b16d1605e6f76189f1c018 版本就可以解決你遇到的問題」,他也很難記住)。更重要的是散列碼只能標識兩個版本是否相同,卻無法體現版本之間的時間關係(哪一個版本是新的)和兼容性(新版本是否完全包含老版本的功能)。所以我們應該使用規範化的版本號作為二進制包的標識符。

所以我們必須採用語義化版本規範,使用三段式版本號,如下:

主版本號.次版本號.修訂號

版本號遞增規則如下:

  • 主版本號:當做了不兼容的 API 修改,
  • 次版本號:當做了向下兼容的功能性新增,
  • 修訂號:當做了向下兼容的問題修正。

使用這種方式,版本號就不再是一個隨意的命名。任何一個以 API 方式對外提供服務的程序(無論是 Web API、消息處理、函數 API),都應該遵循語義化版本規範。從 API 規範的意義上說,語義化版本實際上是對軟件接口規格的描述。例如一個軟件模塊對外提供 Web API 服務,模塊版本是 2.3.1,可以理解成這樣的規格描述:

為了準確描述程序的接口規範,我們在設計和開發的時候要盡量把接口規範和實現代碼分離,這樣就可以更準確的控制 API 規格。以 Web API 為例,我們可以把與服務接口相關的代碼放在單獨的目錄里,比如把控制器代碼全部放在 controller 目錄,輸入輸出數據結構全部在 vo 目錄。這樣就可以在發佈版本的時候根據變更的範圍準確確定版本號。

當以下變更發生時,接口的調用方法發生了變化,需要升級主版本號:

  • controller 刪除了原有接口;
  • controller 在原有接口上添加了參數,並且參數是必須的;
  • vo 刪除了輸出數據的屬性;
  • vo 添加了輸出數據的屬性,並且屬性是必須的。

當以下變更發生時,原有的接口仍然可以工作,需要升級次版本號:

  • controller 添加了新接口;
  • controller 在原有加快上添加了參數,但是參數可以不輸入;
  • vo 添加了輸出數據的屬性;
  • vo 添加了輸入數據的屬性,但是屬性可以不輸入。

當以下變更發生時,只需要升級修訂號:

  • controller 和 vo 的代碼都沒有改動,只改動了程序其他的部分。

用這樣的方法,版本號就可以描述程序內部的變更範圍。 

下面說一下版本號對依賴管理的作用。在構建和運行軟件時,軟件的一部分要依賴於另一部分,就產生了依賴關係。在任何應用程序(甚至是最小的應用程序)中也會有一些依賴關係。至少,大多數軟件應用都對其運行的操作系統環境有依賴,Java 應用程序依賴於 JVM,它提供了 JavaSE API 的一個實現。網絡服務之間也存在依賴關係。在大型軟件中,從組件中選擇好用的版本,組成一個完整的系統是一個極具難度的事。為了做好依賴管理,我們必須做下面幾件事:

  1. 為每一個二進制包制定唯一的版本號,禁止一物多碼,更要禁止一碼多物。必須標識版本,才能管理依賴;
  2. 發佈版本時描述依賴關係;
  3. 使用語義化版本號,只要確定了一個版本是可用的,就可以確定一個區間的版本都可用。

我們以 Linux 操作系統為例看一下依賴管理的過程。Linux 是一個非常複雜的體系,它本身由很多二進制包組成,使用者也需要在操作系統上安裝自己需要的程序。如果沒有一個依賴管理機制,要在 Linux 上安裝一個軟件,將會是一件困難的任務。幸運的是各種 Linux 發行版都提供了完善的包管理機制,還附帶了包管理工具。比如 Debian 操作系統,提供了 dpkg 工具,以下是使用 dpkg 查看 wget 信息:

$ dpkg -s wget
Package: wget
Section: web
Maintainer: Noël Köthe <[email protected]>
Architecture: amd64
Version: 1.18-5+deb9u3
Depends: libc6 (>= 2.17), libgnutls30 (>= 3.5.6), libidn11 (>= 1.13), libnettle6, libpcre3, libpsl5 (>= 0.13.0), libuuid1 (>= 2.16), zlib1g (>= 1:1.1.4)

這裡列出了主要信息,有兩個信息非常重要:

  • wget 本身的版本號:1.18-5+deb9u3
  • wget 依賴的其他組件版本(Depends 行)

有了這些信息,就可以在安裝 wget 的時候檢查 Debian 上已經安裝的庫,判斷是否滿足依賴條件,包管理工具可以級聯安裝所有的依賴項。也可以檢查 wget 與已經安裝的程序是否存在依賴衝突,提示用戶進行處理。如果沒有這一套包管理機制,在 Linux 上安裝一個包是非常冒險的事情。

最後我們再來看看語義化版本是怎樣幫助我們做好老版本維護的。有時候正在生產環境運行的老版本忽然發現一個缺陷,或者需要添加一個功能,都需要對老版本進行維護。這種事情在 To B 業務非常多見,To B 業務部署在很多現場,每個現場項目實施的時期不一樣,所以版本都有一些差異,對老版本進行維護是一件不可避免的事情。

如果不使用語義化版本號,比如用一個不斷增長的序號來標識版本號,連續發佈多個版本就會形成這樣的版本路徑:

 

 

隨着時間的發展,有一些老版本會在部署在不同的現場。現在 1002 版本上發現一個缺陷,需要緊急修復。這時候該怎麼辦呢?1002 版本已經經過了 2 次升級,直接替換成 1004 版本行不行,很難判斷,所以只能基於 1002 版本升級替換。這個好辦,使用 Git 做一個分支,修改後重新發佈一個版本就可以了。缺陷修改後,形成下面這樣的版本路徑:

 

 

 

如果以後需要維護 1001、1003 版本,繼續發展下去,版本路徑就會越來越複雜:

 

 

維護版本分支越來越多,基本上要為每一個老版本創建一個維護分支,工作量隨着項目發展越來越大。開發團隊要把大量的精力放在老項目維護上,產品開發的工作受到越來越多的牽制。語義化版本號能怎樣改變這種局面呢?如果每一次發佈都按照語義化版本編號,那麼最初的版本路徑就是下面這樣:

 

 

用這種方式,我們就能準確判斷版本之間的兼容關係,根據版本之間的替換關係確定最佳維護位置。當我們在 1.0.6 版本上發現一個缺陷,需要緊急修復,1.2.1 版本可以完全兼容 1.0.6 版本的功能。如果這個缺陷已經在 1.2.1 版本得到修復,那麼升級現場的版本即可。如果必須修改代碼,也只需要在 1.2.1 的基礎上修改,再發佈一個版本即可,不增加維護分支:

 

 

 

如果已經發生了主版本升級的情況,我們也只需要為每一個主版本創建一個維護分支,就能同時滿足多個項目的維護工作,降低維護工作量。

 

 

如上圖,對於所有 1.x 主版本,只需要基於 1.2.2 版本建立一個維護分支即可。