談談軟體包

fsword發表於2015-07-10

我學習C語言的時候是在大學課程上,老實說,能理解那些語言概念就很不容易了,對於軟體包管理這件事聽都沒聽說過。但真實情況下,大部分的軟體專案都不可能是從零開始的,我們總要依賴某些開源的或者團隊自己開發的工具和框架庫來幫助工作,我是學習java的時候才慢慢聽說了maven。

maven的核心配置是pom.xml檔案,開發者可以根據需要在其中列出專案的依賴包,像這樣:

<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>4.1.5.RELEASE</version>
</dependency>

maven命令在工作時會找到spring-core所依賴的其它庫

$ mvn dependency:tree
......
[INFO] sample_java:sample_java:war:1.0-SNAPSHOT
[INFO] +- org.springframework:spring-core:jar:4.1.5.RELEASE:compile
[INFO] |  \- commons-logging:commons-logging:jar:1.2:compile
......

但是這樣有個問題,某些間接依賴會導致在不同時候打出不同的包,比如上述這樣個例子,兩次打包期間如果commons-logging釋出了新版本,那麼兩次打包的內容就不一樣了,如果遇到新版本的差異,開發人員可能會莫名其妙。

ruby社群針對這個問題發明了一個叫做bundle的工具,它也有個Gemfile檔案用來記錄直接的依賴庫,類似mvn,但是bundle多了一個功能,工程師可以在當前專案下執行bundle install 命令,bundle系統將根據當前的軟體倉庫狀態計算出間接依賴,並將這些間接依賴鎖定到某個版本,內容寫在 Gemfile.lock 檔案中。比如這個簡單的例子:

source 'https://ruby.taobao.org'
gem 'activesupport'

生成的Gemfile.lock是這樣:

GEM
remote: https://ruby.taobao.org/
specs:
activesupport (4.2.3)
  i18n (~> 0.7)
  json (~> 1.7, >= 1.7.7)
  minitest (~> 5.1)
  thread_safe (~> 0.3, >= 0.3.4)
  tzinfo (~> 1.1)
i18n (0.7.0)
json (1.8.3)
minitest (5.7.0)
thread_safe (0.3.5)
tzinfo (1.2.2)
  thread_safe (~> 0.1)

PLATFORMS
ruby

DEPENDENCIES
activesupport

當再次執行bundle命令時,bundle系統會根據Gemfile.lock檔案來決定間接依賴,所以開發者通常把這個檔案放入版本控制系統,確保所有人和線上都用同一份Gemfile.lock,就能避免上述的問題。

我一直覺得bundle的做法是最先進的,不過和做nodejs開發的同學聊天時,瞭解到了npm的做法頗為特別,雖然不見得比bundle更好,卻是各有優劣。

npm的做法是直接把被依賴的庫放入當前庫的node_modules目錄,依賴庫也以此類推,它的核心檔案是package.json,比如這個例子:

$  cat package.json
{
"name": "sample_js",
"version": "1.0.0",
"dependencies": {
"browserify": "10.2.4"
}
}
$ npm install
$ npm dedupe

檢視一下依賴庫

$  ls node_modules/browserify/node_modules
JSONStream             concat-stream          glob                   labeled-stream-splicer readable-stream        syntax-error
acorn                  console-browserify     has                    module-deps            readable-wrap          through2
assert                 constants-browserify   htmlescape             os-browserify          resolve                timers-browserify
browser-pack           crypto-browserify      http-browserify        parents                sha.js                 tty-browserify
browser-resolve        defined                https-browserify       path-browserify        shasum                 url
browserify-zlib        deps-sort              indexof                process                shell-quote            util
buffer                 domain-browser         inherits               punycode               stream-browserify      vm-browserify
builtins               duplexer2              insert-module-globals  querystring-es3        string_decoder         xtend
commondir              events                 isarray                read-only-stream       subarg

檢視一下依賴庫的依賴庫

$ ls node_modules/browserify/node_modules/crypto-browserify/node_modules
bn.js           browserify-aes  browserify-sign create-hash     diffie-hellman  parse-asn1      public-encrypt
brorand         browserify-rsa  create-ecdh     create-hmac     elliptic        pbkdf2          randombytes

這種做法實際上是在開發環節就確定並下載了間接依賴的庫,可以看做在開發者手裡就完成了打包,這麼做有什麼好處?

相比maven,npm和bundle更具備一致性,無論到哪裡,bundle系統都保證使用lock版本,不會有“失控”的依賴庫;而相對於bundle,npm可以允許在一個專案中依賴同一個庫的不同版本,這比較靈活。

但是這麼做也有缺點,有些js開發者就吐槽這一點,認為浪費了記憶體——不同庫依賴同一個庫時,都會在自己的node_modules目錄下存放一份被依賴庫的程式碼。從這個角度看,bundle又顯得有些優勢,因為require在同一個ruby程式中是有快取的,不會額外浪費記憶體。

打個比方吧,bundle就好像大規模的軍事單位,除了作戰部隊外還有專門的,好處是可以統一劃撥管理,降低了維護成本,缺點是有些細微的差別不好滿足。
而npm就好像一個精幹的小分隊,每個人帶自己適合的食物,雖然可能並不豐富,但是每個單兵都是一個可以獨立生存的單元。

我把npm這種方式稱為“自帶乾糧”。軟體技術中,“自帶乾糧”的設計思想有很多應用場景,比如動態編譯和靜態編譯。

如果你喜歡自己從原始碼編譯軟體,那麼多半熟悉LD_LIBRARY_PATH這個環境變數,這是用來指明動態連結庫查詢路徑的,我們常常把一些模組程式碼編譯為字尾為 so 的動態連結庫檔案,然後再執行時動態載入。

與之相比,還有一種做法叫靜態編譯,比如這樣的命令:

/opt/apache_src $ ./configure --prefix=/usr/ --enable-file-cache

其中的enable-file-cache是靜態編譯的選項,使用這類選項,編譯工具會將模組直接編譯進入最終的執行檔案(比如apache的執行檔案就是httpd)。

使用動態連結庫還是選擇靜態編譯?應該說兩種方式各有優劣,前者可以減少記憶體消耗,避免裝入暫時用不到的程式碼,而後者則是某種角度的“自帶乾糧”,這樣編譯的可執行程式,遷移起來比較容易,不會由於目標系統上沒有相應的動態連結庫而執行失敗。

Go語言是 google 建立的一門語言,它有很多獨特的設計,其中之一就是——它是靜態編譯的,這一點曾經讓很多人詬病,認為它寫一個hello world都要輸出很大的可執行檔案。

但是從另外一個角度看這個做法很有價值,Go語言的定位是系統級程式設計,這類程式和應用軟體不同,它往往比較底層,本身就是其它軟體的基礎,因此對穩定性很重視,除了硬體這種不得不考慮的因素,其它方面干擾越少越好,使用靜態連結方式產生兩個好處:

  • 降低遷移成本:在一個linux裡編譯好的Go程式,通常可以直接copy到其它linux上使用,而如果依賴動態連結庫,那麼可能由於目標系統上缺乏動態連結庫而失敗,這樣,Go程式的安裝複雜性會很高,這對基礎的系統軟體是不利的。
  • 外部錯誤干擾bug定位:即使目標系統上有所需的動態連結庫,也不一定就沒有問題,由於版本依賴等問題,目標系統的動態連結庫未必和本來設想的相同,如果由於外部錯誤導致bug,而開發人員並不知道這一情況,對bug定位無疑是一個災難。

Go語言具備這些好處並不是偶然的,作為大規模叢集計算起家的網際網路公司,Google對於系統的橫向擴充套件能力、可靠性、故障恢復能力都有很高的要求,自帶乾糧的語言可以很好的幫助實現這些目標:

  • 橫向擴充套件能力:這個和遷移成本相關,由於不再需要動態連結庫的配合,應用程式可以很方便完整的在新系統上部署,因此,一旦需要橫向擴充套件,至少在軟體安裝方面,Go語言就有了很大的便利性。
  • 可靠性:Google的哲學是,透過軟體而不是硬體提供可靠性。那麼,如果硬體的可靠性不可依賴,軟體系統的可靠性和容錯就要透過類似備份之類的冗餘計算來得到,這時,一個“自帶乾糧”的應用程式顯然很容易部署為冪等的叢集,因此可以在很大程度上幫助實現冗餘計算。
  • 故障恢復能力:這將受益於對外部干擾的排除,由於“自帶乾糧”,Go程式已經包含了bug分析的幾乎全部資訊,開發人員不需要依靠線上環境,線上下就能分析界定問題。

這樣看來,“自帶乾糧”的做法其實非常適合網際網路應用的場景,這裡充斥著“叢集”、“彈性擴充套件”、“服務冪等化”的做法,如果我們從事網際網路應用領域,那麼理解“自帶乾糧”的做法很有價值。

這個做法其實並不神秘,“自帶乾糧”的理念的一個重要體現,就是我們很熟悉的“打包”環節。

Linux服務端開發的專案,通常都會有一個“打包”環節,很多人並不完全理解這個環節的作用,實際上,這只是“自帶乾糧”原則在專案管理中的落實而已。

舉兩個例子,ruby bundle的打包是這樣的:

$ bundle package
...
$ ls -l vendor/cache
total 1744
-rw-r--r--  1 john  staff   322K  7  9 03:48 activesupport-4.2.3.gem
-rw-r--r--  1 john  staff    57K  7  9 03:48 i18n-0.7.0.gem
-rw-r--r--  1 john  staff   149K  7  9 03:48 json-1.8.3.gem
-rw-r--r--  1 john  staff    70K  7  9 03:48 minitest-5.7.0.gem
-rw-r--r--  1 john  staff   118K  7  9 03:48 thread_safe-0.3.5.gem
-rw-r--r--  1 john  staff   144K  7  9 03:48 tzinfo-1.2.2.gem

bundle packge命令把需要使用的gem包統統放入vendor/cache目錄,那麼當前目錄就是一個“自帶乾糧”的體系。

再看Java:

$ mvn pacakge
...
$ ls -l target/*.war
-rw-r--r--  1 john  staff   1.0M  7  9 03:51 target/sample_java.war

mvn命令最後會輸出一個war檔案,按照javaEE的相關規範,它自身包含了所有依賴的第三方jar,所以這也是一個“自帶乾糧”的產出。

結合上面的例子,我們可以得出結論:打包這個行為本質上就是透過“自帶乾糧”的方式把相關依賴完全納入控制,這使得我們的交付物能夠很容易的線上上水平擴充套件並減少環境影響。

那麼,考慮到大多數語言或者框架都有打包機制,是不是Go語言相對與ruby或者java其實沒有什麼優勢呢?答案是未必,這裡需要分開討論:

  • ruby(使用bundle作為包管理工具):因為是虛擬機器語言,通常情況下,bundle的“打包”僅限於ruby語言的各種gem,有些對動態連結庫的依賴並沒有納入管理,因此,雖然“自帶乾糧”卻會有“忘記帶飯碗”的尷尬情況
  • java(使用maven作為包管理工具):同上,也是虛擬機器語言,不過,和ruby不同的是,java由於社群風格的差異,通常習慣於使用pure java的方式解決問題,因此,雖然理論上也可能依賴其它動態連結庫,但是實踐中很少遇到這種軟體包(當然,很少不等於沒有)。

顯然,由於考慮到作業系統層面的動態連結庫問題,ruby和java這種虛擬機器語言必須面對打包不完整的問題(與之相比,Go語言除了Glibc基本算是沒有依賴),ruby的bundle沒有處理這個問題,java則付出了“自己重新搞一遍”的代價。

說到這裡自然會產生一個問題——即使是Go,也需要glibc,因此切換OS時還是需要交叉編譯技術的,那有沒有更“完備”的方案呢?比如把所有依賴都隔離開?

答案是Docker,它把基礎庫也放入了映象,因此,從打包這件事來看是Docker image是真正的“自帶乾糧”並且沒有遺漏的做法,未來的Build系統,以Docker image進行交付是大勢所趨。

包管理的做法各不相同,然而基本思路差別不大,理解“打包”的目的,是學生和軟體工程師的一個重要區別。

相關文章