logo

Rails Migration (1/2) - 了解一点数据库迁移的知识

我第一次接触数据库迁移的概念是通过 Golang 的 migrate,还记得当时被那些开源库作者们的奇思妙想所折服的瞬间,羡慕他们聪明伶俐的脑袋里蕴含着无尽的解决方案,仅仅一个数据库迁移问题就解决的这么毫无破绽。

直到我开始接触 Rails。

才知道什么叫 “简陋”,什么叫 “一山更比一山高”(我意不在批判 Go 或 migrate,但应当承认的是:正如 Ruby 永远不会在性能上超过 Go 一样,Go 在代码的优雅性上也永远难望 Ruby 之项背)。

在日常开发中,最常见的问题之一就是对数据库的修改:今天有个需求要添加一个字段,明天可能要更改一个数据类型,后天也许要加个索引 … 而实际生产中一张表可能有几十上百个字段,要在很多开发人员之间 “对齐” 这张表,显然不能靠口口相传。

数据库迁移 (Migration) 就是为了解决了这样一个问题: 它通过命令的方式自动化我们对数据库的修改,保存修改历史,使得仅需一行命令就能保持开发人员之间数据库结构的同步。

1. Rails Migration 基础

Rails Migration 的大体过程如下:

  1. 在开发中我们使用命令生成迁移文件,文件被统一保存在 db/migrate 目录
  2. 文件名以创建时的 UTC 时间戳和下划线开头,如 20240529094628_xxx.rb
  3. 然后我们根据需要,手动修改生成的迁移文件,添加或补充字段
  4. 之后运行 rails db:migrate 命令,指示数据库运行我们的迁移

“迁移”、“迁移文件” 可能听起来有些陌生,其实就是:我们想要修改数据库,但是不想直接手写 SQL,于是用 Rails 命令生成一些代码,当我们运行这些代码时,这些代码被转化为 SQL 语句在数据库中运行,完成我们在数据库中创建表、修改字段等的需求。

1.1 生成迁移文件的命令

Rails 中把生成文件的命令叫生成器 (generator)。有两个可以创建迁移文件的生成器: modelmigration。它们的用法如下:

rails generate model/migration [model_name/MigrationName] [field]:[field_type]:[index]

# 即
rails g model/migration 数据库表名 字段:字段类型:索引

其中:

更现实的例子是:

# model 生成器可以使用 `--skip-migration` 选项跳过生成迁移
rails g model event name:string:uniq location:string price:decimal

rails g migration AddFieldsToEvents starts_at:datetime description:text

我们一般使用 model 生成器创建数据库表,而使用 migration 生成器修改已存在的表,如添加、删除字段或索引。Rails 能够识别 migration 的两种特殊模式,即 AddXXXToTABLERemoveXXXFromTABLE (其中,XXX 是什么并不重要),如果我们的 migration 名字符合这两个模式之一,那么 Rails 知道我们要向某个表中添加或删除字段,从而会在生成的迁移文件中自动为我们填写后面的字段和类型。

  • 在 Rails 项目目录内
    • 运行 rails g model -h 命令可以查看 model 生成器的详细用法。
    • 运行 rails g migration -h 命令可以查看 migration 生成器的详细用法。
  • 官方文档有更丰富的例子:The Rails Command Line - generate

1.2 迁移文件名

我本地有一个项目的 db/migrate 目录如下:

db/migrate
├── 20240527140216_create_events.rb
├── 20240528031744_add_fields_to_events.rb
├── 20240529094628_add_image_and_capacity_to_events.rb
├── 20240529141627_create_registrations.rb
├── 20240530041815_create_users.rb
├── 20240601132721_add_admin_to_users.rb
├── 20240601135646_make_registrations_a_join_table.rb

迁移文件前面的时间戳也被称为版本号,是由年月日时分秒组成的,一般是 14 个数字。这使得迁移文件能够有序排列,Rails 正是通过这个版本号来确定文件顺序并进行迁移的。虽然理论上存在版本号冲突的可能性,但具有可确定性排序的时间戳的好处远远超过发生这种情况的微小风险

1.3 迁移文件的内容剖析

迁移文件的内容一般类似这样:

# db/migrate/20240527140216_create_events.rb
class CreateEvents < ActiveRecord::Migration[7.1]
  def change
    create_table :events do |t|
      t.string :name, null: false
      t.string :location
      t.decimal :price

      t.timestamps
    end
  end
end

这里的类名 CreateEvents 来自文件名 create_events,而所有迁移文件的类名都是 ActiveRecord::Migration 的子类。

Rails 不依赖于底层数据库,我们可以使用任何自己喜欢的数据库,但不同数据库支持的数据类型并不一致,所以 Rails 迁移通过使用逻辑类型 (logical types) 来与底层数据库系统进行隔离。假如我们创建迁移时使用的某个字段是 :datetime 类型,那么如果使用的是 PostgreSQL 数据库,该字段就会被转换为 timestamp 类型。

在定义迁移中的列时,有几个键值对形式的常用选项:

除了上面几个选项之外,在定义浮点数字段时,还强烈建议使用 :precision:scale 选项,前者定义有效位数,后者定义小数点后的位数,如 precision: 5, scale: 2 限制存储的浮点数范围为 -999.99 到 +999.99。

此外比较重要的一点是:迁移可以被执行,也可以被撤销,Rails 使用 up 方法执行修改,使用 down 方法撤销修改。但在很多情况下,Rails 可以检测到如何撤销给定的操作,比如 create_table 的反向操作是 drop_tableadd_column 的反向操作是 remove_column,而像 rename_column 操作也是可逆的,所以这些迁移只使用一个 change 方法就足够了。

change_column 操作就可能例外,比如整数可以转换为字符串,但反向操作字符串转整数就未必会成功。这种情况下,Rails 无法通过 change 方法发挥主观能动性,我们需要显式定义 updown 方法,Rails 还为我们提供了一个这种情况下可以抛出的特殊异常:

class ChangeOrderTypeString < ActiveRecord::Migration
  def up
    change_column :orders, :order_type, :string, null: false
  end

  def down
    raise ActiveRecord::IrreversibleMigration
  end
end

当 Rails 无法通过 change 方法进行自动反向操作时,也会抛出 ActiveRecord::IrreversibleMigration 异常。

1.4 迁移命令

通过 rails db:migrate 迁移命令,我们可以执行迁移文件,即对数据库的修改。迁移代码在每个 Rails 数据库中维护一个名为 schema_migrations 的表,连接到数据库就可以查看这张表。这个表只有一列,即 version,列的内容正是前面讲的迁移文件的时间戳/版本号

运行迁移命令时,Rails 会根据版本号查找 db/migrate 目录中的哪些文件还没有在 schema_migrations 表中,没有则执行,有则跳过。

可以通过 VERSION= 参数指定版本号 (如,rails db:migrate VERSION=20221207000009),任何小于等于这个版本号且还未被迁移的文件都会被运行,任何大于这个版本号且已经运行的迁移文件都会被撤销,即迁移会运行到这个版本号为止,少则加,多则删

如果你执行 rails db:migrate 修改了数据库,但由于发现了错误而想要撤销这个修改,可以使用 rails db:rollbackSTEP= 参数可以撤销最近的多次迁移,如 rails db:rollback STEP=3 撤销最近三次迁移。

由于数据库服务器故障或其他原因导致我们的迁移没有执行成功,那么可以使用 redo 重试一次,rails db:migrate:redo,此命令同样支持 STEP= 参数。

2. 表的管理

2.1 表

再看一下创建表的方法:

create_table :events do |t|
  t.string :name, null: false
  t.string :location
  t.decimal :price

  t.timestamps
end

create_table 方法接受表名和一个块作为参数,传递一个表定义对象到块,我们使用这个对象来定义列。Rails 迁移会自动将一个 id 列作为主键添加到我们创建的表中,而 timestamps 方法则会使用正确的时间戳类型创建 created_atupdated_at 列,所以默认情况下,每一张 Rails 迁移创建的表都会有 idcreated_atupdated_at 这三列。

create_table 接受一个哈希选项作为第二个参数,常用的如下:

还可以使用 :primary_key 选项修改主键名,但并不推荐;以及使用 id: false 告诉 Rails 不要自动添加 id 列,这在连接表的情况下可能常见,但要注意,对于不指定主键的表,那么我们最好加上索引,否则全表扫描令人昼夜不安。

除了创建表之外,还可以使用 drop_table 删除表,以及 rename_table 重命名表。

rename_table 虽然是可逆的,但在迁移时可能会有个小问题:

这种情况的解决方案是我们可以在迁移中创建一个迁移所需要的 model 类的虚拟版本,例如:

class CreateOrderHistories < ActiveRecord::Migration
  class Order < ApplicationRecord::Base; end
  class OrderHistory < ApplicationRecord::Base; end

  def change
    create_table :order_histories do |t|
     # ...
    end
  end
end

这种方法要求我们的 model 类中不能包含迁移中使用的任何附加功能,否则不会有效。

2.2 索引

说到表的管理,就不能不包含索引。在迁移中我们使用 add_index 方法来添加索引,其中一些值得注意的细节是:

如下:

class AddPartNumberToProducts < ActiveRecord::Migration
  def change
    add_column :products, :part_number, :string
    add_index :products, :part_number
    # add_index :products, :part_number, unique: true
    # add_index :products, :part_number, name: "this_is_index_name"
    # add_index :products, [:part_number, :name]
  end
end

remove_index 则用来删除索引,由于用的不多,而且官方文档列出了更丰富的例子,这里就不再赘述、徒增篇幅了。

写一篇文章并不像想象中那么简单,原本计划一天写完的内容,限于篇幅和精力,不得不把一些内容拆分到第二篇。 下一篇会总结一些 Rails Migration 的高级用法。