11.1 微博模型
最后更新于:2022-04-01 22:30:20
# 11.1 微博模型
实现微博资源的第一步是创建微博数据模型,在模型中设定微博的基本特征。和 [2.3 节](chapter2.html#the-microposts-resource)创建的模型类似,我们要实现的微博模型要包含数据验证,以及和用户模型之间的关联。除此之外,我们还会做充分的测试,指定默认的排序方式,以及自动删除已注销用户的微博。
如果使用 Git 做版本控制的话,和之前一样,建议你新建一个主题分支:
```
$ git checkout master
$ git checkout -b user-microposts
```
## 11.1.1 基本模型
微博模型只需要两个属性:一个是 `content`,用来保存微博的内容;另一个是 `user_id`,把微博和用户关联起来。微博模型的结构如[图 11.1](#fig-micropost-model) 所示。
![micropost model 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306e48377.png)图 11.1:微博数据模型
注意,在这个模型中,`content` 属性的类型为 `text`,而不是 `string`,目的是存储任意长度的文本。虽然我们会限制微博内容的长度不超过 140 个字符([11.1.2 节](#micropost-validations)),也就是说在 `string` 类型的 255 个字符长度的限制内,但使用 `text` 能更好地表达微博的特性,即把微博看成一段文本更符合常理。在 [11.3.2 节](#creating-microposts),会把文本字段换成多行文本字段,用于提交微博。而且,如果以后想让微博的内容更长一些(例如包含多国文字),使用 `text` 类型处理起来更灵活。何况,在生产环境中使用 `text` 类型并[没有什么性能差异](http://www.postgresql.org/docs/9.1/static/datatype-character.html),所以不会有什么额外消耗。
和用户模型一样([代码清单 6.1](chapter6.html#listing-generate-user-model)),我们要使用 `generate model` 命令生成微博模型:
```
$ rails generate model Micropost content:text user:references
```
这个命令会生成一个迁移文件,用于在数据库中生成一个名为 `microposts` 的表,如[代码清单 11.1](#listing-micropost-migration) 所示。可以和生成 `users` 表的迁移对照一下,参见[代码清单 6.2](chapter6.html#listing-users-migration)。二者之间最大的区别是,前者使用了 `references` 类型。`references` 会自动添加 `user_id` 列(以及索引),把用户和微博关联起来。和用户模型一样,微博模型的迁移中也自动生成了 `t.timestamps`。[6.1.1 节](chapter6.html#database-migrations)说过,这行代码的作用是添加 `created_at` 和 `updated_at` 两列。([11.1.4 节](#micropost-refinements)和 [11.2.1 节](#rendering-microposts)会使用 `created_at` 列。)
##### 代码清单 11.1:微博模型的迁移文件,还创建了索引
db/migrate/[timestamp]_create_microposts.rb
```
class CreateMicroposts < ActiveRecord::Migration
def change
create_table :microposts do |t|
t.text :content
t.references :user, index: true, foreign_key: true
t.timestamps null: false
end
add_index :microposts, [:user_id, :created_at] end
end
```
因为我们会按照发布时间的倒序查询某个用户发布的所有微博,所以在上述代码中为 `user_id` 和 `created_at` 列创建了索引(参见[旁注 6.2](chapter6.html#aside-database-indices)):
```
add_index :microposts, [:user_id, :created_at]
```
我们把 `user_id` 和 `created_at` 放在一个数组中,告诉 Rails 我们要创建的是“多键索引”(multiple key index),因此 Active Record 会同时使用这两个键。
然后像之前一样,执行下面的命令更新数据库:
```
$ bundle exec rake db:migrate
```
## 11.1.2 微博模型的数据验证
我们已经创建了基本的数据模型,下面要添加一些验证,实现符合需求的约束。微博模型必须要有一个属性表示用户的 ID,这样才能知道某篇微博是由哪个用户发布的。实现这样的属性,最好的方法是使用 Active Record 关联。[11.1.3 节](#user-micropost-associations)会实现关联,现在我们直接处理微博模型。
我们可以参照用户模型的测试([代码清单 6.7](chapter6.html#listing-name-presence-test)),在 `setup` 方法中新建一个微博对象,并把它和固件中的一个有效用户关联起来,然后在测试中检查这个微博对象是否有效。因为每篇微博都要和用户关联起来,所以我们还要为 `user_id` 属性的存在性验证编写一个测试。综上所述,测试如[代码清单 11.2](#listing-micropost-validity-test) 所示。
##### 代码清单 11.2:测试微博是否有效 RED
test/models/micropost_test.rb
```
require 'test_helper'
class MicropostTest < ActiveSupport::TestCase
def setup
@user = users(:michael)
# 这行代码不符合常见做法 @micropost = Micropost.new(content: "Lorem ipsum", user_id: @user.id) end
test "should be valid" do
assert @micropost.valid?
end
test "user id should be present" do
@micropost.user_id = nil
assert_not @micropost.valid?
end
end
```
如 `setup` 方法中的注释所说,创建微博使用的方法不符合常见做法,我们会在 [11.1.3 节](#user-micropost-associations)修正。
微博是否有效的测试能通过,但用户 ID 存在性验证的测试无法通过,因为微博模型目前还没有任何验证规则:
##### 代码清单 11.3:**RED**
```
$ bundle exec rake test:models
```
为了让测试通过,我们要添加用户 ID 存在性验证,如[代码清单 11.4](#listing-micropost-user-id-validation) 所示。(注意,这段代码中 `belongs_to` 那行由[代码清单 11.1](#listing-micropost-migration) 中的迁移自动生成。[11.1.3 节](#user-micropost-associations)会深入介绍这行代码的作用。)
##### 代码清单 11.4:微博模型 `user_id` 属性的验证 GREEN
app/models/micropost.rb
```
class Micropost < ActiveRecord::Base
belongs_to :user
validates :user_id, presence: true end
```
现在,整个测试组件应该都能通过:
##### 代码清单 11.5:**GREEN**
```
$ bundle exec rake test
```
接下来,我们要为 `content` 属性加上数据验证(参照 [2.3.2 节](chapter2.html#putting-the-micro-in-microposts)的做法)。和 `user_id` 一样,`content` 属性必须存在,而且还要限制内容的长度不能超过 140 个字符,这才是真正的“微”博。首先,我们要参照 [6.2 节](chapter6.html#user-validations)用户模型的验证测试,编写一些简单的测试,如[代码清单 11.6](#listing-micropost-validations-tests) 所示。
##### 代码清单 11.6:测试微博模型的验证 RED
test/models/micropost_test.rb
```
require 'test_helper'
class MicropostTest < ActiveSupport::TestCase
def setup
@user = users(:michael)
@micropost = Micropost.new(content: "Lorem ipsum", user_id: @user.id)
end
test "should be valid" do
assert @micropost.valid?
end
test "user id should be present" do
@micropost.user_id = nil
assert_not @micropost.valid?
end
test "content should be present" do @micropost.content = " " assert_not @micropost.valid? end
test "content should be at most 140 characters" do @micropost.content = "a" * 141 assert_not @micropost.valid? end end
```
和 [6.2 节](chapter6.html#user-validations)一样,[代码清单 11.6](#listing-micropost-validations-tests)也用到了字符串连乘来测试微博内容长度的验证:
```
$ rails console
>> "a" * 10
=> "aaaaaaaaaa"
>> "a" * 141
=> "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
```
在模型中添加的代码基本上和用户模型 `name` 属性的验证一样([代码清单 6.16](chapter6.html#listing-length-validation)),如[代码清单 11.7](#listing-micropost-validations) 所示。
##### 代码清单 11.7:微博模型的验证 GREEN
app/models/micropost.rb
```
class Micropost < ActiveRecord::Base
belongs_to :user
validates :user_id, presence: true
validates :content, presence: true, length: { maximum: 140 } end
```
现在,测试组件应该能通过了:
##### 代码清单 11.8:**GREEN**
```
$ bundle exec rake test
```
## 11.1.3 用户和微博之间的关联
为 Web 应用构建数据模型时,最基本的要求是要能够在不同的模型之间建立关联。在这个应用中,每篇微博都属于某个用户,而每个用户一般都有多篇微博。用户和微博之间的关系在 [2.3.3 节](chapter2.html#a-user-has-many-microposts)简单介绍过,如[图 11.2](#fig-micropost-belongs-to-user) 和[图 11.3](#fig-user-has-many-microposts) 所示。在实现这种关联的过程中,我们会为微博模型和用户模型编写一些测试。
![micropost belongs to user](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306e65763.png)图 11.2:微博和所属用户之间的 `belongs_to`(属于)关系![user has many microposts](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733306e7b37d.png)图 11.3:用户和微博之间的 `has_many`(拥有多个)关系
使用本节实现的 `belongs_to`/`has_many` 关联之后,Rails 会自动创建一些方法,如[表 11.1](#table-association-methods) 所示。注意,从表中可知,相较于下面的方法
```
Micropost.create
Micropost.create!
Micropost.new
```
我们得到了
```
user.microposts.create
user.microposts.create!
user.microposts.build
```
后者才是创建微博的正确方式,即通过相关联的用户对象创建。通过这种方式创建的微博,其 `user_id` 属性会自动设为正确的值。所以,我们可以把[代码清单 11.2](#listing-micropost-validity-test) 中的下述代码
```
@user = users(:michael)
# 这行代码不符合常见做法
@micropost = Micropost.new(content: "Lorem ipsum", user_id: @user.id)
```
改为
```
@user = users(:michael)
@micropost = @user.microposts.build(content: "Lorem ipsum")
```
(和 `new` 方法一样,`build` 方法返回一个存储在内存中的对象,不会修改数据库。)只要关联定义的正确,`@micropost` 变量的 `user_id` 属性就会自动设为所关联用户的 ID。
表 11.1:用户和微博之间建立关联后得到的方法简介
| 方法 | 作用 |
| --- | --- |
| `micropost.user` | 返回和微博关联的用户对象 |
| `user.microposts` | 返回用户发布的所有微博 |
| `user.microposts.create(arg)` | 创建一篇 `user` 发布的微博 |
| `user.microposts.create!(arg)` | 创建一篇 `user` 发布的微博(失败时抛出异常) |
| `user.microposts.build(arg)` | 返回一个 `user` 发布的新微博对象 |
| `user.microposts.find_by(id: 1)` | 查找 `user` 发布的一篇微博,而且微博的 ID 为 1 |
为了让 `@user.microposts.build` 这样的代码能使用,我们要修改用户模型和微博模型,添加一些代码,把这两个模型关联起来。[代码清单 11.1](#listing-micropost-migration) 中的迁移已经自动添加了 `belongs_to :user`,如[代码清单 11.9](#listing-micropost-belongs-to-user) 所示。关联的另一头,`has_many :microposts`,我们要自己动手添加,如[代码清单 11.10](#listing-user-has-many-microposts) 所示。
##### 代码清单 11.9:一篇微博属于(`belongs_to`)一个用户 GREEN
app/models/micropost.rb
```
class Micropost < ActiveRecord::Base
belongs_to :user validates :user_id, presence: true
validates :content, presence: true, length: { maximum: 140 }
end
```
##### 代码清单 11.10:一个用户有多篇(`has_many`)微博 GREEN
app/models/user.rb
```
class User < ActiveRecord::Base
has_many :microposts .
.
.
end
```
定义好关联后,我们可以修改[代码清单 11.2](#listing-micropost-validity-test) 中的 `setup` 方法了,使用正确的方式创建一个微博对象,如[代码清单 11.11](#listing-micropost-validity-test-idiomatic) 所示。
##### 代码清单 11.11:使用正确的方式创建微博对象 GREEN
test/models/micropost_test.rb
```
require 'test_helper'
class MicropostTest < ActiveSupport::TestCase
def setup
@user = users(:michael)
@micropost = @user.microposts.build(content: "Lorem ipsum") end
test "should be valid" do
assert @micropost.valid?
end
test "user id should be present" do
@micropost.user_id = nil
assert_not @micropost.valid?
end
.
.
.
end
```
当然,经过这次简单的重构后测试组件应该还能通过:
##### 代码清单 11.12:**GREEN**
```
$ bundle exec rake test
```
## 11.1.4 改进微博模型
本节,我们要改进一下用户和微博之间的关联:按照特定的顺序取回用户的微博,并且让微博依属于用户,如果用户注销了,就自动删除这个用户发布的所有微博。
### 默认作用域
默认情况下,`user.microposts` 不能确保微博的顺序,但是按照博客和 Twitter 的习惯,我们希望微博按照创建时间倒序排列,也就是最新发布的微博在前面。[[1](#fn-1)]为此,我们要使用“默认作用域”(default scope)。
这样的功能很容易让测试意外通过(就算应用代码不对,测试也能通过),所以我们要使用测试驱动开发技术,确保实现的方式是正确的。首先,我们编写一个测试,检查数据库中的第一篇微博和微博固件中名为 `most_recent` 的微博相同,如[代码清单 11.13](#listing-micropost-order-test) 所示。
##### 代码清单 11.13:测试微博的排序 RED
test/models/micropost_test.rb
```
require 'test_helper'
class MicropostTest < ActiveSupport::TestCase
.
.
.
test "order should be most recent first" do
assert_equal Micropost.first, microposts(:most_recent)
end
end
```
这段代码要使用微博固件,所以我们要定义固件,如[代码清单 11.14](#listing-micropost-fixtures) 所示。
##### 代码清单 11.14:微博固件
test/fixtures/microposts.yml
```
orange:
content: "I just ate an orange!"
created_at: <%= 10.minutes.ago %>
tau_manifesto:
content: "Check out the @tauday site by @mhartl: http://tauday.com"
created_at: <%= 3.years.ago %>
cat_video:
content: "Sad cats are sad: http://youtu.be/PKffm2uI4dk"
created_at: <%= 2.hours.ago %>
most_recent:
content: "Writing a short test"
created_at: <%= Time.zone.now %>
```
注意,我们使用嵌入式 Ruby 明确设置了 `created_at` 属性的值。因为这个属性由 Rails 自动更新,一般无法手动设置,但在固件中可以这么做。实际上可能不用自己设置这些属性,因为在某些系统中固件会按照定义的顺序创建。在这个文件中,最后一个固件最后创建(因此是最新的一篇微博)。但是绝不要依赖这种行为,因为并不可靠,而且在不同的系统中有差异。
现在,测试应该无法通过:
##### 代码清单 11.15:**RED**
```
$ bundle exec rake test TEST=test/models/micropost_test.rb \
> TESTOPTS="--name test_order_should_be_most_recent_first"
```
我们要使用 Rails 提供的 `default_scope` 方法让测试通过。这个方法的作用很多,这里我们要用它设定从数据库中读取数据的默认顺序。为了得到特定的顺序,我们要在 `default_scope` 方法中指定 `order` 参数,按 `created_at` 列的值排序,如下所示:
```
order(:created_at)
```
可是,这实现的是“升序”,从小到大排列,即最早发布的微博排在最前面。为了让微博降序排列,我们要向下走一层,使用纯 SQL 语句:
```
order('created_at DESC')
```
在 SQL 中,`DESC` 表示“降序”,即新发布的微博在前面。在以前的 Rails 版本中,必须使用纯 SQL 语句才能实现这个需求,但从 Rails 4.0 起,可以使用纯 Ruby 句法实现:
```
order(created_at: :desc)
```
把默认作用域加入微博模型,如[代码清单 11.16](#listing-micropost-ordering) 所示。
##### 代码清单 11.16:使用 `default_scope` 排序微博 GREEN
app/models/micropost.rb
```
class Micropost < ActiveRecord::Base
belongs_to :user
default_scope -> { order(created_at: :desc) } validates :user_id, presence: true
validates :content, presence: true, length: { maximum: 140 }
end
```
[代码清单 11.16](#listing-micropost-ordering) 中使用了“箭头”句法,表示一种对象,叫 Proc(procedure)或 lambda,即“匿名函数”(没有名字的函数)。`->` 接受一个代码块([4.3.2 节](chapter4.html#blocks)),返回一个 Proc。然后在这个 Proc 上调用 `call` 方法执行其中的代码。我们可以在控制台中看一下怎么使用 Proc:
```
>> -> { puts "foo" }
=> #
>> -> { puts "foo" }.call
foo
=> nil
```
(Proc 是高级 Ruby 知识,如果现在不理解也不用担心。)
按照[代码清单 11.16](#listing-micropost-ordering) 修改后,测试应该可以通过了:
##### 代码清单 11.17:**GREEN**
```
$ bundle exec rake test
```
### 依属关系:destroy
除了设定恰当的顺序外,我们还要对微博模型做一项改进。我们在 [9.4 节](chapter9.html#deleting-users)介绍过,管理员有删除用户的权限。那么,在删除用户的同时,有必要把该用户发布的微博也删除。
为此,我们可以把一个参数传给 `has_many` 关联方法,如[代码清单 11.18](#listing-micropost-dependency) 所示。
##### 代码清单 11.18:确保用户的微博在删除用户的同时也被删除
app/models/user.rb
```
class User < ActiveRecord::Base
has_many :microposts, dependent: :destroy .
.
.
end
```
`dependent: :destroy` 的作用是在用户被删除的时候,把这个用户发布的微博也删除。这么一来,如果管理员删除了用户,数据库中就不会出现无主的微博了。
我们可以为用户模型编写一个测试,证明[代码清单 11.18](#listing-micropost-dependency) 中的代码是正确的。我们要保存一个用户(因此得到了用户的 ID),再创建一个属于这个用户的微博,然后检查删除用户后微博的数量有没有减少一个,如[代码清单 11.19](#listing-dependent-destroy-test) 所示。(和[代码清单 9.57](chapter9.html#listing-delete-link-integration-test) 中“删除”链接的集成测试对比一下。)
##### 代码清单 11.19:测试 `dependent: :destroy` GREEN
test/models/user_test.rb
```
require 'test_helper'
class UserTest < ActiveSupport::TestCase
def setup
@user = User.new(name: "Example User", email: "user@example.com",
password: "foobar", password_confirmation: "foobar")
end
.
.
.
test "associated microposts should be destroyed" do
@user.save
@user.microposts.create!(content: "Lorem ipsum")
assert_difference 'Micropost.count', -1 do
@user.destroy
end
end
end
```
如果[代码清单 11.18](#listing-micropost-dependency) 正确,测试组件就应该能通过:
##### 代码清单 11.20:**GREEN**
```
$ bundle exec rake test
```
';