我學習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進行交付是大勢所趨。
包管理的做法各不相同,然而基本思路差別不大,理解“打包”的目的,是學生和軟件工程師的一個重要區別。