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 中的方式构建被关注用户的模型——一个 following
表和 has_many
关联。由于 user.following
应该是一个用户对象组成的数组,所以 following
表中的每一行都应该是一个用户,通过 followed_id
列标识。然后再通过 follower_id
列建立关联。[2]除此之外,由于每一行都是一个用户,所以还要在表中加入用户的其他属性,例如名字、电子邮件地址和密码等。
图 12.6:用户关注的人(天真方式)
图 12.6 中的数据模型有个问题——存在非常多的冗余,每一行不仅包括了被关注用户的 ID,还包括了他们的其他信息,而这些信息在 users
表中都有。 更糟糕的是,为了保存关注我的人,还需要另一个同样冗余的 followers
表。这么做会导致数据模型极难维护:用户修改名字时,不仅要修改 users
表中的数据,还要修改 following
和 followers
表中包含这个用户的每一个记录。
造成这个问题的原因是缺少了一层抽象。找到合适的抽象有一种方法:思考在应用中如何实现关注用户的操作。7.1.2 节介绍过,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]
现在我们集中精力实现“主动关系”,即获取我关注的用户。12.1.5 节会实现“被动关系”。从图 12.6 中可以看出实现的方式:既然我关注的每一个用户都由 followed_id
独一无二的标识出来了,我们就可以把 following
表转化成 active_relationships
表,删掉用户的属性,然后使用 followed_id
从 users
表中获取我关注的用户的信息。这个数据模型如图 12.7 所示。
图 12.7:通过“主动关系”获取我关注的用户
因为“主动关系”和“被动关系”最终会存储在同一个表中,所以我们把这个表命名为“relationships”。这个表对应的模型是 Relationship
,如图 12.8 所示。从 12.1.4 节开始,我们会介绍如何使用这个模型同时实现“主动关系”和“被动关系”。
图 12.8:Relationship 数据模型
为此,我们要生成所需的模型:
$ rails generate model Relationship follower_id:integer followed_id:integer
因为我们会通过 follower_id
和 followed_id
查找关系,所以还要为这两个列建立索引,提高查询的效率,如代码清单 12.1 所示。
代码清单 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 中,我们还设置了一个“多键索引”,确保 (follower_id, followed_id
) 组合是唯一的,避免多次关注同一个用户。(可以和代码清单 6.28 中保持电子邮件地址唯一的索引比较一下。)从 12.1.4 节起会看到,用户界面不会允许这样的事发生,但添加索引后,如果用户试图创建重复的关系(例如使用 curl
这样的命令行工具),应用会抛出异常。
为了创建 relationships
表,和之前一样,我们要执行迁移:
$ bundle exec rake db:migrate
12.1.2 用户和“关系”模型之间的关联
在获取我关注的人和关注我的人之前,我们要先建立用户和“关系”模型之间的关联。一个用户有多个“关系”(has_many
), 因为一个“关系”涉及到两个用户,所以“关系”同时属于(belongs_to
)该用户和被关注的用户。
和 11.1.3 节创建时微博一样,我们要通过关联创建“关系”,如下面的代码所示:
user.active_relationships.build(followed_id: ...)
此时,你可能想在应用中加入类似于 11.1.3 节使用的代码。我们要添加的代码确实很像,但有两处不同。
首先,把用户和微博关联起来时我们写成:
class User < ActiveRecord::Base
has_many :microposts
.
.
.
end
之所以可以这么写,是因为 Rails 会寻找 :microposts
符号对应的模型,即 Micropost
。[4]可是现在模型名为 Relationship
,而我们想写成:
has_many :active_relationships
所以要告诉 Rails 模型的类名。
其次,前面在微博模型中是这么写的:
class Micropost < ActiveRecord::Base
belongs_to :user
.
.
.
end
之所以可以这么写,是因为 microposts
表中有识别用户的 user_id
列(11.1.1 节)。这种连接两个表的列,我们称之为“外键”(foreign key)。当指向用户模型的外键为 user_id
时,Rails 会自动获知关联,因为默认情况下,Rails 会寻找名为 <class>_id
的外键,其中 <class>
是模型类名的小写形式。[5]现在,尽管我们处理的还是用户,但识别用户使用的外键是 follower_id
,所以要告诉 Rails 这一变化。
综上所述,用户和“关系”模型之间的关联如代码清单 12.2 和代码清单 12.3 所示。
代码清单 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 节才会用到 followed
关联,但同时添加易于理解。
建立上述关联后,会得到一系列类似于表 11.1 中的方法,如表 12.1 所示。
表 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)和应用代码(代码清单 12.5)都非常直观。和生成的用户固件一样(代码清单 6.29),生成的“关系”固件也违背了迁移中的唯一性约束(代码清单 12.1)。这个问题的解决方法也和之前一样(代码清单 6.30)——删除自动生成的固件,如代码清单 12.6 所示。
代码清单 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 所示。默认情况下,在 has_many :through
关联中,Rails 会寻找关联名单数形式对应的外键。例如:
has_many :followeds, through: :active_relationships
Rails 发现关联名是“followeds”,把它变成单数形式“followed”,因此会在 relationships
表中获取一个由 followed_id
组成的集合。不过,12.1.1 节说过,写成 user.followeds
有点说不通,所以我们会使用 user.following
。Rails 允许定制默认生成的关联方法:使用 source
参数指定 following
数组由 followed_id
组成,如代码清单 12.8 所示。
代码清单 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 节)检查我关注的用户中有没有某个用户,或者通过关联查找一个用户:
user.following.include?(other_user)
user.following.find(other_user)
很多情况下我们都可以把 following
当成数组来用,Rails 会使用特定的方式处理 following
,所以这么做很高效。例如:
following.include?(other_user)
看起来好像是要把我关注的所有用户都从数据库中读取出来,然后再调用 include?
。其实不然,为了提高效率,Rails 会直接在数据库层执行相关的操作。(和 11.2.1 节使用 user.microposts.count
获取数量一样,都直接在数据库中操作。)
为了处理关注用户的操作,我们要定义两个辅助方法:follow
和 unfollow
。这样我们就可以写 user.follow(other_user)
。我们还要定义 following?
布尔值方法,检查一个用户是否关注了另一个用户。[6]
现在是编写测试的好时机,因为我们还要等很久才会开发关注用户的网页界面,如果一直没人监管,很难向前推进。我们可以为用户模型编写一个简短的测试,先调用 following?
方法确认某个用户没有关注另一个用户,然后调用 follow
方法关注这个用户,再使用 following?
方法确认关注成功了,最后调用 unfollow
方法取消关注,并确认操作成功,如代码清单 12.9 所示。
代码清单 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,我们要使用 following
关联定义 follow
、unfollow
和 following?
方法,如代码清单 12.10 所示。(注意,只要可能,我们就省略 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 中得知,获取关注我的人所需的数据都已经存在于 relationships
表中(我们要参照代码清单 12.2 中实现 active_relationships
表的方式)。其实我们要使用的方法和实现我关注的人一样,只要对调 follower_id
和 followed_id
的位置,并把 active_relationships
换成 passive_relationships
即可,如图 12.9 所示。
图 12.9:通过“被动关系”获取关注我的用户
参照代码清单 12.8,我们可以使用代码清单 12.12 中的代码实现图 12.9 中的模型。
代码清单 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 之所以保留了 source
参数,是为了和 has_many :following
关联的结构保持一致。
我们可以使用 followers.include?
测试这个数据模型,如代码清单 12.13 所示。(这段测试本可以使用与 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 的基础上增加了一行代码,但若想让这个测试通过,很多事情都要正确处理才行,所以足以测试代码清单 12.12 中的关联。
现在,整个测试组件都能通过:
$ bundle exec rake test