解读 Rails: Migrations

2017-10-26 09:51:00来源:http://Martin91.github.io/blog/articles/2017/10/14/jie-du-ra作者:Martin人点击

分享


此文翻译自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
。一个类似"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
方法将会执行懒加载migrationload_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 已经在你的数据库里执行过。


最新文章

123

最新摄影

闪念基因

微信扫一扫

第七城市微信公众平台