# 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` 表中取出來,如圖中的箭頭所示。
圖 12.21:某個用戶登錄后看到的首頁,顯示有動態流圖 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) 所示。
圖 12.23:首頁,顯示有動態流圖 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 練習