# 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](#fig-naive-user-has-many-following) 中的方式構建被關注用戶的模型——一個 `following` 表和 `has_many` 關聯。由于 `user.following` 應該是一個用戶對象組成的數組,所以 `following` 表中的每一行都應該是一個用戶,通過 `followed_id` 列標識。然后再通過 `follower_id` 列建立關聯。[[2](#fn-2)]除此之外,由于每一行都是一個用戶,所以還要在表中加入用戶的其他屬性,例如名字、電子郵件地址和密碼等。
圖 12.6:用戶關注的人(天真方式)
[圖 12.6](#fig-naive-user-has-many-following) 中的數據模型有個問題——存在非常多的冗余,每一行不僅包括了被關注用戶的 ID,還包括了他們的其他信息,而這些信息在 `users` 表中都有。 更糟糕的是,為了保存關注我的人,還需要另一個同樣冗余的 `followers` 表。這么做會導致數據模型極難維護:用戶修改名字時,不僅要修改 `users` 表中的數據,還要修改 `following` 和 `followers` 表中包含這個用戶的每一個記錄。
造成這個問題的原因是缺少了一層抽象。找到合適的抽象有一種方法:思考在應用中如何實現關注用戶的操作。[7.1.2 節](chapter7.html#a-users-resource)介紹過,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](#fn-3)]
現在我們集中精力實現“主動關系”,即獲取我關注的用戶。[12.1.5 節](#followers)會實現“被動關系”。從[圖 12.6](#fig-naive-user-has-many-following) 中可以看出實現的方式:既然我關注的每一個用戶都由 `followed_id` 獨一無二的標識出來了,我們就可以把 `following` 表轉化成 `active_relationships` 表,刪掉用戶的屬性,然后使用 `followed_id` 從 `users` 表中獲取我關注的用戶的信息。這個數據模型如[圖 12.7](#fig-user-has-many-following) 所示。
圖 12.7:通過“主動關系”獲取我關注的用戶
因為“主動關系”和“被動關系”最終會存儲在同一個表中,所以我們把這個表命名為“relationships”。這個表對應的模型是 `Relationship`,如[圖 12.8](#fig-relationship-model) 所示。從 [12.1.4 節](#followed-users)開始,我們會介紹如何使用這個模型同時實現“主動關系”和“被動關系”。
圖 12.8:Relationship 數據模型
為此,我們要生成所需的模型:
```
$ rails generate model Relationship follower_id:integer followed_id:integer
```
因為我們會通過 `follower_id` 和 `followed_id` 查找關系,所以還要為這兩個列建立索引,提高查詢的效率,如[代碼清單 12.1](#listing-relationships-migration) 所示。
##### 代碼清單 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](#listing-relationships-migration) 中,我們還設置了一個“多鍵索引”,確保 (`follower_id, followed_id`) 組合是唯一的,避免多次關注同一個用戶。(可以和[代碼清單 6.28](chapter6.html#listing-email-uniqueness-index) 中保持電子郵件地址唯一的索引比較一下。)從 [12.1.4 節](#followed-users)起會看到,用戶界面不會允許這樣的事發生,但添加索引后,如果用戶試圖創建重復的關系(例如使用 `curl` 這樣的命令行工具),應用會拋出異常。
為了創建 `relationships` 表,和之前一樣,我們要執行遷移:
```
$ bundle exec rake db:migrate
```
## 12.1.2 用戶和“關系”模型之間的關聯
在獲取我關注的人和關注我的人之前,我們要先建立用戶和“關系”模型之間的關聯。一個用戶有多個“關系”(`has_many`), 因為一個“關系”涉及到兩個用戶,所以“關系”同時屬于(`belongs_to`)該用戶和被關注的用戶。
和 [11.1.3 節](chapter11.html#user-micropost-associations)創建時微博一樣,我們要通過關聯創建“關系”,如下面的代碼所示:
```
user.active_relationships.build(followed_id: ...)
```
此時,你可能想在應用中加入類似于 [11.1.3 節](chapter11.html#user-micropost-associations)使用的代碼。我們要添加的代碼確實很像,但有兩處不同。
首先,把用戶和微博關聯起來時我們寫成:
```
class User < ActiveRecord::Base
has_many :microposts
.
.
.
end
```
之所以可以這么寫,是因為 Rails 會尋找 `:microposts` 符號對應的模型,即 `Micropost`。[[4](#fn-4)]可是現在模型名為 `Relationship`,而我們想寫成:
```
has_many :active_relationships
```
所以要告訴 Rails 模型的類名。
其次,前面在微博模型中是這么寫的:
```
class Micropost < ActiveRecord::Base
belongs_to :user
.
.
.
end
```
之所以可以這么寫,是因為 `microposts` 表中有識別用戶的 `user_id` 列([11.1.1 節](chapter11.html#the-basic-model))。這種連接兩個表的列,我們稱之為“外鍵”(foreign key)。當指向用戶模型的外鍵為 `user_id` 時,Rails 會自動獲知關聯,因為默認情況下,Rails 會尋找名為 `<class>_id` 的外鍵,其中 `<class>` 是模型類名的小寫形式。[[5](#fn-5)]現在,盡管我們處理的還是用戶,但識別用戶使用的外鍵是 `follower_id`,所以要告訴 Rails 這一變化。
綜上所述,用戶和“關系”模型之間的關聯如[代碼清單 12.2](#listing-user-relationships-association) 和[代碼清單 12.3](#listing-relationship-belongs-to) 所示。
##### 代碼清單 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 節](#followers)才會用到 `followed` 關聯,但同時添加易于理解。
建立上述關聯后,會得到一系列類似于[表 11.1](chapter11.html#table-association-methods) 中的方法,如[表 12.1](#table-association-methods-relationships) 所示。
表 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](#listing-relationship-validation-tests))和應用代碼([代碼清單 12.5](#listing-relationship-validations))都非常直觀。和生成的用戶固件一樣([代碼清單 6.29](chapter6.html#listing-default-fixtures)),生成的“關系”固件也違背了遷移中的唯一性約束([代碼清單 12.1](#listing-relationships-migration))。這個問題的解決方法也和之前一樣([代碼清單 6.30](chapter6.html#listing-empty-fixtures))——刪除自動生成的固件,如[代碼清單 12.6](#listing-empty-relationship-fixture) 所示。
##### 代碼清單 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](#fig-user-has-many-following) 所示。默認情況下,在 `has_many :through` 關聯中,Rails 會尋找關聯名單數形式對應的外鍵。例如:
```
has_many :followeds, through: :active_relationships
```
Rails 發現關聯名是“followeds”,把它變成單數形式“followed”,因此會在 `relationships` 表中獲取一個由 `followed_id` 組成的集合。不過,[12.1.1 節](#a-problem-with-the-data-model-and-a-solution)說過,寫成 `user.followeds` 有點說不通,所以我們會使用 `user.following`。Rails 允許定制默認生成的關聯方法:使用 `source` 參數指定 `following` 數組由 `followed_id` 組成,如[代碼清單 12.8](#listing-has-many-following-through-active-relationships) 所示。
##### 代碼清單 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 節](chapter4.html#arrays-and-ranges))檢查我關注的用戶中有沒有某個用戶,或者通過關聯查找一個用戶:
```
user.following.include?(other_user)
user.following.find(other_user)
```
很多情況下我們都可以把 `following` 當成數組來用,Rails 會使用特定的方式處理 `following`,所以這么做很高效。例如:
```
following.include?(other_user)
```
看起來好像是要把我關注的所有用戶都從數據庫中讀取出來,然后再調用 `include?`。其實不然,為了提高效率,Rails 會直接在數據庫層執行相關的操作。(和 [11.2.1 節](chapter11.html#rendering-microposts)使用 `user.microposts.count` 獲取數量一樣,都直接在數據庫中操作。)
為了處理關注用戶的操作,我們要定義兩個輔助方法:`follow` 和 `unfollow`。這樣我們就可以寫 `user.follow(other_user)`。我們還要定義 `following?` 布爾值方法,檢查一個用戶是否關注了另一個用戶。[[6](#fn-6)]
現在是編寫測試的好時機,因為我們還要等很久才會開發關注用戶的網頁界面,如果一直沒人監管,很難向前推進。我們可以為用戶模型編寫一個簡短的測試,先調用 `following?` 方法確認某個用戶沒有關注另一個用戶,然后調用 `follow` 方法關注這個用戶,再使用 `following?` 方法確認關注成功了,最后調用 `unfollow` 方法取消關注,并確認操作成功,如[代碼清單 12.9](#listing-utility-method-tests) 所示。
##### 代碼清單 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](#table-association-methods-relationships),我們要使用 `following` 關聯定義 `follow`、`unfollow` 和 `following?` 方法,如[代碼清單 12.10](#listing-follow-unfollow-following) 所示。(注意,只要可能,我們就省略 `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](#fig-user-has-many-following) 中得知,獲取關注我的人所需的數據都已經存在于 `relationships` 表中(我們要參照[代碼清單 12.2](#listing-user-relationships-association) 中實現 `active_relationships` 表的方式)。其實我們要使用的方法和實現我關注的人一樣,只要對調 `follower_id` 和 `followed_id` 的位置,并把 `active_relationships` 換成 `passive_relationships` 即可,如[圖 12.9](#fig-user-has-many-followers) 所示。
圖 12.9:通過“被動關系”獲取關注我的用戶
參照[代碼清單 12.8](#listing-has-many-following-through-active-relationships),我們可以使用[代碼清單 12.12](#listing-has-many-following-through-passive-relationships) 中的代碼實現[圖 12.9](#fig-user-has-many-followers) 中的模型。
##### 代碼清單 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](#listing-has-many-following-through-passive-relationships) 之所以保留了 `source` 參數,是為了和 `has_many :following` 關聯的結構保持一致。
我們可以使用 `followers.include?` 測試這個數據模型,如[代碼清單 12.13](#listing-followers-test) 所示。(這段測試本可以使用與 `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](#listing-utility-method-tests) 的基礎上增加了一行代碼,但若想讓這個測試通過,很多事情都要正確處理才行,所以足以測試[代碼清單 12.12](#listing-has-many-following-through-passive-relationships) 中的關聯。
現在,整個測試組件都能通過:
```
$ bundle exec rake test
```
- 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 練習