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]除此之外,由于每一行都是一个用户,所以还要在表中加入用户的其他属性,例如名字、电子邮件地址和密码等。

naive user has many following图 12.6:用户关注的人(天真方式)

图 12.6 中的数据模型有个问题——存在非常多的冗余,每一行不仅包括了被关注用户的 ID,还包括了他们的其他信息,而这些信息在 users 表中都有。 更糟糕的是,为了保存关注我的人,还需要另一个同样冗余的 followers 表。这么做会导致数据模型极难维护:用户修改名字时,不仅要修改 users 表中的数据,还要修改 followingfollowers 表中包含这个用户的每一个记录。

造成这个问题的原因是缺少了一层抽象。找到合适的抽象有一种方法:思考在应用中如何实现关注用户的操作。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_idusers 表中获取我关注的用户的信息。这个数据模型如图 12.7 所示。

user has many following 3rd edition图 12.7:通过“主动关系”获取我关注的用户

因为“主动关系”和“被动关系”最终会存储在同一个表中,所以我们把这个表命名为“relationships”。这个表对应的模型是 Relationship,如图 12.8 所示。从 12.1.4 节开始,我们会介绍如何使用这个模型同时实现“主动关系”和“被动关系”。

relationship model图 12.8:Relationship 数据模型

为此,我们要生成所需的模型:

$ rails generate model Relationship follower_id:integer followed_id:integer

因为我们会通过 follower_idfollowed_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 会寻找名为 &lt;class&gt;_id 的外键,其中 &lt;class&gt; 是模型类名的小写形式。[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 获取数量一样,都直接在数据库中操作。)

为了处理关注用户的操作,我们要定义两个辅助方法:followunfollow。这样我们就可以写 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 关联定义 followunfollowfollowing? 方法,如代码清单 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_idfollowed_id 的位置,并把 active_relationships 换成 passive_relationships 即可,如图 12.9 所示。

user has many followers 3rd edition图 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