11.1 微博模型
实现微博资源的第一步是创建微博数据模型,在模型中设定微博的基本特征。和 2.3 节创建的模型类似,我们要实现的微博模型要包含数据验证,以及和用户模型之间的关联。除此之外,我们还会做充分的测试,指定默认的排序方式,以及自动删除已注销用户的微博。
如果使用 Git 做版本控制的话,和之前一样,建议你新建一个主题分支:
$ git checkout master
$ git checkout -b user-microposts
11.1.1 基本模型
微博模型只需要两个属性:一个是 content
,用来保存微博的内容;另一个是 user_id
,把微博和用户关联起来。微博模型的结构如图 11.1 所示。
图 11.1:微博数据模型
注意,在这个模型中,content
属性的类型为 text
,而不是 string
,目的是存储任意长度的文本。虽然我们会限制微博内容的长度不超过 140 个字符(11.1.2 节),也就是说在 string
类型的 255 个字符长度的限制内,但使用 text
能更好地表达微博的特性,即把微博看成一段文本更符合常理。在 11.3.2 节,会把文本字段换成多行文本字段,用于提交微博。而且,如果以后想让微博的内容更长一些(例如包含多国文字),使用 text
类型处理起来更灵活。何况,在生产环境中使用 text
类型并没有什么性能差异,所以不会有什么额外消耗。
和用户模型一样(代码清单 6.1),我们要使用 generate model
命令生成微博模型:
$ rails generate model Micropost content:text user:references
这个命令会生成一个迁移文件,用于在数据库中生成一个名为 microposts
的表,如代码清单 11.1 所示。可以和生成 users
表的迁移对照一下,参见代码清单 6.2。二者之间最大的区别是,前者使用了 references
类型。references
会自动添加 user_id
列(以及索引),把用户和微博关联起来。和用户模型一样,微博模型的迁移中也自动生成了 t.timestamps
。6.1.1 节说过,这行代码的作用是添加 created_at
和 updated_at
两列。(11.1.4 节和 11.2.1 节会使用 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):
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 节会实现关联,现在我们直接处理微博模型。
我们可以参照用户模型的测试(代码清单 6.7),在 setup
方法中新建一个微博对象,并把它和固件中的一个有效用户关联起来,然后在测试中检查这个微博对象是否有效。因为每篇微博都要和用户关联起来,所以我们还要为 user_id
属性的存在性验证编写一个测试。综上所述,测试如代码清单 11.2 所示。
代码清单 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 节修正。
微博是否有效的测试能通过,但用户 ID 存在性验证的测试无法通过,因为微博模型目前还没有任何验证规则:
代码清单 11.3:RED
$ bundle exec rake test:models
为了让测试通过,我们要添加用户 ID 存在性验证,如代码清单 11.4 所示。(注意,这段代码中 belongs_to
那行由代码清单 11.1 中的迁移自动生成。11.1.3 节会深入介绍这行代码的作用。)
代码清单 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 节的做法)。和 user_id
一样,content
属性必须存在,而且还要限制内容的长度不能超过 140 个字符,这才是真正的“微”博。首先,我们要参照 6.2 节用户模型的验证测试,编写一些简单的测试,如代码清单 11.6 所示。
代码清单 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 节一样,代码清单 11.6也用到了字符串连乘来测试微博内容长度的验证:
$ rails console
>> "a" * 10
=> "aaaaaaaaaa"
>> "a" * 141
=> "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
在模型中添加的代码基本上和用户模型 name
属性的验证一样(代码清单 6.16),如代码清单 11.7 所示。
代码清单 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 节简单介绍过,如图 11.2 和图 11.3 所示。在实现这种关联的过程中,我们会为微博模型和用户模型编写一些测试。
图 11.2:微博和所属用户之间的 belongs_to
(属于)关系图 11.3:用户和微博之间的 has_many
(拥有多个)关系
使用本节实现的 belongs_to
/has_many
关联之后,Rails 会自动创建一些方法,如表 11.1 所示。注意,从表中可知,相较于下面的方法
Micropost.create
Micropost.create!
Micropost.new
我们得到了
user.microposts.create
user.microposts.create!
user.microposts.build
后者才是创建微博的正确方式,即通过相关联的用户对象创建。通过这种方式创建的微博,其 user_id
属性会自动设为正确的值。所以,我们可以把代码清单 11.2 中的下述代码
@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 中的迁移已经自动添加了 belongs_to :user
,如代码清单 11.9 所示。关联的另一头,has_many :microposts
,我们要自己动手添加,如代码清单 11.10 所示。
代码清单 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 中的 setup
方法了,使用正确的方式创建一个微博对象,如代码清单 11.11 所示。
代码清单 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]为此,我们要使用“默认作用域”(default scope)。
这样的功能很容易让测试意外通过(就算应用代码不对,测试也能通过),所以我们要使用测试驱动开发技术,确保实现的方式是正确的。首先,我们编写一个测试,检查数据库中的第一篇微博和微博固件中名为 most_recent
的微博相同,如代码清单 11.13 所示。
代码清单 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 所示。
代码清单 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 所示。
代码清单 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 中使用了“箭头”句法,表示一种对象,叫 Proc(procedure)或 lambda,即“匿名函数”(没有名字的函数)。->
接受一个代码块(4.3.2 节),返回一个 Proc。然后在这个 Proc 上调用 call
方法执行其中的代码。我们可以在控制台中看一下怎么使用 Proc:
>> -> { puts "foo" }
=> #<Proc:[email protected](irb):1 (lambda)>
>> -> { puts "foo" }.call
foo
=> nil
(Proc 是高级 Ruby 知识,如果现在不理解也不用担心。)
按照代码清单 11.16 修改后,测试应该可以通过了:
代码清单 11.17:GREEN
$ bundle exec rake test
依属关系:destroy
除了设定恰当的顺序外,我们还要对微博模型做一项改进。我们在 9.4 节介绍过,管理员有删除用户的权限。那么,在删除用户的同时,有必要把该用户发布的微博也删除。
为此,我们可以把一个参数传给 has_many
关联方法,如代码清单 11.18 所示。
代码清单 11.18:确保用户的微博在删除用户的同时也被删除
app/models/user.rb
class User < ActiveRecord::Base
has_many :microposts, dependent: :destroy .
.
.
end
dependent: :destroy
的作用是在用户被删除的时候,把这个用户发布的微博也删除。这么一来,如果管理员删除了用户,数据库中就不会出现无主的微博了。
我们可以为用户模型编写一个测试,证明代码清单 11.18 中的代码是正确的。我们要保存一个用户(因此得到了用户的 ID),再创建一个属于这个用户的微博,然后检查删除用户后微博的数量有没有减少一个,如代码清单 11.19 所示。(和代码清单 9.57 中“删除”链接的集成测试对比一下。)
代码清单 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: "[email protected]",
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 正确,测试组件就应该能通过:
代码清单 11.20:GREEN
$ bundle exec rake test