想象一下,你为一家叫CarCorp的公司工作,他们给你一个项目。你必须在几周后带着一个全面的流程回来,以帮助客户在短时间内安装你的软件。你知道你最喜欢的一些Python代码,如pandas 和requests ,都可以在网上以包的形式获得,你想为自己的消费者提供同样的安装便利。
打包是将软件与描述这些文件的元数据一起存档的行为。开发者通常创建这些档案,或称包,目的是为了分享或发布它们。
Python 生态系统对两个不同的概念使用 "包 "这个词。Python Packaging Authority (PyPA) 在Python Packaging User Guide(https://packaging.python.org) 中对这些术语进行了区分:
- 导入包将多个 Python 模块组织到一个目录中,以便于发现(https://packaging.python.org/glossary/#term-Import-Package)。
- 分发包将Python 项目归档,以便发布给其他人安装(https://packaging.python.org/glossary/#term-Distribution-Package)。
导入包并不总是在归档中分发,尽管发行包经常包含一个或多个导入包。发行包是这篇文章的主要主题,在必要时将与导入包区分开来以避免混淆。
在可能有无数种方法将软件及其元数据卷在一起的情况下,软件的维护者和用户如何管理期望值并减少手工工作?这就是软件包管理系统的作用。
标准化包装以实现自动化
软件包管理系统,或称软件包管理器,对特定领域内的软件包的存档和元数据格式进行了标准化。软件包管理器提供工具,帮助消费者在项目、编程语言、框架或操作系统层面安装依赖关系。大多数软件包管理器出厂时都有一套熟悉的安装、卸载或更新软件包的说明。你可能已经使用了以下一些软件包管理器。
- pip(https://pip.pypa.io)
- conda(https://docs.conda.io)
- Homebrew(https://brew.sh/)
- NPM(https://www.npmjs.com/)
- asdf(https://asdf-vm.com/)
包管理的早期阶段
尽管开发者非正式地打包他们的代码已经有一段时间了,但直到20世纪90年代初包管理系统的广泛使用,这种方法才开始起飞(见Jeremy Katz,"包管理的简史",Tidelift,blog.tidelift.com/a-brief-his…。
声明性地定义项目依赖关系的能力,通过抽象化管理软件项目中的主要工作领域,证明了对开发者生产力的促进作用。
软件库通过作为集中的市场来发布和托管其他人可以安装的软件包,进一步规范了包装(图1)。许多编程语言社区为安装软件包提供了一个官方或事实上的标准库。PyPI(https://pypi.org)、RubyGems(https://rubygems.org/)和Docker Hub(https://hub.docker.com/)是几个流行的软件库。

图1.软件包、软件包管理器和软件库都是共享软件的关键。
如果你拥有一部智能手机、平板电脑或台式电脑,并从应用商店安装了应用程序,这就是包装的作用。包装是将软件与关于该软件的元数据捆绑在一起,而这正是应用程序的本质。软件库托管人们可以安装的软件,这就是应用商店。
所以,包是软件和元数据以商定的格式卷在一起,编入相关的包管理系统中。在更细的层面上,软件包通常还包括在用户的系统上构建软件的方法,或者它们可能为各种目标系统提供几个预构建的软件版本。
发行包的内容
图2显示了一些你可能选择放在发行包中的文件。开发者通常在软件包中包括源代码文件,但他们也可以提供编译的工件、测试数据和其他消费者或同事可能需要的东西。通过分发软件包,你的消费者将有一个一站式的商店来获取他们需要的所有文件,以开始使用你的软件。

图2.一个软件包通常包括源代码、用于编译代码的Makefile、关于代码的元数据,以及给消费者的说明。
分发非代码文件是一种重要的能力。尽管代码通常是首先分发任何东西的原因,但许多用户和工具依赖于关于代码的元数据来区分它与其他代码。开发者通常在元数据中指定一个软件项目的名称、它的创建者、它可以被重新使用的许可等等。重要的是,元数据通常包括存档的版本,以区别于该项目以前和将来的出版物。
共享软件的早期
在Unix操作系统问世后的十多年里,团队和个人之间的软件共享在很大程度上仍然是一个手工过程。下载源码、编译源码,以及处理编译后的工件,都是由试图使用该代码的人自己决定的。这个过程中的每一步都会因为人为错误和系统间的结构或环境差异而带来失败的机会。像Make这样的工具(https://www.gnu.org/software/make/)从这个过程中消除了一些变化,但在包的版本、依赖性和安装管理方面却止步不前。
现在你已经熟悉了软件包的内容,你将学习这种共享软件的方法在实践中如何解决具体问题。
共享软件的挑战
你的项目的第一次迭代已经完成,你的老板把你拉到一个会议上,要求知道为什么它不工作了。你意识到你忘了让他们先安装你项目的所有依赖项。你倒退了几步,引导他们完成依赖关系的安装。不幸的是,你忘了检查你的一个主要依赖的版本,最新的版本似乎不能工作。你引导他们安装以前的每一个版本,直到你最终找到一个可以工作的版本。危机勉强避免了。
当你开发越来越复杂的系统时,确保你正确安装每个依赖的必要版本的努力会迅速增加。在最坏的情况下,你可能会达到这样的程度:你需要同一个依赖的两个不同版本,而它们不能共存。这被亲切地称为 "依赖性地狱"。 从这一点上分离项目可能被证明是具有挑战性的。
即使没有遇到依赖性地狱,如果没有一个标准化的打包方法,也很难以标准的方式分享软件,让任何人在任何地方都知道他们需要为你的项目安装哪些其他的依赖性。软件社区创造了管理软件包的惯例和标准,将这些做法编入你用来完成工作的软件包管理系统。
现在你明白了为什么打包对共享软件有好处,继续阅读,了解打包可以提供的一些优势,即使你并不总是公开你的软件。
打包如何帮助你
如果你是包装的新手,到目前为止,它可能看起来主要是对与全球各地的人分享软件有用。尽管这当然是打包你的代码的一个好理由,但你也可能喜欢打包在开发软件时带来的一些好处:
- 更强的内聚力和封装性
- 更清晰的所有权定义
- 代码区域之间的耦合更松散
- 有更多的机会进行组合
下面几节将详细介绍这些好处:
通过打包强制执行内聚力和封装
一个特定区域的代码通常应该有一个工作。内聚力衡量代码如何尽职尽责地坚持这项工作。流浪的功能越多,代码的内聚性就越差。
你可能已经使用了函数、类、模块和导入包来组织你的 Python 代码 (见 Hillard, Dane. "The hierarchy of separation in Python,"Practices of the Python Pro,Manning Publications, 2020, pp. 25-39,manning.com/books/pract…。这些结构都是在有特定工作的代码区域周围放置一种命名的边界。如果做得好的话,命名可以向开发者传达什么属于边界内,重要的是,什么不属于。
尽管做出了最大的努力,名字和人很少是完美的。如果你把所有的Python代码放在一个应用程序中,很可能有些代码最终会渗入不属于它的领域。想一想你所开发的一些大型项目。你有多少次创建了一个utils.py 或helpers.py 模块,其中包含了大量的功能?你用一个函数或一个模块创建的边界很容易被克服。代码中的这些 "实用 "区域往往会吸引新的 "实用程序",随着时间的推移,内聚力呈下降趋势。
想象一下,你的自动驾驶汽车系统可以使用激光雷达(https://oceanservice.noaa.gov/facts/lidar.html)作为一种类型的输入。CarCorp的车辆并不包括激光雷达传感器。作为一个勤奋的开发者,你在代码库中创建了一个针对激光雷达的部分,将其与其他问题分开。虽然评估命名和定期重构代码库可以保持较高的凝聚力,但这也是一种维护负担。分布式软件包增加了将代码添加到可能不属于它的地方的障碍。因为更新软件包需要经历打包、发布和安装更新的周期,它促使开发者更深入地思考他们所做的改变。你将不太可能在没有明确意图的情况下向包中添加代码,而这是值得更新周期的投资。
创造凝聚力和打包一个有凝聚力的代码区域是进入封装的一个通道。封装帮助你与你的消费者建立正确的期望,通过定义是否以及如何暴露代码的行为来与你的代码互动。想一想你建立的一个项目,并与别人分享使用。现在想想你改变了多少次你的代码,而他们又不得不改变他们的代码。这对他们来说是多么的令人沮丧?对你来说呢?封装可以通过更好地定义API合同来减少这种流失,这种合同不容易被改变。图3显示了你如何从代码的内聚区域中创建多个包。

图3.打包可以通过引入更强的边界来减少代码区域之间意外的相互依赖。
过去,当你发现一段原本只在模块内部使用的代码在整个代码中被广泛使用时,你可能会感到沮丧。每次你更新 "内部 "代码时,你都需要去更新其他地方的使用。这种高流失率的环境可能会导致错误,当你没有把一个变化传播到各处时,你或你的团队的生产力就会大大降低。
封装良好、高度内聚的代码,即使被广泛使用,也很少有变化。这种代码有时被称为 "成熟的"。成熟的代码是作为一个包发布的最佳候选者,因为你不需要经常重新发布它。你可以从你的代码库中提取一些比较成熟的代码来开始打包,然后用你所知道的内聚力和封装来使不太成熟的代码达到要求。
促进代码的明确所有权
团队受益于对代码区域的明确所有权。所有权往往超越了维护代码本身的行为。团队建立自动化以简化单元测试、部署、集成测试、性能测试等等。这是一个需要同时保持旋转的很多盘子。保持代码范围的小范围,以便团队能够拥有所有这些方面,将确保代码的寿命。封装是管理范围的一个工具。
你通过打包代码创建的封装使你能够开发独立于其他代码的自动化。举个例子,对于一个没有什么结构的代码库的自动化,可能需要你写条件逻辑来决定根据哪些文件的变化来运行哪些测试。另外,你可能会为每一个变化运行所有的测试,这可能很慢。创建可以独立于其他代码进行测试和发布的包,将导致从源代码到测试代码到发布代码的更清晰的映射(图4)。

图4.团队可以对单个包拥有完全的所有权,定义他们要如何管理开发、测试和发布的生命周期。
对一个包的目的进行明确的划分,使得它更有可能有明确的所有权划分。如果一个团队不确定他们对一些代码的所有权有什么承诺,他们就会有戒心。试着提供一个有明确范围、故事和操作手册的包,看看情绪如何转变。
将实施与使用脱钩
你可能听说过松散耦合这个词来描述代码区域之间的相互依赖程度。
定义:耦合是对代码区域之间相互依赖的一种衡量。松散耦合的代码提供了多种灵活的途径,因此你可以从各种执行策略中实现和选择,而不是被迫走上一条特定的道路。低耦合度的两段代码之间几乎没有依赖性,它们可以以不同的速度变化。
你在本章前面读到的内聚和封装实践是一种减少因代码组织不善而出现紧耦合的可能性的方法。高度内聚的代码在其内部会有紧耦合,而对其边界以外的任何东西则是松耦合。封装暴露了一个有意的API,限制了与该API的任何耦合。那么,你对打包和封装的选择,可以帮助你把消费者与代码中的实现细节解耦。封装也使得通过版本管理、命名间距,甚至是编写软件的编程语言,使消费者与实现脱钩成为可能。
在一个大泥球中,你只能运行每个模块中的任何代码。如果你或你的团队中的某人更新了一个模块,所有使用该模块的代码都需要立即适应这种变化。如果更新改变了一个调用签名或一个返回值,它可能会有一个大的爆炸半径。打包大大减少了这种限制(图5)。

图5.打包提供了灵活性,因此两个区域的代码可以以不同的速度演进
想象一下,如果每次对requests 包的更新都需要你立即做出反应,更新自己的代码。那将是一场恶梦!因为包对它们所包含的代码进行了改版,而且消费者可以指定他们要安装的版本,一个包可以多次更新而不影响消费的代码。开发者可以准确地选择何时更新他们的代码以适应包的最新版本的变化。
另一个可以将代码解耦的点是命名空间。命名空间将价值和行为附加到人类可读的名字上。当你安装一个包时,你让它在它指定的命名空间中可用。作为一个例子,requests 包在requests 命名空间中是可用的。
不同的软件包可以有相同的命名空间。这意味着如果你安装了多个软件包,它们可能会发生冲突,但它也使一些有趣的事情成为可能:命名空间的这种灵活性意味着软件包可以作为彼此的完全替代品。如果一个开发者创建了一个流行包的替代品,它更快、更安全、更易维护,只要API相同,你就可以安装它来代替原来的包。作为一个例子,下面的包都提供了大致相当的MySQL(https://www.mysql.com)客户端功能(具体来说,它们实现了与PEP249,www.python.org/dev/peps/pe…的某种程度的兼容):
- mysqlclient(https://github.com/PyMySQL/mysqlclient)
- PyMySQL(https://github.com/PyMySQL/PyMySQL)
- mysql-python(https://github.com/arnaudsj/mysql-python)
- oursql(https://github.com/python-oursql/oursql)
最后,Python 打包甚至可以将 Python 中的使用与编写包的语言脱钩!许多 Python 包是用 C 甚至 Fortran 编写的,以提高性能或与遗留系统集成。软件包作者可以提供这些软件包的预编译版本,同时提供消费者可以从源代码构建的版本,如果需要的话。这也使得软件包更具有可移植性,使开发者在某种程度上与他们所使用的计算机或服务器的细节脱钩。你将在后面的章节中学习更多关于打包构建目标的知识。
如果没有其他原因,你可能想打包你的一些代码,以试验版本解耦的自由。看看你的版本包是如何随时间演变的。那些变化很快的可能指向低内聚力,因为代码有很多理由要改变。另一方面,它可能只表明代码仍在成熟之中。至少,这些数据点是可以被观察到的!你将在后面的章节中学习更多关于版本控制的知识。
通过组成小包来填补角色
将代码提取到多个包中的行为有点像分解。成功的分解需要对松散耦合有一个很好的把握。分解代码是一门艺术,它将代码片断分开,以便它们能以新的方式重新组合(关于分解和耦合的精彩简明的介绍,见Josh Justice, "Breaking Up Is Hard To Do:How to Decompose Your Code",Big Nerd Ranch,www.bignerdranch.com/blog/breaki…。
通过包装你的代码的较小区域,你将开始识别完成一个非常具体的目标的代码,这些代码可以被概括或扩大以完成一个角色。举个例子,你可以使用一个内置的Python工具,如urllib.request.urlopen ,来创建一次性的HTTP请求。一旦你做了几次,你就可以看到用例之间的共性,并将这个概念概括为一个更高级的实用程序。因此,requests 包并不是为了进行一个特定的 HTTP 请求而建立的;它扮演了一个 HTTP 客户端的一般角色。你的一些代码现在可能是非常具体的,但当你发现新的领域需要类似的行为时,你可能会看到一个机会来识别它所填补的角色,概括一下,并创建一个可以填补这个角色的包。
当你为CarCorp修改你的软件时,你记得代码的主要部分是关于汽车的导航系统的。你意识到,只要稍加调整,导航代码也可以用于Acme Auto的车辆。这段代码可以充当与汽车导航系统进行通信的角色。因为你已经知道了包可以依赖于其他的包,而且你的导航系统代码已经相当有凝聚力了,所以你承诺在下一次CarCorp会议之前,不是创建一个而是两个包。
组成的成功故事
你可以通过Django(https://www.djangoproject.com)等Python框架看到组合在打包中发挥作用的伟大例子。Django本身就是一个包,由于它是作为一个基于插件的架构,你可以通过安装和配置额外的包来扩展其功能。仔细阅读Django包(https://djangopackages.org)上列出的数百个包,可以看到这种打包方式所享有的广泛采用。
对组成和分解的思考突出了这样一个事实:分发包可以以任何规模存在,就像函数、类、模块和导入包那样。把内聚和解耦看成是取得正确平衡的指路明灯。一百个分布包,每个都提供一个函数,这将是一个维护的负担,而一个分布包提供一百个导入包,这和没有包是一样的。如果一切都失败了,总是问自己:"我想让这段代码扮演什么角色?"
现在你已经了解到,打包可以帮助你写出具有明确所有权的、有凝聚力的、松散耦合的代码,你可以以一种可访问的方式提供给消费者,我希望你卷起袖子来潜心研究这些细节。