跨越边界: Rails 迁移

2006 年 9 月 25 日

Ruby on Rails 是不断发展的 Web 开发框架,它实现了一些先进的想法,例如通过配置进行约定、大量的元编程、特定于域的语言以及用数据库包装代替对象关系映射。这篇文章研究的 Rails 模式迁移是一种把每个数据库的模式变化与基本对象模型分离的思想。
<!--start RESERVED FOR FUTURE USE INCLUDE FILES--><!-- include java script once we verify teams wants to use this and it will work on dbcs and cyrillic characters --><!--end RESERVED FOR FUTURE USE INCLUDE FILES-->

作为喜欢冒险的摩托车手,我关注两个严肃的社区:山地摩托车手和公路摩托车手。常规的看法是山地摩托车手更危险,但我并不同意。公路摩托手必须考虑要比石头和树危险得多的障碍:汽车。类似的异议也出现在面向对象应用程序开发使用的两个持久性策略之间。

目前,持久性框架使用两种方法中的一种: 映射包装 。映射解决方案允许创建独立的数据库模式和对象模型,然后用一个软件层来管理两者间的差异。映射解决方案试图构建一个与数据库模式的结构非常相似的对象模型。与之相反,包装解决方案用对象作为数据库表和行的包装器来操纵数据库中的数据。常规的想法认为在解决方案发布之后,映射解决方案通常更灵活,因为映射软件能够更好地处理模式或对象模型中的变化。但是这个想法忽略了其中最重要的部分:数据。要有效地管理涉及持久性域模型的应用程序变化,必须协调数据、模式和模型的变化。大多数项目团队做不到这一点。

开发团队处理模式变化时,通常会用 SQL 脚本从头开始生成一个新版模式。脚本可能会删除所有的表,再添加所有的表。这样的策略会破坏所有测试数据,因此对于生产场景来说毫无价值。偶尔,工具可能会创建脚本,由脚本生成 delta 模式,或者生成使用 alter_table 这样的 SQL 命令修改以前版本的模式。但是很少有团队会费力气创建能取消模式变化的脚本,而且成功地创建了处理数据变化的自动脚本的团队更少。简而言之,传统的映射策略忽略公路上的汽车:回退坏的模式变化并处理数据。

关于这个系列

跨越边界 系列中,作者 Bruce Tate 倡导这样一个概念:今天的 Java 程序员可以通过学习其他方式和语言而受益。在编程领域中,Java 技术是所有开发项目的最佳选择的情况已经变了。其他框架正在影响 Java 框架的构建方式,从其他语言学到的概念有助于 Java 编程。编写的 Python(或 Ruby、Smalltalk 等等)代码可以改变 Java 编码的方式。

这个系列介绍了与 Java 开发有根本不同,但是却直接适用于 Java 开发的编程概念和技术。在某些情况下,需要集成这些技术以利用它们。在其他情况下,可以直接应用这些概念。其他语言和框架能够影响 Java 社区的开发人员、框架甚至基本方式,与此相比,单独的工具并不那么重要。

这篇文章深入研究了 Ruby on Rails 迁移 —— Rails 处理生产数据库变化的解决方案。迁移用包装的方式组合了协调模式变化和数据变化的威力和简单性。(如果以前没有学习过活动记录 —— Rails 底层的持久性层,我建议您先阅读 跨越边界 系列中以前的这篇 Rails 文章 。)

在 Java 编程中处理模式变化

基于映射的框架需要模式、模型和映射。这样的框架是重复性的。请想像一下指定一个属性需要重复多少次:

  • 模型中的 getter
  • 模型中的 setter
  • 模型中的实例变量
  • 映射的 “to” 端
  • 映射的 “from” 端
  • 列定义

公平地讲,Hibernate 这样的 Java 框架通过代码生成的方式消除了不少重复工作。对象关系映射器可以处理遗留模式,对于新的数据库模式,可以用 Hibernate 提供的工具直接从模型生成模式并用 IDE 生成 getter 和 setter。可以用 Java 标注把映射嵌入域模型,但是按我的观点,这在一定程度上违背了使用映射的初衷。这种代码生成技术还有另一个用途:模式迁移。有些代码生成工具可以发现新的域模型和旧的模式之间的区别,并生成沟通这些不同的 SQL 脚本。请记住这些脚本处理的是模式,而不是数据。

例如,考虑这样一个迁移:把 first_namelast_name 这两个数据库列合并成叫作 name 的单一列。来自典型 Java 持久性框架的工具对数据库管理员没有帮助,因为这些工具只处理问题的一部分:模式中的变化。在进行这个模式变化时,还需要能够处理现有数据。在需要部署这个假想应用程序的新版本时,数据库管理员通常必须手工创建 SQL 脚本来完成以下工作:

  • 创建叫作 name 的新列。
  • first_namelast_name 的数据放到新列中。
  • 删除 first_namelast_name 列。

如果造成模式变化的代码版本仍然处在不完善的状态,那么经常必须手工回退变化。只有很少的团队有这个素养可以集成并自动进行跨模型、模式和数据的变化。





回页首


Rails 迁移基础

在 Rails 中,所有模式变化 —— 包括模式最初的创建 —— 都在迁移中发生。数据库模式中的每个变化都有自己的迁移对象,迁移对象包装了前进和后退。清单 1 显示了一个空迁移:


清单 1. 空迁移
                        class EmptyMigration < ActiveRecord::Migration
                        def self.up
                        end
                        def self.down
                        end
                        end
                        

我很快将介绍如何调用迁移,但是现在请看看清单 1 中的迁移结构。在这个迁移的 up 方法中,要放置进行一个逻辑数据库变化所需的全部代码。还要捕获任何变化,从而能够取消模式变化。通过封装 updown ,Rails 开发和生产工具可以自动进行涉及持久性对象模型的变化的部署和回退过程。这些数据库变化可能包括:

通过封装 updown 方法,Rails 开发和生产工具可以自动进行涉及持久性对象模型的变化的部署和回退过程。

  • 添加或删除新表。
  • 添加或删除新列。
  • 以其他方式修改数据库,包括添加、删除或修改索引或其他约束。
  • 修改数据库数据。

通过允许改变数据,迁移大大简化了相关数据和模式的变化的同步过程。例如,可以添加一个查询表,把每个州和州的两位数字 ZIP 代码关联起来。在迁移中,可以填充数据库表,可能通过调用 SQL 脚本或装载 fixture。如果迁移正确,那么每个迁移都会把数据库置于一个一致的状态,不需要手工干预。

每个迁移的文件名都以一个惟一的编号开头。这个约定让 Rails 可以对迁移实现很强的排序。用这个策略,可以前后转移到逻辑数据库模式的任何状态。





回页首


使用迁移

要使用迁移,只需要一个 Rails 项目和一个数据库。如果想试验这里的代码,请安装一个关系数据库管理器、Ruby 和 Rails 1.1 以上版本。就可以开始了。请按以下步骤创建数据库支持的 Rails 项目:

  1. 输入 rails blog ,创建叫作 blog 的 Rails 项目。

  2. 创建叫作 blog_development 的数据库。如果使用 MySQL,只需在 MySQL 命令行上输入 create database blog_development

  3. 通过 config/database.yml 对数据库做必要的配置,添加数据库登录 ID 和口令。

要查看编号的工作方式,请生成一个迁移:

  1. 在 blog 目录,输入 ruby script/generate migration create_blog 。(如果正在运行 Unix,可以省略 ruby 。从现在起我就省略它。)

  2. 输入 script/generate migration create_user 。(请看看 blog/db/migrate 中的文件,会看到两个顺序编号的文件。迁移生成器负责管理编号。)

  3. 删除叫作 001_create_blog.rb 的迁移,并用 script/generate migration create_blog 重新创建它。可以注意到,新创建的迁移是 003_create_blog.rb,如清单 2 所示:

清单 2. 生成迁移
                        > cd blog
                        > script/generate migration create_blog
                        create  db/migrate
                        create  db/migrate/001_create_blog.rb
                        > script/generate migration create_user
                        exists  db/migrate
                        create  db/migrate/002_create_user.rb
                        > ls db/migrate/
                        001_create_blog.rb      002_create_user.rb
                        > rm db/migrate/001_create_blog.rb
                        > script/generate migration create_blog
                        exists  db/migrate
                        create  db/migrate/003_create_blog.rb
                        > ls db/migrate/
                        002_create_user.rb      003_create_blog.rb
                        

可以看到每个迁移的数字前缀。新迁移的编号是目录中的最大前缀加 1。这个策略保证了迁移按顺序生成并执行。在其他迁移的结果之上构建的迁移(例如一个迁移要向其他迁移创建的表中添加一列)会保持一致。编号机制简单、直观而一致。

要查看迁移在数据库中的工作方式,请删除 db/migrations 目录中的全部迁移。输入 script/generate model Article ,生成 Article 的模型对象以及与 清单 1 中的迁移类似的空迁移。请注意,Rails 为每篇文章生成了模型对象和迁移。请把 db/migrate/001_create_articles.rb 编辑成清单 3 这样:


清单 3. CreateArticles 的迁移
                        class CreateArticles < ActiveRecord::Migration
                        def self.up
                        create_table :articles do |t|
                        t.column :name, :string, :limit => 80
                        t.column :author, :string, :limit => 40
                        t.column :body, :text
                        t.column :created_on, :datetime
                        end
                        end
                        def self.down
                        drop_table :articles
                        end
                        end
                        





回页首


前后迁移

要准确地看到迁移做的工作,只需运行迁移并查看数据库。在 blog 目录中,输入 rake migrate 。( rake 是 Ruby 上与 C 平台的 make 或 Java 平台的 ant 等价的东西。) migrate 是一个 rake 任务。

接下来,显示数据库中的表。如果使用 MySQL,只需进入 mysql> 命令提示符,输入 use blog_development; ,然后输入 show table; ,就可以看到清单 4 的结果:


清单 4. Rails 迁移创建的 schema_info 表
                        mysql> show tables;
                        +----------------------------+
                        | Tables_in_blog_development |
                        +----------------------------+
                        | articles                   |
                        | schema_info                |
                        +----------------------------+
                        2 rows in set (0.00 sec)
                        mysql> select * from schema_info;
                        +---------+
                        | version |
                        +---------+
                        |       1 |
                        +---------+
                        1 row in set (0.00 sec)
                        

请注意第二个表: schema_info 。我的迁移指定了 articles 表,但是 rake migrate 命令自动创建了 schema_info 。请执行 select * from schema_info

不带参数运行 rake migrate 时,就是要求 Rails 运行所有还没有应用的迁移。Rails 做以下工作:

  • 如果 schema_info 表还不存在,就创建这个表。

  • 如果 schema_info 中没有行,就用值 0 插入一行。

  • 运行编号大于当前迁移的所有迁移的 up 方法。 rake 通过读取 schema_info 表中 version 列的值,判断当前迁移的编号。 rake 按照编号从小到大运行 up 迁移,从大到小运行 down 迁移。

要向下迁移,只要带着版本号运行 rake migrate 即可。向下迁移会破坏数据,所以要小心。有些操作(例如删除表或列)也会破坏数据。清单 5 显示了向下迁移然后回退的结果。可以看到 schema_info 忠实地跟踪当前版本号。这种方式完成了一项精彩的工作:允许在代表不同开发阶段的模式之间平滑地移动。


清单 5. 向下迁移
                        > rake migrate VERSION=0
                        (in /Users/batate/rails/blog)
                        == CreateArticles: reverting ==================================================
                        -drop_table(:articles)
                        -> 0.1320s
                        == CreateArticles: reverted (0.1322s) =========================================
                        > mysql -u root blog_development;
                        mysql> show tables;
                        +----------------------------+
                        | Tables_in_blog_development |
                        +----------------------------+
                        | schema_info                |
                        +----------------------------+
                        1 row in set (0.00 sec)
                        mysql> select * from schema_info;
                        +---------+
                        | version |
                        +---------+
                        |       0 |
                        +---------+
                        1 row in set (0.00 sec)
                        mysql> exit
                        Bye
                        > rake migrate
                        (in /Users/batate/rails/blog)
                        == CreateArticles: migrating ==================================================
                        -create_table(:articles)
                        -> 0.0879s
                        == CreateArticles: migrated (0.0881s) =========================================
                        > mysql -u root blog_development;
                        mysql> select * from schema_info;
                        +---------+
                        | version |
                        +---------+
                        |       1 |
                        +---------+
                        1 row in set (0.00 sec)
                        

现在要打开表本身了。请看 清单 3 和表定义。如果使用 MySQL,可以执行 show create table articles; 命令,生成清单 6 的结果:


清单 6. articles 的表定义
                        mysql> show create table articles;
                        +----------+...-----------------+
                        | Table    | Create Table |
                        +----------+...-----------------+
                        | articles | CREATE TABLE 'articles' (
                        'id' int(11) NOT NULL auto_increment,
                        'name' varchar(80) default NULL,
                        'author' varchar(40) default NULL,
                        'body' text,
                        'created_on' datetime default NULL,
                        PRIMARY KEY  ('id')
                        ) ENGINE=InnoDB DEFAULT CHARSET=latin1 |
                        +----------+...-----------------+
                        1 row in set (0.00 sec)
                        

可以看到,这个表定义的大部分都直接来自迁移。Rails 迁移的一个核心优势就是不需要使用直接的 SQL 语法来创建表。由于在 Ruby 中处理每个模式修改,所以生成的 SQL 是独立于数据库的。但是请注意 id 列。虽然没有指定这个列,但是 Rails 迁移会自动创建它,并具有 auto_incrementNOT NULL 属性。具有这个特殊列定义的 id 列符合 Rails 标识符列的规范。如果想创建这个表,但不要 id ,迁移只需添加 :id 选项,如清单 7 的迁移所示:


清单 7. 创建没有 id 列的表
                        def up
                        create_table :articles, :id => false do |t|
                        ...
                        end
                        end
                        

现在已经深入研究了单一迁移,但是还没有在模式中进行变化。现在要创建另一个表,这次是用于评论的表。请输入 script/generate model Comment 生成叫作 Comment 的模型。把 db/migrate/002_create_comments.rb 生成的迁移编辑成像清单 8 一样。还需要一个有几个列的新表,还要利用 Rails 的功能添加非空列和默认值。


清单 8. 用于评论的第二个迁移
                        class CreateComments < ActiveRecord::Migration
                        def self.up
                        create_table :comments do |t|
                        t.column :name, :string, :limit => 40, :null => false
                        t.column :body, :text
                        t.column :author, :string, :limit => 40, :default => 'Anonymous coward'
                        t.column :article_id, :integer
                        end
                        end
                        def self.down
                        drop_table :comments
                        end
                        end
                        

运行这个迁移。如果在迁移过程中出错,只要记住迁移的工作方式即可。需要检查 schema_info 表中行的值,并查看数据库的状态。在纠正代码之后,可能需要手工删除某些表,或者修改 schema_info 中行的值。请记住,并没有发生什么魔术。Rails 运行所有还没运行的迁移上的 up 方法。如果要添加的表或列已经存在,那么操作就会失败,所以需要确保迁移在一致的状态下运行。至于现在,请运行 rake migrate 。清单 9 显示了结果:


清单 9. 运行第二个迁移
                        > rake migrate(in /Users/batate/rails/blog)
                        == CreateComments: migrating ==================================================
                        -create_table(:comments)
                        -> 0.0700s
                        == CreateComments: migrated (0.0702s) =========================================
                        > mysql -u root blog_development;
                        mysql> select * from schema_info;
                        +---------+
                        | version |
                        +---------+
                        |       2 |
                        +---------+
                        1 row in set (0.00 sec)
                        

迁移可以处理许多不同类型的模式变化。可以添加和删除索引;通过删除、改名或添加列来修改表;甚至在必要的时候借助于 SQL。用 SQL 能做什么,用迁移就能做什么。Rails 对大多数常见操作都有包装器,包括:

  • 创建表( create_table
  • 删除表( drop_table
  • 向表中添加列( add_column
  • 从表中删除列( remove_column
  • 给列改名( rename_column
  • 修改列( change_column
  • 创建索引( create_index
  • 删除索引( drop_index

有些迁移修改不只一个列,形成数据库中单一的逻辑变化。请考虑这个迁移:添加顶级 blog,带有属于这个 blog 的文章。需要创建一个新表,还要添加指向 blog 的每个文章的外键。清单 10 显示了完整的迁移。可以输入 rake migrate 来运行迁移。


清单 10. 创建表并添加列的迁移
                        class CreateBlogs < ActiveRecord::Migration
                        def self.up
                        create_table :blogs do |t|
                        t.column :name, :string, :limit => 40;
                        end
                        add_column "articles", "blog_id", :integer
                        end
                        def self.down
                        drop_table :blogs
                        remove_column "articles", "blog_id"
                        end
                        end
                        





回页首


还有数据

用 SQL 能做什么,用迁移就能做什么。

迄今为止,我只把重点放在模式的变化上,但是数据的变化也是重要的。有些数据库变化要求数据和模式一起变化,有些数据变化要求逻辑变化。例如,假设想为每篇 blog 文章创建一个新评论,表明文章是对评论开放的。如果在 blog 已经开放一段时间之后才实施这个变化,那么希望只对还没有评论的文章添加新评论。用迁移可以容易地进行这个变化,因为迁移可以访问对象模型并根据模型的状态进行决策。请输入 script/generate migration add_open_for_comments 。需要对评论进行修改,捕捉 belongs_to 关系,并编写新的迁移。清单 11 显示了模型对象和新的迁移:


清单 11. 模型对象和新迁移
                        class AddOpenForComments < ActiveRecord::Migration
                        def self.up
                        Article.find_all.each do |article|
                        if article.comments.size == 0
                        Comment.new do |comment|
                        comment.name = 'Welcome.'
                        comment.body = "Article '#{article.name}' is open for comments."
                        article.comments << comment
                        comment.save
                        article.save
                        end
                        end
                        end
                        end
                        def self.down
                        end
                        end
                        

对于清单 11 中的迁移,做了个战术性决策。它认定用户在加入之后不想看到欢迎信息消失,所以选择向下迁移时不删除任何记录。在迁移中解决数据变化的能力是个强大的工具。可以同步数据和模式中的变化,也可以解决涉及对模型对象进行逻辑操作的数据变化。

我已经演示了迁移中能做的大部分工作。还可以利用其他一些工具。如果想对现有数据库使用迁移,可以用 rake schema_dump 对现在的模式做个快照。这个 rake 任务在 db/schema.rb 中用正确的迁移语法创建 Ruby 模式。然后可以生成迁移,并把导出的模式拷贝进迁移。(请参阅 参考资料 获得更多细节。)我没有谈到测试 fixture,在设置测试数据或填充数据库时它们会很有帮助。请参阅 跨越边界 系列中以前关于单元测试的 文章 获得更多细节。





回页首


最终比较

Java 编程的迁移方案并不强壮。有些产品对于有些模式迁移问题有针对性强的解决方案,但是没有协调模式变化的系统化过程 —— 包括前进和后退 —— 处理数据和对象模型中的变化会是项艰难的任务。Rails 解决方案有一些核心优势:

  • Rails 迁移是 DRY 的(不重复你自己)。使用 Rails 时,只要准确地指定每个列的定义一次。其他一些映射器要求指定一个列六次:在模式、 getter、setter、模型的实例变量、“from” 映射和 “to” 映射中。
  • Rails 迁移既支持数据迁移,也支持模式迁移。
  • Rails 迁移支持把模型逻辑用于数据迁移,而 SQL 脚本做不到这一点。
  • Rails 迁移独立于数据库,但 SQL 脚本不独立。
  • Rails 迁移允许对不支持的扩展(例如存储过程或约束)直接使用 SQL,而有些 ORM 映射器不支持。

迁移有这么多好处,您可能以为会有复杂的代码段,但实际上它们出奇的简单。迁移具备有意义的名称和版本编号。每个迁移都有 updown 方法。最后, rake 协调它们以正确的顺序运行。这个简单的策略是革命性的。不在模型中表达每个模式变化而是在独立的迁移中表达,这个概念既优雅又有效。协调数据和模式变化是另一个理念的变化,而且是个有效的变化。更好的是,这些想法完全不依赖于语言。如果要构建新的 Java 包装框架,最好考虑迁移。

在这个系列的下一篇文章中,您将看到新的 Ajax 和 Web 服务支持的框架,它充分利用了 Rails 中的元编程。届时请敞开心灵继续跨越边界。



参考资料

学习
  • 您可以参阅本文在 developerWorks 全球站点上的 英文原文

  • Beyond Java (Bruce Tate,O'Reilly,2005):作者关于 Java 语言的兴起、平台期以及其他能够在某些领域挑战 Java 语言的技术的一本书。

  • ActiveRecord::Migration :关于 ActiveRecord::Migration 的 Rails API 文档,这是了解迁移的最新功能的好地方。

  • Book review: Agile Web Development with Rails ”(Darren Torpey,developerWorks,2005 年 5 月):深入研究这本书可以加深读者对 Rails 和敏捷开发方式背后的逻辑的理解。

  • Understanding Migrations :Rails Wiki 上有一份关于迁移的良好概述,是代码之外最新的信息源。

  • From Java To Ruby: Things Every Manager Should Know (Pragmatic Bookshelf,2006):作者的书,介绍了什么时候和在哪里从 Java 编程转向 Ruby on Rails 才有意义,以及如何进行转换。

  • Programming Ruby (Dave Thomas 等,Pragmatic Bookshelf,2005):关于 Ruby 编程的流行书。

  • Java 技术专区 :数百份 Java 编程各方面的文章。


获得产品和技术
  • Ruby on Rails :下载开放源码的 Ruby on Rails Web 框架。

  • Ruby :从项目的 Web 站点得到 Ruby。


讨论

你可能感兴趣的:(Rails)