文章轉自 :https://github.com/WapeYang/The-Flask-Mega-Tutorial/blob/master/followers.rst
感謝原作者的付出
轉載時間爲:2014-05-06
關注者,聯繫人和好友
回顧
我們小型的 microblog 應用程序已經慢慢變大,到現在爲止我們已經接觸了需要完成應用程序的大部分的話題。
今天我們將更加深入地學習數據庫。我們應用程序的每一個用戶都能夠選擇他或者她的關注者,因此我們的數據庫必須能夠記錄誰關注了誰。所有的社交應用都會以某種形式實現這個功能。一些應用程序稱它爲聯繫人,其他連接,好友,夥伴或者關注者。其他的站點使用同樣的主意去實現允許和忽略的用戶列表。我們稱它爲關注者,儘管名字不同實現方式卻是一樣的。
‘關注者’ 特色的設計
在編碼之前,我們需要考慮下我們要從這個功能上得到些什麼,換句話說,我們要實現些什麼。
讓我們先從最明顯一個開始。我們想要用戶容易地維護關注者的列表。
從另外一方面來看,對於每一個用戶,我們想要知道他的或者她的關注者列表。
我們也想要有一種方式去查詢用戶是否被關注或者關注過其他用戶。
用戶點擊任何用戶的信息頁上一個 “關注” 的鏈接就開始關注這個用戶。否則,他們點擊 “取消關注” 鏈接將會停止關注這個用戶。
最後一個需求就是對於一個給定的用戶,我們能夠容易地查詢數據庫獲取用戶的被關注者的所有 blog。
所以,如果你認爲這將是一個快速和容易的章節,請再想想!
數據庫關係
我們說過我們想要有所有用戶都擁有 “關注者” 和 “被關注者” 的列表。不幸地是,一個關係型數據庫是沒有 list 類型,我們有的是含有記錄的表以及記錄與記錄之間的關係。
我們已經在數據庫中有一個表來表示用戶,所以剩下的就是找出適當的關係類型,它能模擬關注者/被關注者的鏈接。這是重新回顧三種數據關係類型的好時候:
一對多
在前面的章節中,我們已經見過一對多的關係。下面是這種關係的圖表:
users 和 posts 是具有這種關係的兩個表。我們說一個用戶會有撰寫多篇 blog,一篇 blog 會有一個撰寫人。這種關係在數據庫中的表示就是在 “多” 的這一邊中使用了外鍵。在上面的例子中外鍵就是 posts 表中的 user_id。 這個字段把每一篇 blog 鏈接到用戶表的作者的數據記錄上。
user_id 字段提供了到給定 blog 作者的直接入口,然而相反的情況了?因爲關係是很有作用的,我們應該能夠得到一個給定的用戶所撰寫的 blog 列表。原來在 posts 表中 user_id 字段是足夠能夠回答這個問題,因爲數據庫有高效的查詢索引允許我們查詢類似 “獲取用戶 user_id 爲 X 的所有的 blog” 的操作。
多對多
多對多的關係是有些複雜。例如,考慮一個數據庫有 students 以及 teachers。我們可以說一個學生會有很多個老師,以及一個老師下也有多個學生。這就像兩端(學生和老師)都是一對多的關係。
對於這種類型的關係,我們應該能夠查詢數據庫獲取在一個 teachers 類中教某一個學生的老師列表,以及一個老師下所有教的學生的列表。表示上述關係是相當棘手的,它不能簡單地在已存在的表中添加外鍵。
這種多對多的關係的表示需要一個額外的稱爲關聯表的輔助表。下面是數據庫如何表示學生和教師的關係的例子:
雖然它可能不會看起來很簡單,兩個外鍵的關聯表能夠有效地回答很多種類的查詢,如:
- 哪些老師教學生 S?
- 哪些學生是老師 T 教的?
- 老師 T 有多少個學生?
- 學生 S 有多少個老師?
- 老師 T 正在教學生 S 嗎?
- 學生 S 在老師 T 的類裏嗎?
一對一
一對一的關係是一對多關係的一種特殊情況。表示方式是類似的,但是限制是添加到數據庫中爲了禁止 “多” 的這一邊有一個以上的鏈接。
雖然某些情況下,這種類型的關係是有用的,它對其他兩種類型來說是不常用,因爲任何時候一個表的一個記錄映射到另外一個表中的一個記錄,可以說把兩個表合併成一個更有意義。
表示關注者和被關注者
從上面講述到關係來說,我們很容易地決定最合適的模型是多對多的關係,因爲一個用戶可以關注多個其他的用戶,同樣一個用戶可以被其他多個用戶關注。但是這有一個問題。我們想要表示用戶關注其他用戶,因爲我們只有用戶。我們應該使用什麼作爲多對多關係的第二個表(實體)?
好的,這種關係的第二個表(實體)也是用戶。如果一個表是指向自己的關係叫做 自我指向 關係,這就是我們現在需要的。
下面是多對多關係的圖:
followers 表示我們的關聯表。外鍵都是來自於用戶表中,因爲我們是用戶連接到用戶。在這個表中的每一個記錄都是表示關注的用戶以及被關注的用戶的連接。像學生和老師的例子,像這樣的一個設置允許回答所有我們將需要的關注者以及被關注者的問題。
數據模型
我們數據庫的改變不是很大。我們首先開始添加 followers 表(文件 app/models.py):
followers = db.Table('followers', db.Column('follower_id', db.Integer, db.ForeignKey('user.id')), db.Column('followed_id', db.Integer, db.ForeignKey('user.id')) )
這是對上面圖表上的關係表的直接翻譯。注意我們並沒有像對 users 和 posts 一樣把它聲明爲一個模式。因爲這是一個輔助表,我們使用 flask-sqlalchemy 中的低級的 APIs 來創建沒有使用關聯模式。
接着我們在 users 表中定義一個多對多的關係:
class User(db.Model): id = db.Column(db.Integer, primary_key = True) nickname = db.Column(db.String(64), unique = True) email = db.Column(db.String(120), index = True, unique = True) role = db.Column(db.SmallInteger, default = ROLE_USER) posts = db.relationship('Post', backref = 'author', lazy = 'dynamic') about_me = db.Column(db.String(140)) last_seen = db.Column(db.DateTime) 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')
關係的設置不是很簡單,需要一些解釋。像我們在前面章節設置一對多關係一樣,我們使用了 db.relationship 函數來定義關係。我們將連接 User 實例到其它 User 實例,換一種通俗的話來說,在這種關係下連接的一對用戶,左邊的用戶是關注着右邊的用戶。因爲我們定義左邊的用戶爲 followed,當我們從左邊用戶查詢這種關係的時候,我們將會得到被關注用戶的列表。讓我們一個一個來解釋下 db.relationship() 中的所有參數:
- 'User' 是這種關係中的右邊的表(實體)(左邊的表/實體是父類)。因爲定義一個自我指向的關係,我們在兩邊使用同樣的類。
- secondary 指明瞭用於這種關係的輔助表。
- primaryjoin 表示輔助表中連接左邊實體(發起關注的用戶)的條件。注意因爲 followers 表不是一個模式,獲得字段名的語法有些怪異。
- secondaryjoin 表示輔助表中連接右邊實體(被關注的用戶)的條件。
- backref 定義這種關係將如何從右邊實體進行訪問。當我們做出一個名爲 followed 的查詢的時候,將會返回所有跟左邊實體聯繫的右邊的用戶。當我們做出一個名爲 followers 的查詢的時候,將會返回一個所有跟右邊聯繫的左邊的用戶。lazy 指明瞭查詢的模式。dynamic 模式表示直到有特定的請求才會運行查詢,這是對性能有很好的考慮。
- lazy 是與 backref 中的同樣名稱的參數作用是類似的,但是這個是應用於常規查詢。
如果上面的解釋很難理解的話,沒有關係。我們會在後面使用這些查詢,一切就會明瞭。
因爲我們對數據庫做出了修改,現在我們必須生成一個新的遷移腳本:
./db_migrate.py
添加和移除 ‘關注者’
爲了使得代碼具有可重用性,我們將會在 User 模型中實現 follow 和 unfollow 函數,而不是在視圖函數中。這種方式不僅可以讓這個功能應用於真實的應用也能在單元測試中測試。原則上,從視圖函數中移除應用程序的邏輯到數據模型中是一種好的方式。你們必須要保證視圖函數儘可能簡單,因爲它能難被自動化測試。
下面是添加了添加和移除 ‘關注者’ 功能的 User 模型(文件 app/models.py):
class User(db.Model): #... def follow(self, user): if not self.is_following(user): self.followed.append(user) return self def unfollow(self, user): if self.is_following(user): self.followed.remove(user) return self def is_following(self, user): return self.followed.filter(followers.c.followed_id == user.id).count() > 0
上面這些方法是很簡單了,多虧了 sqlalchemy 在底層做了很多的工作。我們只是從 followed 關係中添加或者移除了表項,sqlalchemy 爲我們管理輔助表。
follow 和 unfollow 方法是定義成當它們成功的話返回一個對象或者失敗的時候返回 None。當返回一個對象的時候,這個對象必須被添加到數據庫並且提交。
is_following 方法在一行代碼中做了很多。我們做了一個 followed 關係查詢,這個查詢返回所有當前用戶作爲關注者的 (follower, followed) 對。
測試
讓我們編寫單元測試框架來檢驗目前我們已經寫好的代碼(文件 tests.py):
class TestCase(unittest.TestCase): #... def test_follow(self): u1 = User(nickname = 'john', email = '[email protected]') u2 = User(nickname = 'susan', email = '[email protected]') db.session.add(u1) db.session.add(u2) db.session.commit() assert u1.unfollow(u2) == None u = u1.follow(u2) db.session.add(u) db.session.commit() assert u1.follow(u2) == None assert u1.is_following(u2) assert u1.followed.count() == 1 assert u1.followed.first().nickname == 'susan' assert u2.followers.count() == 1 assert u2.followers.first().nickname == 'john' u = u1.unfollow(u2) assert u != None db.session.add(u) db.session.commit() assert u1.is_following(u2) == False assert u1.followed.count() == 0 assert u2.followers.count() == 0
通過執行下面的命令來運行這個測試:
./tests.py
數據庫查詢
我們的數據庫模型已經能夠支持大部分我們列出來的需求。我們缺少的實際上是最難的。我們的首頁將會顯示登錄用戶所有關注者撰寫的 blog,因爲我們需要一個返回這些 blog 的查詢。
最明瞭的解決方式就是查詢給定的關注者用戶的列表,這也是我們目前可以做到的。接着對每一個返回的用戶去查詢他的或者她的 blog。一旦我們完成所有的查詢工作,我們把它們整合到一個列表中然後排序。聽起來不錯?實際上不是。
這種方法其實問題很大。當一個用戶擁有上千個關注者的話會發生些什麼?我們需要執行上千次甚至更多的數據庫查詢,並且在內存中我們需要維持一個數據量很大的 blog 的列表,接着還要排序。不知道這些做完,要花上多久的時間?
這種收集以及排序的工作需要在其它的地方完成,我們只要使用結果就行。這類的工作其實就是關係型數據庫擅長。數據庫有索引,因此允許以一種高效地方式去查詢以及排序。
所以我們真正想要的是要拿出一個單一的數據庫查詢,表示我們想要得到什麼樣的信息,然後我們讓數據庫弄清楚什麼是最有效的方式來爲我們獲取數據。
下面這種查詢可以實現上述的要求,這個單行的代碼又被我們添加到 User 模型(文件 app/models.py):
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())
讓我們來分解這個查詢。它一共有三部分:連接,過濾以及排序。
連接
爲了理解一個連接操作做了什麼,讓我們看看例子。假設我們有一個如下內容的 User 表:
只爲了簡化例子,表裏面還有一些額外的字段沒有顯示。
比如說,我們的 followers 輔助表中表示用戶 “john” 關注着 用戶 “susan” 以及 “david”,用戶 “susan” 關注着 “mary” 以及 用戶 “mary” 關注着 “david”。表示上述的數據是這樣的:
最後,我們的 Post 表中,每一個用戶有一篇 blog:
這裏再次申明爲了使得例子顯得簡單,我們忽略了一些字段。
下面是我們的查詢的連接部分的,獨立於其餘的查詢:
Post.query.join(followers, (followers.c.followed_id == Post.user_id))
在 Post 表中調用了 join 操作。這裏有兩個參數,第一個是其它的表,我們的 followers 表。第二參數就是連接的條件。
連接操作所做的就是創建一個數據來自於 Post 和 followers 表的臨時新的表,根據給定條件進行整合。
在這個例子中,我們要 followers 表中的字段 followed_id 與 Post 表中的字段 user_id 相匹配。
爲了演示整合的過程,我們從 Post 表中取出所有記錄,從 followers 表中取出符合條件的記錄插入在後邊。如果沒有匹配的話,Post 表中的記錄就會被移除。
我們例子中這個臨時表的連接的結果如下:
注意 Post 表中的 user_id=1 記錄被移除了,因爲在 followers 表中沒有 followed_id=1 的記錄。
過濾
連接操作給我們被某人關注的用戶的 blog 的列表,但是沒有指出誰是關注者。我們僅僅對這個列表的子集感興趣,我們只需要被某一特定用戶關注的用戶的 blog 列表。
因此我們過濾這個表格,查詢的過濾操作是:
filter(followers.c.follower_id == self.id)
注意查詢是在我們目標用戶的內容中執行,因爲這是 User 類的一個方法,self.id 就是我們感興趣的用戶的 id。因此在我們的例子中,如果我們感興趣的用戶的 id 是 id=1,那麼我們會得到另一個臨時表:
這就是我們要的 blog。請注意查詢是關注在 Post 類,因此即使我們得到一個不符合我們任何一個數據庫模型的臨時表,結果還是包含在這個臨時表中的 blog。
排序
最後一步就是根據我們的規則對結果進行排序。排序操作如下:
order_by(Post.timestamp.desc())
在這裏,我們要說的結果應該按照 timestamp 字段按降序排列,這樣的第一個結果將是最近的 blog。
這裏還有一個小問題需要我們改善我們的查詢操作。當用戶閱讀他們關注者的 blog 的時候,他們可能也想看到自己的 blog。因此最好把用戶自己的 blog 也包含進查詢結果中。
其實這不需要做任何改變。我們只需要把自己添加爲自己的關注者。
爲了結束我們長時間的查詢操作的討論,讓我們爲我們查詢寫些單元測試(文件 tests.py):
#... from datetime import datetime, timedelta from app.models import User, Post #... class TestCase(unittest.TestCase): #... def test_follow_posts(self): # make four users u1 = User(nickname = 'john', email = '[email protected]') u2 = User(nickname = 'susan', email = '[email protected]') u3 = User(nickname = 'mary', email = '[email protected]') u4 = User(nickname = 'david', email = '[email protected]') db.session.add(u1) db.session.add(u2) db.session.add(u3) db.session.add(u4) # make four posts utcnow = datetime.utcnow() p1 = Post(body = "post from john", author = u1, timestamp = utcnow + timedelta(seconds = 1)) p2 = Post(body = "post from susan", author = u2, timestamp = utcnow + timedelta(seconds = 2)) p3 = Post(body = "post from mary", author = u3, timestamp = utcnow + timedelta(seconds = 3)) p4 = Post(body = "post from david", author = u4, timestamp = utcnow + timedelta(seconds = 4)) db.session.add(p1) db.session.add(p2) db.session.add(p3) db.session.add(p4) db.session.commit() # setup the followers u1.follow(u1) # john follows himself u1.follow(u2) # john follows susan u1.follow(u4) # john follows david u2.follow(u2) # susan follows herself u2.follow(u3) # susan follows mary u3.follow(u3) # mary follows herself u3.follow(u4) # mary follows david u4.follow(u4) # david follows himself db.session.add(u1) db.session.add(u2) db.session.add(u3) db.session.add(u4) 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() assert len(f1) == 3 assert len(f2) == 2 assert len(f3) == 2 assert len(f4) == 1 assert f1 == [p4, p2, p1] assert f2 == [p3, p2] assert f3 == [p4, p3] assert f4 == [p4]
可能的改進
我們現在已經實現 ‘follower’ 功能所需要的內容,但是還能改進我們的設計使得變得更加合理。
所有的社會網絡,我們對這種連接其它用戶的功能是又愛又恨,但他們有更多的選擇來控制信息的共享。
例如,我們沒有權利拒絕別人的關注。這將要花費很大的底層代碼用於查詢,因爲我們不僅僅需要查詢到我們所關注的用戶的 blog,而且還要過濾掉拒絕關注的用戶的 blog。怎麼實現這種需求了?簡單,新增一個多對多的自我指向關係用來記錄誰拒絕誰的關注,接着一個新的連接+過濾的查詢用來返回這些 blog。
社交網絡中另一個流行的特色就是能夠定製關注者的分組,僅僅共享某些分組的內容。這也是能夠通過添加額外的關係以及複雜的查詢來實現。
我們不打算把這些加入到我們的 microblog,但是如果大家都感興趣的話,我將會就此話題新寫一章節。
收尾
今天我們已經取得了巨大的進步。儘管我們已經解決了所有的問題,但是有關數據庫的設置和查詢,我們還沒有在應用程序中啓用的這些新功能。
幸運地是,這些不存在什麼挑戰。我們只需要修改下視圖函數和模版,因此讓我們完成最後的部分來結束這一章節吧。
成爲自己的關注者
我們已經決定用戶可以關注所有的用戶,因此我們可以關注自己。
我們決定在 after_login 中處理 OpenID 的時候就設置自己成爲自己的關注者(文件 app/views.py):
@oid.after_login def after_login(resp): if resp.email is None or resp.email == "": flash('Invalid login. Please try again.') redirect(url_for('login')) user = User.query.filter_by(email = resp.email).first() if user is None: nickname = resp.nickname if nickname is None or nickname == "": nickname = resp.email.split('@')[0] nickname = User.make_unique_nickname(nickname) user = User(nickname = nickname, email = resp.email, role = ROLE_USER) db.session.add(user) db.session.commit() # make the user follow him/herself db.session.add(user.follow(user)) db.session.commit() remember_me = False if 'remember_me' in session: remember_me = session['remember_me'] session.pop('remember_me', None) login_user(user, remember = remember_me) return redirect(request.args.get('next') or url_for('index'))
關注以及取消關注的鏈接
接着,我們將會定義關注以及取消關注用戶的視圖函數(文件 app/views.py):
@app.route('/follow/<nickname>') def follow(nickname): user = User.query.filter_by(nickname = nickname).first() if user == None: flash('User ' + nickname + ' not found.') return redirect(url_for('index')) if user == g.user: flash('You can\'t follow yourself!') return redirect(url_for('user', nickname = nickname)) u = g.user.follow(user) if u is None: flash('Cannot follow ' + nickname + '.') return redirect(url_for('user', nickname = nickname)) db.session.add(u) db.session.commit() flash('You are now following ' + nickname + '!') return redirect(url_for('user', nickname = nickname)) @app.route('/unfollow/<nickname>') def unfollow(nickname): user = User.query.filter_by(nickname = nickname).first() if user == None: flash('User ' + nickname + ' not found.') return redirect(url_for('index')) if user == g.user: flash('You can\'t unfollow yourself!') return redirect(url_for('user', nickname = nickname)) u = g.user.unfollow(user) if u is None: flash('Cannot unfollow ' + nickname + '.') return redirect(url_for('user', nickname = nickname)) db.session.add(u) db.session.commit() flash('You have stopped following ' + nickname + '.') return redirect(url_for('user', nickname = nickname))
這裏應該不需要做過多的解釋,但是需要注意的是檢查周圍的錯誤,爲了防止期望之外的錯誤,試着給用戶提供信息並且重定向到合適的位置當錯誤發生的時候。
最後需要修改下模版(文件 app/templates/user.html):
<!-- extend base layout --> {% extends "base.html" %} {% block content %} <table> <tr valign="top"> <td><img src="{{user.avatar(128)}}"></td> <td> <h1>User: {{user.nickname}}</h1> {% if user.about_me %}<p>{{user.about_me}}</p>{% endif %} {% if user.last_seen %}<p><i>Last seen on: {{user.last_seen}}</i></p>{% endif %} <p>{{user.followers.count()}} followers | {% if user.id == g.user.id %} <a href="{{url_for('edit')}}">Edit your profile</a> {% elif not g.user.is_following(user) %} <a href="{{url_for('follow', nickname = user.nickname)}}">Follow</a> {% else %} <a href="{{url_for('unfollow', nickname = user.nickname)}}">Unfollow</a> {% endif %} </p> </td> </tr> </table> <hr> {% for post in posts %} {% include 'post.html' %} {% endfor %} {% endblock %}
在編輯一行上,我們會顯示關注者的用戶數目,後面可能會跟隨三種可能的鏈接:
- 如果用戶屬於登錄狀態,“編輯” 鏈接會顯示。
- 否則,如果用戶不是關注者,“關注” 鏈接會顯示。
- 否則,一個 “取消關注” 將會顯示。
這個時候你可以運行應用程序,創建一些用戶,試試關注以及取消關注用戶。
最後剩下的就是 index 頁,但是現在還不是完成的時候,我們會在下一章完成它。
結束語
今天的話題涉及到數據庫關係以及查詢,所以可能有些複雜。不用着急,慢慢的消化。
如果你想要節省時間的話,你可以下載 microblog-0.8.zip。
我希望能在下一章繼續見到各位!