现代化程序开发笔记(4)——包管理工具

本系列文章以我的个人博客的搭建为线索(GitHub 仓库:Evian-Zhang/evian-blog),记录我在现代化程序设计中的一些笔记。在这篇文章中,我会就项目构建工具和包管理工具做一些讨论,先讨论一个理想的包管理工具应该做到什么,再就一些具体语言的相应工具作一些对比。

刀耕火种的时代

在编程语言最早出现的时代,大家写的项目都不大,开发者手边,只有用来写代码的编辑器,和用来编译结果的编译器(假设链接器已经包含在编译器内了)。打个比方来说,最早期的开发者,就像是刚开业的快餐店,只制作并售卖一种食物。开发者开发一个项目,就像是快餐店做一个炸鸡,很简单

hand chicken.raw oil.raw --output fried_chicken

也许还会给编译器hand加一些编译参数,比如说炸5分钟:

hand chicken.raw oil.raw --fry-time=5 --output fried_chicken

这一切都很美妙。

后来,老板感觉,这炸鸡似乎肉质不太好。于是,老板又去找了专门养鸡的行家,请教了养鸡的技巧。养鸡的行家提供了feed.raw这个养鸡的方法,老板把它拿过来,然后自己在自己店里养鸡。至此,做炸鸡就变成了

hand chicken.raw oil.raw feed.raw --fry-time=5 --output fried_chicken

炸鸡逐渐做出了名声,老板决定再做汉堡!他又费劲心思请教了做汉堡的技巧,得到了hamburger.raw这个方法。但是,做汉堡的专家告诉他,他们使用的鸡肉也是特制的,是向之前的养鸡行家请教的。但是,快餐店老板一对比,发现做汉堡的专家的养鸡方法是很早之前的方法,而他请教的养鸡方法则是近期的。这两种养鸡方法不大一样,也不能合并。老板一狠心,造俩养鸡场,一个用feed-1.0.0.raw这个方法养,一个按feed-2.0.0.raw的方法养。

后来随着快餐店越做越大,快餐店的工作变成了

hand chicken.raw oil.raw feed-1.0.0.raw feed-2.0.0.raw hamburger.raw ... --fry-time=5 --no-water --heavy-salt ... --output fast_food

如果按照刀耕火种时代的办法,开发者开发一个大型软件就像最后的快餐店老板一样,如果要使用别人写的代码,直接整个搬到自己的代码仓库里,被同一个库的不同版本绕晕,同时编译一次项目还要加各种各样不同的参数,少一个结果可能就会出错。

因此可见,如果我们按照原始的做法,那对于大型项目的开发和维护简直是灾难性的。

现代化解决方案

首先,我们需要总结一下目前遇到的问题:

  • 科学合理有条理地引入别人的库代码
  • 妥善高效编排项目的构建策略

解决第一件事,我们需要的是包管理工具;解决第二件事,我们需要的是项目构建系统。我们需要注意到,包管理工具其实是需要根据项目构建系统来实现的。例如,项目构建系统寻找引入模块的路径是固定的,那么包管理工具下载的库就需要在指定的路径。同时,每个库也应当包含构建库时需要的指令,使项目构建系统能够根据相应的指令构建对应的库。

那么,实现一个现代化的,服务于项目构建系统的包管理工具,我们需要注意什么呢?

首先,我们来思考一下我们使用别人写的库的全过程:

  1. 库开发者写库
  2. 库开发者将库发布
  3. 应用开发者下载库
  4. 应用开发者构建应用

这个过程就像把大象装进冰箱一样清晰。但事实上,每一个步骤都需要很多的思考。

库开发者写库

一个良好的库,必然要遵循模块化编程的思想,做好访问控制,将合理的接口暴露给用户。这些开发阶段的环节和包管理工具实际上没有太大的关系。那么,开发者写完了代码,想把自己的代码变成一个库,需要注意什么呢?

代码和库之间,差的是一个清单文件(Manifest file). 我的库想给别人用,那么必须要的是什么呢?养鸡行家和汉堡行家想融入现代社会,所以也想把自己的知识写成库发布,而不是简单地直接被快餐店老板复制到自己的代码库里。他们需要做什么呢?

首先,是库的名字。养鸡行家和汉堡行家想把品牌变得高端一点,决定把库叫做iChickenS和iHamburger-pro.

语义化版本号

只有库的名字可不可以呢?从逻辑上来看,似乎是可以的。只要别的用户搜一下有没有叫iChickenS的库,就可以直接下载我们的养鸡行家的库了。但是,会不会出现我们之前炸鸡店老板遇到的问题呢?炸鸡店老板直接用到的是最新版本,但是汉堡行家用到的养鸡方法却是之前的版本。直接下载iChickenS的话,怎么分辨这些方法呢?

这就需要的是版本号。养鸡专家比较懒,直接用v1, v2, v3来表明版本号;但是汉堡行家就想学TeX系统,版本号是3, 3.1, 3.14这样逐步逼近pi。每个人都有不同的给版本号起名的方法,这给使用者带来了很大的不便。万幸的是,有一个标准出现了,那就是语义化版本号

语义化版本号规定,所有的版本号都应该采用X.Y.Z的格式:

  • X是大版本号(Major),如果库接口出现了不向前兼容的break change,那么需要大版本号变更
  • Y是小版本号(Minor),如果库接口保持向前兼容,但是增加了新的功能和接口,那么需要小版本号的变更
  • Z是补丁号(Patch),如果库作者发现已经发布的版本出现了bug,那么对bug的修复就应该是变更补丁号。

比较特别地,如果X是0,也就是0.Y.Z版本的库,一般代表尚在开发,不适于生产环境的库,在这个阶段,每一次的版本号升级并不严格遵守上述规定,而且API随时有可能变化。而版本号之后也可以加上一些后缀,如1.0.0-alpha01, 1.0.0-beta03, 1.0.0-rc4,这代表一些尚未正式发布,不保证没bug的测试版本。

养鸡行家和汉堡行家这下开心了,给自己的最新版起名一个叫iChickenS-2.0.0, 一个叫iHamburger-pro-1.2.1

构建策略及库依赖

在大多数的包管理系统中,库名+版本号唯一确定一个库。对于简单的库来说,只有这两个就足以变成一个库了。但是对于更复杂的库来说,还差两样东西:构建策略和库依赖。

我们之前提到,项目构建系统需要依据每一个库指定的策略来构建对应的库,那么这些策略也应该写在清单文件之中。比如说养家行家说鸡必须要用矿泉水喂养,那么他就需要在清单文件中,根据包管理器指定的规范,写上--mine-water.

而一个库也有可能依赖别的库,就像是汉堡行家的方法里有依赖到养鸡行家的方案。最简单的方法,就是直接在清单文件里写上对应的库和使用的版本号。比如说,汉堡行家需要养鸡行家库的1.2.0方案,那么他就需要在清单文件里写iChickenS-1.2.0. 但是,语义化版本号的作用就在此时显现了。根据语义化版本号的规范,只要大版本号不变,那么已有的接口就肯定不会变,所以可以汉堡行家可以在自己的清单里写,需要iChickenS库的大版本号为1的方案。如果他更谨慎一点,可以写需要大版本号为1,且小版本号大于等于2的方案。

编程语言实例

我们使用的编程语言,大多数的包管理工具都会规定这样一个清单文件。对于清单文件的格式,一般要求是通用的格式,且可读性高。因此,TOML,YAML等都是很常见的清单文件格式。

具体而言:

  • Rust的Cargo包管理工具需要TOML格式的cargo.toml文件发布到crates.io仓库
  • Swift的Swift Package Manager包管理工具需要Swift编写的Package.swift文件
  • 基于Gradle构建系统的语言,如Kotlin等,需要build.gradle文件发布到各种maven仓库
  • JavaScript/TypeScript的npm或yarn需要package.json文件发布到npm仓库,TypeScript还需要tsconfig.json
  • Python的pip包管理工具需要setup.py文件发布到PyPI仓库

库开发者将库发布

库开发者的库完成了,那么把库发布的时候需要考虑什么呢?显而易见,把库发布到哪里?有两种常见的包管理策略:集中式包管理和分布式包管理。

所谓的集中式包管理就是,包管理工具有一个集中的服务器,所有包都上传到那个服务器里,然后别的用户可以从那个服务器里下载;分布式包管理就是每个库开发者把库发布到自己的服务器上,并提供一个下载的地址,然后别的用户根据下载的地址,从库开发者的服务器里下载。

通过集中式包管理,所有开发者的库都上传到同一个仓库里,这也是大多数包管理器采用的策略。与集中式包管理相关联的,还有一个概念是镜像。因为全球那么大,如果只在一个地区设立自己的仓库,那么地球正对面的那个地区下载库的速度就变的特别特别慢。为了解决这个问题,大家常常采用的是镜像的办法。各个地区的大学、知名互联网企业等组织,会提供自己的服务器,与包管理工具的总服务器实时同步。那么,在那些地区的用户,就可以直接通过镜像下载自己想要的库,速度就是当地的网速了。

这里顺便一提,大家找镜像的时候,推荐的方法是,先在搜索引擎中找到别人说的镜像的网址,然后看一下那个镜像所属哪个组织,比如说是USTC,还是Aliyun。然后,不要直接用别人提供的镜像网址,而是去对应组织的镜像站,查找其使用指南,一般常用的镜像,镜像站都会提供替换指南。这是因为,镜像站的网址可能会变,而包管理器的镜像替换方法,也可能会变。那么网上找的那些方法,有可能就是失效的。所以,还是直接找官方的指南更靠谱一些。

至于分布式包管理,是近些年部分语言使用的策略,库开发者可以将自己写的库发布在GitHub上,或是自己的服务器上,供他人下载。这样做虽然可以让包管理器方面不用再维护一个庞大臃肿的服务器,但是这也直接阻止了镜像的方案。使用镜像,就要求那些包是已知的,镜像站将总服务器里所有的包都提前缓存到本地。但是,分布式包管理的包的总数是不知道的,镜像也就没有办法了。此外,如果库的开发者不乐意了,将自己的库下架,那么所有使用这个库的用户都gg。这种事并非不会发生,Rust最有名的服务器框架actix的作者因为受不了一些键盘侠发的issue,直接将actix库的GitHub仓库设置为private。但所幸Rust使用的是集中式包管理策略,所以并不会造成严重的危害。

关于包管理器,还有一点比较有趣的,就是某些语言的新的包管理器。比如说Gradle,它就是一个比较新的包管理器,理应每一个包管理器都会有自己的清单文件格式,但是因为使用maven的清单文件格式发布库的作者实在太多了,所以Gradle直接支持导入maven库,这也就是在Gradle的配置文件里经常看见maven身影的原因。

应用开发者下载库

养鸡行家和汉堡行家开心地将自己的库上传到了集中式包管理仓库FOOD,快餐店老板就可以直接从FOOD上下载他需要的iChickenS-2.0.0和iHamburger-pro-1.2.1了。这时候,需要注意什么呢?

第一个问题:下载到哪。

常用的有两种策略,我们还是拿快餐店老板举例。第一种方案,是把养鸡场和汉堡车间安排在快餐店里。但是,带来的缺点就是,快餐店老板要开分店了,那么他的两家分店各有一个养鸡场,这就比较僵硬了。第二种方案,是把养鸡场和汉堡车间安排在老板家,老板开再多的分店,都是直接用老板家的养鸡场和汉堡车间。这带来的缺点是,分店A想升级,想使用养鸡场3.0,但是分店B不想升级,还在使用养鸡场2.0。 那么,直接升级老板家的养鸡场的话分店B就gg了。

这两种方案实际上就是,一种将库放在项目目录里,一种将库放在用户系统中一个固定的地方。npm和yarn会将下载下来的库放在项目目录的node_modules目录里,这也经常被别人吐槽为node_modules黑洞。而Cargo和pip则是另辟蹊径,这两种包管理器默认将所有的库都缓存在本地的特定目录下,也就是默认使用第二套方案。但是,我们可以用cargo vendor或者python的venv环境,将项目使用的库下载到项目当前目录里,也就是方案一。因此,使用这种用户可定制的方案是最佳的。

第二个问题:下载什么

这看上去很简单,但实际上是整个包管理器最主要的地方。我们如果想下载汉堡行家的秘方,能不能直接下载一个iHamburger-pro-1.2.1呢?我们之前说过,汉堡专家的方案是需要iChickenS-1.2.0的支持的,所以我们下载它的库的时候,还需要顺带下载一个iChickenS-1.2.0。这个简单的情况很简单。事实上,再复杂的包之间的依赖逻辑,也能通过这种方案解决,那就是先下载需要的库,然后查看当前库需要哪些库,如果没有那种库,就下载,如果有,就跳过。

接下来,情况复杂了!汉堡专家的方案实际上是要求iChickenS的大版本为1,小版本号大于等于2。而快餐店老板又来事了,他找了做塔克的!塔克行家的库Taco-1.0.0需要iChickenS-1.3.0。一个足够聪明的包管理器,应该知道我们只需要iChickenS-1.3.0就行了。但是,如果按照我们之前的包管理器的策略,它先看到了汉堡行家的库,然后下载了iChickenS-1.2.0, 然后才看到了塔克的,又下载了iChickenS-1.3.0。这种情况显然是愚笨的。

所以,现代包管理器通用的做法是会构建一个依赖图,利用图算法确定应该下载的所有库的版本,然后保存到lock文件中。然后每次编译,都会根据lock文件查看当前缺少哪些库,然后直接下载。

一个常见的问题是,lock文件应不应该提交到版本记录工具里呢?你需要保证的是:根据你在清单文件中声明的库版本的约束,以及库自己在清单文件里声明的依赖库版本的约束,始终能保持项目的正常运行,那么,就不需要提交;否则,将lock文件提交也是一个保持版本稳定性的做法。

应用开发者构建应用

万事俱备,应用开发者只需要使用项目构建系统,根据当前项目的清单文件,以及每个依赖库的清单文件的构建规则,构建项目就ok了。而现代的大多数语言,都会更偏向于开源库,也就是包管理系统下载的是库的源代码,而库的构建则在本地,和项目源代码一同构建。

这时候,又会出现什么意想不到的问题呢?iHamburger-pro-1.2.1里,用到了iChickenS-1.2.0的feed函数,如果按照JavaScript的语言,他应该写成

import { feed } from 'iChickenS/feed.js'

类似地,快餐店老板在炸鸡的过程里面也用到了这个函数,只不过他用的是iChickenS-2.0.0版本的这个函数。但是,在项目构建的时候,看到的代码都是同样的代码,怎么分辨不同的版本库呢?

这就需要语言的支持了。在模块化编程中,我们提到,语言支持的模块化编程,可以有效地解决命名冲突的问题。他们解决的实际方法实际上是name mangling, 也就是把某个命名在符号表中加上一些表示其身份的符号,比如说feed这个函数,在iChickenS-2.0.0里的符号表里也许就叫iChickenS_2_0_0_feed了。通过这样的方法,有效地解决了不同版本依赖里的命名冲突问题。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值