上一章里,我们通过 ppw 生成了一个规范的 python 项目,对初学者来说,许多闻所未闻、见所未见的概念和名词扑面而来,不免让人一时眼花缭乱,目不暇接。然而,如果我们不从头讲起,可能读者也无从理解,ppw 为何要应用这些技术,又倒底解决了哪些问题。
在 2021 年 3 月的某个孤独的夜晚,我决定创建一个创建一个 python 项目以打发时间,这个项目有以下文件:
├── foo
│ ├── foo
│ │ ├── bar
│ │ │ └── data.py
│ └── README.md
当然,作为一个有经验的开发者,我的机器上已经有了好多个其它的 python 项目,这些项目往往使用不同的 Python 版本,彼此相互冲突。所以,从一开始,我就决定通过虚拟开发环境来隔离这些不同的工程。这一次也不例外:我通过 conda 创建了一个名为 foo 的虚拟环境,并且始终在这个环境下工作。
我们的程序将会访问 postgres 数据库里的 users 表。一般来说,我们都会使用 sqlalchemy 来访问数据库,而避免直接使用特定的数据库驱动。这样做的好处是,万一将来我们需要更换数据库,那么这种迁移带来的工作量将轻松不少。
在 2021 年 3 月,python 的异步 io 已经大放异彩。而 sqlalchemy 依然不支持这一最新特性,这不免让人有些失望——这会导致在进行数据库查询时,python 进程会死等数据库返回结果,从而无法有效利用 CPU 时间。好在有一个名为 Gino 的项目弥补了这一缺陷:
$ pip install gino
做完这一切准备工作,开始编写代码,其中 data.py 的内容如下:
# 运行以下代码前,请确保本地已安装 POSTGRES 数据库,并且创建了名为 GINO 的数据库。
import asyncio
from gino import Gino
db = Gino()
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer(), primary_key=True)
nickname = db.Column(db.Unicode(), default='noname')
async def main():
# 请根据实际情况,添加用户名和密码
# 示例:POSTGRESQL://ZILLIONARE:123456@LOCALHOST/GINO
# 并在本地 POSTGRES 数据库中,创建 GINO 数据库。
await db.set_bind('postgresql://localhost/gino')
await db.gino.create_all()
# FURTHER CODE GOES HERE
await db.pop_bind().close()
asyncio.run(main())
作为一个对代码有洁癖的人,我坚持始终使用black
来格式化代码:
$ pip install black
$ black .
一切 ok,现在运行一下:
$ python foo/bar/data.py
检查数据库,发现 users 表已经创建。一切正常。
我希望这个程序在 macos, windows 和 linux 等操作系统上都能运行,并且可以运行在从 python 3.6 到 3.9 的所有版本上。
这里出现第一个问题。你需要准备 12 个环境:三个操作系统,每个操作系统上 4 个 python 版本,而且还要考虑如何进行"可复现的部署"的问题。在通过 ppw 创建的项目中,这些仅仅是通过修改 tox.ini 和.github\dev.yaml 中相关配置就可以做到了。但在没有使用 ppw 之前,我只能这么做:
在三台分别安装有 macos, windows 和 ubuntu 的机器上,分别创建 python 3.6 到 python 3.9 的虚拟环境,然后安装相同的依赖。首先,我通过pip freeze
把开发机器上的依赖抓取出来:
$ pip freeze > requirements.txt
然后在另一台机器上的准备好的虚拟环境中,运行安装命令:
$ pip install -r requirements.txt
这里又出现了**第二个问题。black
纯粹是只用于开发目的,为什么也需要在测试/部署环境上安装呢?**因此,在制作requirements.txt
之前,我决定将black
卸载掉:
$ pip uninstall -y black && pip freeze > requirements.txt
然而,仔细检查 requirements.txt 之后发现,black
是被移除了,但仅仅是它自己。它的一些依赖,比如click
, tomli
等等,仍然出现在这个文件中。
!!! Info
这里的 click 就是我们前面提到的 Pallets 开发的那个 click。 black 作为格式化工具,它既可以作为 API 被其它工具调用,也可以作为独立应用,通过命令行来运行。black 就使用了 click 来进行命令行参数的解析。
于是,我不得不抛弃 pip freeze 这种作法,只在 requirements.txt 中加上直接依赖(在这里, black 是直接依赖,而 click 是间接依赖,由 black 引入),并且,将这个文件一分为二,将 black 放在 requirements_dev.txt 中。
# REQUIREMENTS.TXT
gino==1.0
# REQUIREMENTS_DEV.TXT
black==18.0
现在,在测试环境下,我们将只安装 requirements.txt 中的那些依赖。不出所料,项目运行得很流畅,目标达成,放心地去睡觉了。但是,gino 还依赖于 sqlalchemy 和 asyncpg。后二者被称为传递依赖。我们锁定了 gino 的版本,但是 gino 是否正确锁定了 sqlalchemy 和 asyncpg 的版本呢?这一切仍然不得而知。
第二天早晨醒来,sqlalchemy 1.4 版本发布了。突然地,当我再安装新的测试环境并进行测试时,程序报出了以下错误:
Traceback (most recent call last):
File "/Users/aaronyang/workspace/best-practice-python/code/05/foo/foo/bar/data.py", line 3, in
from gino import Gino
File "/Users/aaronyang/miniforge3/envs/bpp/lib/python3.9/site-packages/gino/__init__.py", line 2, in
from .engine import GinoEngine, GinoConnection # NOQA
File "/Users/aaronyang/miniforge3/envs/bpp/lib/python3.9/site-packages/gino/engine.py", line 181, in
class GinoConnection:
File "/Users/aaronyang/miniforge3/envs/bpp/lib/python3.9/site-packages/gino/engine.py", line 211, in GinoConnection
schema_for_object = schema._schema_getter(None)
AttributeError: module 'sqlalchemy.sql.schema' has no attribute '_schema_getter'
我差不多花了整整两天才弄明白发生了什么。我的程序依赖于 gino, 而 gino 又依赖于著名的 SQLAlchemy。gino 1.0 是这样锁定 SQLAlchemy 的版本的:
$pip install gino==1.0
Looking in indexes: https://pypi.jieyu.ai/simple, https://pypi.org/simple
Collecting gino==1.0
Downloading gino-1.0.0-py3-none-any.whl (48 kB)
|████████████████████████████████| 48 kB 129 kB/s
Collecting SQLAlchemy<2.0,>=1.2
Downloading SQLAlchemy-1.4.0.tar.gz (8.5 MB)
|████████████████████████████████| 8.5 MB 2.3 MB/s
从 pip 的安装日志可以看到,gino 声明能接受的 SQLAlchemy 的最小版本是 1.2,最大版本则是不到 2.0。因此,当我们安装 gino 1.0 时,只要 SQLAlchemy 存在超过 1.2,且小于 2.0 的最新版本,它就一定会选择安装这个最新版本,最终,SQLAlchemy 1.4.0 被安装到环境中。
SQLAlchemy 在 2020 年也意识到了 asyncio 的重要性,并计划在 1.4 版本时转向 asyncio。然而,这样一来,调用接口就必须发生改变 – 也就是,之前依赖于 SQLAlchemy 的那些程序,不进行修改是无法直接使用 SQLAlchemy 1.4 的。1.4.0 这个版本发布于 2021 年 3 月 16 日。
原因找到了,最终问题也解决了。最终,我把这个错误报告给了 gino,gino 的开发者承担了责任,发布了 1.0.1,将 SQLAlchemy 的版本锁定在">1.2,<1.4"这个范围内。
pip install gino==1.0.1
Looking in indexes: https://pypi.jieyu.ai/simple, https://pypi.org/simple
Collecting gino==1.0.1
Using cached gino-1.0.1-py3-none-any.whl (49 kB)
Collecting SQLAlchemy<1.4,>=1.2.16
Using cached SQLAlchemy-1.3.24-cp39-cp39-macosx_11_0_arm64.whl
在这个案例中,我并没有要求升级并使用 SQLAlchemy 的新功能,因此,新的安装本不应该去升级这样一个破坏性的版本;但是如果 SQLAlchemy 出了新的安全更新,或者 bug 修复,显然,我们也希望我们的程序在不进行更新发布的情况下,就能对依赖进行更新(否则,如果任何一个依赖发布安全更新,都将导致主程序不得不发布更新的话,这种耦合也是很难接受的)。因此,是否存在一种机制,使得我们的应用在指定直接依赖时,也可以恰当地锁定传递依赖的版本,并且允许传递依赖进行合理的更新?这是我们这个案例提出来的第三个问题。
现在,似乎是我们将产品发布的时候了。我们看到其它人开发的开源项目发布在 pypi 上,这很酷。我也希望我的程序能被千百万人使用。这就需要编写 MANINFEST.in, setup.cfg, setup.py 等文件。
MANIFEST.in 用来告诉 setup tools 哪些额外的文件应该被包含在发行包里,以及哪些文件则应该被排除掉。当然在我们这个简单的例子中,这个文件是可以被忽略的。
setup.py 中需要指明依赖项、版本号等等信息。由于我们已经使用了 requirements.txt 和 requirements_dev.txt 来管理依赖,所以,我们并不希望在 setup.py 中重复指定 – 我们希望只更新 requirements.txt,就可以自动更新 setup.py:
from setuptools import setup
with open('requirements.txt') as f:
install_requires = f.read().splitlines()
with open('requirements_dev.txt') as f:
extras_dev_requires = f.read().splitlines()
# SETUP 是一个有着庞大参数体的函数,这里只显示了部分相关参数
setup(
name='foo',
version='0.0.1',
install_requires=install_requires,
extras_require={'dev': extras_dev_requires},
packages=['foo'],
)
看上去还算完美。但实际上,我们每一次发布时,还会涉及到修改版本号等问题,这都是容易出错的地方。而且,它还不涉及打包和发布。通常,我们还需要编写一个 makefile,通过 makefile 命令来实现打包和发布。
这些看上去都是很常规的操作,为什么不将它自动化呢?这是第四个问题,即如何简化打包和发布。
这四个问题,就是我们这一章要讨论的主题。我们将以 Poetry 为主要工具,结合 semantic versioning 来串起这一话题的讨论。
在软件开发领域中,我们常常对同一软件进行不断的修补和更新,每次更新,我们都保留大部分原有的代码和功能,修复一些漏洞,引入一些新的构件。
有一个古老的思想实验,被称之为忒修斯船(The Ship of Theseus)问题,它描述的正是同样的场景:
忒修斯船问题最早出自公元一世纪普鲁塔克的记载。它描述的是一艘可以在海上航行几百年的船,只要一块木板腐烂了,它就会被替换掉,以此类推,直到所有的功能部件都不是最开始的那些了。现在的问题是,最后的这艘船是原来的那艘忒修斯之船呢,还是一艘完全不同的船?如果不是原来的船,那么从什么时候起它就不再是原来的船了?
忒修斯船之问,发生在很多领域。象 IBM 这样的百年老店,不仅 CEO 换了一任又一任,就连股权也在不停地变更。可能很少人有在意,今天的 IBM,跟百年之前的 IBM 还是不是同一家 IBM,就象我们很少关注,人类是从什么时候起,不再是动物一样。又比如,如果有一家创业公司,当初吸引你加入,后来创始人变现走人了,尽管公司名字可能没换,但公司新进了管理层和新同学,业务也可能发生了一些变化。这家公司,还是你当初加入的公司吗?你是要选择潇洒的离开,还是坚持留下来?
在软件开发领域中,我们更是常常遇到同样的问题。每遇到一个漏洞(bug),我们就更换一块"木板"。随着这种修补和替换越来越多,软件也必然出现忒修斯船之问:现在的软件还是不是当初的软件,如果不是,那它是在什么时候不再是原来的软件了呢?
当然,忒修斯船之问有着深刻的哲学内涵。我们在软件领域中,尽管也遇到同样的场景,但我们需要的回答就要简单很多:
软件应该如何向外界表明它已发生了实质性的变化;生态内依赖于该软件的其它软件,又应该如何识别软件的蜕变呢?
为了解决上述问题,Tom Preston-Werner(Github 的共同创始人)提出 Semantic versioning 方案,即基于语义的版本管理。Semantic version 表示法提出的初衷是:
Semantic versioning 简单地说,就是用版本号的变化向外界表明软件变更的剧烈程度。要理解 Semantic versioning,我们首先得了解软件的版本号。
当我们说起软件的版本号时,我们通常会意识到,软件的版本号一般由主版本号 (major),次版本号 (minor),修订号 (patch) 和构建编号 (build no.) 四部分组成。由于 Python 程序没有其它语言通常意义上的构建,所以,对 Python 程序而言,一般只用三段,即 major.minor.patch 来表示。
上述版本表示法没有反映出任何规则。在什么情况下,你的软件应该定义为 0.x,什么时候又应该定义为 1.x,什么时候递增主版本号,什么时候则只需要递增修订号呢?如果不同的软件生产商对以这些问题没有共识的话,会产生什么问题吗?
实际上,由于随意定义版本号引起的问题很多。在前面我们提到过 SQLAlchemy 的升级导致许多 Python 软件不能正常工作的例子。在讲述那个例子时,我指出,是 gino 的开发者承担了责任,发行了新的 gino 版本,解决了这个问题。但实际上,责任的根源在 SQLAlchemy 的开发者那里。
从 1.3.x 到 1.4.x, 出现了接口的变更,这是一种破坏性的更新,此时,新的 1.4 已不再是过去的忒修斯之船了,使用者如果不修改他们的调用方式,就无法使用 SQLAlchemy 的问题。gino 的开发者认为(这也是符合 semantic versioning 思想的),SQLAlchemy 从 1.2 到 2.0 之间的版本,可以增加接口,增强性能,修复安全漏洞,但不应该变更接口;因此,它声明为依赖 SQLAlchemy 小于 2.0 的版本是安全的。但可惜的是,SQLAlchemy 并没有遵循这个约定。
Sematic versioning 提议用一组简单的规则及条件来约束版本号的配置和增长。首先,你规划好公共 API,在此后的新版本发布中,通过修改相应的版本号来向大家说明你的修改的特性。考虑使用这样的版本号格式:X.Y.Z (主版本号. 次版本号. 修订号):修复问题但不影响 API 时,递增修订号;API 保持向下兼容的新增及修改时,递增次版本号;进行不向下兼容的修改时,递增主版本号。
我们在前面提到过 SQLAlchemy 从 1.x 升级到 1.4 的例子。实际上,由于引入了异步机制,这是个不能向下兼容的修改,因此,SQLAlchemy 本应该启用 2.x 的全新版本序列号,而把 1.4 留作 1.x 的后续修补发布版本号使用。如此一来,SQLAlchemy 的使用者就很容易明白,如果要使用最新的 SQLAlchemy 版本,则必须对他们的应用程序进行完全的适配和测试,而不能象之前的升级一样,简单地把最新版本安装上,就仍然期望它能像之前一样工作。不仅如此,一个定义了良好依赖关系的软件,还能自动从升级中排除掉升级到 SQLAlchemy 2.x,而始终只在 1.x,甚至更小的范围内进行升级。
一个正确地使用 semantic versioning 的例子是 aioredis 从 1.x 升级到 2.0。尽管 aioredis 升级到 2.0 时,大多数 API 并没有发生改变–只是在内部进行了性能增强,但它的确改变了初始化 aioredis 的方式,从而使得你的应用程序,不可能不加修改就直接更新到 2.0 版本。因此,aioredis 在这种情况下,将版本号更新为 2.0 是非常正确的。
事实上,如果你的程序的 API 发生了变化(函数签名发生改变),或者会导致旧版的数据无法继续使用,你都应该考虑主版本号的递增。
此外,从 0.1 到 1.0 之前的每一个 minor 版本,都被认为在 API 上是不稳定的,都可能是破坏性的更新。因此,如果你的程序使用了还未定型到 1.0 版本的第三方库,你需要谨慎地声明依赖关系。而我们自己如果作为开发者,在软件功能稳定下来之前,不要轻易地将版本发布为1.0。
本系列文章来自《Python能做大项目》(暂定名),将由机械工业出版社出版。
【系列文章链接】
Python能做大项目(1)为什么要学Python之一
Python能做大项目(2) -开发环境构建
Python能做大项目(3) - 依赖地狱与Conda虚拟环境
Python能做大项目(4)项目布局与生成向导