长话短说:本文介绍了fastmigrate
,一款 Python 数据库迁移工具。它专注于 SQLite,并且不需要任何特定的 ORM 库。如果您希望直接使用 SQLite 并保持简单,它非常适合您。有关说明,请查看fastmigrate 代码库。
让我们讨论一下迁移!
不是我们谈论的迁移
呃,不。我们来谈谈数据库迁移模式。
迁移是一种强大的架构模式,用于管理数据库的变更。它允许您编写应用程序代码,使其只需要了解数据库的最新版本,并简化了用于更新数据库本身的代码。
但是很容易忽视这种模式,因为许多数据库帮助库同时以非常复杂的方式执行许多其他操作,以至于它们掩盖了这种基本模式的简单性。
因此,今天我们发布了fastmigrate ,一个用于数据库迁移的库和命令行工具。它本身就是一个简单的工具,并秉承了底层模式的简洁性。它提供了一小套命令。它将迁移视为您自己的脚本的目录。它只需要理解基本概念,而无需太多额外的术语。我们喜欢它!
本文将解释数据库迁移的一般概念以及它们解决的问题,然后说明如何使用 fastmigrate 在 sqlite 中进行迁移。
迁移解决的问题
迁移解决的核心问题是,在不破坏应用程序的情况下,更轻松地更改数据库模式(以及其他基本结构)。迁移通过使数据库版本显式化并可管理来实现这一点,就像应用程序代码中的更改一样。
要了解复杂性是如何逐渐增加的,请考虑开发应用程序时的一个典型事件序列。应用程序首次运行时,它只需要处理一种情况:没有数据库,需要创建一个。此时,应用程序的启动代码可能如下所示:
# App v1 db.execute( "CREATE TABLE documents (id INT, content TEXT);" )
但是等等……当用户第二次运行同一个应用时,该表已经存在了。所以实际上你的代码应该处理两种可能的情况——表不存在的情况和表已经存在的情况。
因此,在应用程序的下一个版本中,您需要将初始化代码更新为以下内容:
# App v2 db.execute( "CREATE TABLE IF NOT EXISTS documents (id INT, content TEXT);" )
稍后,您可能决定向数据库添加新列。因此,在应用程序的第三个版本中,您添加了第二行:
# App v3 db.execute( "CREATE TABLE IF NOT EXISTS documents (id INT, content TEXT);" ) db.execute( "ALTER TABLE documents ADD COLUMN title TEXT;" )
但是等等……如果该列已经存在,您肯定不想像这样修改表。因此,App v4 需要更复杂的逻辑来处理这种情况。等等。
即使是这个简单的例子,如果处理不当,也会产生 bug。在实际应用中,随着表关系的引入和修改,这类问题会变得更加微妙、繁多,也更加棘手,因为一步出错就可能丢失用户数据。
实际情况是,随着每个新版本的推出,应用程序的代码变得更加复杂,因为它不仅需要处理数据库的一个状态,还需要处理每个可能的先前状态。
为了避免这种情况,您需要强制执行单独的数据库更新,以便应用程序代码确切地知道数据库的预期结果。当应用程序管理数据库并且每个用户都可以决定何时运行自己的应用程序安装时,这通常是不可行的,例如在移动应用程序、桌面应用程序或每个用户一个数据库的Web应用程序中。即使在具有单个数据库的系统中,强制执行单独的数据库更新也会引入一种重要的新更改需要管理——即数据库更改,这需要与应用程序代码中的更改巧妙地耦合。
这触及了问题的核心,即默认情况下这些不同的数据库状态是隐式的和非托管的。
对于应用程序代码,git commit 明确指定了代码的版本以及产生该版本的变更。然后,部署系统可以让你精确控制用户接下来会看到哪个版本的应用程序。但是对于数据库,如果没有系统的支持,你只能知道数据库处于由之前代码生成的某种未命名的状态。那些能够很好地管理应用程序代码的版本控制和部署工具,无法自动控制应用程序接下来会看到哪个版本的数据库。
迁移如何解决这个问题
数据库迁移模式通过两个关键措施解决了这个问题:
首先,基于迁移定义数据库版本。我们不再依赖未命名的数据库状态,而是引入了明确的数据库版本管理。
我们该怎么做呢?使用迁移脚本。迁移脚本是一个独立的、单一用途的脚本,其唯一作用是将数据库从一个版本(例如 5)迁移到下一个版本(例如 6)。
Fastmigrate 保持了这种简单性,并根据脚本生成的数据库版本来命名它们。例如,名为0006-add_user.sql
的脚本必然是唯一生成数据库版本 6 的脚本。从根本上讲,迁移脚本中的版本号定义了一组可识别的数据库版本。因此,您可以通过列出生成这些版本的脚本来查看数据库的过去版本,就像查看 git 提交日志一样:
$ ls -1 migrations/ 0001-initialize.sql 0002-add-title-to-documents.sql 0003-add-users-table.sql
这种结构化方法使得下一个关键措施成为可能。
其次,编写应用程序以针对一个数据库版本。将数据库演进代码移到这些迁移脚本中意味着应用程序代码可以忽略数据库更改,并且只针对数据库的一个版本,即最新版本。
应用程序可以依赖迁移库(例如fastmigrate
)来运行所需的任何迁移。这可能意味着在开发环境中运行新实例时,重新执行所有迁移,从零开始创建数据库的最新版本。也可能意味着只应用最新的迁移,将最近的数据库版本更新到最新版本。或者,也可能是介于两者之间的某种方式。关键在于,应用程序无需关心这些。
衡量简化程度的一种方法是计算系统不同部分需要处理的情况数量。
在迁移之前,您的应用程序代码实际上负责处理所有可能的先前数据库状态,即使需要更加仔细地记住和理解所有这些状态。迁移之后,一切都变得清晰明了、清晰易懂且可分解。应用程序只需处理一个数据库版本。并且每个数据库版本都只有一个脚本,可以从一个先前版本生成该版本。(如此简洁!是不是让你想叹息?啊……)
特征 | 无需迁移 | 随着迁移 |
---|---|---|
DB 状态 | 未统计,未命名 | |
数据库管理 | 没有任何 | |
应用程序要求 | 应用程序必须支持所有数据库状态,并管理数据库更改 | 应用程序必须仅支持一个数据库版本,即最新版本 |
如何使用 fastmigrate
让我们再次遵循前面的示例,看看它在fastmigrate
中是如何工作的。
您无需在应用启动时嵌入不断变化的数据库架构逻辑,而是可以定义一系列迁移脚本。这些脚本是 SQL 脚本,但您也可以使用 Python 或 Shell 脚本。然后,您的应用程序将使用fastmigrate
的 API 根据需要运行这些脚本,从而自动将数据库迁移到最新的预期版本。
您的第一个迁移脚本会创建表。创建一个目录migrations/
,并将文件0001-initialize.sql
放入该目录中。
-- migrations/0001-initialize.sql CREATE TABLE documents ( id INTEGER PRIMARY KEY , content TEXT );
0001
前缀是关键:它表示这是第一个运行的脚本,并且它会生成数据库的版本 1。
运行pip install fastmigrate
从 PyPi 安装它,以便您的应用程序可以使用它。
现在,你的应用程序启动代码可以依赖fastmigrate
来创建和/或更新数据库了。在名为app.py
的文件中创建你的应用程序:
from fastmigrate.core import create_db, run_migrations, get_db_version db_path = "./app.db" migrations_dir = "./migrations/" # Ensures a versioned database exists. # If no db exists, it's created and set to version 0. # If a db exists, nothing happens create_db(db_path) # Apply any pending migrations from migrations_dir. success = run_migrations(db_path, migrations_dir) if not success: print ( "Database migration failed! Application cannot continue." ) exit( 1 ) # Or your app's specific error handling # After this point, your application code can safely assume # the 'documents' table exists exactly as defined in 0001-initialize.sql. # The database is now at version 1. version = get_db_version(db_path) print ( f"Database is at version { version } " )
此 Python 代码第一次运行时, create_db()
会初始化您的数据库,并插入元数据以将其标记为版本 0 的托管数据库。这是通过添加一个小的_meta
表来完成的,该表存储当前版本并指示它是一个托管数据库。
然后,函数run_migrations()
看到0001-initialize.sql
。由于版本 1 大于数据库当前的版本 0,因此该函数执行该语句,并将数据库的版本标记为 1。在后续运行中,如果没有添加新的迁移脚本, run_migrations()
会认为数据库已经是版本 1,因此不会执行任何操作。
现在,您可以使用python3 app.py
运行您的应用程序,无论您运行多少次,该应用程序都会报告数据库处于版本 1。您还将能够在目录中看到它创建的数据库文件data.db
。
但是模式演变又如何呢?
当您确定documents
表需要title
列时,您只需要添加一个添加该列的迁移脚本。
此更改定义了数据库的版本 2。在 migrations 目录中,添加一个名为0002-add-title-to-documents.sql
文件。
-- migrations/0002-add-title-to-documents.sql ALTER TABLE documents ADD COLUMN title TEXT;
关键是,您的应用程序启动代码不会改变:它仍然是上面显示的相同的 Python 代码片段。
当该代码在之前的版本 1 的数据库上运行时(即仅应用了0001-initialize.sql
),会发生以下情况:
-
create_db(db_path)
确认数据库存在并且版本为 1。 -
run_migrations()
扫描migrations/
目录。它找到了0002-add-title-to-documents.sql
。由于该脚本的版本 (2) 高于数据库的当前版本 (1),因此它会执行这个新脚本。 -
执行成功后,
fastmigrate
将数据库的版本标记为2。 -
在这些
fastmigrate
调用之后运行的应用程序代码现在可以假定documents
表具有id
、content
和新的title
列。
使用python3 app.py
再次运行您的应用程序,现在它将报告数据库为版本 2。
如果你好奇它背后的工作原理,其实并不难懂。Fastmigrate 通过添加_meta
表来标记数据库,你可以使用 sqlite3 可执行文件直接查看:
$ sqlite3 app.db .tables _meta documents
您可以查看它,发现版本现在是 2:
$ sqlite3 app.db "select * from _meta;" 1 | 2
但这只是实现细节。关键在于方法的转变:
-
复杂的条件逻辑已完全从应用程序的主启动序列中删除。
-
模式更改被隔离为小的、名称明确、版本化的 SQL 脚本。
-
即使数据库模式不断发展,应用程序的核心启动例程(
create_db()
、run_migrations()
)仍然是稳定的。 -
应用程序的其余代码(即实际使用数据库的部分)始终可以编写为使用最高编号的迁移脚本定义的唯一最新架构版本。它不需要为较旧的数据库结构设置条件路径。
这种“仅追加”的迁移方法,让您始终为后续更改添加新的、编号更高的脚本,从而使数据库的演进更加明确、易于管理且易于集成。达到目标架构版本的责任委托给了fastmigrate
。
将代码提交到版本控制时,应注意将定义新数据库版本的迁移脚本与需要该新数据库版本的应用程序代码一起包含进去。这样,您的应用程序代码就能始终看到它所需的数据库版本。
在命令行上测试
在将新的迁移脚本集成到您的应用之前,您当然需要对其进行测试。这很简单,因为迁移脚本被设计为独立运行。为了方便以交互方式运行它们, fastmigrate
还提供了命令行界面 (CLI)。
如果您想检查应用程序刚刚创建的数据库,您可以运行检查版本命令:
$ fastmigrate_check_version --db app.db FastMigrate version: 0.3.0 Database version: 2
当 CLI 命令的名称与 API 匹配时,它们会执行完全相同的操作。fastmigrate_create_db fastmigrate_create_db
行为与fastmigrate.create_db
一样, fastmigrate_run_migrations
行为与fastmigrate.run_migrations
一样,等等。
例如,您可以运行以下命令来创建一个空的托管数据库并在其上运行迁移:
$ fastmigrate_create_db --db data.db Creating database at data.db Created new versioned SQLite database with version=0 at: data.db $ fastmigrate_run_migrations --db data.db --migrations migrations/ Applying migration 1: 0001-initialize.sql ✓ Database updated to version 1 ( 0.00s ) Applying migration 2: 0002-add-title-to-documents.sql ✓ Database updated to version 2 ( 0.00s ) Migration Complete • 2 migrations applied • Database now at version 2 • Total time: 0.00 seconds
没什么新东西可学!
有关引入新迁移时推荐工作流程的更详细演练,请参阅有关安全添加迁移的指南。
还有一些指南,指导您如何将非fastmigrate
启动的数据库注册为托管数据库。从技术上讲,这只不过是添加标记数据库版本的私有元数据。但该工具会生成一个0001-initialize.sql
迁移脚本草稿,帮助您入门,因为您需要一个脚本来初始化与您正在注册的数据库等效的数据库。生成的脚本只是一个草稿,因为您一定要手动验证它是否适合您的数据库。
简单=清晰=平静
再看看那张地图,想想我们的祖先在当时没有空调、播客和AI聊天机器人的情况下,跋涉了数千英里。当时很艰难,是的,我们现在的生活还不算太糟。
但尽管如此,管理生产数据库的演变仍然充满压力。
这很自然,因为这是用户的数据。大多数软件的最终目的就是转换和存储这些数据。所以,如果你的数据库搞乱了,你的软件就违背了它存在的主要初衷。
缓解压力的良方是清晰的思路。你要知道自己在做什么。
想象一下,当有人用哈希值引用 git 提交时,你会感到多么温暖舒适。(嗯。)这种感觉是因为哈希值是明确的。如果你让 git 计算两个提交哈希之间哪些文件发生了变化,你就能清楚地知道答案的含义。你也希望你的数据库也能有同样的清晰度。
迁移模式通过确保您的数据库具有一个简单的版本号来实现这一点,该版本号可以告诉您它处于什么状态,从而准确地告诉您您的应用程序可以期待什么。
由于这是一个简单的想法,因此只需要一个简单的工具。
这就是为什么 fastmigrate 只引入几个主要命令 – create_db
、 get_db_version
和run_migrations
– 并且依赖于您已经知道的内容,例如如何列出文件和解释整数。
相比之下,许多现有的数据库工具之所以复杂,是因为它们还提供了许多其他功能——对象关系映射器、模板系统、对各种后端的支持,以及对多个不同语法的配置文件的要求。如果你的系统已经变得复杂到需要所有这些功能的地步,那么这些就是你需要的。
但是,如果你能保持系统的简洁,那么简单的解决方案会更适合你。它会更容易理解、更容易使用,更容易记住和掌握。如果你要切胡萝卜,你会想要一把锋利的刀吗?还是一台带有特殊胡萝卜切碎附件的食品加工机,你只需要阅读说明书就能弄清楚如何安装它?
fastmigrate
目标是成为一把锋利的利刀。愿您能够清晰自信地使用它!