我学习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语言的定位是系统级编程,这类程序和应用软件不同,它往往比较底层,本身就是其它软件的基础,因此对稳定性很重视,除了硬件这种不得不考虑的因素,其它方面干扰越少越好,使用静态链接方式产生两个好处:
Go语言具备这些好处并不是偶然的,作为大规模集群计算起家的互联网公司,Google对于系统的横向扩展能力、可靠性、故障恢复能力都有很高的要求,自带干粮的语言可以很好的帮助实现这些目标:
这样看来,“自带干粮”的做法其实非常适合互联网应用的场景,这里充斥着“集群”、“弹性扩展”、“服务幂等化”的做法,如果我们从事互联网应用领域,那么理解“自带干粮”的做法很有价值。
这个做法其实并不神秘,“自带干粮”的理念的一个重要体现,就是我们很熟悉的“打包”环节。
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和java这种虚拟机语言必须面对打包不完整的问题(与之相比,Go语言除了Glibc基本算是没有依赖),ruby的bundle没有处理这个问题,java则付出了“自己重新搞一遍”的代价。
说到这里自然会产生一个问题——即使是Go,也需要glibc,因此切换OS时还是需要交叉编译技术的,那有没有更“完备”的方案呢?比如把所有依赖都隔离开?
答案是Docker,它把基础库也放入了镜像,因此,从打包这件事来看是Docker image是真正的“自带干粮”并且没有遗漏的做法,未来的Build系统,以Docker image进行交付是大势所趋。
包管理的做法各不相同,然而基本思路差别不大,理解“打包”的目的,是学生和软件工程师的一个重要区别。