第9回 忒修斯船之问:SQLAlchemy如何毁掉了我美好的两天

第9回 忒修斯船之问:SQLAlchemy如何毁掉了我美好的两天

首页卡牌对战忒修斯计划更新时间:2024-04-29

上一章里,我们通过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

在那个孤独的夜晚,上述命令将安装gino 1.0版本。如果读者想运行这里的程序,请将gino的版本改为1.0.1,即运行 pip install gino==1.0.1

做完这一切准备工作,开始编写代码,其中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.get_event_loop().run_until_complete(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.8到python 3.11的虚拟环境,然后安装相同的依赖。首先,我通过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等等,仍然出现在这个文件中。

于是,我不得不抛弃pip freeze这种作法,只在requirements.txt中加上直接依赖,并且,将这个文件一分为二,将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 <module> from gino import Gino File "/Users/aaronyang/miniforge3/envs/bpp/lib/python3.9/site-packages/gino/__init__.py", line 2, in <module> from .engine import GinoEngine, GinoConnection # NOQA File "/Users/aaronyang/miniforge3/envs/bpp/lib/python3.9/site-packages/gino/engine.py", line 181, in <module> 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

上述文本是在2021年3月安装gino 1.0时的输出。如果您现在运行pip install gino==1.0,会安装SQLAlchemy 1.4.46版本,这是它在1.x下的最后一个版本。

从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来串起这一话题的讨论。

1. Semantic Versioning(基于语义的版本管理)

说到版本管理,我不禁想起忒修斯之船(The Ship of Theseus)问题,该问题是最为古老的思想实验之一。最早出自公元一世纪普鲁塔克的记载。

它描述的是一艘可以在海上航行几百年的船,归功于不间断的维修和替换部件。只要一块木板腐烂了,它就会被替换掉,以此类推,直到所有的功能部件都不是最开始的那些了。

现在的问题是,最后的这艘船是原来的那艘忒修斯之船呢,还是一艘完全不同的船?如果不是原来的船,那么在什么时候它不再是原来的船了?

在软件开发领域中,我们也常常对同一软件进行不断的修补和更新。但是,随着这种修补和替换越来越多,软件会不会也出现忒修斯船之问:现在的软件还是不是当初的软件,如果不是,那它是在什么时候不再是原来的软件了呢?该软件应该如何向外界表明它已发生了实质性的变化;生态内依赖于该软件的其它软件,又应该如何识别软件的蜕变呢?

为了解决上述问题,Tom Preston-Werner(Github的共同创始人)提出Semantic versioning方案,即基于语义的版本管理。Semantic version表示法提出的初衷是:

在软件管理的领域里存在着被称作“依赖地狱”的死亡之谷,系统规模越大,加入的包越多,你就越有可能在未来的某一天发现自己已深陷绝望之中。

在依赖高的系统中发布新版本包可能很快会成为噩梦。如果依赖关系过高,可能面临版本控制被锁死的风险(必须对每一个依赖包改版才能完成某次升级)。而如果依赖关系过于松散,又将无法避免版本的混乱(假设兼容于未来的多个版本已超出了合理数量)。当你专案的进展因为版本依赖被锁死或版本混乱变得不够简便和可靠,就意味着你正处于依赖地狱之中。

Semantic versioning简单地说,就是用版本号的变化向外界表明软件变更的剧烈程度。要理解Semantic versioning,我们首先得了解软件的版本号。

当我们说起软件的版本号时,我们通常会意识到,软件的版本号一般由主版本号(major),次版本号(minor),修订号(patch)和构建编号(build no.)四部分组成。由于Python程序没有其它语言通常意义上的构建,所以,对Python程序而言,一般只用三段,即major.minor.patch来表示。

实际上,出于内部开发的需要,我们仍然可能给Python程序的版本用上Build No,特别是在CI集成中。当我们向仓库推送一个commit时,CI都需要进行一轮构建和自动验证,此时并不会修改正式版本号,因此,一般倾向于使用构建号来区分不同的提交导致的版本上的不同。在python project wizard生成的项目中,其CI就实现了这个逻辑。

上述版本表示法没有反映出任何规则。在什么情况下,你的软件应该定义为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,甚至更小的范围内进行升级。

SQLAlchemy的错误并非孤例。一个更有影响的例子涉及到python的cryptography库。这是一个广泛使用的密码学相关的python库。

为了提升性能,许多代码最初是使用c写的。有一天作者意识到,使用c会存在很多安全问题,而安全性又是cryptography的核心。于是,在2021年2月8日前后,他改用rust来进行实现。这导致安装cryptography库的人,必须在本机上有rust的编译工具链 -- 事实是,rust与c和python相比,还是相当小众的,很多人的机器上显然不会有这套工具链。

需要指出的是,cryptography改用rust实现,并没有改变它的python接口。相反,其python接口完全保持着一致。

因此,cryptography的作者也既没有重命名cryptography,也没有变更主版本号。

但是这一小小的改动,掀起了轩然大波。一夜之间,它摧毁了无数的CI系统,无数docker镜像必须被重构,抱怨声如潮水般涌向作者。在短短几个小时,作者就收到了100条激烈的评论,最终作者不得不关掉了这个[issue](https://github.com/pyca/cryptography/issues/5771)

一个正确地使用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版本的第三方库,你需要谨慎地声明依赖关系。

查看全文
大家还看了
也许喜欢
更多游戏

Copyright © 2024 妖气游戏网 www.17u1u.com All Rights Reserved