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 命令修改以前版本的模式。但是很少有团队会费力气创建能取消模式变化的脚本,而且成功地创建了处理数据变化的自动脚本的团队更少。简而言之,传统的映射策略忽略公路上的汽车:回退坏的模式变化并处理数据。
|
这篇文章深入研究了 Ruby on Rails 迁移 —— Rails 处理生产数据库变化的解决方案。迁移用包装的方式组合了协调模式变化和数据变化的威力和简单性。(如果以前没有学习过活动记录 —— Rails 底层的持久性层,我建议您先阅读 跨越边界 系列中以前的这篇 Rails 文章 。)
基于映射的框架需要模式、模型和映射。这样的框架是重复性的。请想像一下指定一个属性需要重复多少次:
公平地讲,Hibernate 这样的 Java 框架通过代码生成的方式消除了不少重复工作。对象关系映射器可以处理遗留模式,对于新的数据库模式,可以用 Hibernate 提供的工具直接从模型生成模式并用 IDE 生成 getter 和 setter。可以用 Java 标注把映射嵌入域模型,但是按我的观点,这在一定程度上违背了使用映射的初衷。这种代码生成技术还有另一个用途:模式迁移。有些代码生成工具可以发现新的域模型和旧的模式之间的区别,并生成沟通这些不同的 SQL 脚本。请记住这些脚本处理的是模式,而不是数据。
例如,考虑这样一个迁移:把 first_name
和 last_name
这两个数据库列合并成叫作 name
的单一列。来自典型 Java 持久性框架的工具对数据库管理员没有帮助,因为这些工具只处理问题的一部分:模式中的变化。在进行这个模式变化时,还需要能够处理现有数据。在需要部署这个假想应用程序的新版本时,数据库管理员通常必须手工创建 SQL 脚本来完成以下工作:
name
的新列。 first_name
和 last_name
的数据放到新列中。 first_name
和 last_name
列。 如果造成模式变化的代码版本仍然处在不完善的状态,那么经常必须手工回退变化。只有很少的团队有这个素养可以集成并自动进行跨模型、模式和数据的变化。
|
|
在 Rails 中,所有模式变化 —— 包括模式最初的创建 —— 都在迁移中发生。数据库模式中的每个变化都有自己的迁移对象,迁移对象包装了前进和后退。清单 1 显示了一个空迁移:
class EmptyMigration < ActiveRecord::Migration def self.up end def self.down end end |
我很快将介绍如何调用迁移,但是现在请看看清单 1 中的迁移结构。在这个迁移的 up
方法中,要放置进行一个逻辑数据库变化所需的全部代码。还要捕获任何变化,从而能够取消模式变化。通过封装 up
和 down
,Rails 开发和生产工具可以自动进行涉及持久性对象模型的变化的部署和回退过程。这些数据库变化可能包括:
|
通过允许改变数据,迁移大大简化了相关数据和模式的变化的同步过程。例如,可以添加一个查询表,把每个州和州的两位数字 ZIP 代码关联起来。在迁移中,可以填充数据库表,可能通过调用 SQL 脚本或装载 fixture。如果迁移正确,那么每个迁移都会把数据库置于一个一致的状态,不需要手工干预。
每个迁移的文件名都以一个惟一的编号开头。这个约定让 Rails 可以对迁移实现很强的排序。用这个策略,可以前后转移到逻辑数据库模式的任何状态。
|
|
要使用迁移,只需要一个 Rails 项目和一个数据库。如果想试验这里的代码,请安装一个关系数据库管理器、Ruby 和 Rails 1.1 以上版本。就可以开始了。请按以下步骤创建数据库支持的 Rails 项目:
rails blog
,创建叫作 blog
的 Rails 项目。 blog_development
的数据库。如果使用 MySQL,只需在 MySQL 命令行上输入 create database blog_development
。 要查看编号的工作方式,请生成一个迁移:
ruby script/generate migration create_blog
。(如果正在运行 Unix,可以省略 ruby
。从现在起我就省略它。) script/generate migration create_user
。(请看看 blog/db/migrate 中的文件,会看到两个顺序编号的文件。迁移生成器负责管理编号。) script/generate migration create_blog
重新创建它。可以注意到,新创建的迁移是 003_create_blog.rb,如清单 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 这样:
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 的结果:
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
忠实地跟踪当前版本号。这种方式完成了一项精彩的工作:允许在代表不同开发阶段的模式之间平滑地移动。
> 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 的结果:
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_increment
和 NOT NULL
属性。具有这个特殊列定义的 id
列符合 Rails 标识符列的规范。如果想创建这个表,但不要 id
,迁移只需添加 :id
选项,如清单 7 的迁移所示:
def up create_table :articles, :id => false do |t| ... end end |
现在已经深入研究了单一迁移,但是还没有在模式中进行变化。现在要创建另一个表,这次是用于评论的表。请输入 script/generate model Comment
生成叫作 Comment
的模型。把 db/migrate/002_create_comments.rb 生成的迁移编辑成像清单 8 一样。还需要一个有几个列的新表,还要利用 Rails 的功能添加非空列和默认值。
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 显示了结果:
> 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
来运行迁移。
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 |
|
|
|
迄今为止,我只把重点放在模式的变化上,但是数据的变化也是重要的。有些数据库变化要求数据和模式一起变化,有些数据变化要求逻辑变化。例如,假设想为每篇 blog 文章创建一个新评论,表明文章是对评论开放的。如果在 blog 已经开放一段时间之后才实施这个变化,那么希望只对还没有评论的文章添加新评论。用迁移可以容易地进行这个变化,因为迁移可以访问对象模型并根据模型的状态进行决策。请输入 script/generate migration add_open_for_comments
。需要对评论进行修改,捕捉 belongs_to
关系,并编写新的迁移。清单 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 解决方案有一些核心优势:
迁移有这么多好处,您可能以为会有复杂的代码段,但实际上它们出奇的简单。迁移具备有意义的名称和版本编号。每个迁移都有 up
和 down
方法。最后, rake
协调它们以正确的顺序运行。这个简单的策略是革命性的。不在模型中表达每个模式变化而是在独立的迁移中表达,这个概念既优雅又有效。协调数据和模式变化是另一个理念的变化,而且是个有效的变化。更好的是,这些想法完全不依赖于语言。如果要构建新的 Java 包装框架,最好考虑迁移。
在这个系列的下一篇文章中,您将看到新的 Ajax 和 Web 服务支持的框架,它充分利用了 Rails 中的元编程。届时请敞开心灵继续跨越边界。