第四章 Rails 中的模型
课程概要:
本课程讲解Rails 模型(Model)中基本的 CRUD 操作、模型间的关联关系、属性校验、回调以及编写 Rspec 测试的方法,并完成网店的数据库模型设计。
知识点:
- CRUD
- 数据库迁移(Migration)
- 表间关联(Relations)
- 属性校验(Validates)
- 回调(Callback)
课程背景
模型(Model)是 MVC 架构中的 M,代表数据库,通过对模型的学习,可以了解 Rails 是如何实现数据库操作的。
4.1 模型的基础操作
概要:
本课时讲解模型的基础操作,数据迁移,常用的 CRUD 方法,在数据查询时,如何避免 N+1问题,如何使用 scope 包装查询条件,编写模型 Rspec 测试。
知识点:
- Active Record
- Migration
- 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
命令执行它(们),这里会判断,哪个迁移文件是还没有被执行的。
如果我们对执行过的迁移操作不满意,我们可以回滚这个迁移:
rake db:rollback [1]
rake db:rollback STEP=3 [2]
[1] 回滚最近的一个迁移
[2] 回滚指定的迁移个数
回滚之后,迁移停留在回滚到的那个位置的,schema 也会更新到那个位置时的状态。比如,我们上一次迁移执行了5个文件,我们回滚的时候,是一个个文件回滚的,所以我们指定 STEP=5,才能把刚才迁移的5个文件回滚。
在我们开发代码的过程中,有是会因为失误少写了一个字段,我们回滚之后,在迁移文件中把它加上,然后,我们 rake db:migrate
再次运行。不过,rake dbredo [STEP=3]
直接回滚然后再次运行迁移,这样会方便些。
这种回滚操作适合开发过程中,出现了新的想法,而回滚最近连续的几个迁移。
如果我们想回滚很久以前的某个操作,而且在那个迁移之后,我们已经执行了多个迁移。这时该如何处理呢?
如果在开发阶段,我们干脆 rake db:drop
,rake db:create
,rake db:migrate
。但是在生产环境,我们决不能这么做,这时我们要针对需求,编写一个迁移文件:
class ChangeProductsPrice < ActiveRecord::Migration
def change
reversible do |dir|
change_table :products do |t|
dir.up { t.change :price, :string }
dir.down { t.change :price, :integer }
end
end
end
end
或者:
class ChangeProductsPrice < ActiveRecord::Migration
def up
change_table :products do |t|
t.change :price, :string
end
end
def down
change_table :products do |t|
t.change :price, :integer
end
end
end
up
是向前迁移到最新的,down
用于回滚。
我们创建一个 model 的时候,会自动创建它的 migration 文件,我们还可以使用 rails g migration XXX
的方法,添加自定义的迁移文件。如果我们的命名是 “AddXXXToYYY” 或者 “RemoveXXXFromYYY” 时,会自动为我们添加字符类型的字段,比如我为 variant 添加一个color 字段:
rails g migration AddColorToVariants color:string
它的内容是:
class AddColorToVariants < ActiveRecord::Migration
def change
add_column :variants, :color, :string
end
end
4.1.4 CRUD
CRUD并不是一个 Rails 的概念,它表示系统(业务层)和数据库(持久层)之间的基本操作,简单的讲叫“增(C)删(D)改(U)查(R)”。
我们已经使用 scaffold 命令创建了资源:商品(product),我们现在使用 app/models/product.rb
来演示这些操作。
首先,我们需要让 Product 类继承 ActiveRecord:
class Product < ActiveRecord::Base
end
这样,Product 类就可以操作数据库了,是不是很简单。
4.1.5 创建记录
我们使用 Product 类,向数据添加一条记录,我们先进入 Rails 控制台:
% rails c
Loading development environment (Rails 4.2.0)
> Product.create [1]
(0.2ms) begin transaction [2]
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"]]
(0.8ms) commit transaction [2]
=> #<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
方法,来保存记录到数据,比如:
> product = Product.new [1]
=> #<Product id: nil, name: nil, description: nil, price: nil, created_at: nil, updated_at: nil> [2]
> product.save [3]
(0.1ms) begin transaction [4]
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"]]
(9.3ms) commit transaction [4]
=> true [5]
[1],我们使用类方法 new,来创建一个实例,注意,[2] 告诉我们,这是一个没有保存到数据库的实例,因为它的 id 还是 nil。
[3] 我们使用实例方法 save,把这个实例,保存到数据库。
[4] 调用 save 后,会返回执行结果,true 或者 false。这种判断很有用,而且也很常见,如果你现在打开 app/controllers/products_controller.rb
的话,可以看到这样的判断:
if @product.save
...
else
...
end
那么,你可能会有个疑问,使用类方法 create 保存的时候,如果失败,会返回我们什么呢?是一个实例,还是 false?
我们使用下一章里要介绍的属性校验,来让保存失败,比如,我们让商品的名称必须填写:
class Product < ActiveRecord::Base
validates :name, presence: true [1]
end
[1] validates 是校验命令,要求 name 属性必须填写。
好了,我们来测试下类方法 create 会返回给我们什么:
> product = Product.create
(0.3ms) begin transaction
(0.1ms) rollback transaction
=> #<Product id: nil, name: nil, description: nil, price: nil, created_at: nil, updated_at: nil>
2.2.0 :003 >
答案揭晓,它返回给我们一个未保存的实例,它有一个实用的方法,可以查看哪里出了错误:
> product.errors.full_messages
=> ["名称不能为空字符"]
当然,判断一个实例是否保存成功,不必去检查它的 errors 是否为空,有两个方法会根据 errors 是否添加,而返回实例的状态:
person = Person.new
person.invalid?
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 的查询方法,它可以接收多个查询参数,返回符合条件的第一个记录。比如:
Product.find_by(name: 'T-Shirt', price: 59.99)
find_by
有一个常用的变形,比如:
Product.find_by_name("Hat")
Product.find_by_name_and_price("Hat", 9.99)
如果需要查询不到结果抛出异常,可以使用 find_by!
。通常,以!
结尾的方法都会抛出异常,这也是一种约定。不过,直接使用 find,会查询主索引,查询不到直接抛出异常,所以是没有 find!
方法的。
使用 find_by 的时候,还可以使用 sql 语句,比如:
Product.find_by("name = ?", "T")
这是一个有用的查询,当我们搜索多个条件,并且是 OR 关系时,可以这样做:
User.find_by("id = ? OR login = ?", params[:id], params[:id])
这句话还可以改写成:
User.find_by("id = :id OR login = :name", id: params[:id], name: params[:id])
或者更简洁的:
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 查询,常见的还有模糊查询:
Product.where("name like ?", "%a%")
查询某个区间:
Product.where(price: 5..6)
以及上面提到的,sql 的查询:
Product.where("color = ? OR price > ?", "red", 9)
Active Record 有多种查询方法,以至于 Rails 手册中单独列出一章来讲解,而且讲解的很细致,如果你想灵活的掌握这些数据查询方法,建议你经常阅读 Active Record Query Interface 一章,这是 中文版。
4.1.7 更新记录(Update)
和创建记录一样,更新记录也可以使用类方法和实力方法。
类方法是 update,比如:
Product.update(1, name: "T-Shirt", price: 23)
1 是更新目标的 ID,如果该记录不存在,update 会抛出 ActiveRecord::RecordNotFound
异常。
update
也可以更新多条记录,比如:
Product.update([1, 2], [{ name: "Glove", price: 19 }, { name: "Scarf" }])
我们看看它的源代码:
# File activerecord/lib/active_record/relation.rb, line 363
def update(id, attributes)
if id.is_a?(Array)
id.map.with_index { |one_id, idx| update(one_id, attributes[idx]) }
else
object = find(id)
object.update(attributes)
object
end
end
如果要更新全部记录,可以使用 update_all :
Product.update_all(price: 20)
在使用 update 更新记录的时候,会调用 Model 的 validates(校验) 和 callbacks(回调),保证我们写入正确的数据,这个是定义在 Model 中的方法。但是,update_all 会略过校验和回调,直接将数据写入到数据库中。
和 update_all 类似,update_column/update_columns 也是将数据直接写入到数据库,它是一个实例方法:
product = Product.first
product.update_column(:name, "")
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 方法,将新的属性保存到数据库,这也会触发调用和回调,以及更新时间戳:
product = Product.first
product.name = "Shoes"
product.save
4.1.8 删除记录(Destroy)
在我们接触计算机英语里,表示删除的英文有很多,这里我们用到的是 destroy, delete。
4.1.8.1 Delete 删除
使用 delete 删除时,会跳过回调,以及关联关系中定义的 :dependent
选项,直接从数据库中删除,它是一个类方法,比如:
Product.delete(1)
Product.delete([2,3,4])
当传入的 id 不存在的时候,它不会抛出任何异常,看下它的源码:
# File activerecord/lib/active_record/relation.rb, line 502
def delete(id_or_array)
where(primary_key => id_or_array).delete_all
end
它使用不抛出异常的 where 方法查找记录,然后调用 delete_all。
delete 也可以是实例方法,比如:
product = Product.first
product.delete
在有具体实例的时候,可以这样使用,否则会产生 NoMethodError: undefined method
delete’ for nil:NilClass`,这在我们设计逻辑的时候要注意。
delete_all 方法和 delete 是一样的,直接发送数据删除的命令,看一下 api 文档中的例子:
Post.delete_all("person_id = 5 AND (category = 'Something' OR category = 'Else')")
Post.delete_all(["person_id = ? AND (category = ? OR category = ?)", 5, 'Something', 'Else'])
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!
将它真正的删除掉。