11.1 微博模型

实现微博资源的第一步是创建微博数据模型,在模型中设定微博的基本特征。和 2.3 节创建的模型类似,我们要实现的微博模型要包含数据验证,以及和用户模型之间的关联。除此之外,我们还会做充分的测试,指定默认的排序方式,以及自动删除已注销用户的微博。

如果使用 Git 做版本控制的话,和之前一样,建议你新建一个主题分支:

$ git checkout master
$ git checkout -b user-microposts

11.1.1 基本模型

微博模型只需要两个属性:一个是 content,用来保存微博的内容;另一个是 user_id,把微博和用户关联起来。微博模型的结构如图 11.1 所示。

micropost model 3rd edition图 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.timestamps6.1.1 节说过,这行代码的作用是添加 created_atupdated_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_idcreated_at 列创建了索引(参见旁注 6.2):

add_index :microposts, [:user_id, :created_at]

我们把 user_idcreated_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 所示。在实现这种关联的过程中,我们会为微博模型和用户模型编写一些测试。

micropost belongs to user图 11.2:微博和所属用户之间的 belongs_to(属于)关系user has many microposts图 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,即“匿名函数”(没有名字的函数)。-&gt; 接受一个代码块(4.3.2 节),返回一个 Proc。然后在这个 Proc 上调用 call 方法执行其中的代码。我们可以在控制台中看一下怎么使用 Proc:

>> -> { puts "foo" }
=> #<Proc:0x007fab938d0108@(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