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 ```
';