Flask 教程 第八章:粉絲

weixin_33797791發表於2019-02-21

本文翻譯自The Flask Mega-Tutorial Part VIII: Followers

這是Flask Mega-Tutorial系列的第八部分,我將告訴你如何實現類似於Twitter和其他社交網路的“粉絲”功能。

在本章中,我將更多地使用應用的資料庫。 我希望應用的使用者能夠輕鬆便捷地關注其他使用者。 所以我要擴充套件資料庫,以便跟蹤誰關注了誰,這比你想象的要難得多。

本章的GitHub連結為:Browse, Zip, Diff.

深入理解資料庫關係

我上面說過,我想為每個使用者維護一個“粉絲”使用者列表和“關注”使用者列表。 不幸的是,關係型資料庫沒有列表型別的欄位來儲存它們,那麼只能通過表的現有欄位和他們之間的關係來實現。

資料庫已有一個代表使用者的表,所以剩下的就是如何正確地組織他們之間的關注與被關注的關係。 這正是回顧基本資料庫關係型別的好時機:

一對多

我已經在第四章中用過了一對多關係。這是該關係的示意圖(譯者注:實際表名分別為user和post):

4961528-789b72ec95228315.png
一對多關係

使用者和使用者動態通過這個關係來關聯。其中,一個使用者擁有條使用者動態,而一條使用者動態屬於個使用者(作者)。資料庫在的這方使用了一個外來鍵以表示一對多關係。在上面的一對多關係中,外來鍵是post表的user_id欄位,這個欄位將使用者的每條動態都與其作者關聯了起來。

很明顯,user_id欄位提供了直接訪問給定使用者動態的作者,但是反向呢? 透過這層關係,我如何通過給定的使用者來獲得其使用者動態的列表?post表中的user_id欄位也足以回答這個問題,資料庫具有索引,可以進行高效的查詢“返回所有user_id欄位等於X的使用者動態”。

多對多

多對多關係會更加複雜,舉個例子,資料庫中有students表和teachers表,一名學生學習位老師的課程,一位老師教授名學生。這就像兩個重疊的一對多關係。

對於這種型別的關係,我想要能夠查詢資料庫來獲取教授給定學生的教師的列表,以及某個教師課程中的學生的列表。 想要在關係型資料庫中梳理這樣的關係並非輕易而舉,因為無法通過向現有表新增外來鍵來完成此操作。

展現多對多關係需要使用額外的關聯表。以下是資料庫如何查詢學生和教師的示例:

4961528-ac7e6ea64131bc16.png
多對多

雖然起初看起來並不明顯,但具有兩個外來鍵的關聯表的確能夠有效地回答所有多對多關係的查詢。

多對一和一對一

多對一關係類似於一對多關係。 不同的是,這種關係是從“多”的角度來看的。

一對一的關係是一對多的特例。 實現是相似的,但是一個約束被新增到資料庫,以防止“多”一方有多個連結。 雖然有這種型別的關係是有用的,但並不像其他型別那麼普遍。

譯者注:如果讀者有興趣,也可以看看我寫的一篇類似的資料庫關係文章——Web開發中常用的資料關係

實現粉絲機制

檢視所有關係型別的概要,很容易確定維護粉絲關係的正確資料模型是多對多關係,因為使用者可以關注個其他使用者,並且使用者可以擁有個粉絲。 不過,在學生和老師的例子中,多對多關係關聯了兩個實體。 但在粉絲關係中,使用者關注其他使用者,只有一個使用者實體。 那麼,多對多關係的第二個實體是什麼呢?

該關係的第二個實體也是使用者。 一個類的例項被關聯到同一個類的其他例項的關係被稱為自引用關係,這正是我在這裡所用到的。

使用自引用多對多關係來實現粉絲機制的表結構示意圖:

4961528-00282d0dec00d11c.png
多對多

followers表是關係的關聯表。 此表中的外來鍵都指向使用者表中的資料行,因為它將使用者關聯到使用者。 該表中的每個記錄代表關注者和被關注者的一個關係。 像學生和老師的例子一樣,像這樣的設計允許資料庫回答所有關於關注和被關注的問題,並且足夠乾淨利落。

資料庫模型的實現

首先,讓我們在資料庫中新增粉絲機制吧。這是followers關聯表:

followers = db.Table('followers',
    db.Column('follower_id', db.Integer, db.ForeignKey('user.id')),
    db.Column('followed_id', db.Integer, db.ForeignKey('user.id'))
)

這是上圖中關聯表的直接翻譯。 請注意,我沒有像我為使用者和使用者動態所做的那樣,將表宣告為模型。 因為這是一個除了外來鍵沒有其他資料的輔助表,所以我建立它的時候沒有關聯到模型類。

現在我可以在使用者表中宣告多對多的關係了:

class User(UserMixin, db.Model):
    # ...
    followed = db.relationship(
        'User', secondary=followers,
        primaryjoin=(followers.c.follower_id == id),
        secondaryjoin=(followers.c.followed_id == id),
        backref=db.backref('followers', lazy='dynamic'), lazy='dynamic')

建立關係的過程實屬不易。 就像我為post一對多關係所做的那樣,我使用db.relationship函式來定義模型類中的關係。 這種關係將User例項關聯到其他User例項,所以按照慣例,對於通過這種關係關聯的一對使用者來說,左側使用者關注右側使用者。 我在左側的使用者中定義了followed的關係,因為當我從左側查詢這個關係時,我將得到已關注的使用者列表(即右側的列表)。 讓我們逐個檢查這個db.relationship()所有的引數:

  • 'User'是關係當中的右側實體(將左側實體看成是上級類)。由於這是自引用關係,所以我不得不在兩側都使用同一個實體。
  • secondary 指定了用於該關係的關聯表,就是使用我在上面定義的followers
  • primaryjoin 指明瞭通過關係表關聯到左側實體(關注者)的條件 。關係中的左側的join條件是關係表中的follower_id欄位與這個關注者的使用者ID匹配。followers.c.follower_id表示式引用了該關係表中的follower_id列。
  • secondaryjoin 指明瞭通過關係表關聯到右側實體(被關注者)的條件 。 這個條件與primaryjoin類似,唯一的區別在於,現在我使用關係表的欄位的是followed_id了。
  • backref定義了右側實體如何訪問該關係。在左側,關係被命名為followed,所以在右側我將使用followers來表示所有左側使用者的列表,即粉絲列表。附加的lazy參數列示這個查詢的執行模式,設定為動態模式的查詢不會立即執行,直到被呼叫,這也是我設定使用者動態一對多的關係的方式。
  • lazybackref中的lazy類似,只不過當前的這個是應用於左側實體,backref中的是應用於右側實體。

如果理解起來比較困難,你也不必過於擔心。我待會兒就會向你展示如何利用這些關係來執行查詢,一切就會變得清晰明瞭。

資料庫的變更,需要記錄到一個新的資料庫遷移中:

(venv) $ flask db migrate -m "followers"
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added table 'followers'
  Generating /home/miguel/microblog/migrations/versions/ae346256b650_followers.py ... done

(venv) $ flask db upgrade
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.runtime.migration] Running upgrade 37f06a334dbf -> ae346256b650, followers

關注和取消關注

感謝SQLAlchemy ORM,一個使用者關注另一個使用者的行為可以通過followed關係抽象成一個列表來簡便使用。 例如,如果我有兩個使用者儲存在user1user2變數中,我可以用下面這個簡單的語句來實現:

user1.followed.append(user2)

要取消關注該使用者,我可以這麼做:

user1.followed.remove(user2)

即便關注和取消關注的操作相當容易,我仍然想提高這段程式碼的可重用性,所以我不會直接在程式碼中使用“appends”和“removes”,取而代之,我將在User模型中實現“follow”和“unfollow”方法。 最好將應用邏輯從檢視函式轉移到模型或其他輔助類或輔助模組中,因為你會在本章之後將會看到,這使得單元測試更加容易。

下面是使用者模型中新增和刪除關注關係的程式碼變更:

class User(UserMixin, db.Model):
    #...

    def follow(self, user):
        if not self.is_following(user):
            self.followed.append(user)

    def unfollow(self, user):
        if self.is_following(user):
            self.followed.remove(user)

    def is_following(self, user):
        return self.followed.filter(
            followers.c.followed_id == user.id).count() > 0

follow()unfollow()方法使用關係物件的append()remove()方法。有必要在處理關係之前,使用一個is_following()方法來確認操作的前提條件是否符合,例如,如果我要求user1關注user2,但事實證明這個關係在資料庫中已經存在,我就沒必要重複操作了。 相同的邏輯可以應用於取消關注。

is_following()方法發出一個關於followed關係的查詢來檢查兩個使用者之間的關係是否已經存在。 你已經看到過我使用SQLAlchemy查詢物件的filter_by()方法,例如,查詢給定使用者名稱的使用者。 我在這裡使用的filter()方法很類似,但是更加偏向底層,因為它可以包含任意的過濾條件,而不像filter_by(),它只能檢查是否等於一個常量值。 我在is_following()中使用的過濾條件是,查詢關聯表中左側外來鍵設定為self使用者且右側設定為user引數的資料行。 查詢以count()方法結束,返回結果的數量。 這個查詢的結果是01,因此檢查計數是1還是大於0實際上是相等的。 至於其他的查詢結束符all()first(),你已經看到我使用過了。

檢視已關注使用者的動態

在資料庫中支援粉絲機制的工作幾近尾聲,但是我卻遺漏了一項重要的功能。應用主頁中需要展示已登入使用者關注的其他所有使用者的動態,我需要用資料庫查詢來返回這些使用者動態。

最顯而易見的方案是先執行一個查詢以返回已關注使用者的列表,如你所知,可以使用user.followed.all()語句。然後對每個已關注的使用者執行一個查詢來返回他們的使用者動態。最後將所有使用者的動態按照日期時間倒序合併到一個列表中。聽起來不錯?其實不然。

這種方法有幾個問題。 如果一個使用者關注了一千人,會發生什麼? 我需要執行一千個資料庫查詢來收集所有的使用者動態。 然後我需要合併和排序記憶體中的一千個列表。 作為第二個問題,考慮到應用主頁最終將實現分頁,所以它不會顯示所有可用的使用者動態,只能是前幾個,並顯示一個連結來提供感興趣的使用者檢視更多動態。 如果我要按它們的日期排序來顯示動態,我怎麼能知道哪些使用者動態才是所有使用者中最新的呢?除非我首先得到了所有的使用者動態並對其進行排序。 這實際上是一個糟糕的解決方案,不能很好地應對規模化。

使用者動態的合併和排序操作是無法避免的,但是在應用中執行會導致效率十分低下, 而這種工作是關聯式資料庫擅長的。 我可以使用資料庫的索引,命令它以更有效的方式執行查詢和排序。 所以我真正想要提供的方案是,定義我想要得到的資訊來執行一個資料庫查詢,然後讓資料庫找出如何以最有效的方式來提取這些資訊。

看看下面的這個查詢:

class User(db.Model):
    #...
    def followed_posts(self):
        return Post.query.join(
            followers, (followers.c.followed_id == Post.user_id)).filter(
                followers.c.follower_id == self.id).order_by(
                    Post.timestamp.desc())

這是迄今為止我在這個應用中使用的最複雜的查詢。 我將嘗試一步一步地解讀這個查詢。 如果你看一下這個查詢的結構,你會注意到有三個主要部分,分別是join()filter()order_by(),他們都是SQLAlchemy查詢物件的方法:

Post.query.join(...).filter(...).order_by(...)

聯合查詢

要理解join操作的功能,我們來看一個例子。 假設我有一個包含以下內容的User表:

id username
1 john
2 susan
3 mary
4 david

為了簡單起見,我只會保留使用者模型的idusername欄位以便進行查詢,其他的都略去。

假設followers關係表中資料表達的是使用者john關注使用者susandavid,使用者susan關注使用者mary,使用者mary關注使用者david。這些的資料如下表所示:

follower_id followed_id
1 2
1 4
2 3
3 4

最後,使用者動態表中包含了每個使用者的一條動態:

id text user_id
1 post from susan 2
2 post from mary 3
3 post from david 4
4 post from john 1

這張表也省略了一些不屬於這個討論範圍的欄位。

這是我為該查詢再次設計的join()呼叫:

Post.query.join(followers, (followers.c.followed_id == Post.user_id))

我在使用者動態表上呼叫join操作。 第一個引數是followers關聯表,第二個引數是join條件。 我的這個呼叫表達的含義是我希望資料庫建立一個臨時表,它將使用者動態表和關注者表中的資料結合在一起。 資料將根據引數傳遞的條件進行合併。

我使用的條件表示了followers關係表的followed_id欄位必須等於使用者動態表的user_id欄位。 要執行此合併,資料庫將從使用者動態表(join的左側)獲取每條記錄,並追加followers關係表(join的右側)中的匹配條件的所有記錄。 如果followers關係表中有多個記錄符合條件,那麼使用者動態資料行將重複出現。 如果對於一個給定的使用者動態,followers關係表中卻沒有匹配,那麼該使用者動態的記錄不會出現在join操作的結果中。

利用我上面定義的示例資料,執行join操作的結果如下:

id text user_id follower_id followed_id
1 post from susan 2 1 2
2 post from mary 3 2 3
3 post from david 4 1 4
3 post from david 4 3 4

注意user_idfollowed_id列在所有資料行中都是相等的,因為這是join條件。 來自使用者john的使用者動態不會出現在臨時表中,因為被關注列表中沒有包含john使用者,換句話說,沒有任何人關注john。 而來自david的使用者動態出現了兩次,因為該使用者有兩個粉絲。

雖然建立了這個join操作,但卻沒有得到想要的結果。請繼續看下去,因為這只是更大的查詢的一部分。

過濾

Join操作給了我一個所有被關注使用者的使用者動態的列表,遠超出我想要的那部分資料。 我只對這個列表的一個子集感興趣——某個使用者關注的使用者們的動態,所以我需要用filter()來剔除所有我不需要的資料。

這是過濾部分的查詢語句:

filter(followers.c.follower_id == self.id)

該查詢是User類的一個方法,self.id表示式是指我感興趣的使用者的ID。filter()挑選臨時表中follower_id列等於這個ID的行,換句話說,我只保留follower(粉絲)是該使用者的資料。

假如我現在對id為1的使用者john能看到的使用者動態感興趣,這是從臨時表過濾後的結果:

id text user_id follower_id followed_id
1 post from susan 2 1 2
3 post from david 4 1 4

這正是我想要的結果!

請記住,查詢是從Post類中發出的,所以儘管我曾經得到了由資料庫建立的一個臨時表來作為查詢的一部分,但結果將是包含在此臨時表中的使用者動態, 而不會存在由於執行join操作新增的其他列。

排序

查詢流程的最後一步是對結果進行排序。這部分的查詢語句如下:

order_by(Post.timestamp.desc())

在這裡,我要說的是,我希望使用使用者動態產生的時間戳按降序排列結果列表。排序之後,第一個結果將是最新的使用者動態。

組合自身動態和關注的使用者動態

我在followed_posts()函式中使用的查詢是非常有用的,但有一個限制,人們期望看到他們自己的動態包含在他們的關注的使用者動態的時間線中,而該查詢卻力有未逮。

有兩種可能的方式來擴充套件此查詢以包含使用者自己的動態。 最直截了當的方法是將查詢保持原樣,但要確保所有使用者都關注了他們自己。 如果你是你自己的粉絲,那麼上面的查詢就會找到你自己的動態以及你關注的所有人的動態。 這種方法的缺點是會影響粉絲的統計資料。 所有人的粉絲數量都將加一,所以它們必須在顯示之前進行調整。 第二種方法是通過建立第二個查詢返回使用者自己的動態,然後使用“union”操作將兩個查詢合併為一個查詢。

深思熟慮之後,我選擇了第二個方案。 下面你可以看到followed_posts()函式已被擴充套件成通過聯合查詢來併入使用者自己的動態:

    def followed_posts(self):
        followed = Post.query.join(
            followers, (followers.c.followed_id == Post.user_id)).filter(
                followers.c.follower_id == self.id)
        own = Post.query.filter_by(user_id=self.id)
        return followed.union(own).order_by(Post.timestamp.desc())

請注意,followedown查詢結果集是在排序之前進行的合併。

對使用者模型執行單元測試

雖然我不擔心這個稍顯“複雜”的粉絲機制的執行是否無誤。 但當我編寫舉足輕重的程式碼時,我擔心的是我在應用的不同部分修改了程式碼之後,如何確保本處程式碼將來會繼續工作。 確保已經編寫的程式碼在將來繼續有效的最佳方法是建立一套自動化測試,你可以在每次更新程式碼後執行測試。

Python包含一個非常有用的unittest包,可以輕鬆編寫和執行單元測試。 讓我們來為User類中的現有方法編寫一些單元測試並儲存到tests.py模組:

from datetime import datetime, timedelta
import unittest
from app import app, db
from app.models import User, Post

class UserModelCase(unittest.TestCase):
    def setUp(self):
        app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite://'
        db.create_all()

    def tearDown(self):
        db.session.remove()
        db.drop_all()

    def test_password_hashing(self):
        u = User(username='susan')
        u.set_password('cat')
        self.assertFalse(u.check_password('dog'))
        self.assertTrue(u.check_password('cat'))

    def test_avatar(self):
        u = User(username='john', email='john@example.com')
        self.assertEqual(u.avatar(128), ('https://www.gravatar.com/avatar/'
                                         'd4c74594d841139328695756648b6bd6'
                                         '?d=identicon&s=128'))

    def test_follow(self):
        u1 = User(username='john', email='john@example.com')
        u2 = User(username='susan', email='susan@example.com')
        db.session.add(u1)
        db.session.add(u2)
        db.session.commit()
        self.assertEqual(u1.followed.all(), [])
        self.assertEqual(u1.followers.all(), [])

        u1.follow(u2)
        db.session.commit()
        self.assertTrue(u1.is_following(u2))
        self.assertEqual(u1.followed.count(), 1)
        self.assertEqual(u1.followed.first().username, 'susan')
        self.assertEqual(u2.followers.count(), 1)
        self.assertEqual(u2.followers.first().username, 'john')

        u1.unfollow(u2)
        db.session.commit()
        self.assertFalse(u1.is_following(u2))
        self.assertEqual(u1.followed.count(), 0)
        self.assertEqual(u2.followers.count(), 0)

    def test_follow_posts(self):
        # create four users
        u1 = User(username='john', email='john@example.com')
        u2 = User(username='susan', email='susan@example.com')
        u3 = User(username='mary', email='mary@example.com')
        u4 = User(username='david', email='david@example.com')
        db.session.add_all([u1, u2, u3, u4])

        # create four posts
        now = datetime.utcnow()
        p1 = Post(body="post from john", author=u1,
                  timestamp=now + timedelta(seconds=1))
        p2 = Post(body="post from susan", author=u2,
                  timestamp=now + timedelta(seconds=4))
        p3 = Post(body="post from mary", author=u3,
                  timestamp=now + timedelta(seconds=3))
        p4 = Post(body="post from david", author=u4,
                  timestamp=now + timedelta(seconds=2))
        db.session.add_all([p1, p2, p3, p4])
        db.session.commit()

        # setup the followers
        u1.follow(u2)  # john follows susan
        u1.follow(u4)  # john follows david
        u2.follow(u3)  # susan follows mary
        u3.follow(u4)  # mary follows david
        db.session.commit()

        # check the followed posts of each user
        f1 = u1.followed_posts().all()
        f2 = u2.followed_posts().all()
        f3 = u3.followed_posts().all()
        f4 = u4.followed_posts().all()
        self.assertEqual(f1, [p2, p4, p1])
        self.assertEqual(f2, [p2, p3])
        self.assertEqual(f3, [p3, p4])
        self.assertEqual(f4, [p4])

if __name__ == '__main__':
    unittest.main(verbosity=2)

我新增了四個使用者模型的測試,包含密碼雜湊、使用者頭像和粉絲功能。 setUp()tearDown()方法是單元測試框架分別在每個測試之前和之後執行的特殊方法。 我在setUp()中實現了一些小技巧,以防止單元測試使用我用於開發的常規資料庫。 通過將應用配置更改為sqlite://,我在測試過程中通過SQLAlchemy來使用SQLite記憶體資料庫。 db.create_all()建立所有的資料庫表。 這是從頭開始建立資料庫的快速方法,在測試中相當好用。 而對於開發環境和生產環境的資料庫結構管理,我已經通過資料庫遷移的手段向你展示過了。

你可以使用以下命令執行整個測試元件:

(venv) $ python tests.py
test_avatar (__main__.UserModelCase) ... ok
test_follow (__main__.UserModelCase) ... ok
test_follow_posts (__main__.UserModelCase) ... ok
test_password_hashing (__main__.UserModelCase) ... ok

----------------------------------------------------------------------
Ran 4 tests in 0.494s

OK

從現在起,每次對應用進行更改時,都可以重新執行測試,以確保正在測試的功能沒有受到影響。 另外,每次將另一個功能新增到應用時,都應該為其編寫一個單元測試。

在應用中整合粉絲機制

資料庫和模型中粉絲機制的實現現在已經完成,但是我沒有將它整合到應用中,所以我現在要新增這個功能。 值得高興的是,實現它沒有什麼大的挑戰,都將基於你已經學過的概念。

讓我們來新增兩個新的路由和檢視函式,它們提供了使用者關注和取消關注的URL和邏輯實現:

@app.route('/follow/<username>')
@login_required
def follow(username):
    user = User.query.filter_by(username=username).first()
    if user is None:
        flash('User {} not found.'.format(username))
        return redirect(url_for('index'))
    if user == current_user:
        flash('You cannot follow yourself!')
        return redirect(url_for('user', username=username))
    current_user.follow(user)
    db.session.commit()
    flash('You are following {}!'.format(username))
    return redirect(url_for('user', username=username))

@app.route('/unfollow/<username>')
@login_required
def unfollow(username):
    user = User.query.filter_by(username=username).first()
    if user is None:
        flash('User {} not found.'.format(username))
        return redirect(url_for('index'))
    if user == current_user:
        flash('You cannot unfollow yourself!')
        return redirect(url_for('user', username=username))
    current_user.unfollow(user)
    db.session.commit()
    flash('You are not following {}.'.format(username))
    return redirect(url_for('user', username=username))

檢視函式的邏輯不言而喻,但要注意所有的錯誤檢查,以防止出現意外的問題,並嘗試在出現問題時向使用者提供有用的資訊。

我將新增這兩個檢視函式的路由到每個使用者的個人主頁中,以便其他使用者執行關注和取消關注的操作:

        ...
        <h1>User: {{ user.username }}</h1>
        {% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %}
        {% if user.last_seen %}<p>Last seen on: {{ user.last_seen }}</p>{% endif %}
        <p>{{ user.followers.count() }} followers, {{ user.followed.count() }} following.</p>
        {% if user == current_user %}
        <p><a href="{{ url_for('edit_profile') }}">Edit your profile</a></p>
        {% elif not current_user.is_following(user) %}
        <p><a href="{{ url_for('follow', username=user.username) }}">Follow</a></p>
        {% else %}
        <p><a href="{{ url_for('unfollow', username=user.username) }}">Unfollow</a></p>
        {% endif %}
        ...

使用者個人主頁的變更,一是在最近訪問的時間戳之下新增一行,以顯示此使用者擁有多少個粉絲和關注使用者。二是當你檢視自己的個人主頁時出現的“Edit”連結的行,可能會變成以下三種連結之一:

  • 如果使用者檢視他(她)自己的個人主頁,仍然是“Edit”連結不變。
  • 如果使用者檢視其他並未關注的使用者的個人主頁,顯示“Follow”連結。
  • 如果使用者檢視其他已經關注的使用者的個人主頁,顯示“Unfollow”連結。

此時,你可以執行該應用,建立一些使用者並測試一下關注和取消關注使用者的功能。 唯一需要記住的是,需要手動鍵入你要關注或取消關注的使用者的個人主頁URL,因為目前沒有辦法檢視使用者列表。 例如,如果你想關注susan,則需要在瀏覽器的位址列中輸入http://localhost:5000/user/susan以訪問該使用者的個人主頁。 請確保你在測試關注和取消關注的時候,留意到了其粉絲和關注的數量變化。

我應該在應用的主頁上顯示使用者動態的列表,但是我還沒有完成所有依賴的工作,因為使用者不能發表動態。 所以我會暫緩這個頁面的完善工作,直到發表使用者動態功能的完成。

相關文章