解读 Rails: Migrations

此文翻译自Reading Rails – Migrations,限于本人水平,翻译不当之处,敬请指教!

今天我们将会探讨一下 Rails 经常被忽视的可靠的工作伙伴 —— Migrator。它是如何搜寻你的 migrations 并且执行它们的呢?我们将再一次慢慢地挖掘 Rails 的源代码,并在此过程中慧海拾珠。

为了跟随本文的步骤,请使用qwandry打开相关的代码库,或者直接在Github上查看这些代码。

动身启程

在展开讨论之前,此处并无特殊准备要求。或许你已经创建好了项目所需要的但是仍是空的数据库。如果你执行 rake db:migrate,所有的未执行的 migrations 就会开始执行。让我们从查看 databases.rake 里的 Rake 任务的源码开始动起来:

1
2
3
4
5
6
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"

1
2
3
4
5
# 通过环境变量启动 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 里就有一些不错的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 是如何被搜寻到的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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)是个方法?”是的!这虽然不是很正统,但定义一个驼峰式命名的方法是合法的,甚至这样的方法名还可以和类同名:

1
2
3
4
5
6
7
8
9
10
class Flummox
end

def Flummox()
  "confusing"
end

Flummox       #=> Flummox
Flummox.new   #=> #<Flummox:0x0000000bf0b5d0>
Flummox()     #=> "confusing"

Ruby 使用了这个特性定义了一个 Array() 方法,这个方法始终返回一个数组。

1
2
3
4
5
Array(nil)                #=> []
Array([])                 #=> []
Array(1)                  #=> [1]
Array("Hello")            #=> ["Hello"]
Array(["Hello", "World"]) #=> ["Hello", "World"]

这个方法类似于 to_a,但是可以在任何(类型的)对象上调用。Rails 通过 paths = Array(paths)使用了这个(方法),得以确保 paths 将是一个数组。

在接下来一行的代码里,Rails 搜寻了指定的路径并且进行了过滤:

1
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)的方式返回。比如:

1
2
3
4
5
6
7
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 = ...。这是得益于数组的解构:

1
2
3
4
version, name, scope = ["20131127051346", "create_people"]
version #=> "20131127051346"
name    #=> "create_people"
scope   #=> nil

注意一下,如果(等号左边)变量的数量大于(等号右边)数组的元素的数量,多余变量的值将会被赋值为nil。这是一种从正则表达式(匹配后的值)进行多个赋值的快捷技巧。

匹配的版本号 version 通过 to_i 方法转换为一个整数(Fixnum),而同时,名字 name 通过 name.camelize 完成了格式转换。String#camelizeActiveSupport 里的方法,用于下划线命名 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 }

1
2
3
4
5
6
7
8
9
10
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)达到目的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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_migrationload_migration 方法按序加载(require) ruby 源码,然后使用 name.constantize.new 创建一个新的实例。String#constantize 是 ActiveSupport 中定义的方法,用于返回名字与字符串相同的常量:

1
2
3
"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”中的文章。

Comments