12.1 “关系”模型
最后更新于:2022-04-01 22:30:36
# 12.1 “关系”模型
为了实现用户关注功能,首先要创建一个看上去并不是那么直观的数据模型。一开始我们可能会认为 `has_many` 关联能满足我们的要求:一个用户关注多个用户,而且也被多个用户关注。但实际上这种实现方式有问题,下面我们会学习如何使用 `has_many :through` 解决。
和之前一样,如果使用 Git,现在应该新建一个主题分支:
```
$ git checkout master
$ git checkout -b following-users
```
## 12.1.1 数据模型带来的问题以及解决方法
在构建关注用户所需的数据模型之前,我们先来分析一个典型的案例。假如一个用户关注了另外一个用户,比如 Calvin 关注了 Hobbes,也就是 Hobbes 被 Calvin 关注了,那么 Calvin 就是“关注人”(follower),Hobbes 则是“被关注人”(followed)。按照 Rails 默认的复数命名习惯, 我们称关注了某个用户的所有用户为这个用户的“followers”,因此,`hobbes.followers` 是一个数组,包含所有关注了 Hobbes 的用户。不过,如果顺序颠倒,这种表述就说不通了:默认情况下,所有被关注的用户应该叫“followeds”,但是这样说并不符合英语语法。所以,参照 Twitter 的叫法,我们把被关注的用户叫做“following”(例如,“50 following, 75 followers”)。因此,Calvin 关注的人可以通过 `calvin.following` 数组获取。
经过上述讨论,我们可以按照[图 12.6](#fig-naive-user-has-many-following) 中的方式构建被关注用户的模型——一个 `following` 表和 `has_many` 关联。由于 `user.following` 应该是一个用户对象组成的数组,所以 `following` 表中的每一行都应该是一个用户,通过 `followed_id` 列标识。然后再通过 `follower_id` 列建立关联。[[2](#fn-2)]除此之外,由于每一行都是一个用户,所以还要在表中加入用户的其他属性,例如名字、电子邮件地址和密码等。
![naive user has many following](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733307a1d245.png)图 12.6:用户关注的人(天真方式)
[图 12.6](#fig-naive-user-has-many-following) 中的数据模型有个问题——存在非常多的冗余,每一行不仅包括了被关注用户的 ID,还包括了他们的其他信息,而这些信息在 `users` 表中都有。 更糟糕的是,为了保存关注我的人,还需要另一个同样冗余的 `followers` 表。这么做会导致数据模型极难维护:用户修改名字时,不仅要修改 `users` 表中的数据,还要修改 `following` 和 `followers` 表中包含这个用户的每一个记录。
造成这个问题的原因是缺少了一层抽象。找到合适的抽象有一种方法:思考在应用中如何实现关注用户的操作。[7.1.2 节](chapter7.html#a-users-resource)介绍过,REST 架构涉及到资源的创建和销毁两个操作。 由此引出了两个问题:用户关注另一个用户时,创建了什么?用户取消关注另一个用户时,销毁了什么?按照这样的方式思考,我们会发现,在关注用户的过程中,创建和销毁的是两个用户之间的“关系”。因此,一个用户有多个“关系”,从而通过这个“关系”得到很多我关注的人(`following`)和关注我的人(`followers`)。
在实现应用的数据模型时还有一个细节要注意:Facebook 实现的关系是对称的,A 关注 B 时,B 也就关注了 A;而我们要实现的关系和 Twitter 类似,是不对称的,Calvin 可以关注 Hobbes,但 Hobbes 并不需要关注 Calvin。为了区分这两种情况,我们要使用专业的术语:如果 Calvin 关注了 Hobbes,但 Hobbes 没有关注 Calvin,那么 Calvin 和 Hobbes 之间建立的是“主动关系”(Active Relationship),而 Hobbes 和 Calvin 之间是“被动关系”(Positive Relationship)。[[3](#fn-3)]
现在我们集中精力实现“主动关系”,即获取我关注的用户。[12.1.5 节](#followers)会实现“被动关系”。从[图 12.6](#fig-naive-user-has-many-following) 中可以看出实现的方式:既然我关注的每一个用户都由 `followed_id` 独一无二的标识出来了,我们就可以把 `following` 表转化成 `active_relationships` 表,删掉用户的属性,然后使用 `followed_id` 从 `users` 表中获取我关注的用户的信息。这个数据模型如[图 12.7](#fig-user-has-many-following) 所示。
![user has many following 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733307a32226.png)图 12.7:通过“主动关系”获取我关注的用户
因为“主动关系”和“被动关系”最终会存储在同一个表中,所以我们把这个表命名为“relationships”。这个表对应的模型是 `Relationship`,如[图 12.8](#fig-relationship-model) 所示。从 [12.1.4 节](#followed-users)开始,我们会介绍如何使用这个模型同时实现“主动关系”和“被动关系”。
![relationship model](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733307a4e1bf.png)图 12.8:Relationship 数据模型
为此,我们要生成所需的模型:
```
$ rails generate model Relationship follower_id:integer followed_id:integer
```
因为我们会通过 `follower_id` 和 `followed_id` 查找关系,所以还要为这两个列建立索引,提高查询的效率,如[代码清单 12.1](#listing-relationships-migration) 所示。
##### 代码清单 12.1:在 `relationships` 表中添加索引
db/migrate/[timestamp]_create_relationships.rb
```
class CreateRelationships < ActiveRecord::Migration
def change
create_table :relationships do |t|
t.integer :follower_id
t.integer :followed_id
t.timestamps null: false
end
add_index :relationships, :follower_id add_index :relationships, :followed_id add_index :relationships, [:follower_id, :followed_id], unique: true end
end
```
在[代码清单 12.1](#listing-relationships-migration) 中,我们还设置了一个“多键索引”,确保 (`follower_id, followed_id`) 组合是唯一的,避免多次关注同一个用户。(可以和[代码清单 6.28](chapter6.html#listing-email-uniqueness-index) 中保持电子邮件地址唯一的索引比较一下。)从 [12.1.4 节](#followed-users)起会看到,用户界面不会允许这样的事发生,但添加索引后,如果用户试图创建重复的关系(例如使用 `curl` 这样的命令行工具),应用会抛出异常。
为了创建 `relationships` 表,和之前一样,我们要执行迁移:
```
$ bundle exec rake db:migrate
```
## 12.1.2 用户和“关系”模型之间的关联
在获取我关注的人和关注我的人之前,我们要先建立用户和“关系”模型之间的关联。一个用户有多个“关系”(`has_many`), 因为一个“关系”涉及到两个用户,所以“关系”同时属于(`belongs_to`)该用户和被关注的用户。
和 [11.1.3 节](chapter11.html#user-micropost-associations)创建时微博一样,我们要通过关联创建“关系”,如下面的代码所示:
```
user.active_relationships.build(followed_id: ...)
```
此时,你可能想在应用中加入类似于 [11.1.3 节](chapter11.html#user-micropost-associations)使用的代码。我们要添加的代码确实很像,但有两处不同。
首先,把用户和微博关联起来时我们写成:
```
class User < ActiveRecord::Base
has_many :microposts
.
.
.
end
```
之所以可以这么写,是因为 Rails 会寻找 `:microposts` 符号对应的模型,即 `Micropost`。[[4](#fn-4)]可是现在模型名为 `Relationship`,而我们想写成:
```
has_many :active_relationships
```
所以要告诉 Rails 模型的类名。
其次,前面在微博模型中是这么写的:
```
class Micropost < ActiveRecord::Base
belongs_to :user
.
.
.
end
```
之所以可以这么写,是因为 `microposts` 表中有识别用户的 `user_id` 列([11.1.1 节](chapter11.html#the-basic-model))。这种连接两个表的列,我们称之为“外键”(foreign key)。当指向用户模型的外键为 `user_id` 时,Rails 会自动获知关联,因为默认情况下,Rails 会寻找名为 `<class>_id` 的外键,其中 `<class>` 是模型类名的小写形式。[[5](#fn-5)]现在,尽管我们处理的还是用户,但识别用户使用的外键是 `follower_id`,所以要告诉 Rails 这一变化。
综上所述,用户和“关系”模型之间的关联如[代码清单 12.2](#listing-user-relationships-association) 和[代码清单 12.3](#listing-relationship-belongs-to) 所示。
##### 代码清单 12.2:实现“主动关系”中的 `has_many` 关联
app/models/user.rb
```
class User < ActiveRecord::Base
has_many :microposts, dependent: :destroy
has_many :active_relationships, class_name: "Relationship", foreign_key: "follower_id", dependent: :destroy .
.
.
end
```
(因为删除用户时也要删除涉及这个用户的“关系”,所以我们在关联中加入了 `dependent: :destroy`。)
##### 代码清单 12.3:在“关系”模型中添加 `belongs_to` 关联
app/models/relationship.rb
```
class Relationship < ActiveRecord::Base
belongs_to :follower, class_name: "User" belongs_to :followed, class_name: "User" end
```
尽管 [12.1.5 节](#followers)才会用到 `followed` 关联,但同时添加易于理解。
建立上述关联后,会得到一系列类似于[表 11.1](chapter11.html#table-association-methods) 中的方法,如[表 12.1](#table-association-methods-relationships) 所示。
表 12.1:用户和“主动关系”关联后得到的方法简介
| 方法 | 作用 |
| --- | --- |
| `active_relationship.follower` | 获取关注我的用户 |
| `active_relationship.followed` | 获取我关注的用户 |
| `user.active_relationships.create(followed_id: other_user.id)` | 创建 `user` 发起的“主动关系” |
| `user.active_relationships.create!(followed_id: other_user.id)` | 创建 `user` 发起的“主动关系”(失败时抛出异常) |
| `user.active_relationships.build(followed_id: other_user.id)` | 构建 `user` 发起的“主动关系”对象 |
## 12.1.3 数据验证
在继续之前,我们要在“关系”模型中添加一些验证。测试([代码清单 12.4](#listing-relationship-validation-tests))和应用代码([代码清单 12.5](#listing-relationship-validations))都非常直观。和生成的用户固件一样([代码清单 6.29](chapter6.html#listing-default-fixtures)),生成的“关系”固件也违背了迁移中的唯一性约束([代码清单 12.1](#listing-relationships-migration))。这个问题的解决方法也和之前一样([代码清单 6.30](chapter6.html#listing-empty-fixtures))——删除自动生成的固件,如[代码清单 12.6](#listing-empty-relationship-fixture) 所示。
##### 代码清单 12.4:测试“关系”模型中的验证
test/models/relationship_test.rb
```
require 'test_helper'
class RelationshipTest < ActiveSupport::TestCase
def setup
@relationship = Relationship.new(follower_id: 1, followed_id: 2)
end
test "should be valid" do
assert @relationship.valid?
end
test "should require a follower_id" do
@relationship.follower_id = nil
assert_not @relationship.valid?
end
test "should require a followed_id" do
@relationship.followed_id = nil
assert_not @relationship.valid?
end
end
```
##### 代码清单 12.5:在“关系”模型中添加验证
app/models/relationship.rb
```
class Relationship < ActiveRecord::Base
belongs_to :follower, class_name: "User"
belongs_to :followed, class_name: "User"
validates :follower_id, presence: true validates :followed_id, presence: true end
```
##### 代码清单 12.6:删除“关系”固件
test/fixtures/relationships.yml
```
# empty
```
现在,测试应该可以通过:
##### 代码清单 12.7:**GREEN**
```
$ bundle exec rake test
```
## 12.1.4 我关注的用户
现在到“关系”的核心部分了——获取我关注的用户(`following`)和关注我的用户(`followers`)。这里我们要首次用到 `has_many :through` 关联:用户通过“关系”模型关注了多个用户,如[图 12.7](#fig-user-has-many-following) 所示。默认情况下,在 `has_many :through` 关联中,Rails 会寻找关联名单数形式对应的外键。例如:
```
has_many :followeds, through: :active_relationships
```
Rails 发现关联名是“followeds”,把它变成单数形式“followed”,因此会在 `relationships` 表中获取一个由 `followed_id` 组成的集合。不过,[12.1.1 节](#a-problem-with-the-data-model-and-a-solution)说过,写成 `user.followeds` 有点说不通,所以我们会使用 `user.following`。Rails 允许定制默认生成的关联方法:使用 `source` 参数指定 `following` 数组由 `followed_id` 组成,如[代码清单 12.8](#listing-has-many-following-through-active-relationships) 所示。
##### 代码清单 12.8:在用户模型中添加 `following` 关联
app/models/user.rb
```
class User < ActiveRecord::Base
has_many :microposts, dependent: :destroy
has_many :active_relationships, class_name: "Relationship",
foreign_key: "follower_id",
dependent: :destroy
has_many :following, through: :active_relationships, source: :followed .
.
.
end
```
定义这个关联后,我们可以充分利用 Active Record 和数组的功能。例如,可以使用 `include?` 方法([4.3.1 节](chapter4.html#arrays-and-ranges))检查我关注的用户中有没有某个用户,或者通过关联查找一个用户:
```
user.following.include?(other_user)
user.following.find(other_user)
```
很多情况下我们都可以把 `following` 当成数组来用,Rails 会使用特定的方式处理 `following`,所以这么做很高效。例如:
```
following.include?(other_user)
```
看起来好像是要把我关注的所有用户都从数据库中读取出来,然后再调用 `include?`。其实不然,为了提高效率,Rails 会直接在数据库层执行相关的操作。(和 [11.2.1 节](chapter11.html#rendering-microposts)使用 `user.microposts.count` 获取数量一样,都直接在数据库中操作。)
为了处理关注用户的操作,我们要定义两个辅助方法:`follow` 和 `unfollow`。这样我们就可以写 `user.follow(other_user)`。我们还要定义 `following?` 布尔值方法,检查一个用户是否关注了另一个用户。[[6](#fn-6)]
现在是编写测试的好时机,因为我们还要等很久才会开发关注用户的网页界面,如果一直没人监管,很难向前推进。我们可以为用户模型编写一个简短的测试,先调用 `following?` 方法确认某个用户没有关注另一个用户,然后调用 `follow` 方法关注这个用户,再使用 `following?` 方法确认关注成功了,最后调用 `unfollow` 方法取消关注,并确认操作成功,如[代码清单 12.9](#listing-utility-method-tests) 所示。
##### 代码清单 12.9:测试关注用户相关的几个辅助方法 RED
test/models/user_test.rb
```
require 'test_helper'
class UserTest < ActiveSupport::TestCase
.
.
.
test "should follow and unfollow a user" do
michael = users(:michael)
archer = users(:archer)
assert_not michael.following?(archer) michael.follow(archer) assert michael.following?(archer) michael.unfollow(archer) assert_not michael.following?(archer) end
end
```
参照[表 12.1](#table-association-methods-relationships),我们要使用 `following` 关联定义 `follow`、`unfollow` 和 `following?` 方法,如[代码清单 12.10](#listing-follow-unfollow-following) 所示。(注意,只要可能,我们就省略 `self`。)
##### 代码清单 12.10:定义关注用户相关的几个辅助方法 GREEN
app/models/user.rb
```
class User < ActiveRecord::Base
.
.
.
def feed
.
.
.
end
# 关注另一个用户
def follow(other_user)
active_relationships.create(followed_id: other_user.id) end
# 取消关注另一个用户
def unfollow(other_user)
active_relationships.find_by(followed_id: other_user.id).destroy end
# 如果当前用户关注了指定的用户,返回 true
def following?(other_user)
following.include?(other_user) end
private
.
.
.
end
```
现在,测试能通过了:
##### 代码清单 12.11:**GREEN**
```
$ bundle exec rake test
```
## 12.1.5 关注我的人
“关系”的最后一部分是定义与 `user.following` 对应的 `user.followers` 方法。从[图 12.7](#fig-user-has-many-following) 中得知,获取关注我的人所需的数据都已经存在于 `relationships` 表中(我们要参照[代码清单 12.2](#listing-user-relationships-association) 中实现 `active_relationships` 表的方式)。其实我们要使用的方法和实现我关注的人一样,只要对调 `follower_id` 和 `followed_id` 的位置,并把 `active_relationships` 换成 `passive_relationships` 即可,如[图 12.9](#fig-user-has-many-followers) 所示。
![user has many followers 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733307a6029b.png)图 12.9:通过“被动关系”获取关注我的用户
参照[代码清单 12.8](#listing-has-many-following-through-active-relationships),我们可以使用[代码清单 12.12](#listing-has-many-following-through-passive-relationships) 中的代码实现[图 12.9](#fig-user-has-many-followers) 中的模型。
##### 代码清单 12.12:使用“被动关系”实现 `user.followers`
app/models/user.rb
```
class User < ActiveRecord::Base
has_many :microposts, dependent: :destroy
has_many :active_relationships, class_name: "Relationship",
foreign_key: "follower_id",
dependent: :destroy
has_many :passive_relationships, class_name: "Relationship", foreign_key: "followed_id", dependent: :destroy has_many :following, through: :active_relationships, source: :followed
has_many :followers, through: :passive_relationships, source: :follower .
.
.
end
```
值得注意的是,其实我们可以省略 `followers` 关联中的 `source` 参数,直接写成:
```
has_many :followers, through: :passive_relationships
```
因为 Rails 会把“followers”转换成单数“follower”,然后查找名为 `follower_id` 的外键。[代码清单 12.12](#listing-has-many-following-through-passive-relationships) 之所以保留了 `source` 参数,是为了和 `has_many :following` 关联的结构保持一致。
我们可以使用 `followers.include?` 测试这个数据模型,如[代码清单 12.13](#listing-followers-test) 所示。(这段测试本可以使用与 `following?` 方法对应的 `followed_by?` 方法,但应用中用不到,所以没这么做。)
##### 代码清单 12.13:测试 `followers` 关联 GREEN
test/models/user_test.rb
```
require 'test_helper'
class UserTest < ActiveSupport::TestCase
.
.
.
test "should follow and unfollow a user" do
michael = users(:michael)
archer = users(:archer)
assert_not michael.following?(archer)
michael.follow(archer)
assert michael.following?(archer)
assert archer.followers.include?(michael) michael.unfollow(archer)
assert_not michael.following?(archer)
end
end
```
我们只在[代码清单 12.9](#listing-utility-method-tests) 的基础上增加了一行代码,但若想让这个测试通过,很多事情都要正确处理才行,所以足以测试[代码清单 12.12](#listing-has-many-following-through-passive-relationships) 中的关联。
现在,整个测试组件都能通过:
```
$ bundle exec rake test
```
';