在线版的内容可能落后于电子书,如果想及时获得更新,请购买电子书

第 14 章 关注用户

这一章,我们要为演示应用添加社交功能,允许用户关注(及取消关注)其他人,并在主页显示被关注用户发布的微博(动态流)。我们将在 14.1 节学习如何建立用户之间的关系,然后在 14.2 节编写相应的 Web 界面(还会介绍 Ajax)。最后,在 14.3 节实现功能完善的动态流。

这是本书最后一章,有些内容具有挑战性。比如说,为了实现动态流,我们会使用一些 Ruby 和 SQL 技巧。通过这些示例,你将了解到 Rails 是如何处理更加复杂的数据模型的,这些知识也会在你日后开发其他应用时发挥作用。 为了帮助你平稳地从学习过渡到独立开发,14.4 节会列出一些进阶学习资源。

因为本章的内容比较有挑战性,所以在开始编写代码之前,我们先来讨论一下界面。和之前的章节一样,在开发之前,我们将使用构思图。[1]完整的页面流程是这样的:一个用户 (John Calvin) 从他的资料页面(图 14.1)浏览到用户列表页面(图 14.2),寻找想关注的用户;然后他打开另一个用户 Thomas Hobbes 的资料页面(图 14.3),点击“Follow”(关注)按钮关注了他,这时“Follow”按钮会变为“Unfollow”(取消关注),而且关注 Hobbes 的人数增加了一个(图 14.4);接着,Calvin 回到主页,看到他关注的人数也增加了一个,而且在动态流中能看到 Hobbes 发布的微博(图 14.5)。本章接下来的内容就是要实现这样的页面流程。

page flow profile mockup 3rd edition
图 14.1:当前用户的资料页面
page flow user index mockup bootstrap
图 14.2:找一个想关注的用户
page flow other profile follow button mockup 3rd edition
图 14.3:想关注的那个用户的资料页面,有一个“Follow”(关注)按钮
page flow other profile unfollow button mockup 3rd edition
图 14.4:资料页面中显示了“Unfollow”(取消关注)按钮,而且关注他的人数增加了一个
page flow home page feed mockup 3rd edition
图 14.5:首页,显示了动态流,而且关注的人数增加了一个

14.1 Relationship 模型

为了实现用户关注功能,首先要创建一个看上去并不是那么直观的数据模型。一开始我们可能以为 has_many 关联能满足我们的要求:一个用户关注多个用户,而且也被多个用户关注。但实际上这种实现方式有问题,下面我们将学习如何使用 has_many :through 解决。

和之前一样,如果使用 Git,现在应该新建一个主题分支:

$ git checkout -b following-users

14.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 数组获取。

经过上述讨论,我们可以按照图 14.6 中的方式构建被关注用户的模型——一个 following 表和 has_many 关联。由于 user.following 应该是一个用户对象组成的数组,所以 following 表中的每一行都应该是一个用户,通过 followed_id 列标识,然后再通过 follower_id 列建立关联。[2]除此之外,由于每一行都是一个用户,所以还要在表中加入用户的其他属性,例如名字、电子邮件地址和密码等。

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

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

造成这个问题的原因是缺少底层抽象。找到合适的抽象有一种方法:思考在 Web 应用中如何实现关注用户的操作。7.1.2 节介绍过,REST 架构涉及到资源的创建和销毁两个操作。由此引出了两个问题:用户关注另一个用户时,创建的是什么?用户取消关注另一个用户时,销毁的是什么?按照这样的方式思考,我们会发现,在关注用户的过程中,创建和销毁的是两个用户之间的“关系”。因此,一个用户有多个“关系”,从而通过这个“关系”得到很多我关注的人(following)和关注我的人(followers)。

在实现应用的数据模型时还有一个细节要注意:Facebook 实现的关系是对称的(至少在数据模型层是),而我们要实现的关系和 Twitter 类似,是不对称的,Calvin 可以关注 Hobbes,但 Hobbes 并不需要关注 Calvin。为了区分这两种情况,我们要使用专业的术语:如果 Calvin 关注了 Hobbes,但 Hobbes 没有关注 Calvin,那么 Calvin 和 Hobbes 之间建立的是主动关系(active relationship),而 Hobbes 和 Calvin 之间是被动关系(passive relationship)。[3]

现在我们集中精力实现主动关系,即获取我关注的用户。14.1.5 节再实现被动关系。从图 14.6 中可以看出实现的方式:既然我关注的每一个用户都由 followed_id 独一无二地标识出来了,我们就可以把 following 表转化成 active_relationships 表,删掉用户的属性,然后使用 followed_idusers 表中检索我关注的用户的信息。这个数据模型如图 14.7 所示。

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

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

relationship model
图 14.8Relationship 数据模型

为此,我们要生成一个迁移,对应于图 14.8 中的模型:

$ rails generate model Relationship follower_id:integer followed_id:integer

因为我们将通过 follower_idfollowed_id 查找关系,所以还要为这两个列建立索引,提高查询的效率,如代码清单 14.1 所示。

代码清单 14.1:在 relationships 表中添加索引
db/migrate/[timestamp]_create_relationships.rb
class CreateRelationships < ActiveRecord::Migration[5.0]
  def change
    create_table :relationships do |t|
      t.integer :follower_id
      t.integer :followed_id

      t.timestamps
    end
    add_index :relationships, :follower_id
    add_index :relationships, :followed_id
    add_index :relationships, [:follower_id, :followed_id], unique: true
  end
end

代码清单 14.1 中,我们还设置了一个多键索引,确保 (follower_id, followed_id) 组合是唯一的,避免多次关注同一个用户。(可以和代码清单 6.29 中保持电子邮件地址唯一的索引,以及代码清单 13.3 中的多键索引比较一下。)从 14.1.4 节起会看到,用户界面不会允许这样的事发生,但添加索引后,如果用户试图创建重复的关系(例如使用 curl 这样的命令行工具),应用会抛出异常。

为了创建 relationships 表,和之前一样,我们要迁移数据库:

$ rails db:migrate
练习
  1. 图 14.7 中 ID 为 1 的用户来说,user.following.map(&:id) 的值是什么?(4.3.2 节 介绍过 map(&:method_name) 这种句法;user.following.map(&:id) 返回的是 ID 组成的数组。)

  2. 查看图 14.7,对 ID 为 2 的用户来说,user.following 的值是什么?user.following.map(&:id) 呢?

14.1.2 User 模型和 Relationship 模型之间的关联

在获取我关注的人和关注我的人之前,我们要先建立 User 模型和 Relationship 模型之间的关联。一个用户有多个“关系”(has_many),因为一个“关系”涉及到两个用户,所以“关系”同时属于(belongs_to)该用户和被关注的用户。

13.1.3 节创建微博的方式一样,我们要通过关联创建“关系”,如下面的代码所示:

user.active_relationships.build(followed_id: ...)

此时,你可能想在应用中加入类似于 13.1.3 节使用的代码。我们要添加的代码确实很像,但有两处不同。

首先,把用户和微博关联起来时我们是这么写的:

class User < ApplicationRecord
  has_many :microposts
  .
  .
  .
end

之所以可以这么写,是因为 Rails 会寻找 :microposts 符号对应的模型,即 Micropost[4]可是现在这个模型名为 Relationship,而我们想写成:

has_many :active_relationships

所以要告诉 Rails 模型的类名。

其次,前面在 Micropost 模型中是这么写的:

class Micropost < ApplicationRecord
  belongs_to :user
  .
  .
  .
end

之所以可以这么写,是因为 microposts 表中有识别用户的 user_id 列(13.1.1 节)。这种连接两个表的列,我们称之为外键(foreign key)。当指向 User 模型的外键为 user_id 时,Rails 会自动获知关联,因为默认情况下,Rails 会寻找名为 <class>_id 的外键,其中 <class> 是模型类名的小写形式。[5]现在,尽管我们处理的还是用户,但识别用户使用的外键是 follower_id,所以要告诉 Rails 这一变化。

综上所述,UserRelationship 模型之间的关联如代码清单 14.2代码清单 14.3 所示。

代码清单 14.2:实现主动关系中的 has_many 关联
app/models/user.rb
class User < ApplicationRecord
  has_many :microposts, dependent: :destroy
  has_many :active_relationships, class_name:  "Relationship",
                                  foreign_key: "follower_id",
                                  dependent:   :destroy
  .
  .
  .
end

(因为删除用户时也要删除涉及这个用户的“关系”,所以我们在关联中加入了 dependent: :destroy。)

代码清单 14.3:在 Relationship 模型中添加 belongs_to 关联
app/models/relationship.rb
class Relationship < ApplicationRecord
  belongs_to :follower, class_name: "User"
  belongs_to :followed, class_name: "User"
end

尽管 14.1.5 节才会用到 followed 关联,但同时添加易于理解。

建立上述关联后,会得到一系列类似于表 13.1 中的方法,如表 14.1 所示。

表 14.1User 模型与 Relationship 模型建立主动关系之后得到的方法简介
方法 作用

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 发起的主动关系对象

练习
  1. 打开 Rails 控制台,使用表 14.1 中的 create 方法为数据库中的第一个用户和第二个用户建立主动关系。

  2. 确认 active_relationship.followedactive_relationship.follower 返回的值是正确的。

14.1.3 数据验证

在继续之前,我们要在 Relationship 模型中添加一些验证。测试(代码清单 14.4)和应用代码(代码清单 14.5)都非常直观。与生成的用户固件一样(代码清单 6.30),生成的“关系”固件也违背了迁移中的唯一性约束(代码清单 14.1)。这个问题的解决方法也和之前一样(代码清单 6.31)——删除自动生成的固件,如代码清单 14.6 所示。

代码清单 14.4:测试 Relationship 模型中的验证
test/models/relationship_test.rb
require 'test_helper'

class RelationshipTest < ActiveSupport::TestCase

  def setup
    @relationship = Relationship.new(follower_id: users(:michael).id,
                                     followed_id: users(:archer).id)
  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
代码清单 14.5:在 Relationship 模型中添加验证
app/models/relationship.rb
class Relationship < ApplicationRecord
  belongs_to :follower, class_name: "User"
  belongs_to :followed, class_name: "User"
  validates :follower_id, presence: true
  validates :followed_id, presence: true
end
代码清单 14.6:删除“关系”固件
test/fixtures/relationships.yml
# empty

现在,测试应该可以通过:

代码清单 14.7GREEN
$ rails test
练习
  1. 代码清单 14.5 中的验证注释掉,确认测试仍能通过。(这是 Rails 5 的变化,在之前的 Rails 版本中,必须添加那两个验证。为了明确表明意图,我们会留着验证。不过你要知道这一点,以防其他人编写的代码中没有这两个验证。)

14.1.4 我关注的用户

现在到“关系”的核心部分了——获取我关注的用户(following)和关注我的用户(followers)。这里我们要首次用到 has_many :through 关联:用户通过 Relationship 模型关注多个用户,如图 14.7 所示。默认情况下,在 has_many :through 关联中,Rails 会寻找关联名单数形式对应的外键。例如:

has_many :followeds, through: :active_relationships

Rails 发现关联名是“followeds”,先把它变成单数形式“followed”,然后在 relationships 表中获取一个由 followed_id 组成的集合。不过,14.1.1 节说过,写成 user.followeds 有点说不通,所以我们会使用 user.following。Rails 允许定制默认生成的关联方法:使用 source 参数指定 following 数组由 followed_id 组成,如代码清单 14.8 所示。

代码清单 14.8:在 User 模型中添加 following 关联
app/models/user.rb
class User < ApplicationRecord
  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)

还可以像数组那样添加和删除元素:

user.following << other_user
user.following.delete(other_user)

4.3.1 节说过,<< 运算符把元素添加到数组末尾。)

很多情况下都可以把 following 当成数组来用,Rails 会使用特定的方式处理 following,所以这么做很高效。例如:

following.include?(other_user)

这看起来好像是要把我关注的所有用户都从数据库中读取出来,然后再调用 include?。其实不然,为了提高效率,Rails 会直接在数据库层执行相关的操作。(和 13.2.1 节使用 user.microposts.count 获取数量一样,都直接在数据库中操作。)

为了处理关注用户的操作,我们要定义两个辅助方法:followunfollow。这样我们就可以写 user.follow(other_user)。我们还要定义 following? 布尔值方法,检查一个用户是否关注了另一个用户。[6]

现在是编写测试的好时机,因为我们还要等很久才会开发关注用户的网页界面,如果一直没人监管,很难向前推进。我们可以为 User 模型编写一个简短的测试,先调用 following? 方法确认某个用户没有关注另一个用户,然后调用 follow 方法关注那个用户,再使用 following? 方法确认关注成功了,最后调用 unfollow 方法取消关注,并确认操作成功,如代码清单 14.9 所示。

代码清单 14.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

following 关联视作对象,可以像代码清单 14.10 那样定义 followunfollowfollowing? 三个方法。(注意,只要可能,我们就省略 self。)

代码清单 14.10:定义关注用户相关的几个辅助方法 GREEN
app/models/user.rb
class User < ApplicationRecord
  .
  .
  .
  def feed
    .
    .
    .
  end

  # 关注另一个用户
  def follow(other_user)
    following << other_user
  end

  # 取消关注另一个用户
  def unfollow(other_user)
    following.delete(other_user)
  end

  # 如果当前用户关注了指定的用户,返回 true
  def following?(other_user)
    following.include?(other_user)
  end

  private
  .
  .
  .
end

现在,测试能通过了:

代码清单 14.11GREEN
$ rails test
练习
  1. 在 Rails 控制台中重现代码清单 14.9 中的步骤。

  2. 前一题中各个操作对应的 SQL 语句是什么?

14.1.5 关注我的人

“关系”的最后一部分是定义与 user.following 对应的 user.followers 方法。从图 14.7 中得知,获取关注我的人所需的数据都已经存在于 relationships 表中(我们要参照代码清单 14.2 中实现 active_relationships 表的方式)。其实我们要使用的方法和实现我关注的人一样,只要对调 follower_idfollowed_id 的位置,并把 active_relationships 换成 passive_relationships 即可,如图 14.9 所示。

user has many followers 3rd edition
图 14.9:通过被动关系获取关注我的用户

参照代码清单 14.8,我们可以使用代码清单 14.12 中的代码实现图 14.9 中的模型。

代码清单 14.12:使用被动关系实现 user.followers
app/models/user.rb
class User < ApplicationRecord
  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 的外键。代码清单 14.12 之所以保留了 source 参数,是为了和 has_many :following 关联的结构保持一致。

我们可以使用 followers.include? 测试这个数据模型,如代码清单 14.13 所示。(这段测试本可以使用与 following? 方法对应的 followed_by? 方法,但应用中用不到,所以我们没这么做。)

代码清单 14.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

我们只在代码清单 14.9 的基础上增加了一行代码,但若想让这个测试通过,很多事情都要正确处理才行,这足以测试代码清单 14.12 中的关联。

现在,整个测试组件都能通过:

$ rails test
练习
  1. 在 Rails 控制台中为数据库中的第一个用户(赋值给 user 变量)添加几个关注者,user.followers.map(:id) 的值是什么?

  2. 确认 user.followers.count 的值与你在前一题中添加的关注者数量一样。

  3. user.followers.count 对应的 SQL 语句是什么?与 user.followers.to_a.count 有什么区别?提示:假设这个用户有一百万个关注者。

14.2 关注用户的网页界面

14.1 节用到了很多数据模型技术,可能要花些时间才能完全理解。其实,理解这些关联最好的方式是在网页界面中使用。

在本章的导言中,我们介绍了关注用户的操作流程。本节,我们要实现这些构思的页面,以及关注和取消关注功能。我们还会创建两个页面,分别列出我关注的用户和关注我的用户。在 14.3 节,我们会实现用户的动态流,届时,这个演示应用才算完成。

14.2.1 示例数据

和之前的几章一样,我们要使用 rails db:seed 命令把“关系”相关的种子数据加载到数据库中。有了示例数据,我们就可以先实现网页界面,本节末尾再实现后端功能。

“关系”相关的种子数据如代码清单 14.14 所示。我们让第一个用户关注第 3-51 个用户,让第 4-41 个用户关注第一个用户。这样的数据足够用来开发应用的界面了。

代码清单 14.14:在种子数据中添加“关系”相关的数据
db/seeds.rb
# Users
User.create!(name:  "Example User",
             email: "example@railstutorial.org",
             password:              "foobar",
             password_confirmation: "foobar",
             admin:     true,
             activated: true,
             activated_at: Time.zone.now)

99.times do |n|
  name  = Faker::Name.name
  email = "example-#{n+1}@railstutorial.org"
  password = "password"
  User.create!(name: name,
              email: email,
              password:              password,
              password_confirmation: password,
              activated: true,
              activated_at: Time.zone.now)
end

# Microposts
users = User.order(:created_at).take(6)
50.times do
  content = Faker::Lorem.sentence(5)
  users.each { |user| user.microposts.create!(content: content) }
end

# Following relationships
users = User.all
user  = users.first
following = users[2..50]
followers = users[3..40]
following.each { |followed| user.follow(followed) }
followers.each { |follower| follower.follow(user) }

然后像之前一样,执行下面的命令,重置数据库之后重新加载种子数据:

$ rails db:migrate:reset
$ rails db:seed
练习
  1. 在 Rails 控制台中确认 User.first.followers.count 的值与代码清单 14.14 设定的一样。

  2. 确认 User.first.following.count 的值也正确。

14.2.2 数量统计和关注表单

现在示例用户已经关注了其他用户,也被其他用户关注了,我们要更新一下用户资料页面和首页,把这些变动显示出来。首先,我们要创建一个局部视图,在资料页面和首页显示我关注的人和关注我的人的数量。然后再添加关注和取消关注表单,并且在专门的页面中列出我关注的用户和关注我的用户。

14.1.1 节说过,我们参照了 Twitter 的叫法,在我关注的用户数量后使用“following”作标注,例如“50 following”。图 14.1 中的构思图就使用了这种表述方式,现在把这部分单独摘出来,如图 14.10 所示。

stats partial mockup
图 14.10:数量统计局部视图的构思图

图 14.10 中显示的数量统计包含当前用户关注的人数和关注当前用户的人数,而且分别链接到专门的用户列表页面。在第 5 章,我们使用 # 占位符代替真实的网址,因为那时我们还没怎么接触路由。现在,虽然 14.2.3 节才会创建所需的页面,不过可以先设置路由,如代码清单 14.15 所示。这段代码在 resources 块中使用了 :member 方法。我们以前没用过这个方法,你可以猜一下这个方法的作用是什么。

代码清单 14.15:在 Users 控制器中添加 followingfollowers 两个动作
config/routes.rb
Rails.application.routes.draw do
  root   'static_pages#home'
  get    '/help',    to: 'static_pages#help'
  get    '/about',   to: 'static_pages#about'
  get    '/contact', to: 'static_pages#contact'
  get    '/signup',  to: 'users#new'
  get    '/login',   to: 'sessions#new'
  post   '/login',   to: 'sessions#create'
  delete '/logout',  to: 'sessions#destroy'
  resources :users do
    member do
      get :following, :followers
    end
  end
  resources :account_activations, only: [:edit]
  resources :password_resets,     only: [:new, :create, :edit, :update]
  resources :microposts,          only: [:create, :destroy]
end

你可能猜到了,设定上述路由后,得到的 URL 地址类似 /users/1/following 和 /users/1/followers 这种形式。不错,代码清单 14.15 的作用确实如此。因为这两个页面都是用来显示数据的,所以我们使用了 get 方法,指定这两个地址响应的是 GET 请求。而且,使用 member 方法后,这两个动作对应的 URL 地址中都会包含用户的 ID。除此之外,我们还可以使用 collection 方法,但这样 URL 中就没有用户 ID 了。所以,如下的代码

resources :users do
  collection do
    get :tigers
  end
end

得到的 URL 是 /users/tigers(或许可以用来显示应用中所有的老虎)。[7]

代码清单 14.15 生成的路由如表 14.2 所示。留意一下我关注的用户页面和关注我的用户页面的具名路由是什么,稍后会用到。

表 14.2代码清单 14.15 中设置的规则生成的 REST 路由
HTTP 请求 URL 动作 具名路由

GET

/users/1/following

following

following_user_path(1)

GET

/users/1/followers

followers

followers_user_path(1)

设好了路由后,我们来编写数量统计局部视图。我们要在一个 div 元素中显示几个链接,如代码清单 14.16 所示。

代码清单 14.16:显示数量统计的局部视图
app/views/shared/_stats.html.erb
<% @user ||= current_user %>
<div class="stats">
  <a href="<%= following_user_path(@user) %>">
    <strong id="following" class="stat">
      <%= @user.following.count %>
    </strong>
    following
  </a>
  <a href="<%= followers_user_path(@user) %>">
    <strong id="followers" class="stat">
      <%= @user.followers.count %>
    </strong>
    followers
  </a>
</div>

因为用户资料页面和首页都要使用这个局部视图,所以在代码清单 14.16 的第一行,我们要获取正确的用户对象:

<% @user ||= current_user %>

我们在旁注 8.1中介绍过这种用法,如果 @user 不是 nil(在用户资料页面),这行代码没什么效果;如果是 nil(在首页),就会把当前用户赋值给 @user。还有一处要注意,我关注的人数和关注我的人数是通过关联获取的,分别使用 @user.following.count@user.followers.count

我们可以和代码清单 13.24 中获取微博数量的代码对比一下,微博的数量通过 @user.microposts.count 获取。为了提高效率,Rails 会直接在数据库层统计数量。

最后还有一个细节需要注意,我们为某些元素指定了 CSS ID,例如:

<strong id="following" class="stat">
...
</strong>

这些 ID 是为 14.2.5 节中的 Ajax 准备的,因为 Ajax 要通过独一无二的 ID 获取页面中的元素。

编写好局部视图,把它放入首页就很简单了,如代码清单 14.17 所示。

代码清单 14.17:在首页显示数量统计
app/views/static_pages/home.html.erb
<% if logged_in? %>
  <div class="row">
    <aside class="col-md-4">
      <section class="user_info">
        <%= render 'shared/user_info' %>
      </section>
      <section class="stats">
        <%= render 'shared/stats' %>
      </section>
      <section class="micropost_form">
        <%= render 'shared/micropost_form' %>
      </section>
    </aside>
    <div class="col-md-8">
      <h3>Micropost Feed</h3>
      <%= render 'shared/feed' %>
    </div>
  </div>
<% else %>
  .
  .
  .
<% end %>

我们要添加一些 SCSS 代码,美化数量统计,如代码清单 14.18 所示(包含本章用到的所有样式)。添加样式后,首页如图 14.11 所示。

代码清单 14.18:首页侧边栏的 SCSS 样式
app/assets/stylesheets/custom.scss
.
.
.
/* sidebar */
.
.
.
.gravatar {
  float: left;
  margin-right: 10px;
}

.gravatar_edit {
  margin-top: 15px;
}

.stats {
  overflow: auto;
  margin-top: 0;
  padding: 0;
  a {
    float: left;
    padding: 0 10px;
    border-left: 1px solid $gray-lighter;
    color: gray;
    &:first-child {
      padding-left: 0;
      border: 0;
    }
    &:hover {
      text-decoration: none;
      color: blue;
    }
  }
  strong {
    display: block;
  }
}

.user_avatars {
  overflow: auto;
  margin-top: 10px;
  .gravatar {
    margin: 1px 1px;
  }
  a {
    padding: 0;
  }
}

.users.follow {
  padding: 0;
}

/* forms */
.
.
.
home page follow stats 3rd edition
图 14.11:显示有数量统计的首页

稍后再把数量统计局部视图添加到用户资料页面中,现在先来编写关注和取消关注按钮的局部视图,如代码清单 14.19 所示。

代码清单 14.19:显示关注或取消关注表单的局部视图
app/views/users/_follow_form.html.erb
<% unless current_user?(@user) %>
  <div id="follow_form">
  <% if current_user.following?(@user) %>
    <%= render 'unfollow' %>
  <% else %>
    <%= render 'follow' %>
  <% end %>
  </div>
<% end %>

这段代码其实也没做什么,只是把具体的工作委托给 followunfollow 局部视图了。我们要再次设置路由,加入 Relationships 资源,如代码清单 14.20 所示,这与 Microposts 资源的设置类似(代码清单 13.30)。

代码清单 14.20:添加 Relationships 资源的路由规则
config/routes.rb
Rails.application.routes.draw do
  root                'static_pages#home'
  get    'help'    => 'static_pages#help'
  get    'about'   => 'static_pages#about'
  get    'contact' => 'static_pages#contact'
  get    'signup'  => 'users#new'
  get    'login'   => 'sessions#new'
  post   'login'   => 'sessions#create'
  delete 'logout'  => 'sessions#destroy'
  resources :users do
    member do
      get :following, :followers
    end
  end
  resources :account_activations, only: [:edit]
  resources :password_resets,     only: [:new, :create, :edit, :update]
  resources :microposts,          only: [:create, :destroy]
  resources :relationships,       only: [:create, :destroy]
end

followunfollow 局部视图的代码分别如代码清单 14.21代码清单 14.22 所示。

代码清单 14.21:关注用户的表单
app/views/users/_follow.html.erb
<%= form_for(current_user.active_relationships.build) do |f| %>
  <div><%= hidden_field_tag :followed_id, @user.id %></div>
  <%= f.submit "Follow", class: "btn btn-primary" %>
<% end %>
代码清单 14.22:取消关注用户的表单
app/views/users/_unfollow.html.erb
<%= form_for(current_user.active_relationships.find_by(followed_id: @user.id),
             html: { method: :delete }) do |f| %>
  <%= f.submit "Unfollow", class: "btn" %>
<% end %>

这两个表单都使用 form_for 处理 Relationship 模型对象,二者之间主要的不同是,代码清单 14.21 用于构建一个新“关系”,而代码清单 14.22 查找现有的“关系”。很显然,第一个表单会向 Relationships 控制器的 create 动作发送 POST 请求,创建“关系”;而第二个表单向 destroy 动作发送 DELETE 请求,销毁“关系”。(这两个动作在 14.2.4 节编写。)你可能还注意到了,关注用户的表单中除了按钮之外什么内容也没有,但是仍然要把 followed_id 发送给控制器。在代码清单 14.21 中,我们使用 hidden_field_tag 方法把 followed_id 添加到表单中,生成的 HTML 如下:

<input id="followed_id" name="followed_id" type="hidden" value="3" />

12.3 节说过,隐藏的 input 标签会把所需的信息包含在表单中,但在浏览器中不显示。

现在我们可以在资料页面中加入关注表单和数量统计了,如代码清单 14.23 所示,只需渲染相应的局部视图即可。显示有关注按钮和取消关注按钮的用户资料页面分别如图 14.12图 14.13 所示。

代码清单 14.23:在用户资料页面加入关注表单和数量统计
app/views/users/show.html.erb
<% provide(:title, @user.name) %>
<div class="row">
  <aside class="col-md-4">
    <section>
      <h1>
        <%= gravatar_for @user %>
        <%= @user.name %>
      </h1>
    </section>
    <section class="stats">
      <%= render 'shared/stats' %>
    </section>
  </aside>
  <div class="col-md-8">
    <%= render 'follow_form' if logged_in? %>
    <% if @user.microposts.any? %>
      <h3>Microposts (<%= @user.microposts.count %>)</h3>
      <ol class="microposts">
        <%= render @microposts %>
      </ol>
      <%= will_paginate @microposts %>
    <% end %>
  </div>
</div>

稍后我们会让这些按钮起作用,而且将使用两种方式实现,一种是常规方式(14.2.4 节),另一种使用 Ajax(14.2.5 节)。不过在此之前,我们要创建剩下的页面——我关注的用户列表页面和关注我的用户列表页面。

练习
  1. 确认 /users/2 页面有关注按钮,/users/5 页面有取消关注按钮。/users/1 页面有关注按钮吗?

  2. 在浏览器中确认首页和资料页面有数量统计。

  3. 为首页中的数量统计编写测试。提示:把测试添加到代码清单 13.28 中。为什么不用再测试资料页面的数量统计了?

profile follow button 3rd edition
图 14.12:某个用户的资料页面(/users/2),显示有关注按钮
profile unfollow button 3rd edition
图 14.13:某个用户的资料页面(/users/5),显示有取消关注按钮

14.2.3 我关注的用户列表页面和关注我的用户列表页面

我关注的用户列表页面和关注我的用户列表页面是资料页面和用户列表页面的混合体,在侧边栏显示用户的信息(包括数量统计),再列出一系列用户。除此之外,还会在侧边栏中显示一个用户头像列表。构思图如图 14.14(我关注的用户)和图 14.15(关注我的用户)所示。

following mockup bootstrap
图 14.14:我关注的用户列表页面构思图
followers mockup bootstrap
图 14.15:关注我的用户列表页面构思图

首先,我们要让这两个页面的地址可访问。按照 Twitter 的方式,访问这两个页面都需要先登录。我们要先编写测试,参照以前的访问限制测试,写出的测试如代码清单 14.24 所示。注意,代码清单 14.24 用到了表 14.2 中的具名路由。

代码清单 14.24:测试我关注的用户列表页面和关注我的用户列表页面的访问限制
test/controllers/users_controller_test.rb
require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
    @other_user = users(:archer)
  end
  .
  .
  .
  test "should redirect following when not logged in" do
    get following_user_path(@user)
    assert_redirected_to login_url
  end

  test "should redirect followers when not logged in" do
    get followers_user_path(@user)
    assert_redirected_to login_url
  end
end

在实现这两个页面的过程中,唯一很难想到的是,我们要在 Users 控制器中添加相应的两个动作。按照代码清单 14.15 中的路由规则,这两个动作应该命名为 followingfollowers。在这两个动作中,需要设置页面的标题、查找用户,获取 @user.followed_users@user.followers(要分页显示),然后再渲染页面,如代码清单 14.25 所示。

代码清单 14.25followingfollowers 动作 RED
app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update, :destroy,
                                        :following, :followers]
  .
  .
  .
  def following
    @title = "Following"
    @user  = User.find(params[:id])
    @users = @user.following.paginate(page: params[:page])
    render 'show_follow'
  end

  def followers
    @title = "Followers"
    @user  = User.find(params[:id])
    @users = @user.followers.paginate(page: params[:page])
    render 'show_follow'
  end

  private
  .
  .
  .
end

读过本书前面的内容我们发现,按照 Rails 的约定,动作最后都会隐式渲染对应的视图,例如 show 动作最后会渲染 show.html.erb。而代码清单 14.25 中的两个动作都显式调用了 render 方法,渲染一个名为 show_follow 的视图。下面我们来编写这个视图。这两个动作之所以使用同一个视图,是因为两种情况用到的 ERb 代码差不多,如代码清单 14.26 所示。

代码清单 14.26:渲染我关注的用户列表页面和关注我的用户列表页面的 show_follow 视图
app/views/users/show_follow.html.erb
<% provide(:title, @title) %>
<div class="row">
  <aside class="col-md-4">
    <section class="user_info">
      <%= gravatar_for @user %>
      <h1><%= @user.name %></h1>
      <span><%= link_to "view my profile", @user %></span>
      <span><b>Microposts:</b> <%= @user.microposts.count %></span>
    </section>
    <section class="stats">
      <%= render 'shared/stats' %>
      <% if @users.any? %>
        <div class="user_avatars">
          <% @users.each do |user| %>
            <%= link_to gravatar_for(user, size: 30), user %>
          <% end %>
        </div>
      <% end %>
    </section>
  </aside>
  <div class="col-md-8">
    <h3><%= @title %></h3>
    <% if @users.any? %>
      <ul class="users follow">
        <%= render @users %>
      </ul>
      <%= will_paginate %>
    <% end %>
  </div>
</div>

代码清单 14.25 中的动作会按需渲染代码清单 14.26 中的视图,分别显式我关注的用户列表和关注我的用户列表,如图 14.16图 14.17 所示。注意,上述代码都没用到“当前用户”,所以这两个链接对其他用户也可用,如图 14.18 所示。

现在,代码清单 14.24 中的测试应该能通过:

代码清单 14.27GREEN
$ rails test
user following 3rd edition
图 14.16:显示某个用户关注的人
user followers 3rd edition
图 14.17:显示关注某个用户的人
diferent user followers 3rd edition
图 14.18:显示关注另一个用户的人

现在,这两个页面可以使用了,下面要编写一些简短的集成测试,确认表现正确。这些测试只是健全检查,无需面面俱到。正如 5.3.4 节所说的,全面的测试,例如检查 HTML 结构,并不牢靠,而且可能适得其反。对这两个页面来说,我们计划确认显示的数量正确,而且页面中有指向正确的 URL 的链接。

首先,和之前一样,生成一个集成测试文件:

$ rails generate integration_test following
      invoke  test_unit
      create    test/integration/following_test.rb

然后,准备测试数据。我们要在“关系”固件中创建一些关注关系。13.2.3 节使用下面的代码把微博和用户关联起来:

orange:
  content: "I just ate an orange!"
  created_at: <%= 10.minutes.ago %>
  user: michael

注意,我们没有用 user_id: 1,而是 user: michael

按照这种方式编写“关系”固件,如代码清单 14.28 所示。

代码清单 14.28:“关系”固件
test/fixtures/relationships.yml
one:
  follower: michael
  followed: lana

two:
  follower: michael
  followed: malory

three:
  follower: lana
  followed: michael

four:
  follower: archer
  followed: michael

在这些固件中,Michael 关注了 Lana 和 Malory,Lana 和 Archer 关注了 Michael。为了测试数量,我们可以使用检查资料页面中微博数量的 assert_match 方法(代码清单 13.28)。然后再检查页面中有没有正确的链接,如代码清单 14.29 所示。

代码清单 14.29:测试我关注的用户列表页面和关注我的用户列表页面 GREEN
test/integration/following_test.rb
require 'test_helper'

class FollowingTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
    log_in_as(@user)
  end

  test "following page" do
    get following_user_path(@user)
    assert_not @user.following.empty?
    assert_match @user.following.count.to_s, response.body
    @user.following.each do |user|
      assert_select "a[href=?]", user_path(user)
    end
  end

  test "followers page" do
    get followers_user_path(@user)
    assert_not @user.followers.empty?
    assert_match @user.followers.count.to_s, response.body
    @user.followers.each do |user|
      assert_select "a[href=?]", user_path(user)
    end
  end
end

注意,在这段测试中有下面这个断言:

assert_not @user.following.empty?

如果不加入这个断言,下面这段代码就没有实际意义:

@user.following.each do |user|
  assert_select "a[href=?]", user_path(user)
end

(对关注我的用户列表页面的测试也是一样。)也就是说,如果 @user.following.empty? 的值是 true,循环中的 assert_select 都不会执行,这样测试会通过,给我们一种安全的错觉。

测试组件应该可以通过:

代码清单 14.30GREEN
$ rails test
练习
  1. 在浏览器中确认 /users/1/followers 和 /users/1/following 页面能显示正确的内容。侧边栏中的头像链接正确吗?

  2. 代码清单 14.29assert_select 断言对应的应用代码注释掉,确认测试会失败,从而证明测试写的正确。

14.2.4 关注按钮的常规实现方式

视图创建好了,下面我们要让关注和取消关注按钮起作用。因为关注和取消关注涉及到创建和销毁“关系”,所以我们需要一个控制器。像之前一样,我们使用下面的命令生成这个控制器:

$ rails generate controller Relationships

代码清单 14.32 中会看到,限制访问这个控制器中的动作没有太大的意义,但我们还是要加入安全机制。我们要在测试中确认,访问这个控制器中的动作之前要先登录(没登录就重定向到登录页面),而且数据库中的“关系”数量没有变化,如代码清单 14.31 所示。

代码清单 14.31:测试 Relationships 控制器的基本访问限制 RED
test/controllers/relationships_controller_test.rb
require 'test_helper'

class RelationshipsControllerTest < ActionDispatch::IntegrationTest

  test "create should require logged-in user" do
    assert_no_difference 'Relationship.count' do
      post relationships_path
    end
    assert_redirected_to login_url
  end

  test "destroy should require logged-in user" do
    assert_no_difference 'Relationship.count' do
      delete relationship_path(relationships(:one))
    end
    assert_redirected_to login_url
  end
end

Relationships 控制器中添加 logged_in_user 前置过滤器后,这个测试就能通过,如代码清单 14.32 所示。

代码清单 14.32:为 Relationships 控制器添加访问限制 GREEN
app/controllers/relationships_controller.rb
class RelationshipsController < ApplicationController
  before_action :logged_in_user

  def create
  end

  def destroy
  end
end

为了让关注和取消关注按钮起作用,我们需要找到表单中 followed_id 字段(参见代码清单 14.21代码清单 14.22)对应的用户,然后再调用代码清单 14.10 中定义的 followunfollow 方法。各个动作完整的实现如代码清单 14.33 所示。

代码清单 14.33Relationships 控制器的代码
app/controllers/relationships_controller.rb
class RelationshipsController < ApplicationController
  before_action :logged_in_user

  def create
    user = User.find(params[:followed_id])
    current_user.follow(user)
    redirect_to user
  end

  def destroy
    user = Relationship.find(params[:id]).followed
    current_user.unfollow(user)
    redirect_to user
  end
end

从这段代码中可以看出为什么前面说“限制访问没有太大意义”:如果未登录的用户直接访问某个动作(例如使用 curl 等命令行工具),current_user 的值是 nil,执行到这两个动作的第二行代码时会抛出异常,即得到一个错误,但对应用和数据来说都没危害。不过完全依赖这样的表现也不好,所以我们添加了一层安全防护措施。

现在,关注和取消关注功能都能正常使用了,任何用户都可以关注或取消关注其他用户。你可以在浏览器中点击相应的按钮验证一下。(我们会在 14.2.6 节编写集成测试检查这些操作。)关注第二个用户前后显示的资料页面如图 14.19图 14.20 所示。

unfollowed user
图 14.19:关注前的资料页面
followed user
图 14.20:关注后的资料页面
练习
  1. 在浏览器中关注第 2 个用户,然后取消关注。操作有效吗?

  2. 查看服务器日志,前一题中的两个操作渲染的是哪个模板?

14.2.5 关注按钮的 Ajax 实现方式

虽然关注用户的功能已经完全实现了,但在实现动态流之前,还有可以增强的地方。你可能已经注意到了,在 14.2.4 节中,Relationships 控制器的 createdestroy 动作最后都返回了一开始访问的用户资料页面。也就是说,用户 A 先访问用户 B 的资料页面,点击关注按钮关注用户 B,然后页面立即又转回到用户 B 的资料页面。因此,对这样的流程我们有一个疑问:为什么要多一次页面转向呢?

Ajax [8]可以解决这种问题。Ajax 向服务器发送异步请求,在不刷新页面的情况下更新页面的内容。因为经常要在表单中处理 Ajax 请求,所以 Rails 提供了简单的实现方式。其实,关注和取消关注表单局部视图不用做大的改动,只要把 form_for 改成 form_for…​, remote: true,Rails 就会自动使用 Ajax 处理表单。这两个局部视图更新后的版本如代码清单 14.34代码清单 14.35 所示。

代码清单 14.34:使用 Ajax 处理关注用户的表单
app/views/users/_follow.html.erb
<%= form_for(current_user.active_relationships.build, remote: true) do |f| %>
  <div><%= hidden_field_tag :followed_id, @user.id %></div>
  <%= f.submit "Follow", class: "btn btn-primary" %>
<% end %>
代码清单 14.35:使用 Ajax 处理取消关注用户的表单
app/views/users/_unfollow.html.erb
<%= form_for(current_user.active_relationships.find_by(followed_id: @user.id),
             html: { method: :delete },
             remote: true) do |f| %>
  <%= f.submit "Unfollow", class: "btn" %>
<% end %>

上述 ERb 代码生成的 HTML 没什么好说的,如果你好奇的话,可以看一下(细节可能不同):

<form action="/relationships/117" class="edit_relationship" data-remote="true"
      id="edit_relationship_117" method="post">
  .
  .
  .
</form>

可以看出,form 标签中设定了 data-remote="true",这个属性告诉 Rails,这个表单可以使用 JavaScript 处理。Rails 遵从非侵入式 JavaScript原则(unobtrusive JavaScript),没有直接在视图中写入 JavaScript 代码(Rails 之前的版本直接写入 JavaScript 代码),而是使用了一个简单的 HTML 属性。

修改表单后,我们要让 Relationships 控制器响应 Ajax 请求。为此,我们要使用 respond_to 方法,根据请求的类型生成合适的响应:

respond_to do |format|
  format.html { redirect_to user }
  format.js
end

这种写法可能会让人困惑,其实只有一行代码会执行。(respond_to 块中的代码更像是 if-else 语句,而不是代码序列。)为了让 Relationships 控制器响应 Ajax 请求,我们要在 createdestroy 动作(代码清单 14.33)中添加类似上面的 respond_to 块,如代码清单 14.36 所示。注意,我们把本地变量 user 改成了实例变量 @user,因为在代码清单 14.33 中无需使用实例变量,而使用 Ajax 处理的表单(代码清单 14.34代码清单 14.35)则需要使用。

代码清单 14.36:让 Relationships 控制器响应 Ajax 请求
app/controllers/relationships_controller.rb
class RelationshipsController < ApplicationController
  before_action :logged_in_user

  def create
    @user = User.find(params[:followed_id])
    current_user.follow(@user)
    respond_to do |format|
      format.html { redirect_to @user }
      format.js
    end
  end

  def destroy
    @user = Relationship.find(params[:id]).followed
    current_user.unfollow(@user)
    respond_to do |format|
      format.html { redirect_to @user }
      format.js
    end
  end
end

代码清单 14.36 中的代码会优雅降级(不过要配置一个选项,如代码清单 14.37 所示),如果浏览器不支持 JavaScript,也能正常运行。

代码清单 14.37:添加优雅降级所需的配置
config/application.rb
require File.expand_path('../boot', __FILE__)
.
.
.
module SampleApp
  class Application < Rails::Application
    .
    .
    .
    # 在使用 Ajax 处理的表单中添加真伪令牌
    config.action_view.embed_authenticity_token_in_remote_forms = true
  end
end

当然,如果支持 JavaScript,也能正确响应。如果是 Ajax 请求,Rails 会自动调用包含 JavaScript 的嵌入式 Ruby 文件(.js.erb),文件名和动作一样,例如 create.js.erbdestroy.js.erb。你可能猜到了,在这种文件中既可以使用 JavaScript 也可以使用嵌入式 Ruby 处理当前页面。所以,为了更新关注后和取消关注后的页面,我们要创建这种文件。

在 JS-ERb 文件中,Rails 自动提供了 jQuery 库的辅助函数,可以通过文档对象模型(Document Object Model,简称 DOM)处理页面中的内容。jQuery 库中有很多处理 DOM 的方法,但现在我们只会用到其中两个。首先,我们要知道通过 ID 获取 DOM 元素的美元符号,例如,要获取 follow_form 元素,可以使用如下的代码:

$("#follow_form")

(参见代码清单 14.19,这个元素是包含表单的 div 元素,而不是表单本身。)上面的句法和 CSS 一样,# 符号表示 CSS ID。由此你可能猜到了,jQuery 和 CSS 一样,使用点号 . 表示 CSS 类。

我们要使用的第二个方法是 html,使用指定的内容修改元素中的 HTML。例如,如果要把整个表单换成字符串 "foobar",可以这么写:

$("#follow_form").html("foobar")

和常规的 JavaScript 文件不同,JS-ERb 文件还可以使用嵌入式 Ruby 代码。在 create.js.erb 文件中,(成功关注后)我们会把关注用户表单换成取消关注用户表单,并更新关注数量,如代码清单 14.38 所示。这段代码中用到了 escape_javascript 方法,在 JavaScript 中写入 HTML 代码必须使用这个方法对 HTML 进行转义。

代码清单 14.38:创建“关系”的 JS-ERb 代码
app/views/relationships/create.js.erb
$("#follow_form").html("<%= escape_javascript(render('users/unfollow')) %>");
$("#followers").html('<%= @user.followers.count %>');

注意,上述代码的行尾有分号,这是 ALGOL 语言系的一个特色。

destroy.js.erb 文件的内容类似,如代码清单 14.39 所示。

代码清单 14.39:销毁“关系”的 JS-ERb 代码
app/views/relationships/destroy.js.erb
$("#follow_form").html("<%= escape_javascript(render('users/follow')) %>");
$("#followers").html('<%= @user.followers.count %>');

加入上述代码后,你应该访问用户资料页面,看一下关注或取消关注用户后页面是不是真的没有刷新。

练习
  1. 在浏览器中取消关注第 2 个用户,然后重新关注。操作有效吗?

  2. 查看服务器日志,前一题中的两个操作渲染的是哪个模板?

14.2.6 关注功能的测试

关注按钮可以使用了,现在我们要编写一些简单的测试,避免回归。关注用户时,我们要向相应的地址发送 POST 请求,确认关注的人数增加了一个:

assert_difference '@user.following.count', 1 do
  post relationships_path, params: { followed_id: @other.id }
end

这是测试普通请求的方式,测试 Ajax 请求的方式基本类似,唯一的区别是要指定 xhr: true 参数:

assert_difference '@user.following.count', 1 do
  post relationships_path, params: { followed_id: @other.id }, xhr: true
end

xhr 是 XmlHttpRequest 的简称,把 xhr 参数的值设为 true 之后,会发送 Ajax 请求,目的是执行 respond_to 块中对应于 JavaScript 的代码(代码清单 14.36)。

取消关注的测试类似,只需把 post 换成 delete。在下面的代码中,我们检查关注的人数减少了一个,而且指定了“关系”和被关注用户的 ID。

普通请求:

assert_difference '@user.following.count', -1 do
  delete relationship_path(relationship)
end

Ajax 请求:

assert_difference '@user.following.count', -1 do
  delete relationship_path(relationship), xhr: true
end

综上所述,测试如代码清单 14.40 所示。

代码清单 14.40:测试关注和取消关注按钮 GREEN
test/integration/following_test.rb
require 'test_helper'

class FollowingTest < ActionDispatch::IntegrationTest

  def setup
    @user  = users(:michael)
    @other = users(:archer)
    log_in_as(@user)
  end
  .
  .
  .
  test "should follow a user the standard way" do
    assert_difference '@user.following.count', 1 do
      post relationships_path, params: { followed_id: @other.id }
    end
  end

  test "should follow a user with Ajax" do
    assert_difference '@user.following.count', 1 do
      post relationships_path, xhr: true, params: { followed_id: @other.id }
    end
  end

  test "should unfollow a user the standard way" do
    @user.follow(@other)
    relationship = @user.active_relationships.find_by(followed_id: @other.id)
    assert_difference '@user.following.count', -1 do
      delete relationship_path(relationship)
    end
  end

  test "should unfollow a user with Ajax" do
    @user.follow(@other)
    relationship = @user.active_relationships.find_by(followed_id: @other.id)
    assert_difference '@user.following.count', -1 do
      delete relationship_path(relationship), xhr: true
    end
  end
end

测试组件应该能通过:

代码清单 14.41GREEN
$ rails test
练习
  1. 分别注释掉 respond_to 块中的各行代码(代码清单 14.36),确认测试会失败,然后再把注释去掉,让测试通过,从而证明测试写的正确。在这个过程中分别是哪个测试失败的?

  2. 如果把代码清单 14.40 中的某个 xhr: true 删掉,会发生什么?说说为什么会这样,以及为什么前一题的操作过程能捕获这个问题。

14.3 动态流

接下来我们要实现这个演示应用最难的功能:微博动态流。基本上本节的内容算是全书最高深的。完整的动态流以 13.3.3 节的动态流原型为基础,动态流中除了当前用户自己的微博之外,还包含他关注的用户发布的微博。我们会采用循序渐进的方式实现动态流。在实现的过程中,会用到一些相当高级的 Rails、Ruby 和 SQL 技术。

因为我们要做的事情很多,在此之前最好先想清楚我们要实现的是什么功能。图 14.5 显示了最终要实现的动态流,图 14.21 是同一幅图。

14.3.1 目的和策略

我们对动态流的构思很简单。图 14.22 中显示了 microposts 表示例和要显示的动态。动态流就是要把当前用户关注的用户发布的微博(也包括当前用户自己的微博)从 microposts 表中取出来,如图中的箭头所示。

page flow home page feed mockup bootstrap
图 14.21:某个用户登录后看到的首页,显示有动态流
user feed
图 14.22:ID 为 1 的用户关注了 ID 为 2,7,8,10 的用户后得到的动态流

虽然我们还不知道怎么实现动态流,但测试的方法很明确,所以我们先写测试。测试的关键是要覆盖三种情况:动态流中既要包含关注的用户发布的微博,还要有用户自己的微博,但是不能包含未关注用户的微博。

按照代码清单 14.28,我们将安排 Michael 关注 Lana,但不关注 Archer。根据代码清单 10.47代码清单 13.53 中的固件,也就是说,Michael 要能看到 Lana 和自己的微博,但不能看到 Archer 的微博。把这个需求转换成测试,如代码清单 14.42 所示。(用到了 代码清单 13.46定义的 feed 方法。)

代码清单 14.42:测试动态流 RED
test/models/user_test.rb
require 'test_helper'

class UserTest < ActiveSupport::TestCase
  .
  .
  .
  test "feed should have the right posts" do
    michael = users(:michael)
    archer  = users(:archer)
    lana    = users(:lana)
    # 关注的用户发布的微博
    lana.microposts.each do |post_following|
      assert michael.feed.include?(post_following)
    end
    # 自己的微博
    michael.microposts.each do |post_self|
      assert michael.feed.include?(post_self)
    end
    # 未关注用户的微博
    archer.microposts.each do |post_unfollowed|
      assert_not michael.feed.include?(post_unfollowed)
    end
  end
end

当然,现在的动态流只是个原型,测试无法通过:

代码清单 14.43RED
$ rails test
练习
  1. 假设微博的 ID 是连续的,而且数字大的是最近发布的。对图 14.22 中的动态流而言,user.feed.map(:id) 的返回值是什么?提示:回顾一下 13.1.4 节定义的默认作用域。

14.3.2 初步实现动态流

有了检查动态流的测试后(代码清单 14.42),我们可以开始实现动态流了。因为要实现的功能有点复杂,因此我们会一点一点实现。首先,我们要知道该使用怎样的查询语句。我们要从 microposts 表中取出关注的用户发布的微博(也要取出用户自己的微博)。为此,我们可以使用类似下面的查询语句:

SELECT * FROM microposts
WHERE user_id IN (<list of ids>) OR user_id = <user id>

编写这个查询语句时,我们假设 SQL 支持使用 IN 关键字检测集合中是否包含指定的元素。(还好,SQL 支持。)

13.3.3 节实现动态流原型时,我们使用 Active Record 中的 where 方法完成上面这种查询(代码清单 13.46)。那时所需的查询很简单,只是通过当前用户的 ID 取出他发布的微博:

Micropost.where("user_id = ?", id)

而现在,我们遇到的情况复杂得多,要使用类似下面的代码实现:

Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id)

从上面的查询条件可以看出,我们需要生成一个数组,其元素是关注的用户的 ID。生成这个数组的方式之一是,使用 Ruby 中的 map 方法,这个方法可以在任意可枚举的对象(enumerable)上调用,[9]例如由一组元素组成的集合(数组或散列)。我们在 4.3.2 节举例介绍过这个方法,下面再举个例子,把整数数组中的元素都转换成字符串:

$ rails console
>> [1, 2, 3, 4].map { |i| i.to_s }
=> ["1", "2", "3", "4"]

像上面这种在每个元素上调用同一个方法的情况很常见,所以 Ruby 为此定义了一种简写形式(4.3.2 节简介过)——在 & 符号后面跟上被调用方法的符号形式:

>> [1, 2, 3, 4].map(&:to_s)
=> ["1", "2", "3", "4"]

使用 join 方法(4.3.1 节)可以把数组中的元素合并起来组成字符串,各元素之间用逗号加一个空格分开:

>> [1, 2, 3, 4].map(&:to_s).join(', ')
=> "1, 2, 3, 4"

参照上面介绍的方法,我们可以在 user.following 中的每个元素上调用 id 方法,得到一个由关注的用户 ID 组成的数组。例如,对数据库中的第一个用户而言,可以使用下面的方法得到这个数组:

>> User.first.following.map(&:id)
=> [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22,
23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41,
42, 43, 44, 45, 46, 47, 48, 49, 50, 51]

其实,因为这种用法太普遍了,所以 Active Record 默认已经提供了:

>> User.first.following_ids
=> [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22,
23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41,
42, 43, 44, 45, 46, 47, 48, 49, 50, 51]

上述代码中的 following_ids 方法是 Active Record 根据 has_many :following 关联(代码清单 14.8)合成的。因此,我们只需在关联名后面加上 _ids 就可以获取 user.following 集合中所有用户的 ID。用户 ID 组成的字符串如下:

>> User.first.following_ids.join(', ')
=> "3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22,
23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41,
42, 43, 44, 45, 46, 47, 48, 49, 50, 51"

不过,插入 SQL 语句时,无须手动生成字符串,? 插值操作会为你代劳(同时也避免了一些数据库之间的兼容问题)。因此,实际上只需要使用 following_ids 而已。所以,之前猜测的写法确实可用:

Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id)

feed 方法的定义如代码清单 14.44 所示。

代码清单 14.44:初步实现的动态流 GREEN
app/models/user.rb
class User < ApplicationRecord
  .
  .
  .
  # 如果密码重设请求超时了,返回 true
  def password_reset_expired?
    reset_sent_at < 2.hours.ago
  end

  # 返回用户的动态流
  def feed
    Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id)
  end

  # 关注另一个用户
  def follow(other_user)
    following << other_user
  end
  .
  .
  .
end

现在测试组件应该可以通过了:

代码清单 14.45GREEN
$ rails test

在某些应用中,这样的初步实现已经能满足大部分需求了,但这不是我们最终要使用的实现方式。在阅读下一节之前,你可以想一下为什么。(提示:如果用户关注了 5000 个人呢?)

练习
  1. 代码清单 14.44 中查询用户自己的微博的代码去掉,代码清单 14.42 中的哪个测试会失败?

  2. 代码清单 14.44 中查询关注用户的微博的代码去掉,代码清单 14.42 中的哪个测试会失败?

  3. 如何修改代码清单 14.44 中的查询才能返回未关注用户的微博(此时代码清单 14.42 中的第三个测试会失败)?提示:返回全部微博即可。

14.3.3 子查询

如前一节末尾所说,对 14.3.2 节的实现方式来说,如果用户关注了 5000 个人,动态流中的微博数量会变多,性能就会下降。本节,我们将重新实现动态流,在关注的用户数量很多时,性能也很好。

14.3.2 节中存在问题的是 following_ids 这行代码,它会把关注的所有用户 ID 取出,加载到内存中,还会创建一个元素数量和关注的用户数量相同的数组。既然代码清单 14.44 的目的只是为了检查集合中是否包含指定的元素,那么就一定有一种更高效的方式。其实 SQL 真的提供了针对这种问题的优化措施:使用子查询(subselect),在数据库层查找关注的用户 ID。

针对动态流的重构,先从代码清单 14.46 中的小改动开始。

代码清单 14.46:在获取动态流的 where 方法中使用键值对 GREEN
app/models/user.rb
class User < ApplicationRecord
  .
  .
  .
  # 返回用户的动态流
  def feed
    Micropost.where("user_id IN (:following_ids) OR user_id = :user_id",
                    following_ids: following_ids, user_id: id)
  end
  .
  .
  .
end

为了给下一步重构做准备,我们把

Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id)

换成了等效的

Micropost.where("user_id IN (:following_ids) OR user_id = :user_id",
                following_ids: following_ids, user_id: id)

使用问号做插值虽然可以,但如果要在多处插入同一个值,后一种写法更方便。

上面这段话表明,我们要在 SQL 查询语句中两次用到 user_id。具体而言,我们要把下面这行 Ruby 代码

following_ids

换成包含 SQL 语句的代码

following_ids = "SELECT followed_id FROM relationships
                 WHERE  follower_id = :user_id"

上面这行代码使用了 SQL 子查询语句。针对 ID 为 1 的用户,整个查询语句是这样的:

SELECT * FROM microposts
WHERE user_id IN (SELECT followed_id FROM relationships
                  WHERE  follower_id = 1)
      OR user_id = 1

使用子查询后,所有的集合包含关系都交由数据库处理,这样效率更高。

有了这些基础,我们就可以着手实现更高效的动态流了,如代码清单 14.47 所示。注意,因为现在使用的是纯 SQL 语句,所以要使用插值方式把 following_ids 加入语句中,而不能使用转义的方式。

代码清单 14.47:动态流的最终实现 GREEN
app/models/user.rb
class User < ApplicationRecord
  .
  .
  .
  # 返回用户的动态流
  def feed
    following_ids = "SELECT followed_id FROM relationships
                     WHERE  follower_id = :user_id"
    Micropost.where("user_id IN (#{following_ids})
                     OR user_id = :user_id", user_id: id)
  end
  .
  .
  .
end

这段代码结合了 Rails、Ruby 和 SQL 的优势,达到了目的,而且做得很好:

代码清单 14.48GREEN
$ rails test

当然,子查询也不是万能的。对于更大型的网站而言,可能要使用后台作业(background job)异步生成动态流。性能优化这个话题已经超出了本书范畴。

现在,动态流完全实现了。13.3.3 节已经在首页加入了动态流,不过第 13 章实现的只是动态流原型(图 13.14),添加代码清单 14.47 中的代码后,首页显示的动态流完整了,如图 14.23 所示。

现在可以把改动合并到 master 分支了:

$ rails test
$ git add -A
$ git commit -m "Add user following"
$ git checkout master
$ git merge following-users

然后再推送到远程仓库,并部署到生产环境:

$ git push
$ git push heroku
$ heroku pg:reset DATABASE
$ heroku run rails db:migrate
$ heroku run rails db:seed

在生产环境的线上网站中也会显示动态流,如图 14.24 所示。

home page with feed 3rd edition
图 14.23:首页,显示有动态流
live status feed
图 14.24:线上网站中显示的动态流
练习
  1. 编写集成测试,检查首页正确显示了动态流的第一页。模板参见代码清单 14.49

  2. 注意,代码清单 14.49 使用 CGI.escapeHTML 方法转义了 HTML,想一下为什么要这么做。提示:把转义的代码去掉,仔细查看不匹配的微博内容源码。在终端中使用搜索功能(大多数系统可按 Cmd-F 或 Ctrl-F 键),查找“sorry”这个词。

代码清单 14.49:测试动态流的 HTML GREEN
test/integration/following_test.rb
require 'test_helper'

class FollowingTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
    log_in_as(@user)
  end
  .
  .
  .
  test "feed on Home page" do
    get root_path
    @user.feed.paginate(page: 1).each do |micropost|
      assert_match CGI.escapeHTML(FILL_IN), FILL_IN
    end
  end
end

14.4 小结

实现动态流之后,本书的演示应用就开发完了。这个应用演示了 Rails 的全部重要功能,包括模型、视图、控制器、模板、局部视图、过滤器、数据验证、回调、has_many/belongs_to 关联、has_many :through 关联、安全、测试和部署。

除此之外,Rails 还有很多功能值得我们学习。下面提供了一些后续学习资源,可在以后的学习中优先使用。

14.4.1 后续的学习资源

商店和网上都有很多 Rails 资源,而且多得让你挑花眼。可喜的是,读完这本书后,你已经可以学习几乎所有其他的知识了。下面是建议你后续学习的资源:

  • The Learn Enough Society:收费订阅服务,提供本书的特别增强版和 15 个小时多的视频课程。我在这些视频中介绍了很多技巧,而且还提供了在线示例,光看本书是得不到这些的。此外,这项服务还包含 Learn Enough 系列教程的文字资料和视频。如果你是学生,可以使用教育优惠。

  • Launch School:近些年涌现了很多开发者现场训练营,我建议在当地参与一个。不过,Launch School 是在线训练营,在任何地方都能参加。如果你希望有人按照结构化课程教你,Launch School 是不错的选择。

  • Bloc:一个在线训练营,有结构化的课程、个人导师,通过具体的项目学习知识。使用 BLOCLOVESHARTL 优惠码报名费可以省 $500。

  • Firehose Project:导师制在线编程训练营,专注于具体的编程技能,如测试驱动开发、算法,以及敏捷 Web 应用开发。有两周免费的入门课程。

  • Thinkful:在线课程,由专业的工程师辅导开发项目。教授的课程包括 Ruby on Rails、前端开发、Web 设计和数据库科学。

  • Pragmatic Studio:Mike 和 Nicole Clark 主讲的 Ruby 和 Rails 在线课程。

  • RailsApps:很多针对特定话题的 Rails 项目和教程,说明详细;

  • Code School:很多交互式编程课程。

14.4.2 本章所学

  • 使用 has_many :through 可以实现数据模型之间的复杂关系;

  • has_many 方法有很多可选的参数,可用来指定对象的类名和外键名;

  • 使用 has_manyhas_many :through,并且指定合适的类名和外键名,可以实现主动关系和被动关系;

  • Rails 支持嵌套路由;

  • where 方法可以创建灵活且强大的数据库查询;

  • Rails 支持使用低层 SQL 语句查询数据库;

  • 把本书实现的所有功能放在一起,最终实现了一个能关注用户并且显示动态流的应用。

  1. 儿童图像的来源:http://www.flickr.com/photos/john_lustig/2518452221/,发布于 2013 年 12 月 16 日。Copyright © 2008 by John Lustig。未经改动,基于“知识共享 署名 2.0 通用”许可证使用。老虎图像来源:https://www.flickr.com/photos/renemensen/9187111340,发布于 2014 年 8 月 15 日。Copyright © 2013 by Rene Mesen。未经改动,基于“知识共享 署名 2.0 通用”许可证使用。
  2. 简单起见,图 14.6 省略了 following 表的 id 列。
  3. 感谢读者 Paul Fioravanti 建议我使用这两个术语。
  4. 严格来说,Rails 使用 classify 方法把 has_many 的参数转换成类名,例如 "foo_bars" 会转换成 "FooBar"
  5. 严格来说,Rails 使用 underscore 方法把类名转换为 id 列的名字。例如,"FooBar".underscore 的返回值是 "foo_bar",所以 FooBar 模型的外键是 foo_bar_id
  6. 当你拥有某个领域大量建模的经验后,总能提前猜到这样的辅助方法。如果没有猜到的话,也经常能发现自己动手写这样的方法可以使测试代码更加整洁。此时,如果你没有猜到的话也很正常。软件开发往往是一个循序渐进的过程,你先埋头编写代码,发现代码很乱时,再重构。为了行文简洁,本书采取的是直捣黄龙的方法。
  7. 路由设定中可以使用的参数详情,参阅 Rails 指南中《Rails 路由全解》一文。
  8. 因为 Ajax 是 Asynchronous JavaScript and XML 的缩写,所以经常被错误的拼写为“AJAX”,不过在最初介绍 Ajax 的文章中,通篇都拼写为“Ajax”。
  9. 可枚举的对象主要的要求是实现了遍历集合的 each 方法。