第四章 Rails 中的模型

课程概要:

本课程讲解Rails 模型(Model)中基本的 CRUD 操作、模型间的关联关系、属性校验、回调以及编写 Rspec 测试的方法,并完成网店的数据库模型设计。

知识点:

  1. CRUD
  2. 数据库迁移(Migration)
  3. 表间关联(Relations)
  4. 属性校验(Validates)
  5. 回调(Callback)

课程背景

模型(Model)是 MVC 架构中的 M,代表数据库,通过对模型的学习,可以了解 Rails 是如何实现数据库操作的。

4.1 模型的基础操作

概要:

本课时讲解模型的基础操作,数据迁移,常用的 CRUD 方法,在数据查询时,如何避免 N+1问题,如何使用 scope 包装查询条件,编写模型 Rspec 测试。

知识点:

  1. Active Record
  2. Migration
  3. CRUD

正文

4.1.1 Active Record 简介

Active Record 模式,是由 Martin Fowler 的《企业应用架构模式》一书中提出的,在该模式中,一个 Active Record(简称 AR)对象包含了持久数据(保存在数据库中的数据)和数据操作(对数据库里的数据进行操作)。

对象关系映射(Object-Relational Mapping,简称 ORM),是将程序中的对象(Object)和关系型数据库(Relational Database)的表之间进行关联。使用 ORM 可以方便的将对象的 属性关联关系 保存入数据库,这样可以不必编写复杂的 SQL 语句,而且不必担心使用的是哪种数据库,一次编写的代码可以应用在 Sqlite,Mysql,PostgreSQL 等各种数据库上。

Active Record 就是个 ORM 框架。

所以,我们可以用 Actice Record 来做这几件事:

  • 表示模型(Model)和模型数据
  • 表示模型间的关系(比如一对多,多对多关系)
  • 通过模型间关联表示继承层次
  • 在保存如数据库前,校验模型(比如属性校验)
  • 面向对象 的方式处理数据库

4.1.2 Active Record 中的约定

Rails 中使用了 ActiveRecord 这个 Gem,使用它可以不必去做任何配置(大多数情况是这样的),还记得 Rails 的两个哲学理念之一么:约定优于配置。(另一个是 不要重复自己,这是 Dave Thomas 在《程序员修炼之道》一书里提出的。)

那么,我们讲两个 Active Record 中的约定:

4.1.2.1 命名约定

  • 数据表名:复数,下划线分隔单词(例如 book_clubs)
  • 模型类名:单数,每个单词的首字母大写(例如 BookClub)

比如:

模型(Class) 数据表(Schema)
Post posts
LineItem line_items
Deer deers
Mouse mice
Person people

单词在单复数转换时,是按照英文语法约定的。

4.1.2.2 Schema 约定

注:数据库中的 Schema,指数据库对象集合,可以被用户直接使用。Schema 包含数据的逻辑结构,用户可以通过命名调用数据库对象,并且安全的管理数据库。

  • 外键 - 使用 singularized_table_name_id 形式命名,例如 item_id,order_id。创建模型关联后,Active Record 会查找这个字段;
  • 主键 - 默认情况下,Active Record 使用整数字段 id 作为表的主键。使用 Active Record 迁移创建数据表时,会自动创建这个字段;

在数据库字段命名的时候,有几个特殊意义的名字,尽量回避:

  • created_at - 创建记录时,自动设为当前的时间戳
  • updated_at - 更新记录时,自动设为当前的时间戳
  • lock_version - 在模型中添加乐观锁定功能
  • type - 让模型使用单表继承,给字段命名的时候,尽量避开这个词
  • (association_name)_type - 多态关联的类型
  • (table_name)_count - 保存关联对象的数量。例如,posts 表中的 comments_count 字段,Rails 会自动更新该文章的评论数

4.1.3 数据库迁移(Migration)

在我们使用 scaffold 创建资源的时候,或者使用 generate 创建 model 的时候,Rails 会给我们自动创建一个数据库迁移文件,它在 db/migrate 中,它的前缀是时间戳,他们按照时间的先后顺序排列,当运行数据库迁移时,他们按照时间顺序先后被执行。

新创建的迁移文件,我们使用 rake db:migrate 命令执行它(们),这里会判断,哪个迁移文件是还没有被执行的。

如果我们对执行过的迁移操作不满意,我们可以回滚这个迁移:

  1. rake db:rollback [1]
  2. rake db:rollback STEP=3 [2]

[1] 回滚最近的一个迁移

[2] 回滚指定的迁移个数

回滚之后,迁移停留在回滚到的那个位置的,schema 也会更新到那个位置时的状态。比如,我们上一次迁移执行了5个文件,我们回滚的时候,是一个个文件回滚的,所以我们指定 STEP=5,才能把刚才迁移的5个文件回滚。

在我们开发代码的过程中,有是会因为失误少写了一个字段,我们回滚之后,在迁移文件中把它加上,然后,我们 rake db:migrate 再次运行。不过,rake db:migrate:redo [STEP=3] 直接回滚然后再次运行迁移,这样会方便些。

这种回滚操作适合开发过程中,出现了新的想法,而回滚最近连续的几个迁移。

如果我们想回滚很久以前的某个操作,而且在那个迁移之后,我们已经执行了多个迁移。这时该如何处理呢?

如果在开发阶段,我们干脆 rake db:droprake db:createrake db:migrate。但是在生产环境,我们决不能这么做,这时我们要针对需求,编写一个迁移文件:

  1. class ChangeProductsPrice < ActiveRecord::Migration
  2. def change
  3. reversible do |dir|
  4. change_table :products do |t|
  5. dir.up { t.change :price, :string }
  6. dir.down { t.change :price, :integer }
  7. end
  8. end
  9. end
  10. end

或者:

  1. class ChangeProductsPrice < ActiveRecord::Migration
  2. def up
  3. change_table :products do |t|
  4. t.change :price, :string
  5. end
  6. end
  7. def down
  8. change_table :products do |t|
  9. t.change :price, :integer
  10. end
  11. end
  12. end

up 是向前迁移到最新的,down用于回滚。

我们创建一个 model 的时候,会自动创建它的 migration 文件,我们还可以使用 rails g migration XXX的方法,添加自定义的迁移文件。如果我们的命名是 “AddXXXToYYY” 或者 “RemoveXXXFromYYY” 时,会自动为我们添加字符类型的字段,比如我为 variant 添加一个color 字段:

  1. rails g migration AddColorToVariants color:string

它的内容是:

  1. class AddColorToVariants < ActiveRecord::Migration
  2. def change
  3. add_column :variants, :color, :string
  4. end
  5. end

4.1.4 CRUD

CRUD并不是一个 Rails 的概念,它表示系统(业务层)和数据库(持久层)之间的基本操作,简单的讲叫“增(C)删(D)改(U)查(R)”。

我们已经使用 scaffold 命令创建了资源:商品(product),我们现在使用 app/models/product.rb 来演示这些操作。

首先,我们需要让 Product 类继承 ActiveRecord:

  1. class Product < ActiveRecord::Base
  2. end

这样,Product 类就可以操作数据库了,是不是很简单。

4.1.5 创建记录

我们使用 Product 类,向数据添加一条记录,我们先进入 Rails 控制台:

  1. % rails c
  2. Loading development environment (Rails 4.2.0)
  3. > Product.create [1]
  4. (0.2ms) begin transaction [2]
  5. SQL (2.8ms) INSERT INTO "products" ("created_at", "updated_at") VALUES (?, ?) [["created_at", "2015-03-14 16:23:44.640578"], ["updated_at", "2015-03-14 16:23:44.640578"]]
  6. (0.8ms) commit transaction [2]
  7. => #<Product id: 1, name: nil, description: nil, price: nil, created_at: "2015-03-14 16:23:44", updated_at: "2015-03-14 16:23:44"> [3]

这里,我贴出了完整的代码。

[1],我们使用了 Product 的类方法 create,创建了一条记录。我们还有其他的方法保存记录。

[2],begin 和 commit ,将我们的数据保存入数据库。如果在保存的时候出现错误,比如属性校验失败,抛出异常等,不会将记录保存到数据库。

[3],我们拿到了一个 Product 类的实例。

除了类方法,我们还可以使用实例的 save 方法,来保存记录到数据,比如:

  1. > product = Product.new [1]
  2. => #<Product id: nil, name: nil, description: nil, price: nil, created_at: nil, updated_at: nil> [2]
  3. > product.save [3]
  4. (0.1ms) begin transaction [4]
  5. SQL (0.9ms) INSERT INTO "products" ("created_at", "updated_at") VALUES (?, ?) [["created_at", "2015-03-14 16:47:26.817663"], ["updated_at", "2015-03-14 16:47:26.817663"]]
  6. (9.3ms) commit transaction [4]
  7. => true [5]

[1],我们使用类方法 new,来创建一个实例,注意,[2] 告诉我们,这是一个没有保存到数据库的实例,因为它的 id 还是 nil。

[3] 我们使用实例方法 save,把这个实例,保存到数据库。

[4] 调用 save 后,会返回执行结果,true 或者 false。这种判断很有用,而且也很常见,如果你现在打开 app/controllers/products_controller.rb 的话,可以看到这样的判断:

  1. if @product.save
  2. ...
  3. else
  4. ...
  5. end

那么,你可能会有个疑问,使用类方法 create 保存的时候,如果失败,会返回我们什么呢?是一个实例,还是 false?

我们使用下一章里要介绍的属性校验,来让保存失败,比如,我们让商品的名称必须填写:

  1. class Product < ActiveRecord::Base
  2. validates :name, presence: true [1]
  3. end

[1] validates 是校验命令,要求 name 属性必须填写。

好了,我们来测试下类方法 create 会返回给我们什么:

  1. > product = Product.create
  2. (0.3ms) begin transaction
  3. (0.1ms) rollback transaction
  4. => #<Product id: nil, name: nil, description: nil, price: nil, created_at: nil, updated_at: nil>
  5. 2.2.0 :003 >

答案揭晓,它返回给我们一个未保存的实例,它有一个实用的方法,可以查看哪里出了错误:

  1. > product.errors.full_messages
  2. => ["名称不能为空字符"]

当然,判断一个实例是否保存成功,不必去检查它的 errors 是否为空,有两个方法会根据 errors 是否添加,而返回实例的状态:

  1. person = Person.new
  2. person.invalid?
  3. person.valid?

要留意的是,invalid? 和 valid? 都会调用实例的校验。

我使用类方法和实例方法的称呼,希望没有给你造成理解的障碍,如果有些难理解,建议你先看一看 Ruby 中关于类和实例的介绍。

4.1.6 查询记录

4.1.6.1 Find 查询

数据查询,是 Rails 项目经常要做的操作,如何拿到准确的数据,优化查询,是我们要重点关注的。

查询时,会得到两种结果,一个实例,或者实例的集合(Array)。如果找不到结果,也会给有两种情况,返回 nil或空数组,或者抛出 ActiveRecord::RecordNotFound 异常。

Rails 给我们提供了这些常用的查询方法:

方法名称 含义 参数 例子 找不到时
find 获取指定主键对应的对象 主键值 Product.find(10) 异常
take 获取一个记录,不考虑任何顺序 Product.take nil
first 获取按主键排序得到的第一个记录 Product.first nil
last 获取按主键排序得到的最后一个记录 Product.last nil
find_by 获取满足条件的第一个记录 hash Product.find_by(name: “T恤”) nil

表中的四个方法不会抛出异常,如果需要抛出异常,可以在他们名字后面加上 !,比如 Product.take!。

如果将上面几个方法的参数改动,我们就会得到集合:

方法名称 含义 参数 例子 找不到时
find 获取指定主键对应的对象 主键值集合 Product.find([1,2,3]) 异常
take 获取一个记录,不考虑任何顺序 个数 Product.take(2) []
first 获取按主键排序得到的第N个记录 个数 Product.first(3) []
last 获取按主键排序得到的最后N个记录 个数 Product.last(4) []
all 获取按主键排序得到的全部记录 Product.all []

Rails 还提供了一个 find_by 的查询方法,它可以接收多个查询参数,返回符合条件的第一个记录。比如:

  1. Product.find_by(name: 'T-Shirt', price: 59.99)

find_by 有一个常用的变形,比如:

  1. Product.find_by_name("Hat")
  2. Product.find_by_name_and_price("Hat", 9.99)

如果需要查询不到结果抛出异常,可以使用 find_by!。通常,以!结尾的方法都会抛出异常,这也是一种约定。不过,直接使用 find,会查询主索引,查询不到直接抛出异常,所以是没有 find! 方法的。

使用 find_by 的时候,还可以使用 sql 语句,比如:

  1. Product.find_by("name = ?", "T")

这是一个有用的查询,当我们搜索多个条件,并且是 OR 关系时,可以这样做:

  1. User.find_by("id = ? OR login = ?", params[:id], params[:id])

这句话还可以改写成:

  1. User.find_by("id = :id OR login = :name", id: params[:id], name: params[:id])

或者更简洁的:

  1. User.find_by("id = :q OR login = :q", q: params[:id])

4.1.6.2 Where 查询

集合的查找,最常用的方法是 where,它可以通过多种形式查找记录:

查询形式 实例
数组(Array)查询 Product.where(“name = ? and price = ?”, “T恤”, 9.99)
哈希(hash)查询 Product.where(name: “T恤”, price: 9.99)
Not查询 Product.where.not(price: 9.99)
Product.none

使用 where 查询,常见的还有模糊查询:

  1. Product.where("name like ?", "%a%")

查询某个区间:

  1. Product.where(price: 5..6)

以及上面提到的,sql 的查询:

  1. Product.where("color = ? OR price > ?", "red", 9)

Active Record 有多种查询方法,以至于 Rails 手册中单独列出一章来讲解,而且讲解的很细致,如果你想灵活的掌握这些数据查询方法,建议你经常阅读 Active Record Query Interface 一章,这是 中文版

4.1.7 更新记录(Update)

和创建记录一样,更新记录也可以使用类方法和实力方法。

类方法是 update,比如:

  1. Product.update(1, name: "T-Shirt", price: 23)

1 是更新目标的 ID,如果该记录不存在,update 会抛出 ActiveRecord::RecordNotFound 异常。

update 也可以更新多条记录,比如:

  1. Product.update([1, 2], [{ name: "Glove", price: 19 }, { name: "Scarf" }])

我们看看它的源代码:

  1. # File activerecord/lib/active_record/relation.rb, line 363
  2. def update(id, attributes)
  3. if id.is_a?(Array)
  4. id.map.with_index { |one_id, idx| update(one_id, attributes[idx]) }
  5. else
  6. object = find(id)
  7. object.update(attributes)
  8. object
  9. end
  10. end

如果要更新全部记录,可以使用 update_all :

  1. Product.update_all(price: 20)

在使用 update 更新记录的时候,会调用 Model 的 validates(校验) 和 callbacks(回调),保证我们写入正确的数据,这个是定义在 Model 中的方法。但是,update_all 会略过校验和回调,直接将数据写入到数据库中。

和 update_all 类似,update_column/update_columns 也是将数据直接写入到数据库,它是一个实例方法:

  1. product = Product.first
  2. product.update_column(:name, "")
  3. product.update_columns(name: "", price: 0)

虽然为 product 增加了 name 非空的校验,但是 update_column(s) 还是可以讲数据写入数据库。

当我们创建迁移文件的时候,Rails 默认会添加两个时间戳字段,created_at 和 updated_at。

当我们使用 update 更新记录时,触发 Model 的校验和回调时,也会自动更新 updated_at 字段。但是 Model.update_all 和 model.update_column(s) 在跳过回调和校验的同时,也不会更新 updated_at 字段。

我们也可以用 save 方法,将新的属性保存到数据库,这也会触发调用和回调,以及更新时间戳:

  1. product = Product.first
  2. product.name = "Shoes"
  3. product.save

4.1.8 删除记录(Destroy)

在我们接触计算机英语里,表示删除的英文有很多,这里我们用到的是 destroy, delete。

4.1.8.1 Delete 删除

使用 delete 删除时,会跳过回调,以及关联关系中定义的 :dependent 选项,直接从数据库中删除,它是一个类方法,比如:

  1. Product.delete(1)
  2. Product.delete([2,3,4])

当传入的 id 不存在的时候,它不会抛出任何异常,看下它的源码:

  1. # File activerecord/lib/active_record/relation.rb, line 502
  2. def delete(id_or_array)
  3. where(primary_key => id_or_array).delete_all
  4. end

它使用不抛出异常的 where 方法查找记录,然后调用 delete_all。

delete 也可以是实例方法,比如:

  1. product = Product.first
  2. product.delete

在有具体实例的时候,可以这样使用,否则会产生 NoMethodError: undefined methoddelete’ for nil:NilClass`,这在我们设计逻辑的时候要注意。

delete_all 方法和 delete 是一样的,直接发送数据删除的命令,看一下 api 文档中的例子:

  1. Post.delete_all("person_id = 5 AND (category = 'Something' OR category = 'Else')")
  2. Post.delete_all(["person_id = ? AND (category = ? OR category = ?)", 5, 'Something', 'Else'])
  3. Post.where(person_id: 5).where(category: ['Something', 'Else']).delete_all

4.1.8.2 Destroy 删除

destroy 方法,会触发 model 中定义的回调(before_remove, after_remove , before_destroy 和 after_destroy),保证我们正确的操作。它也可以是类方法和实例方法,用法和前面的一样。

需要说明,delete/delete_all 和 destroy/destroy_all 都可以作用在关系查询结果,也就是(ActiveRecord::Relation)上,删掉查找到的记录。

如果你不想真正从数据库中抹掉数据,而是给它一个删除标注,可以使用 https://github.com/radar/paranoia 这个 gem,他会给记录一个 deleted_at 时间戳,并且使用 restore 方法把它从数据库中恢复过来,或者使用 really_destroy! 将它真正的删除掉。