# 12.3 动态流
接下来我们要实现演示应用最难的功能:微博动态流。基本上本节的内容算是全书最高深的。完整的动态流以 [11.3.3 节](chapter11.html#a-proto-feed)的动态流原型为基础实现,动态流中除了当前用户自己的微博之外,还包含他关注的用户发布的微博。我们会采用循序渐进的方式实现动态了。在实现的过程中,会用到一些相当高级的 Rails、Ruby 和 SQL 技术。
因为我们要做的事情很多,在此之前最好先清楚我们要实现的是什么样的功能。[图 12.5](#fig-page-flow-home-page-feed-mockup) 显示了最终要实现的动态流,[图 12.21](#fig-home-page-feed-mockup) 是同一幅图。
## 12.3.1 目的和策略
我们对动态流的构思很简单。[图 12.22](#fig-user-feed) 中显示了一个示例的 `microposts` 表和要显示的动态。动态流就是要把当前用户关注的用户发布的微博(也包括当前用户自己的微博)从 `microposts` 表中取出来,如图中的箭头所示。
![page flow home page feed mockup bootstrap](https://box.kancloud.cn/2016-05-11_573330818264f.png)图 12.21:某个用户登录后看到的首页,显示有动态流![user feed](https://box.kancloud.cn/2016-05-11_573330819a3fb.png)图 12.22:ID 为 1 的用户关注了 ID 为 2,7,8,10 的用户后得到的动态流
虽然我们还不知道怎么实现动态流,但测试的方法很明确,所以我们先写测试。测试的关键是要覆盖三种情况:动态流中既要包含关注的用户发布的微博,还要有用户自己的微博,但是不能包含未关注用户的微博。根据[代码清单 9.43](chapter9.html#listing-users-fixtures-extra-users) 和[代码清单 11.51](chapter11.html#listing-add-micropost-different-owner) 中的固件,也就是说,Michael 要能看到 Lana 和自己的微博,但不能看到 Archer 的微博。把这个需求转换成测试,如[代码清单 12.41](#listing-full-feed-test) 所示。(用到了[代码清单 11.44](chapter11.html#listing-proto-status-feed) 中定义的 `feed` 方法。)
##### 代码清单 12.41:测试动态流 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
```
当然,现在的动态流只是个原型,测试无法通过:
##### 代码清单 12.42:**RED**
```
$ bundle exec rake test
```
## 12.3.2 初步实现动态流
有了检查动态流的测试后([代码清单 12.41](#listing-full-feed-test)),我们可以开始实现动态流了。因为要实现的功能有点复杂,因此我们会一点一点实现。首先,我们要知道该使用怎样的查询语句。我们要从 `microposts` 表中取出关注的用户发布的微博(也要取出用户自己的微博)。为此,我们可以使用类似下面的查询语句:
```
SELECT * FROM microposts
WHERE user_id IN (<list of ids>) OR user_id = <user id>
```
编写这个查询语句时,我们假设 SQL 支持使用 `IN` 关键字检测集合中是否包含指定的元素。(还好,SQL 支持。)
[11.3.3 节](chapter11.html#a-proto-feed)实现动态流原型时,我们使用 Active Record 中的 `where` 方法完成上面这种查询([代码清单 11.44](chapter11.html#listing-proto-status-feed))。那时所需的查询很简单,只是通过当前用户的 ID 取出他发布的微博:
```
Micropost.where("user_id = ?", id)
```
而现在,我们遇到的情况复杂得多,要使用类似下面的代码实现:
```
Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id)
```
从上面的查询条件可以看出,我们需要生成一个数组,其元素是关注的用户的 ID。生成这个数组的方法之一是,使用 Ruby 中的 `map` 方法,这个方法可以在任意“可枚举”(enumerable)的对象上调用,[[9](#fn-9)]例如由一组元素组成的集合(数组或哈希)。我们在 [4.3.2 节](chapter4.html#blocks)举例介绍过这个方法,现在再举个例子,把整数数组中的元素都转换成字符串:
```
$ rails console
>> [1, 2, 3, 4].map { |i| i.to_s }
=> ["1", "2", "3", "4"]
```
像上面这种在每个元素上调用同一个方法的情况很常见,所以 Ruby 为此定义了一种简写形式([4.3.2 节](chapter4.html#blocks)简介过)——在 `&` 符号后面跟上被调用方法的符号形式:
```
>> [1, 2, 3, 4].map(&:to_s)
=> ["1", "2", "3", "4"]
```
然后再调用 `join` 方法([4.3.1 节](chapter4.html#arrays-and-ranges)),就可以把数组中的元素合并起来组成字符串,各元素之间用逗号加一个空格分开:
```
>> [1, 2, 3, 4].map(&:to_s).join(', ')
=> "1, 2, 3, 4"
```
参照上面介绍的方法,我们可以在 `user.following` 中的每个元素上调用 `id` 方法,得到一个由关注的用户 ID 组成的数组。例如,对数据库中的第一个用户而言,可以使用下面的方法得到这个数组:
```
>> User.first.following.map(&:id)
=> [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
=> [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` 关联([代码清单 12.8](#listing-has-many-following-through-active-relationships))合成的。因此,我们只需在关联名后面加上 `_ids` 就可以获取 `user.following` 集合中所有用户的 ID。用户 ID 组成的字符串如下:
```
>> User.first.following_ids.join(', ')
=> "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` 方法的定义如[代码清单 12.43](#listing-initial-working-feed) 所示。
##### 代码清单 12.43:初步实现的动态流 GREEN
app/models/user.rb
```
class User < ActiveRecord::Base
.
.
.
# 如果密码重设超时失效了,返回 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)
active_relationships.create(followed_id: other_user.id)
end
.
.
.
end
```
现在测试组件应该可以通过了:
##### 代码清单 12.44:**GREEN**
```
$ bundle exec rake test
```
在某些应用中,这样的初步实现已经能满足大部分需求了,但这不是我们最终要使用的实现方式。在阅读下一节之前,你可以想一下为什么。(提示:如果用户关注了 5000 个人呢?)
## 12.3.3 子查询
如前一节末尾所说,对 [12.3.2 节](#a-first-feed-implementation)的实现方式来说,如果用户关注了 5000 个人,动态流中的微博数量会变多,性能就会下降。本节,我们会重新实现动态流,在关注的用户数量很多时,性能也很好。
[12.3.2 节](#a-first-feed-implementation)中所用代码的问题是 `following_ids` 这行代码,它会把所有关注的用户 ID 取出,加载到内存,还会创建一个元素数量和关注的用户数量相同的数组。既然[代码清单 12.43](#listing-initial-working-feed) 的目的只是为了检查集合中是否包含了指定的元素,那么就一定有一种更高效的方式。其实 SQL 真得提供了针对这种问题的优化措施:使用“子查询”(subselect),在数据库层查找关注的用户 ID。
针对动态流的重构,先从[代码清单 12.45](#listing-feed-second-cut) 中的小改动开始。
##### 代码清单 12.45:在获取动态流的 `where` 方法中使用键值对 GREEN
app/models/user.rb
```
class User < ActiveRecord::Base
.
.
.
# 返回用户的动态流
def feed
Micropost.where("user_id IN (:following_ids) OR user_id = :user_id", following_ids: following_ids, user_id: user) end
.
.
.
end
```
为了给下一步重构做准备,我们把
```
where("user_id IN (?) OR user_id = ?", following_ids, id)
```
换成了等效的
```
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
```
使用子查询后,所有的集合包含关系都交由数据库处理,这样效率更高。
有了这些基础,我们就可以着手实现更高效的动态流了,如[代码清单 12.46](#listing-feed-final) 所示。注意,因为现在使用的是纯 SQL 语句,所以使用插值方式把 `following_ids` 加入语句中,而没使用转义的方式。
##### 代码清单 12.46:动态流的最终实现 GREEN
app/models/user.rb
```
class User < ActiveRecord::Base
.
.
.
# 返回用户的动态流
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 的优势,达到了目的,而且做的很好:
##### 代码清单 12.47:**GREEN**
```
$ bundle exec rake test
```
当然,子查询也不是万能的。对于更大型的网站而言,可能要使用“后台作业”(background job)异步生成动态流。性能优化这个话题已经超出了本书范畴。
现在,动态流完全实现了。[11.3.3 节](chapter11.html#a-proto-feed)已经在首页加入了动态流,下面再次列出来([代码清单 12.48](#listing-real-feed-instance-variable)),以便参考。[第 11 章](chapter11.html#user-microposts)实现的只是动态流原型([图 11.14](chapter11.html#fig-home-with-proto-feed)),添加[代码清单 12.46](#listing-feed-final) 中的代码后,首页显示的动态流完整了,如[图 12.23](#fig-home-page-with-feed) 所示。
##### 代码清单 12.48:`home` 动作中分页显示的动态流
app/controllers/static_pages_controller.rb
```
class StaticPagesController < ApplicationController
def home
if logged_in?
@micropost = current_user.microposts.build
@feed_items = current_user.feed.paginate(page: params[:page]) end
end
.
.
.
end
```
现在可以把改动合并到 `master` 分支了:
```
$ bundle exec rake 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 rake db:migrate
$ heroku run rake db:seed
```
在生产环境的线上网站中也会显示动态流,如[图 12.24](#fig-live-status-feed) 所示。
![home page with feed 3rd edition](https://box.kancloud.cn/2016-05-11_57333081af264.png)图 12.23:首页,显示有动态流![live status feed](https://box.kancloud.cn/2016-05-11_57333081d27fa.png)图 12.24:线上网站中显示的动态流
- Ruby on Rails 教程
- 致中国读者
- 序
- 致谢
- 作者译者简介
- 版权和代码授权协议
- 第 1 章 从零开始,完成一次部署
- 1.1 简介
- 1.2 搭建环境
- 1.3 第一个应用
- 1.4 使用 Git 做版本控制
- 1.5 部署
- 1.6 小结
- 1.7 练习
- 第 2 章 玩具应用
- 2.1 规划应用
- 2.2 用户资源
- 2.3 微博资源
- 2.4 小结
- 2.5 练习
- 第 3 章 基本静态的页面
- 3.1 创建演示应用
- 3.2 静态页面
- 3.3 开始测试
- 3.4 有点动态内容的页面
- 3.5 小结
- 3.6 练习
- 3.7 高级测试技术
- 第 4 章 Rails 背后的 Ruby
- 4.1 导言
- 4.2 字符串和方法
- 4.3 其他数据类型
- 4.4 Ruby 类
- 4.5 小结
- 4.6 练习
- 第 5 章 完善布局
- 5.1 添加一些结构
- 5.2 Sass 和 Asset Pipeline
- 5.3 布局中的链接
- 5.4 用户注册:第一步
- 5.5 小结
- 5.6 练习
- 第 6 章 用户模型
- 6.1 用户模型
- 6.2 用户数据验证
- 6.3 添加安全密码
- 6.4 小结
- 6.5 练习
- 第 7 章 注册
- 7.1 显示用户的信息
- 7.2 注册表单
- 7.3 注册失败
- 7.4 注册成功
- 7.5 专业部署方案
- 7.6 小结
- 7.7 练习
- 第 8 章 登录和退出
- 8.1 会话
- 8.2 登录
- 8.3 退出
- 8.4 记住我
- 8.5 小结
- 8.6 练习
- 第 9 章 更新,显示和删除用户
- 9.1 更新用户
- 9.2 权限系统
- 9.3 列出所有用户
- 9.4 删除用户
- 9.5 小结
- 9.6 练习
- 第 10 章 账户激活和密码重设
- 10.1 账户激活
- 10.2 密码重设
- 10.3 在生产环境中发送邮件
- 10.4 小结
- 10.5 练习
- 10.6 证明超时失效的比较算式
- 第 11 章 用户的微博
- 11.1 微博模型
- 11.2 显示微博
- 11.3 微博相关的操作
- 11.4 微博中的图片
- 11.5 小结
- 11.6 练习
- 第 12 章 关注用户
- 12.1 “关系”模型
- 12.2 关注用户的网页界面
- 12.3 动态流
- 12.4 小结
- 12.5 练习