Rails 鼓励敏捷的开发方式。我们并不期望第一次就做到完美。相反,我们编写测试,以及与客户积极沟通修正我们对需求的理解。
要按上述方式工作必须有一组实践方法支撑我们。通过编写测试帮助我们设计接口,当我们进行修改时有安全的网络支持,并且通过版本控制管理源文件,这样我们便能在遇到问题时撤回以及管理日复一日的改动。
但应用中还有其他模块需要修改,这个模块我们无法直接通过版本控制管理。Rails 应用中的数据库 schema 始终贯穿我们的整个开发过程,有时添加一个表,有时重命名一个字段等等。毕竟数据库也是跟随应用的代码逐步变化的。
在 Rails 中,通过 migration 可以支撑我们的数据库变化需求。在开发 Depot 的过程中我们一直在使用它,在 62 页开始创建 products 表时已经开始使用,在 119 页向 line_items 表添加数量字段时也使用过。现在是时候深入学习 migration 如何运作以及我们能够用它做什么。
创建及运行 Migration
migration 是一种 Ruby 源文件,位于应用的 db/migrate 路径下。每个 migration 文件的名字都是以一串数据(通常是 14 位)加下划线开头。这些数字是 migration 的关键,它们是每个 migration 的版本号。
版本号是 migration 被创建时的 UTC 时间戳。从左至右,前 4 位数字表示年份,接下来的每两位数字分别表示月份、日、时、分和秒,所有的时间都是基于伦敦的格林威治皇家观测站太阳时。因为 migration 的创建不算频繁,所以版本号精确到秒,两个人获取到相同时间戳的机会极小。而且使用时间戳可以避免字符大小写排序的风险。
Depot 中的 db/migrate 文件夹内容如下:
list of migrations虽然可以手工创建 migration 文件,不过使用生成器会更加方便(极少出现错误)。在创建 Depot 时我们已经看到,有两个生成器的功能是创建 migration 文件。
- model 生成器会创建 migration,以此创建与 model 相关的表(如果你没有使用
--skip-migration
参数)。就像下方展示的一样,创建 model discount 时也会创建名为 yyyyMMddhhmmss_create_discount.rb 的 migration 文件:
- 你也可以单独生成 migration。
稍后我们将剖析 migration,我们将学习 migration 文件的内容。但现在我们要按步骤从头开始学习,先了解如何运行 migration。
运行 Migration
migration 通过 Rake 命令 db:migrate
运行。
rake db:migrate
要了解接下来发生了什么需要深入 Rails 内部。
在每个 Rails 数据库内部 migration 都维护了 schema_migrations
表。其中一个字段为 version
,每次成功执行 migration 都会记录一行。
当运行 rake db:migrate
时 Rake 任务会先确定 schema_migration
是否存在。如果不存在它将被创建。
然后 migration 代码会查找 db/migrate 中的 migration 文件,然后跳过已经在表中记录过版本号(也就是文件名前的数字)的文件。接着会应用 migration 记录器,在 schema_migrations
表中创建记录。
如果我们再次运行 migration 什么事都不会发生。migration 文件的版本号会与数据库中的记录匹配,所以并没有 migration 可以运行。
但如果我们随后创建一个新 migration 文件,它的版本号并不存在数据库中。即使版本号比已经运行的 migration 早,它也属于没有运行过的 migration。当多个用户使用同一个版本控制仓库时这种情况时有发生。如果此时再运行 migration,只有新创建的 migration 文件会被执行。也就是说 migration 并不是按顺序执行的,所以我们要确保 migration 是独立的。否则你需要将数据库重置为前一状态然后按顺序执行 migration。
通过 rake db:migrate
的 VERSION=
参数可以指定执行的版本。
rake db:migrate VERSION=2012113000000009
如果指定的版本号大于已经执行过的所有 migration,这些 migration 都将被执行。
但是,如果指定版本号小于 schema_migration
任意数据时会发生与众不同的事。这种情况下,Rails 都会寻找相应版本的 migration 并撤销。这个过程将一直重复,直到 schema_migrations
表中没有版本号高于命令行中指定的版本号。也就是说,migration 会逆向执行,使 schema 回到你指定的版本。
你也可以重复操作一个或多个 migration。
rake db:migrate:redo STEP=3
redo
默认回滚一个 migration,不过通过 STEP=
参数可以回滚多个 migration。
剖析 Migration
migration 是 ActiveRecord::Migration
的子类。而且 migration 必须包含 up()
和 down()
方法。
class SomeMeaningfulName < ActiveRecord::Migration
def up
# ...
end
def down
# ...
end
end
类名将大写字符转为小写字符,并且单词之间使用下划线连接便是除去版本号后的 migration 文件名。比如,上面的例子对应 migration 文件名为 2012113000000017_some_meaningful_name.rb
。migration 不能使用相同的类名。
up()
方法是运行 schema 变化时使用,down()
方法摊销这些变化。更具体一点的例子如下,有个向 orders
表添加 e_mail
字段的 migration:
class AddEmailToOrders < ActiveRecord::Migration
def up
add_column :orders, :e_mail, :string
end
def down
remove_column :orders, :e_mail
end
end
down()
方法是如何摊销 up()
造成的影响呢?
不过上述代码有些重复的地方。在许多情况下,Rails 会自动撤销指定的操作。比如,与 add_column()
相反的就是 remove_column()
。在这些例子中,将 up()
重命名为 change()
,你就能消除对 down()
的需要。
class AddEmailToOrders < ActionRecord::Migration
def change
add_column :orders, :email, :string
end
end
现在还不是特别清楚?
字段类型
add_column
的第三个参数用于指定数据库字段的类型。在前一个例子中,我们指定 e_mail
字段的类型为 string
。但这是什么意思呢?通常数据库不使用 string
类型。
必须要记住 Rails 希望尽力使应用与数据库相互独立,你即可以使用 SQLite3 开发,也可以使用 Postgres 部署,只要你愿意。但不同数据库的字段类型名并不相同。如果你的 migration 字段类型是基于 SQLite3 的,它很可能就无法应用于 Postgres 数据库。所以 Rails migration 通过使用逻辑类型隔离数据库。如果你正基于 SQLite3 数据开发,string
类型将创建 varchar(255)
类型的字段。对于 Postgres,同样的 migration 将添加 char varying(255)
类型的字段。
migration 支持的类型为 :binary
、:boolean
、:date
、:datetime
、:decimal
、:float
、:integer
、:string
、:text
、:time
和 :timestamp
。
db2 | mysql | openbase | oracle | |
---|---|---|---|---|
:binary | blob(32768) | blob | object | blob |
:boolean | decimal(1) | tinyint(1) | boolean | number(1) |
:date | date | date | date | date |
:datetime | timestamp | datetime | datetime | date |
:decimal | decimal | decimal | decimal | decimal |
:float | float | float | float | number |
:integer | int | int(11) | integer | number(38) |
:string | varchar(255) | varchar(255) | char(4096) | varchar2(255) |
:text | clob(32768) | text | text | clob |
:time | time | time | time | date |
:timestamp | timestamp | datetime | timestamp | date |
postgresql | sqlite | sqlserver | sybase | |
---|---|---|---|---|
:binary | bytea | blob | image | image |
:boolean | boolean | boolean | bit | bit |
:date | date | date | date | datetime |
:datetime | timestamp | datetime | datetime | datetime |
:decimal | decimal | decimal | decimal | decimal |
:float | float | float | float(8) | float(8) |
:integer | integer | integer | int | int |
:string | (note 1) | varchar(255) | varchar(255) | varchar(255) |
:text | text | text | text | text |
:time | time | datetime | time | time |
:timestamp | timestamp | datetime | datetime | timestamp |
在 migration 中定义中字段时可以使用三个参数,小数还有另外两个参数。每个参数都以 key: value
键值对的方式提供。常用的参数有下列几种:
null: true or false
如果设置为 false
,数据库字段会添加 not null
限制(如果数据库支持非空的话)。注意,这与 presence: true
验证是相互独立的,presence: true
在 model 层执行。
limit: size
此属性设置字段的容量。最常见的是数据库的字符串大小的定义。
default: value
此属性可以设置字段的默认值。虽然它由数据库执行,但在初始化或存储新建的 model 对象时并不能看见它的痕迹。你必须从数据库加载对象时才能看到数据。要注意,默认值在运行 migration 时会处理一次,所以下面的代码在运行时会给字段设置默认的日期和时间:
add_column :orders, :placed_at, :datetime, default: Time.now
小数字段还可以设置额外参数 :precision
和 :scale
。:precision
可以设置数字的总位数,:scale
决定了小数点处于哪个位置(也就是说它决定了小数位数)。如果小数的设置为 precision 5 scale 0,它将能够存储 -99,999 至 +99,999 之间的数字。设置 precision 5 scale 2 的小数可以存储 -999.99 至 +999.99 间的数字。
:precision
和 :scale
对于小数类型字段也是选填的。但是,数据库间差异性让我们强烈推荐使用小数类型字段时配置这两个参数。
下面是一些 使用 migration 类型和参数的字段定义:
add_column :orders, :attn, :string, limit: 100
add_column :orders, :order_type, :integer
add_column :orders, :ship_class, :string, null: false, default: 'priority'
add_column :orders, :amount, :decimal, precision: 8, scale: 2
重命名列
当我重构代码时,通常会修改变量名使其更有意义。migration 也允许我们重命名数据库字段名。比如,在我们添加了 e_mail
一周后觉得它并不是恰当的名字,所以我们打算创建一个 migration 通过 rename_column()
方法重命名它。
class RenameEmailColumn < ActiveRecord::Migration
def change
rename_column :orders, :email, :customer_email
end
end
因为 rename_column()
可以反向操作,所以 up()
与 down()
并不需要按顺序使用它。
要注意重命名并不会清除已经存在的数据,而且要小心重命名操作并不是被所有适配器支持。
修改列
change_column()
方法能够修改列的类型或者此列配置的参数。使用方式与 add_column
一样,不过需要指定已经存在的列名。比如说订单的类型字段当前是整型,但我们需要将它修改为字符串。因为我们想保持已经存在的数据,所以订单类型的值会从 123
转变为 "123"
。稍后我们还会在 "new"
和 "existing"
使用非整型数。
将整型字段修改为字符串类型十分容易。
def up
change_column :orders, :order_type, :string
end
但是数据的返回转换会出现问题。我们尝试将 down()
方法写出来。
def down
change_column :orders, :order_type, :integer
end
但是,如果此字段已经存储了数据比如 [new]。down()
将无法处理,[new] 无法被转换为整型。如果这里的转换能够正常进行,那么 migration 也能正常运行。如果我们希望创建一个单向 migration,就需要阻止它的 down()
方法执行。在这个例子中,Rails 提供了一个特殊的异常供我们抛出。
class ChangeOrderTypeToString < ActiveRecord::Migration
def up
change_column :orders, :order_type, :string, null: false
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
ActiveRecord:IrreversibleMigration
是 Rails 提供的异常名,当你试图调用 change()
方法而它又无法自动回滚时将抛出此异常。
管理表
之前我们展示的是通过 migration 管理已经存在的表及它的字段。现在,我们要学习创建和删除表。
class CreateOrderHistories < ActiveRecord::Migration
def change
create_table :order_histories do |t|
t.integer :order_id, null: false
t.text :notes
t.timesstamps
end
end
end
create_table()
接收表名(要记住,表名是复数形式)和一个 block 作为参数。(它也有一些选填参数,我们稍后再学习)。block 中定义表的描述对象,也就是表的列定义。
通常不需要使用 drop_table()
,它只接收一个参数,就是需要被删除的表名。
不同的表定义方法都大同小异,它们与前面我们使用的 add_column
方法相似,不同的是定义表的方法第一个参数是表名,block 内部的方法是合适的数据类型名称,这样可以有效地降低重复。
对于新表我们不会定义 id
字段,因为 Rails 会为通过 migration 创建的表添加 id
主键。在 378 页会进行更深入的讨论。
timestamps
方法会创建 created_at
和 updated_at
字段,并将它们设置为 timestamp
类型。所以我们不需要专门为某些表添加这些字段,这也佐证了 Rails 通过约定使最终的实现更加简便且保持一致。
创建表的参数
create_table
的第二个参数你可以使用哈希对象。如果配置 force: true
,migration 会在创建表前将同名表删除。如果你希望创建一个 migration 强制数据库保持明确的状态此参数将十分有用,当前它还有其他用途。
options: "xxxx"
参数可以定义数据库级的参数。就如同添加在 CREATE TABLE
语法后小括号内的参数一样。SQLite 3 建表时并不是必须配置这些参数,但使用其他数据库时很可能会用到。例如,某些版本的 MySQL 允许你指定 id
字段的自增初始值。我们可以按如下代码定义 migration:
create_table :tickets, options: "auto_increment = 10000" do |t|
t.text :description
t.timestamps
end
migration 会在后台通过表描述生成 DLL,再对 MySQL 进行配置:
CREATE TABLE "tickets" (
"id" int(11) default null auto_increment primary key,
"description" text,
"created_at" datetime,
"updated_at" datetime
) auto_increment = 10000;
在 MySQL 中使用 :options
参数时要格外小心。MySQL 的 Rails 适配器会设置默认值 ENGINE=InnoDB
。这将使通过 migration 创建的表默认使用 InnoDB 存储引擎。不过如果你重写了 :options
后,这个设置将失效,新表将使用数据库默认配置的引擎。有时你可能想通过配置参数 ENGINE=InnoDB
明确定义引擎。或许在使用 MySQL 时你希望延用 InnoDB,因为这个引擎可以提供事务支持。如果你在测试时默认使用带有事务的测试夹具,你应该在应用中会需要事务的支持。
重命名表
如果重构时需要重命名变量和字段,有时也希望重命名表。migration 的 rename_table()
支持这种功能。
class RenameOrderHistories < ActiveRecord::Migration
def change
rename_table :order_histories, :order_notes
end
end
可以通过再次重命名的方式回滚此次修改。
rename_table 的问题
当通过 migration 重命名表时总会有些细微的问题。
比如,以创建 order_histories
表的 migration 4 为例,并向其中植入数据。
def up
create_table :orders_histories do |t|
t.integer :order_id, null: false
t.text :notes
end
order = Order.find :first
OrderHistory.create(order_id: order, notes: "test")
end
稍后,我们将在 migration 7 中将 order_histories
重命名为 order_notes
。此时 model OrderHistory
也将被重命名为 OrderNote
。
现在我们决定删除所有开发环境中的数据库并重新运行所有 migration。如果按安排实行,migration 4 将抛出错误,因为应用中并没有 OrderHistory
类,所以 migration 的运行失败。
有一个由 Tim Lucas 提出的解决方案,就是在 migration 中创建一个 migration 需要的模拟 model 类。比如按下面的代码编写,即使应用中没有 OrderHistory
,migration 4 也可以正常运行。
class CreateOrderHistories < ActiveRecord::Migration
class Order < ActiveRecord::Base; end
class OrderHistories < ActiveRecord::Base; end
def change
create_table :order_histories do |t|
t.integer :order_id, null: false
t.text :notes
t.timestamps
end
order = Order.find :first
OrderHistory.create(order: order_id, notes: "test")
end
end
我们添加的 model 类并不包含其他的函数,只是创建了相应的骨架。
定义索引
migration 可以(或者说应该)定义表的索引。比如,如果数据库中有大量订单数据时,通过用户名字查找订单将是较常用的方式。通过 add_index()
方法便可以添加下标。
class AddCustomerNameIndexToOrders < ActiveRecord::Migration
def change
add_index :orders, :name
end
end
如果我们向 add_index
提供参数 unique: true
将创建唯一索引,它会向索引列数据添加唯一性限制。
默认索引名为 index_table_on_column。不过通过 name: "somename"
参数我们也可以自定义索引名。当添加索引时使用 :name
参数,在删除索引时就通过此索引名指定索引。
我们也可以创建联合索引(也就是多个字段组合成的索引),不过需要向 add_index
提供一组字段的名字。但这种情况下只有第一个字段的名字才会用于命名索引。
可通过 remove_index()
方法删除索引。
主键
Rails 默认为每个表都设置了数字型的主键(通常字段名为 id
),并且保证每条数据的主键值都是唯一的。
不过我们需要重新描述它。
如果不是每个表都拥有数字型主键 Rails 将无法像这样运转良好。关于主键字段的名字 Rails 并不挑剔。所以对于常规的 Rails 应用来说,我们都强烈建议使用默认流程,让 Rails 为数据库表创建 id
字段。
如果打算冒险,也可以为主键字段设置不同的名字(不过依然使用递增整型数字)。我们可以在 create_table
方法中通过 :primary_key
参数设置主键名。
create_table :tickets, primary_key: :number do |t|
t.text :description
t.timestamps
end
例子中向表添加了 number
字段并将其设置为主键。
下一步我们打算将主键设置为非数字类型。因为 Rails 开发者并不认为这是个好主意,所以 migration 不允许你如此操作(虽然并还是直接限制)。
无主键表
有时我们需要定义一个无主键的表。最常用的例子是联合表,联合表的每个字段都是其他表的外键。通过 migration 创建联合表时,需要告知 Rails 不要自动添加 id
字段。
create_table :authors_books, id: false do |t|
t.integer :author_id, null: false
t.integer :book_id, null: false
end
这个例子中可以考虑创建一个或多个索引,以此加速关联 books 和 authors 。
Migration 优化
许多 Rails 开发者都是使用 migration 的基本功能创建和维护数据库。不过无论何时更深入地了解 migration 都是有用的。本节将学习 migration 更高级的用法。
使用原生 SQL
migration 提供了一种与数据库隔离的方式维护 schema。但如果 migration 没有能够满足你需求的方法时要怎么做呢?此时你需要直接使用数据库级别的代码。Rails 为此功能提供了两种方式。一种是类似 add_column()
方法的 options
,另一种是 execute()
方法。
当使用 options
或 execute()
时,migration 将与具体的数据库绑定,因为通过这两种方式提供的 SQL 都是数据库原生语法。
最常见的例子就是在 migration 中添加子表的外键。
我们可以通过按如下方式添加代码实现添加外键的功能:
def foreign_key(from_table, from_column, to_table)
constraint_name = "fk_#{from_table}_#{to_table}"
execute %{
CREATE TRIGGER #{constriant_name}_insert
BEFORE INSERT ON #{from_table}
FOR EACH ROW BEGIN
SELECT
RAISE(ABORT, "constraint violation: #{constraint_name}")
WHERE
(SELECT id FROM #{to_table} WHERE
id = NEW.#{from_column}) IS NULL;
END;
}
execute %{
CREATE TRIGGER #{constraint_name}_update
BEFORE UPDATE ON #{from_table}
FOR EACH ROW BEGIN
SELECT
RAISE(ABORT, "constraint violation: #{constaint_name}")
WHERE
(SELECT id FROM #{from_table} WHERE
#{from_column} = OLD.id) IS NOT NULL;
END;
}
end
在 migration 的 up()
方法中,我们可以这样调用上面的新方法:
def up
create_table ... do
end
foreign_key(:line_items, :product_id, :products)
foreign_key(:line_items, :order_id, :orders)
end
也许我们还想更进一步,将 foreign_key()
方法提取为公用方法,供所有的 migration 使用。为了达成此目的,可以在 lib 文件夹中创建一个 module 并添加 foreign_key()
方法。
不过要将此方法处理为实例方法,而不是类方法。
module MigrationHelpers
def foreign_key(from_table, from_column, to_table)
constraint_name = "fk_#{from_table}_#{to_table}"
execute %{
CREATE TRIGGER #{constriant_name}_insert
BEFORE INSERT ON #{from_table}
FOR EACH ROW BEGIN
SELECT
RAISE(ABORT, "constraint violation: #{constraint_name}")
WHERE
(SELECT id FROM #{to_table} WHERE
id = NEW.#{from_column}) IS NULL;
END;
}
execute %{
CREATE TRIGGER #{constraint_name}_update
BEFORE UPDATE ON #{from_table}
FOR EACH ROW BEGIN
SELECT
RAISE(ABORT, "constraint violation: #{constaint_name}")
WHERE
(SELECT id FROM #{from_table} WHERE
#{from_column} = OLD.id) IS NOT NULL;
END;
}
end
end
接着只要在 migration 顶部添加如下代码即可使用上述方法:
require "migration_helpers"
class CreateLineItems < ActiveRecord::Migration
extend MigrationHelpers
end
require
是向 migration 中引入 module,而 extend
是将 MigrationHelpers
中的方法引入 migration 中。通过这种技术可以共享 migration 的辅助方法。
(如果你想更容易地实现这个功能,也可以使用别人已经开发好的插件)。
定制信息和基准数据
尽管不确定是否是 migration 的高级用法,但在 migration 的高级用法中输出我们定义的信息和基准数据依然十分有用。通常我们使用 say_with_time()
方法。
def up
say_with_time "Updating prices..." do
Person.all.each do |p|
p.update_attribute :price, p.lookup_master_price
end
end
end
say_with_time()
会在 block 执行前打印接收的字符串参数,并在 block 结束时打印基准结果。
当 Migration 腐坏时
migration 有时也会遇到一些问题。底层的 DDL 声明虽然可以更新 schema,但却没有事务。这并不是 Rails 的问题,毕竟许多数据库并不支持 create table
、alter table
等 DDL 声明的回滚。
让我们看看准备向数据库添加两个表的 migration:
class ExampleMigration < ActiveRecord::Migration
def change
create_table :one do ...
end
create_table :two do ...
end
end
end
通常来说 up()
方法中添加了两个表,在 down()
方法中都会将它们删除。
但如果在创建第二个表时出错会怎么样?数据库只会存在表 one
,而不会存在表 two
。虽然我们能够修复许多 migration 中的问题,但这个问题却不行,因为我们再次尝试运行时由于表 one
已经存在,所以依然会失败。
我们可以尝试将 migration 回滚,但也会出现问题。因为原来的 migration 执行失败,数据库相应的 schema 版本号并不会更新,所以 Rails 无法回滚。
不过我们可以手动删除表 one
。但可能费力不讨好。我们的推荐是在当前环境中删除整个数据库,再重新创建并更新至最新版本。你不会有任何损失,而且也能保持 schema 的一致性。
所有关于 migration 的讨论都说在生产环境使用时十分危险。我们还应该使用 migration 吗?我们无法下定论。如果在团队中有 DBA,这是 DBA 的活。如果由你操作,一定要评估好其中的风险。一旦你决定运行 migration,首先要将数据库备份。接着便可以在生产环境进入应用的文件夹,执行 migration:
RAILS_ENV=production rake db:migrate
本书开头时已经发出过提醒,如果数据被删除我们不会承担责任。
Migration 外的 Schema 操作
本章讲解的所有 migration 方法都可以作为 Active Record 连接对象的方法,所以也可以在 model、view 和 controller 中访问。
比如,orders
表在 city
字段拥有索引,但你还是发现了相关耗时较长的报告。虽然在应用中此索引并不是时时都需要,但需要经过测试表明维护此索引时会明显变慢。
接着,我们将编写一个方法创建索引,然后运行代码,再将索引删除。它应该是 model 对象中的私有方法,也可以在库中实现。
def run_with_index(*columns)
connection.add_index(:orders, *columns)
begin
yield
ensure
connection.remove_index(:orders, *columns)
end
end
model 中的收集分析方法可以如下编写:
def get_city_statistics
run_with_index(:city) do
# .. calculate stats
end
end
总结
虽然我们已经在 Depot 的开发阶段亲自体验过 migration 的使用,但我们还是在本章中学习了 migration 的基本思想和管理数据库 schema 的方法。
在本章中你已经学习了如何创建、重命名、删除字段和表,甚至还有关于自定义 SQL 的知识,所有的方法都可重复使用。
此时我们已经学习了 Rails 的整体知识。接下来的少量章节将更加深入。我们要了解如何拆解 Rails,然后将其组装还原。其中的第一步就是教你如何选择 Rails 类,以及在 web 服务器外部使用方法。
本文翻译自《Agile Web Development with Rails 4》,目的为学习所用,如有转载请注明出处。
网友评论