此文翻译自Reading Rails - Migrations,限于本人水平,翻译不当之处,敬请指教!
今天我们将会探讨一下 Rails 经常被忽视的可靠的工作伙伴 —— Migrator。它是如何搜寻你的 migrations 并且执行它们的呢?我们将再一次慢慢地挖掘 Rails 的源代码,并在此过程中慧海拾珠。
为了跟随本文的步骤,请使用qwandry打开相关的代码库,或者直接在Github上查看这些代码。
动身启程
在展开讨论之前,此处并无特殊准备要求。或许你已经创建好了项目所需要的但是仍是空的数据库。如果你执行 rake db:migrate
,所有的未执行的 migrations 就会开始执行。让我们从查看 databases.rake
里的 Rake 任务的源码开始动起来:
desc "Migrate the database (options: VERSION=x, VERBOSE=false, SCOPE=blog)."
task :migrate => [:environment, :load_config] do
ActiveRecord::Migration.verbose = ENV["VERBOSE"] ? ENV["VERBOSE"] == "true" : true
ActiveRecord::Migrator.migrate(ActiveRecord::Migrator.migrations_paths, ENV["VERSION"] ? ENV["VERSION"].to_i : nil)
#...
end
虽然我们并不打算揭露 Rake 本身的工作机制,但是值得注意的是,执行 migrate
要求另外两个任务 [:environment, :load_config]
的首先执行。这能确保 Rails 的运行环境以及你的 database.yml
文件被加载进来。
上面的 rake 任务通过环境变量配置了 ActiveRecord::Migration
以及 ActiveRecord::Migrator
。环境变量是一种非常有效的可用于向你的应用程序传递信息的方式。缺省地,诸如USER
的很多(环境)变量都是已经设置好的,他们也可以在每个(终端)命令执行时单独设置。举个例子,如果你通过 VERBOSE=false rake db:migrate
调用了 Rake 任务,ENV["VERBOSE"]
的值就会是字符串"false"
。
# 通过环境变量启动 irb:
# > FOOD=cake irb
ENV['FOOD'] #=> 'cake'
ENV['USER'] #=> 'adam'
ENV['WAFFLES'] #=> nil
migration 的真正工作是从 ActiveRecord::Migrator.migrate
开始的,这个方法接受了第一个参数,用于表示 migrations 文件可能存在的路径的集合,另外还有一个可选参数,用于表示 migrate 执行的目标版本。
搜寻 migrations
现在就打开 ActiveRecord 里的 migration.rb
文件,不过在深入探究之前,先查看下在这个文件里最上面定义的异常。定义自定义的异常是非常容易的,migration.rb
里就有一些不错的例子:
module ActiveRecord
# 可以用于在回滚过程中中止 migrations 的异常类
class IrreversibleMigration < ActiveRecordError
end
#...
class IllegalMigrationNameError < ActiveRecordError#:nodoc:
def initialize(name)
super("Illegal name for migration file: #{name}\n\t(only lower case letters, numbers, and '_' allowed)")
end
end
#...
像我们在之前讲 Rails 处理异常 的文章中一样,自定义异常能够被特别处理。在这个案例里,IrreversibleMigration
表示当前的 migration
不能被回滚。另外一个需要定义你自己的异常的原因是,可以像IllegalMigrationNameError
一样,通过重定义initialize
方法来实现生成一致的错误消息。同时,要确保你调用了 super
。
现在向下滚动(文件),让我们看看 Migrator.migrate
:
class Migrator
class << self
def migrate(migrations_paths, target_version = nil, &block)
case
when target_version.nil?
up(migrations_paths, target_version, &block)
#...
when current_version > target_version
down(migrations_paths, target_version, &block)
else
up(migrations_paths, target_version, &block)
end
end
#...
取决于 target_version
,我们将通过 up
或者 down
完成 migrate。这两个方法遵循了同样的模式,都是扫描了 migration_paths
里的可执行的 migrations,然后初始化一个新的 Migrator
的实例。让我们看看这些 migrations 是如何被搜寻到的:
class Migrator
class << self
def migrations(paths)
paths = Array(paths)
files = Dir[*paths.map { |p| "#{p}/**/[0-9]*_*.rb" }]
migrations = files.map do |file|
version, name, scope = file.scan(/([0-9]+)_([_a-z0-9]*)\.?([_a-z0-9]*)?\.rb\z/).first
raise IllegalMigrationNameError.new(file) unless version
version = version.to_i
name = name.camelize
MigrationProxy.new(name, version, file, scope)
end
migrations.sort_by(&:version)
end
这个方法里满是非常值得学习的实例,让我们停留几分钟并且仔细阅读它。最开始,代码里通过一个 Array()
方法这样的小技巧,确保了参数始终是数组类型。“你说这(Array)是个方法?”是的!这虽然不是很正统,但定义一个驼峰式命名的方法是合法的,甚至这样的方法名还可以和类同名:
class Flummox
end
def Flummox()
"confusing"
end
Flummox #=> Flummox
Flummox.new #=> #<Flummox:0x0000000bf0b5d0>
Flummox() #=> "confusing"
Ruby 使用了这个特性定义了一个 Array()
方法,这个方法始终返回一个数组。
Array(nil) #=> []
Array([]) #=> []
Array(1) #=> [1]
Array("Hello") #=> ["Hello"]
Array(["Hello", "World"]) #=> ["Hello", "World"]
这个方法类似于 to_a
,但是可以在任何(类型的)对象上调用。Rails 通过 paths = Array(paths)
使用了这个(方法),得以确保 paths
将是一个数组。
在接下来一行的代码里,Rails 搜寻了指定的路径并且进行了过滤:
files = Dir[*paths.map { |p| "#{p}/**/[0-9]*_*.rb" }]
让我们将这个代码分解一下。paths.map { |p| "#{p}/**/[0-9]*_*.rb" }
将每一个路径转换成一个 [shell glob
](http://en.wikipedia.org/wiki/Glob_(programming))。一个类似 "db/migrate"
的路径就变成了 "db/migrate/**/[0-9]*_*.rb"
,这将会在 "db/migrate"
或者它的所有子目录里匹配所有用数字开头的文件。这些(shell glob 表示的)路径通过 *
操作符分成(单个元素)并且传递给了 Dir[]
。
Dir[]
是非常有用的。它接收类似 "db/migrate/**/[0-9]*_*.rb"
这样的模式(作为参数),然后返回匹配的文件列表。当你需要在指定路径里查找文件的时候,Dir[]
就是称手利器。其中,**
表示递归地在所有子目录中执行匹配,而 *
则表示一个或多个字符的通配符,也就是说,前面的这个模式就是为了匹配类似 20131127051346_create_people.rb
的 migrations (文件)。
Rails 遍历每一个匹配的文件,并且通过 String#scan
结合正则表达式提取信息。如果你对正则表达式不是很熟悉,那现在就应该抛开一切,先学习好正则表达式再说。String#scan
以字符串形式返回所有匹配的结果。如果表达式里还包含了 capturing groups(匹配分组),它们将会以内嵌数组(subarrays)的方式返回。比如:
s = "123 abc 456"
# 没有 capturing groups:
s.scan(/\d+/) #=> ["123", "456"]
s.scan(/\d+\s\w+/) #=> ["123 abc"]
# 先匹配数字,再匹配单词:
s.scan(/(\d+)\s+(\w+)/) #=> [["123", "abc"]]
所以 file.scan
将会匹配版本号([0-9]+)
,名字([_a-z0-9]*)
,以及一个可选的 scope ([_a-z0-9]*)?
。由于 String#scan
始终返回数组,并且我们知道这个模式只会出现一次,所以 Rails 直接提取第一个匹配结果。Rails 一次性执行了多个变量赋值 version, name, scope = ...
。这是得益于数组的解构:
version, name, scope = ["20131127051346", "create_people"]
version #=> "20131127051346"
name #=> "create_people"
scope #=> nil
注意一下,如果(等号左边)变量的数量大于(等号右边)数组的元素的数量,多余变量的值将会被赋值为nil
。这是一种从正则表达式(匹配后的值)进行多个赋值的快捷技巧。
匹配的版本号 version 通过 to_i
方法转换为一个整数(Fixnum),而同时,名字 name 通过 name.camelize
完成了格式转换。String#camelize
是 ActiveSupport
里的方法,用于下划线命名 snake_case
和 驼峰式命名 CamelCase
之间的相互转换。这个方法可以将 "create_people"
转换为 CreatePeople
。
让我们过会再看下 MigrationProxy
,现在先看下 Migrator#migrations
这个方法的最后一个部分,migrations.sort_by(&:version)
。这个表达式将所有 migrations 基于版本号进行了排序。如何排序的方式会是更有趣的内容。
从 Ruby 1.9 开始,&
操作将会在被它作用的对象上调用 to_proc
方法。当在一个 symbol 上调用时,返回的结果是一个代码块里调用与 symbol 同命名的方法的 Proc
对象。所以 &:version
等同于某行代码的 {|obj| obj.version }
。
Library = Struct.new(:name, :version)
libraries = [
Library.new("Rails", "4.0.1"),
Library.new("Rake", "10.1.0")
]
libraries.map{|lib| lib.version } #=> ["4.0.1", "10.1.0"]
# &:version => Proc.new{|lib| lib.version } (Roughly)
libraries.map(&:version) #=> ["4.0.1", "10.1.0"]
在 Rails 里,这种技巧在排序或者映射的时候非常常见。和众多的技巧一样,请确认你的团队能够适应这种语法。如有疑虑,更好的方案就是不再使用(这种技巧),这会让代码更清晰。
The Migration
现在,回到 MigrationProxy
。顾名思义,只是一个 Migration
的实例的代理。代理对象(Proxy objects)是一个常见的用于透明地将一个对象替换为另一个对象的设计模式。在这个例子中,MigrationProxy
代替了一个真正的 Migration
对象,而且除非必需,它会延缓对 migration 的源码的实际的加载。MigrationProxy
通过委托方法(delegating methods)达到目的:
class MigrationProxy
#...
delegate :migrate, :announce, :write, :disable_ddl_transaction, to: :migration
private
def migration
@migration ||= load_migration
end
def load_migration
require(File.expand_path(filename))
name.constantize.new
end
end
delegate
方法将它的每一个参数都发送给了 to:
选项返回的对象,在这里,这个对象就是我们的 migration
。如果 @migration
实例变量尚未定义或赋值,migration
方法将会执行懒加载migration load_migration
。load_migration
方法按序加载(require) ruby 源码,然后使用 name.constantize.new
创建一个新的实例。String#constantize
是 ActiveSupport 中定义的方法,用于返回名字与字符串相同的常量:
"Person".constantize #=> Person
"Person".constantize.class #=> Class
"person".constantize #=> NameError: wrong constant name person
当你想要动态地引用一个类时,这个技巧非常有效。
通过 MigrationProxy
,Rails 只加载并且实例化必要的 migrations,这能为 migration 的处理提速,同时节约更多内存。
真正的 Migration
类在代理委托了 migrate
方法的时候才被 Migrator
调用。这个按序调用 Migration#up
或者 Migration#down
取决于 migration 是在先前执行,还是在执行回滚。
总结(Recap)
我们仅仅只是一瞥了 Rails 的 migration 机制的源码的表面,但是我们却已经学到了一些有趣的知识。Migrations 由一个调用了 Migrator
的 Rake 任务启动,Migrator
又按序查找到了我们的 migrations,并且使用了 MigrationProxy
对象对这些 migrations 进行了包装,直到真正的 Migration
需要被执行的时候。
一如既往,我们已经了解了一些有趣的方法、习惯以及技巧:
- 环境变量可以通过
ENV
常量访问; - 定义自定义的异常类,是一种常见的对异常进行处理的手段;
Array()
方法将任意对象转换为数组;Dir[]
使用shell glob
语法搜索文件;String#scan
返回字符串里所有匹配的结果,并且支持匹配分组(capturing groups);String#camelize
将下划线形式(snake_case)字符串转换为驼峰式(CamelCase);&
操作符在符号类型的对象上调用时,会创建一个Proc
对象delegate
可以用于实现代理的设计模式- 可以通过
String#contantize
方法动态加载常量
下一次,或许我们就能弄明白 Migrator
是如何确切知道哪些 migrations 已经在你的数据库里执行过。
喜欢这篇文章?
阅读更多“解读Rails”中的文章。“解读Rails”中的文章。
版权声明:本文为原创文章,转载请注明来源:《解读 Rails: Migrations - Hackerpie》,谢绝未经允许的转载。