开发人员:Ruby on Rails
Ruby on Rails 移植指南作者:Matt Kern
加入 Ruby on Rails 移植大军,简化数据库模式管理。 2007 年 6 月发表 开发人员目前热衷于使用 Ruby on Rails。这种转移的一个重要原因是:Rails 提供了一个强健的框架,该框架构建在一种迄今为止最灵活的语言的基础上。 Rails 的特征之一是“移植”概念。移植很好地说明了开发人员希望使用这一框架的原因:一般说来,管理数据库模式的变更是开发人员小组最讨厌的工作之一。大多数人依赖于将 DDL 存储在修订版控制中,时刻保持警惕,从而确保我们的数据库创建脚本是最新的且每次运行时都一致。该解决方案非常不适合极限编程项目。由于 Rails 鼓励迭代开发,人们很容易将持续的模式更改想像成噩梦。 幸运的是,移植使开发人员能够统一管理数据库计划变更的实施和回滚,但这种管理是受限制的。这对于 Rails 程序员来说很普通。 暗淡的过去管理项目模式的方法可能有很多,如同开放源代码内容管理系统一样。大多数开发人员最终追求的是用纯 SQL 编写的现行数据库创建脚本和数据加载脚本的某种组合。通常称之为 create_db.sql 和 populate_db.sql。如下面所示: CREATE TABLE artists ( id NUMBER(38) PRIMARY KEY, name VARCHAR(100) NOT NULL, updated_on DATE NOT NULL, created_on DATE NOT NULL ); CREATE TABLE albums ( id NUMBER PRIMARY KEY, release_name VARCHAR2(255), year DATE, created_on DATE NOT NULL, updated_on DATE NOT NULL, artist_id NUMBER(38) NOT NULL ); CREATE TABLE songs ( id NUMBER(38) PRIMARY KEY, title VARCHAR2(255) NOT NULL, length NUMBER(6,2) NOT NULL, created_on DATE NOT NULL, updated_on DATE NOT NULL, album_id NUMBER(38) NOT NULL ); CREATE SEQUENCE artists_seq; CREATE SEQUENCE albums_seq; CREATE SEQUENCE songs_seq; 但是,这一方法也带来了一些问题。这种方法可以完成工作,但整体上不灵活。每次您为了任何给定的增强功能需要更改数据模型时,都必须更改这两个文件之一或全部。如果您使用 Subversion 或 CVS 等版本控制系统,该问题将迎刃而解,因为您将至少拥有一个版本记录。但如果您正在处理一个即将分发的项目,您需要为每个数据库维护三四组不同的文件,这将是一件非常痛苦的事,且完全违反了 DRY 原则。即使是一组引发更改的数据库文件也是问题。如果您需要回滚到更改之前,可能只有一种方法:删除所有表格,恢复到一个已知的正常模式版本,然后从备份重新装载数据。您可以选择尝试原路回滚到更改以前的状态,但这一过程非常容易出错。 另一个潜在的问题领域是便携性。如果您的模式生成脚本是针对 MySQL 等特定的数据库编写的,而稍后在开发流程中您决定转而使用 Oracle,您将不得不全部重新编写 SQL。移植库负责替您记住每家数据库供应商的实施详细信息。我们将在稍后介绍它的工作流程。 为了更好地理解本文,您至少需要对 Rails 的工作方式有一个基本的了解。如果您刚开始使用 Rails,或者需要复习 Rails 惯例,请参阅 “Oracle 上的 Ruby on Rails:一个简单的教程” 光明的前景到目前为止,我们已经被这种锁链模式的 SQL 生成脚本(或者您“喜欢的”数据库模式管理机制)所吸引。现在开始 Rails 的 ActiveRecord 移植之旅。 基本上,移植允许人们定义对数据模型(以及数据本身)的增量式更改。该方法无缝整合了全球 Rails 开发人员所喜爱的敏捷和 XP 方法。通过对 ActiveRecord::Migration 进行子类划分和改写两个所需的方法定义 self.up 和 self.down 来定义移植: class SampleMigration < ActiveRecord::Migration def self.up end def self.down end end 在 up 和 down 定义中,您可以创建表和索引、添加数据、操作模型等。(要获得允许的表转换的完整列表,请参阅用于移植的 Rails API)。理论和实践中的概念都很简单:up 方法定义中介绍移植到下一版本的模式所需执行的操作。down 定义中介绍恢复到更改前的状态所需执行的操作。例如: 1 class SampleMigration < ActiveRecord::Migration 2 def self.up 3 create_table :people do |t| 4 t.column :name, :string 5 end 6 end 7 8 def self.down 9 drop_table :people 10 end 11 end 在第 3 行,我们创建表定义并输入一个块,在该块中,我们可以通过添加、删除或修改表定义来更改表。在本例中,我们创建了一个字符串类型的新列“name”。Rails 将 Oracle 数据类型映射为 Ruby 逻辑类型,如 :string、:decimal、:text 等。要获得这些类型的以及每种类型所允许的选项(如 :precision)的完整参考,请参见 ActiveRecord::ConnectionAdapters::TableDefinition#column。 恢复更改很简单,只需删除在 up 定义中创建的表即可。在第 9 行,您进行了该操作:删除表。很容易,是吗?当然!这就是 Rails。 为了进一步简化移植的实施,Rails 小组为我们提供了一个生成器脚本,该脚本创建移植文件并使其遵循运行移植所需的 Rails 的惯例。在 RAILS_ROOT 运行过程中: $ script/generate migration SampleMigration create db/migrate create db/migrate/001_sample_migration.rb 打开 db/migrate/001_sample_migration.rb 脚本,您将看到已经为您设计的类定义。您只需填写方法定义! 您可能会注意到,包含我们的移植的文件名附带了三个数字 001。该数字在移植实施过程中扮演了一个重要的角色。Rails 移植以透明方式添加了一个 SCHEMA_INFO 表以跟踪模式的当前版本号,该版本号直接对应于实际移植文件附带的数字(稍后将详细介绍这一主题)。 首先,我们来看如何运行移植。Rails 频繁使用另一个名为“Rake”的 Ruby 库。Rake 是一个与 Unix/Linux“make”实用程序类似的纯 Ruby 实现,是另一个非常有用的项目管理工具。任务通过 Rake 定义为 rake <task> 并运行。因此,为了运行移植,您可以执行此命令。有关 Rake 知道的所有数据库任务的列表,请使用 rake -T db*): $ rake db:migrate 该命令将运行它在 db/migrate 下找到的所有移植,直到最高版本号为止。您可以通过在该命令后附加 VERSION=x 来改写版本号。还可以传递 RAILS_ENV="production",在生产(或测试、开发等)数据库上强制运行移植。执行该操作时需要格外小心;除非您对移植进行了广泛测试,否则会严重损坏生产数据库! 移植示例:Discographr现在,您将在一个名为 Discographr 的示例应用程序(设计的)中大致了解 Rails 的移植的使用。Discographr 利用一个非常简单的数据模型(包含艺术家、唱片和歌曲)。 还记得在本文的第一部分中介绍的陈旧的 SQL 生成脚本吗?现在我们将其转换成移植。首先,您需要使用一个生成器创建移植文件: $ script/generate migration StartupSchema exists db/migrate create db/migrate/001_startup_schema.rb 注意 Rails 是如何提取和分离大小写混合单词以符合 Rails 的移植文件名命名惯例的。如果您查看文件内部,就会发现类名也根据 Rails 惯例自动进行了格式化。编辑 db/migrate/001_startup_schema.rb 并进行读取,如下所示: class StartupSchema < ActiveRecord::Migration def up create_table :artists do |t| t.column :name, :string, :null => false, :limit => 100 t.column :created_on, :timestamp, :null => false t.column :updated_on, :timestamp, :null => false end create_table :albums do |t| t.column :release_name, :string, :null => false t.column :year, :date t.column :created_on, :timestamp, :null => false t.column :updated_on, :timestamp, :null => false t.column :artist_id, :integer, :null => false end create_table :songs do |t| t.column :title, :string, :null => false t.column :length, :decimal, :precision => 6, :scale => 2 t.column :created_on, :timestamp, :null => false t.column :updated_on, :timestamp, :null => false t.column :album_id, :integer, :null => false end end def down drop_table :songs drop_table :albums drop_table :artists end end 要运行移植,只需调用: $ rake db:migrate如果您仔细观察,可能会注意到该移植中缺少了一些在初始 SQL 脚本程序中定义的内容。好吧,现在我们来看看 Oracle 了解该模式的哪些情况?
打开 SQLPlus,然后运行以下查询(确信您以正确的用户身份登录): SQL> SELECT table_name FROM user_tables; TABLE_NAME ------------------------------ SCHEMA_INFO SONGS ALBUMS ARTISTS您看到自己定义的三个表以及该移植添加和维护的 SCHEMA_INFO 表。快速了解 SCHEMA_INFO 表内部,您就会看到当前的模式版本: SQL> SELECT * FROM schema_info; VERSION ---------- 1 如果您查看了 Oracle 的表定义,就会看到: SQL> DESC songs; Name Null?Type --------------------------------- -------- ---------------------------- ID NOT NULL NUMBER(38) TITLE NOT NULL VARCHAR2(255) TRACK_NUMBER NUMBER(3) CREATED_ON NOT NULL DATE UPDATED_ON NOT NULL DATE ALBUM_ID NOT NULL NUMBER(38) SQL> DESC albums; Name Null?Type --------------------------------- -------- ---------------------------- ID NOT NULL NUMBER(38) RELEASE_NAME NOT NULL VARCHAR2(255) YEAR DATE CREATED_ON NOT NULL DATE UPDATED_ON NOT NULL DATE ARTIST_ID NOT NULL NUMBER(38) SQL> DESC artists; Name Null?Type --------------------------------- -------- ---------------------------- ID NOT NULL NUMBER(38) NAME NOT NULL VARCHAR2(100) CREATED_ON NOT NULL DATE UPDATED_ON NOT NULL DATE无需定义 Rails 需要作为主键的 id 列。(“需要”可能是一个强硬的字眼,因为您可以改写主键列的名称,就像您可以对待大多数 Rails 惯例的做法一样。但如果您遵循这些惯例,一切将变得更简单。)移植知道对 Rails 中使用的表的要求是什么,它负责记住所有这些惯例。但 Rails 如何获得 ID 列中使用的值呢?当然,和 Oracle 一样,使用序列!幸运的是,根据惯例,OracleAdapter 知道 Rails 希望为 ID 列找到一个名为 <tablename>_seq 的序列以用作主键值。因此,它关注以下详情: SQL> SELECT sequence_name FROM user_sequences; SEQUENCE_NAME ------------------------------ ARTISTS_SEQ ALBUMS_SEQ SONGS_SEQ您将发现,您需要手动添加外键列。Rails 并不知道一个指定对象与其他对象之间是什么关系,因此它需要在移植表定义中定义这些列。可是,正如我稍后将解释的一样,我并不喜欢确定数据库中的外键关系,而是让使用 :belongs_to 和 :has_and_belongs_to_many 等宏的应用层中的 Rails 来处理。但是目前,您所需要关注的是该列已经设置。模型类将处理其他事情。 现在,我们假装您忘记向 songs 表添加一个代表曲目号的列。您可以运行移植生成器并将其添加到一个新的、单独的移植中,但由于您刚启动了该模式,您决定回滚该模式并将其添加到 StartupSchema 中。(我们希望对该模式进行大量更改,由于您在很大程度上依赖于迭代、敏捷的开发方法,因此我们现在只需保持结构简洁。) 要回滚到一个早期版本,请向 rake 命令添加一个环境变量: $ rake db:migrate VERSION=0只需确保您获得了一个清白的历史: SQL> SELECT table_name FROM user_tables; TABLE_NAME ------------------------------ SCHEMA_INFO SQL> SELECT sequence_name FROM user_sequences; no rows selected SQL> SELECT * FROM schema_info; VERSION ---------- 0一切看起来都很好:序列与表一同被自动删除。SCHEMA_INFO 表仍在,但如果您快速查看其内部,就会发现当前的版本号再次为 0。现在您拥有了一个清白的历史,可将所需的其他任何内容添加到 StartupSchema 类中。因此,向 songs 表定义中的 StartupSchema 移植添加以下代码行: t.column :track_number, :integer, :limit => 3更改后需要做的是重新运行该移植,以便再次转移到版本 1: $ rake db:migrate您现在有一个很好的原型。但我们假设几天后您决定确实需要添加一个 genre 表,从而可以将三种规范模型中的任何一个模型与一种风格相关联,如将一首歌曲或一位艺术家与一种风格相关联。(在本文中,您将添加标记!)您还决定 year 列处理为一个四位数的整数比处理为一个日期对象更好,因为您对使用该数据进行多次日期计算并不感兴趣。 移植使得更改对于实施来说几乎微不足道。首先,您将使用模型生成器构建添加一个新模型所需的所有文件。这包括单元测试的存根,当然还包括移植文件! $ script/generate model Genre exists app/models/ exists test/unit/ exists test/fixtures/ create app/models/genre.rb create test/unit/genre_test.rb create test/fixtures/genres.yml exists db/migrate create db/migrate/002_create_genres.rb如果您编辑新创建的移植文件,您将看到:
1 class CreateGenres < ActiveRecord::Migration 2 def self.up 3 create_table :genres do |t| 4 end 5 end 6 7 def self.down 8 drop_table :genres 9 end 10 end通过删除新 Genre 模型中的一切内容,Rails 的代码生成继续帮助您遵循最佳实践和 Rails 惯例。唯一需要做的就是充实表定义。(它不可能这么智能!) 1 class CreateGenres < ActiveRecord::Migration 2 def self.up 3 create_table :genres do |t| 4 t.column :name, :string, :null => false, :limit => 100 5 t.column :created_on, :timestamp, :null => false 6 t.column :updated_on, :timestamp, :null => false 7 end 8 end 9 10 def self.down 11 drop_table :genres 12 end 13 end现在,更改 year 列: $ script/generate migration ChangeAlbumYearToInteger exists db/migrate create db/migrate/005_change_album_year_to_integer.rbEdit the migration file: 1 class ChangeAlbumYearToInteger < ActiveRecord::Migration 2 def self.up 3 add_column :albums, :year_int, :integer, :limit => 4 4 Album.reset_column_information 5 say_with_time "Updating albums" do 6 albums = Album.find_all 7 albums.each do |a| 8 a.update_attribute(:year_int, a.year.year.to_i) 9 say "#{a.release_name} updated!", true 10 end 11 end 12 13 remove_column :albums, :year 14 rename_column :albums, :year_int, :year 15 change_column :albums, :year, :integer, :limit => 4, :null => false 16 end 17 18 def self.down 19 add_column :albums, :year_date, :date 20 Album.reset_column_information 21 say_with_time "Updating albums" do 22 albums = Album.find_all 23 albums.each do |a| 24 a.update_attribute(:year_date, Date.new(a.year, 01, 01)) 25 a.save! 26 say "#{a.release_name} updated!", true 27 end 28 end 29 remove_column :albums, :year 30 rename_column :albums, :year_date, :year 31 end 32 end该移植有点棘手,因为您假设 year 列中已经有数据。您不希望进行任何可能改变主键的操作,因为 Song 模型依赖于关系。因此,您首先需要在第 3 行创建一个临时列,以允许您进行类型转换且不会丢失数据。接下来,在第 4 行,刷新 Album 模型的列视图,因为您添加了一个新列并且马上将使用它。如果您不这样做,该模型就不会知道对基础表的更改。然后,在第 6 行,检索表中的所有记录,在第 7 行和第 8 行,迭代所有记录并将 year 列中的值作为整数保存到 year_int 列中。 say_with_time 和 say 调用可以为您在移植期间在屏幕上显示信息提供便利。这种方法可以让每个人非常方便地了解正在发生的事。然后,在第 13 行删除该列,在 14 行将临时列 year_int 重命名为 year。然而,您可能认为您确实需要 year 列中的数据。在这种情况下,可将 :null => false 添加到 change_column 调用中。 为了解实际情况,下面显示了运行该移植的输出结果(假设数据库中只有一个唱片“Greatest Hits”): $ rake db:migrate (in /Users/mattkern/dev/discographr) == ChangeAlbumYearToInteger:migrating ======================================== -- add_column(:albums, :year_int, :integer, {:limit=>4}) -> 0.0404s -- Updating albums -> Greatest Hits updated! -> 0.3315s -- remove_column(:albums, :year) -> 1.5591s -- rename_column(:albums, :year_int, :year) -> 0.0987s -- change_column(:albums, :year, :integer, {:limit=>4, :null=>false}) -> 0.1794s == ChangeAlbumYearToInteger:migrated (2.2352s) ===============================self.down 定义撤销了所有这些更改。有时,您会进行无法恢复到前一个模式版本的更改。例如,如果您删除了 year 列中的所有数据,从而有效地丢弃了所有数据,则不能撤销该移植。在这些情况下,您可以在 down 方法中引发一个 IrreversibleMigration 异常。 正如我们迄今为止看到的一样,移植很强大,但并非完美无缺,记住这一点很重要。如果您不够细心,仍有可能损坏您的数据库。在下一节,我们将了解一些潜在的缺陷和窍门。 通往模式天堂之路上的绊脚石任何代码都不是完美的。如果您希望将移植与 Oracle 结合使用,至少必须使用 1.2.1 版(ActiveRecord 1.15.1 版)。1.2.1 之前的版本不能处理 :decimal 数据类型,它们笨手笨脚地地处理 :precision 和 :scale 类型等选项,从而有效地防止您使用任何带小数点的固定和浮动数字。确保在测试移植时当 Oracle 看到您的模式时您仔细检查该模式,并确保在生产中运行移植之前在开发环境中对它们进行测试。Rails 有大量内置功能,可用于运行开发、测试和生产所需的多个环境,因此没有理由未经测试就进行部署。这是使用 Rails 框架和移植的最大优势之一。 同样,确保您为 Rails 应用程序设置了单独的模式(用户)。移植运行包括将模式转储到 schema.rb 文件。可以通过 environment.rb 中的一个配置参数来控制该转储是 SQL 还是 Ruby(当然,采用移植格式)。如果您要使用一个包含大量非 Rails 表的模式,将对所有这些非 Rails 表进行检查和转储。因此,如果您注意到运行结束时移植挂起,则确保您没有使用像“system”这样的用户来进行连接。如果您有足够的耐心等待转储流程结束,您将看到一个填充了所有系统表的 schema.rb。 生成器很方便,但您可以轻松地生成多余的移植类。例如,如过您像在开发 Discographr 时一样使用“StartupSchema”创建了一个应用程序,然后使用生成器创建模型;您将自动获得该模型的另一个移植文件。请毫不迟疑地删除该移植文件,因为您已经处理了该模型所需的表。然而需要注意的是:如果您没有删除该文件,您将收到一条错误,指出数据库中已经存在该对象。 如果移植失败,最好的方法可能是清理数据库,然后重新运行 rake db:migrate 恢复到当前的模式版本。首先先确保您修复了存在导致问题的代码。当然,还要确保您不会丢失关键数据。通过全面测试的移植在生产中出现故障的可能性应非常小,尤其是您从测试设备加载示例数据时。 顺利移植的提示虽然许多开发人员喜欢在数据库中充分利用各种约束,但我发现在数据库中取消大部分限制更简单、更清洁。我认为约束是应用程序业务逻辑的一部分,并且发现当它们位于应用程序代码中和数据库外时更易于跟踪。以外键为例;尽管没有将外键添加到移植的内置方法,但有插件和其他帮助可供使用。然而,我显然有点茫然,因为我认为它们并不可取。向移植添加外键会降低数据库独立性,而且在尝试修复出错数据时会导致问题发生。可以依赖 Rails 过滤器和验证,从而避免与添加外键相关的问题。许多开发人员(包括我自己在内)都希望能够在 SQLite3 等轻型数据库上进行开发,在 Oracle 等大型工具上进行部署和生产,移植中的外键在 SQLite 上将不能正常使用。保持灵活。 尝试使每个移植类成为一个单独的任务或特性。选择最适合的工作并坚持不懈。我的经验是如果文件/类名过长,不能描述即将进行的更改,则应将其分类成单独的移植。您应该能够根据文件名识别更改,而无需深究程序代码本身。记住,这样做的目的是使开发更高效、更愉快。我甚至可以告诉您一个小秘密:ActiveRecord 和移植也可以在 Rails 框架外使用。(当然上,这实际上并不是一个秘密,但了解这一点还是有好处的!) 最后,测试,测试,再测试。在投入生产之前,确保移植是可靠的。运行移植时,任务的完成顺序很重要,因此测试是重中之重。例如,如果您视图在删的数据之前更改包含该数据的列,移植将失败。我认为,最严重的错误莫过于将未经过全面测试的移植投入生产。移植很强大,但果您不够细心,它们会损坏生产数据库。当然,为了提高开发流程效率和舒适度,如何明智地使用移植还需要长期的努力。 最后,移植的作用是无限的。您只需访问 RAILS_ENV 和测试设备即可将数据加载到开发数据库。作为主要特性升级的一部分,您可以进行复杂的数据转换和计算。通过移植,您可以轻松为能想像到的任何模式或数据更改提供整个 Rails 框架的强大功能、精美设计和简单性。 Matt Kern 多年来一直致力于寻求和开发通过技术(如 Rail)让生活简单化的方法,主要是试图寻求有更多的时间来与家人游览俄勒冈州中部山峰的方法。他是 Artisan Technologies Inc. 的创始人,并且是 Atlanta PHP 的共同创始人。 |