第十二章 關(guān)注者
社交類web程序允許用戶與其它用戶聯(lián)絡(luò)。程序把這些關(guān)系稱之為關(guān)注者,朋友,人脈,熟人或者粉絲……不管名稱怎么變化,這一功能實(shí)際一樣,就是保持追蹤用戶之間的定向連接并在數(shù)據(jù)庫(kù)查詢中使用這些連接。
在本章,你將學(xué)到如何實(shí)現(xiàn)Flasky的關(guān)注功能,用戶將能夠關(guān)注其他用戶并在首頁(yè)選擇性顯示自己關(guān)注的用戶的博客列表。
重新審視數(shù)據(jù)庫(kù)關(guān)系
我們?cè)诘谖逭掠懻撨^(guò),數(shù)據(jù)庫(kù)使用關(guān)系在數(shù)據(jù)記錄之間建立連接。一對(duì)多關(guān)系就是最常見(jiàn)的一種關(guān)系類型——一條記錄與多條記錄相關(guān)聯(lián)。為了實(shí)現(xiàn)這種關(guān)系類型,“多”側(cè)的元素?fù)碛幸粋€(gè)外鍵指向"一”側(cè)的那個(gè)元素。在當(dāng)前狀態(tài)下的例程包含了兩個(gè)一對(duì)多關(guān)系:一個(gè)是連接了角色(一)和用戶(多)列表,而另一個(gè)則是連接著用戶(一)和他所寫的文章(多)列表。
其他大多數(shù)關(guān)系可以認(rèn)為是一對(duì)多關(guān)系的演變。多對(duì)一是一對(duì)多關(guān)系的反向視圖,一對(duì)一關(guān)系就是簡(jiǎn)化版的一對(duì)多,多側(cè)簡(jiǎn)化成了只有一個(gè)元素。唯一不能這么理解的關(guān)系就是多對(duì)多關(guān)系,它兩側(cè)都是一個(gè)元素列表。這種關(guān)系我們將在下節(jié)進(jìn)行描述。
多對(duì)多關(guān)系
一對(duì)多,多對(duì)一和一對(duì)一都是有一邊是一個(gè)實(shí)體,所以相關(guān)記錄通過(guò)一個(gè)外鍵來(lái)指向"一"這側(cè)。但是,怎么實(shí)現(xiàn)兩邊都是多的關(guān)系呢
讓我們來(lái)考慮下經(jīng)典的多對(duì)多的例子:一個(gè)學(xué)生數(shù)據(jù)表和他們上的課程數(shù)據(jù)表。很明顯,你不能在學(xué)生表中添加一個(gè)外鍵指向課程表,因?yàn)橐粋€(gè)學(xué)生可能選了多門課程——一個(gè)外鍵是不夠的。同樣的,也不能在課程表中添加指向?qū)W生的外鍵,因?yàn)樵撜n程不止一個(gè)學(xué)生會(huì)選。兩邊都需要一個(gè)外鍵列表。
解決辦法就是在數(shù)據(jù)庫(kù)里添加第三個(gè)表,我們稱之為關(guān)聯(lián)表。現(xiàn)在多對(duì)多關(guān)系可以被分解到兩個(gè)一對(duì)多的關(guān)系,從原來(lái)兩個(gè)原始表分解出第三個(gè)關(guān)聯(lián)表。圖12-1展示了學(xué)生和課程間的多對(duì)多關(guān)系的表現(xiàn)。
本例中的關(guān)聯(lián)表被稱為registrations(注冊(cè)表)。表中的每一行對(duì)應(yīng)一個(gè)獨(dú)立的注冊(cè)信息:描述了某1學(xué)生選擇了某1課程。
對(duì)多對(duì)多關(guān)系的查詢需要執(zhí)行兩個(gè)步驟。為了獲取某個(gè)學(xué)生選課的課程列表,你得先從學(xué)生和注冊(cè)的一對(duì)多關(guān)系開(kāi)始(1學(xué)生多注冊(cè)),獲取該學(xué)生的注冊(cè)列表。然后,反轉(zhuǎn)課程和注冊(cè)之間的一對(duì)多關(guān)系,從多對(duì)一的方向(1注冊(cè)多課程)來(lái)獲取與注冊(cè)列表對(duì)應(yīng)的課程列表。同樣的,要列出某課程中的所有學(xué)生,你先從課程開(kāi)始獲取該課程所有的注冊(cè)列表,然后的得到與這些注冊(cè)相關(guān)聯(lián)的所有學(xué)生列表。
橫跨兩種關(guān)系來(lái)獲取查詢結(jié)果聽(tīng)起來(lái)很困難,但對(duì)于一個(gè)像上面例子中的簡(jiǎn)單關(guān)系來(lái)說(shuō),SQLAlchemy能完成大部分的工作。下列代碼就實(shí)現(xiàn)了圖12-1中的多對(duì)多關(guān)系(
由于排版的關(guān)系,下列代碼可能需要你進(jìn)行重新處理縮進(jìn)換行
):
registrations = db.Table('registrations',
db.Column('student_id', db.Integer, db.ForeignKey('students.id')),
db.Column('class_id', db.Integer, db.ForeignKey('classes.id'))
)
class Student(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String)
classes = db.relationship('Class',
secondary=registrations,
backref=db.backref('students', lazy='dynamic'),
lazy='dynamic')
class Class(db.Model):
id = db.Column(db.Integer, primary_key = True)
name = db.Column(db.String)
使用db.relationship()結(jié)構(gòu)定義的關(guān)系是用于一對(duì)多關(guān)系的,但本例是多對(duì)多關(guān)系,所以附加的secondary參數(shù)必須設(shè)置成關(guān)聯(lián)表。這個(gè)關(guān)系也可以定成在這兩個(gè)類中的任意一個(gè),只要使用backref參數(shù)從另外一邊明確指出這種關(guān)系即可。關(guān)聯(lián)表被定義成一個(gè)簡(jiǎn)單表,而不是一個(gè)模型,因?yàn)镾QLAlchemy會(huì)內(nèi)部管理這個(gè)表。
classes關(guān)系使用語(yǔ)義列表,它使以這種方式處理多對(duì)多關(guān)系非常簡(jiǎn)單。給定一個(gè)學(xué)生 S 和一個(gè)課程 C ,把學(xué)生注冊(cè)到課程的代碼就是:
>>> s.classes.append(c)
>>> db.session.add(s)
查詢學(xué)生S選擇的課程列表和課程C下面所有的學(xué)生同樣簡(jiǎn)單:
>>> s.classes.all()
>>> c.students.all()
課程模型中有效的學(xué)生關(guān)系是在db.backref()參數(shù)中定義的,注意在這個(gè)關(guān)系中,backref參數(shù)也被擴(kuò)展為含有l(wèi)azy='dynamic'屬性,所以兩邊都會(huì)返回一個(gè)能接受過(guò)濾器的查詢。
如果學(xué)生 S 決定不選課程 C 了,你可以更新數(shù)據(jù)庫(kù):
>>> s.classes.remove(c)
自引用關(guān)系
多對(duì)多關(guān)系可以被用在規(guī)范用戶關(guān)注其他用戶上,但這里有個(gè)問(wèn)題。在學(xué)生和課程的例子中,有兩個(gè)非常明顯的被定義的實(shí)體(學(xué)生和課程)通過(guò)關(guān)聯(lián)表被鏈接在一起。但是,在我們描述用戶關(guān)注另外一個(gè)用戶的時(shí)候,并沒(méi)有第二個(gè)實(shí)體——兩邊都是指向用戶實(shí)體自己。
兩邊都屬于同一個(gè)表的關(guān)系我們稱之為自引用關(guān)系。本例中,關(guān)系的左側(cè)實(shí)體是被稱為“關(guān)注者(粉絲)”的用戶,右側(cè)的也是用戶,但應(yīng)該稱之為“被關(guān)注者(博主)”。從概念上來(lái)說(shuō),自引用關(guān)系和普通的關(guān)系并沒(méi)有什么不同,但理解起來(lái)確實(shí)不容易。圖12-2顯示了數(shù)據(jù)庫(kù)中的自引用關(guān)系圖,它描述了用戶關(guān)注其他用戶。
這個(gè)關(guān)系中的中間關(guān)聯(lián)表稱之為follows。這個(gè)表中的每一行記錄著一個(gè)用戶關(guān)注另外一個(gè)用戶。圖中左側(cè)的一對(duì)多關(guān)系中,通過(guò)follows表關(guān)聯(lián)到的多側(cè)的用戶是關(guān)注者。而圖右側(cè)的一對(duì)多關(guān)系中,通過(guò)follows表關(guān)聯(lián)到的多側(cè)非人用戶則是被關(guān)注者。
高級(jí)多對(duì)多關(guān)系
利用像上文例子中自引用多對(duì)多關(guān)系配置,數(shù)據(jù)庫(kù)能描述關(guān)注者,但也有一個(gè)限制。當(dāng)使用多對(duì)多關(guān)系時(shí),一個(gè)常見(jiàn)要求是存儲(chǔ)附加數(shù)據(jù)用來(lái)在兩個(gè)實(shí)體間提供鏈接。對(duì)于關(guān)注者的關(guān)系,保存一個(gè)用戶開(kāi)始關(guān)注另外一個(gè)的時(shí)間是很有用的,因?yàn)榭梢該?jù)此生成按時(shí)間排序的關(guān)注者列表。唯一能存儲(chǔ)這一信息的地方就是關(guān)聯(lián)表,但在類似于前面顯示的學(xué)生和課程的實(shí)現(xiàn)中,這個(gè)關(guān)聯(lián)表是一個(gè)完全由SQLAlchemy管理的內(nèi)部表(不是模型,無(wú)法使用自定義數(shù)據(jù))。
為了能夠在關(guān)系中使用自定義數(shù)據(jù),關(guān)聯(lián)表必須提升為一個(gè)明確的模型以便于程序能訪問(wèn)到。例子12-1展示了新的關(guān)聯(lián)表,由Follow模型定義。
Example 12-1. app/models/user.py: The follows association table as a model
class Follow(db.Model):
__tablename__ = 'follows'
follower_id = db.Column(db.Integer, db.ForeignKey('users.id'),primary_key=True)
followed_id = db.Column(db.Integer, db.ForeignKey('users.id'),primary_key=True)
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
SQLAlchemy不能透明地使用關(guān)聯(lián)表,因此就無(wú)法讓程序訪問(wèn)其中的自定義字段。多對(duì)多關(guān)系必須從左邊和右邊分解成兩個(gè)基本的一對(duì)多關(guān)系,且須定義成標(biāo)準(zhǔn)關(guān)系。如例子12-2:
Example 12-2. app/models/user.py: A many-to-many relationship implemented as two one-to-many relationships
class User(UserMixin, db.Model):
# ...
followed = db.relationship('Follow',foreign_keys=[Follow.follower_id],backref=db.backref('follower', lazy='joined'),lazy='dynamic',cascade='all, delete-orphan')
followers = db.relationship('Follow',foreign_keys=[Follow.followed_id],backref=db.backref('followed', lazy='joined'),lazy='dynamic',cascade='all, delete-orphan')
這里的被關(guān)注者和關(guān)注者關(guān)系是被單獨(dú)定義在一對(duì)多關(guān)系里的。注意,使用foreign_keys附加參數(shù)來(lái)明確指出每個(gè)關(guān)系的外鍵,避免含混不清是十分有必要的。這些關(guān)系中的db.backref()參數(shù)并不是指向彼此,而是都回溯引用到Follow模型。
回溯引用的lazy參數(shù)指定為joined。lazy模式會(huì)立即從join查詢中加載相關(guān)對(duì)象。例如,如果一個(gè)用戶關(guān)注了100個(gè)其他用戶,調(diào)用user.followed.all()將返回一個(gè)100個(gè)follow實(shí)例的列表,其中每個(gè)實(shí)例的關(guān)注者(follower)
和被關(guān)注(followed)的回溯引用
都指向各自對(duì)應(yīng)的一個(gè)用戶。lazy='joined'模式允許使用一個(gè)查詢完成所有上述操作。如果lazy被設(shè)置為默認(rèn)的值'select',關(guān)注者和被關(guān)注的用戶就將會(huì)延遲加載:當(dāng)?shù)谝淮卧L問(wèn)follower或followed的時(shí)候,才會(huì)使用單獨(dú)查詢加載用戶——這就意味著要獲得完整的這100個(gè)被關(guān)注者的名單需要額外100次數(shù)據(jù)庫(kù)查詢才能完成。
這兩個(gè)關(guān)系中User端的lazy參數(shù)有不同的要求。在一這一側(cè)設(shè)置lazy并將返回多側(cè)的數(shù)據(jù),這里lazy就使用了dynamic模式,所以關(guān)系的屬性返回一個(gè)查詢對(duì)象而不是直接返回記錄,這樣一來(lái)我們可以在查詢執(zhí)行之前添加可選過(guò)濾器。
cascade參數(shù)配置了父類的一個(gè)動(dòng)作如何傳播給其子對(duì)象的。cascade選項(xiàng)的一個(gè)例子就是,規(guī)定當(dāng)一個(gè)對(duì)象被添加到數(shù)據(jù)庫(kù)會(huì)話時(shí),任何通過(guò)關(guān)系與之相關(guān)的對(duì)象也應(yīng)該被自動(dòng)添加到會(huì)話中。默認(rèn)的cascade選項(xiàng)足以應(yīng)對(duì)大部分情況,但但在多對(duì)多的關(guān)系中這一默認(rèn)選項(xiàng)就不能很好的工作了。默認(rèn)的cascade會(huì)在一個(gè)對(duì)象被刪除時(shí),把所有指向它的對(duì)象的外鍵統(tǒng)統(tǒng)設(shè)為空值(null)。但對(duì)以一個(gè)關(guān)聯(lián)表來(lái)說(shuō),正確的操作應(yīng)該是在一條數(shù)據(jù)被刪除后,也刪除所有指向它的關(guān)聯(lián)數(shù)據(jù)實(shí)體(而不是外鍵設(shè)空值),以此來(lái)刪除關(guān)聯(lián)。這正好是delete-orphan傳播選項(xiàng)可以做的。
指定給cascade的是一個(gè)逗號(hào)分隔的選項(xiàng)列表,這有點(diǎn)繞,但實(shí)際是名為all的選項(xiàng)包含了除了delete-orphan外所有的級(jí)聯(lián)選項(xiàng)。使用all+delete-orphan將啟用默認(rèn)傳播選項(xiàng),并刪除孤兒數(shù)據(jù)(失聯(lián)的無(wú)效數(shù)據(jù),無(wú)法被引用也沒(méi)有引用別的)。
現(xiàn)在程序需要運(yùn)行兩個(gè)一對(duì)多關(guān)系來(lái)實(shí)現(xiàn)多對(duì)多關(guān)系功能。由于有需要經(jīng)常重復(fù)使用的功能,在User模型中為所有的操作可能創(chuàng)建一個(gè)輔助方法是個(gè)不錯(cuò)的主意。控制這個(gè)關(guān)系的四個(gè)新方法如例子12-3所示:
Example 12-3. app/models/user.py: Followers helper methods
class User(db.Model):
# ...
def follow(self, user):
if not self.is_following(user):
f = Follow(follower=self, followed=user)
db.session.add(f)
def unfollow(self, user):
f = self.followed.filter_by(followed_id=user.id).first()
if f:
db.session.delete(f)
def is_following(self, user):
return self.followed.filter_by(followed_id=user.id).first() is not None
def is_followed_by(self, user):
return self.followers.filter_by(follower_id=user.id).first() is not None
follow()方法手動(dòng)在關(guān)聯(lián)表中插入Follow實(shí)例,來(lái)鏈接關(guān)注者和被關(guān)注者,為程序提供設(shè)置自定義字段的機(jī)會(huì)。這兩個(gè)鏈接起來(lái)的用戶是在Follow實(shí)例的構(gòu)造函數(shù)中被手動(dòng)加入,然后把該實(shí)例對(duì)象添加到數(shù)據(jù)庫(kù)會(huì)話。注意并不需要手動(dòng)設(shè)置timestamp字段因?yàn)樗哪J(rèn)值被定義為當(dāng)前日期和時(shí)間。unfollow()方法使用followed關(guān)系定位鏈接了被關(guān)注者和關(guān)注者的follow實(shí)例。要解除兩個(gè)用戶之間的鏈接,只需簡(jiǎn)單的刪除該follow對(duì)象即可。is_followed()和is_followd_by()方法在左側(cè)和右側(cè)的一對(duì)多關(guān)系中查找指定用戶,并在找到后返回True。
這個(gè)功能的數(shù)據(jù)庫(kù)部分已經(jīng)完成了,你可以嘗試為它編寫一個(gè)測(cè)試單元。
屬性頁(yè)上的關(guān)注者
如果查看某用戶屬性頁(yè)的瀏覽者不是該用戶的關(guān)注者的話,該屬性頁(yè)面需要顯示一個(gè)"Follow"按鈕,或者如果是關(guān)注者就應(yīng)該顯示一個(gè)"Unfollow"按鈕。在合適的時(shí)候,這里顯示關(guān)注數(shù)量和被關(guān)注數(shù)量,顯示關(guān)注者列表和粉絲列表,或顯示一個(gè)“Follows you”的標(biāo)志,這都是不錯(cuò)的。屬性頁(yè)的變化請(qǐng)參見(jiàn)例子12-4,圖12-3顯示了其樣式。
Example 12-4. app/templates/user.html: Follower enhancements to the user profile header
{% if current_user.can(Permission.FOLLOW) and user != current_user %}
{% if not current_user.is_following(user) %}
<a href="{{ url_for('.follow', username=user.username) }}"
class="btn btn-primary">Follow</a>
{% else %}
<a href="{{ url_for('.unfollow', username=user.username) }}"
class="btn btn-default">Unfollow</a>
{% endif %}
{% endif %}
<a href="{{ url_for('.followers', username=user.username) }}">
Followers: <span class="badge">{{ user.followers.count() }}</span>
</a>
<a href="{{ url_for('.followed_by', username=user.username) }}">
Following: <span class="badge">{{ user.followed.count() }}</span>
</a>
{% if current_user.is_authenticated() and user != current_user and
user.is_following(current_user) %}
| <span class="label label-default">Follows you</span>
{% endif %}
圖12-3
在模板的這些變化中,定義了四個(gè)新的端點(diǎn)(endpoint)。
/follow/<username>
路由將在一個(gè)用戶在別的用戶屬性頁(yè)上點(diǎn)擊"follow"按鈕時(shí)被調(diào)用。其實(shí)現(xiàn)如例子12-5所示:
Example 12-5. app/main/views.py: Follow route and view function
@main.route('/follow/<username>')
@login_required
@permission_required(Permission.FOLLOW)#注:注意導(dǎo)入引用
def follow(username):
user = User.query.filter_by(username=username).first()
if user is None:
flash('Invalid user.')
return redirect(url_for('.index'))
if current_user.is_following(user):
flash('You are already following this user.')
return redirect(url_for('.user', username=username))
current_user.follow(user)
flash('You are now following %s.' % username)
return redirect(url_for('.user', username=username))
這個(gè)視圖函數(shù)加載了要關(guān)注的用戶,確認(rèn)其合法并尚未被當(dāng)前的登錄用戶關(guān)注,然后調(diào)用User模型中的follow()輔助函數(shù)來(lái)實(shí)現(xiàn)鏈接。/unfollow/<username>
路由也是以類似的方式實(shí)現(xiàn)。(下面是自寫代碼,你也可以參考作者的github源代碼)
@main.route('/unfollow/<username>')#注:自寫,原無(wú)。
@login_required
@permission_required(Permission.FOLLOW)
def unfollow(username):
user=User.query.filter_by(username=username).first()
if user is None:
flash(u'無(wú)效的用戶!')
return redirect(url_for('.index'))
if current_user.is_following(user):
current_user.unfollow(user)
flash(u'你已取消對(duì) %s 的關(guān)注' % username)
return redirect(url_for('.user',username=username))
當(dāng)瀏覽者點(diǎn)擊關(guān)注者數(shù)量(followers)的時(shí)候,就會(huì)調(diào)用/followers/<username>
路由函數(shù),這一實(shí)現(xiàn)如例子12-6所示(這里還需要在config.py中設(shè)置一下FLASKY_FOLLOWERS_PER_PAGE分頁(yè)變量
):
Example 12-6. app/main/views.py: Followers route and view function
@main.route('/followers/<username>')
@login_required
def followers(username):
user = User.query.filter_by(username=username).first()
if user is None:
flash('Invalid user.')
return redirect(url_for('.index'))
page = request.args.get('page', 1, type=int)
pagination = user.followers.paginate(page, per_page=current_app.config['FLASKY_FOLLOWERS_PER_PAGE'],\
error_out=False)
follows = [{'user': item.follower, 'timestamp': item.timestamp}
for item in pagination.items]
return render_template('followers.html', user=user, title="Followers of",\
endpoint='.followers', pagination=pagination,follows=follows)
這個(gè)函數(shù)加載并驗(yàn)證被請(qǐng)求的用戶,然后使用11章的分頁(yè)技術(shù)對(duì)該用戶的關(guān)注者進(jìn)行分頁(yè)處理。因?yàn)閷?duì)關(guān)注者的查詢將返回一個(gè)Follow實(shí)例列表,我們把它轉(zhuǎn)換成為便于顯示的follows列表,其中每個(gè)實(shí)體只擁有user和timestamp兩個(gè)字段。
用來(lái)顯示關(guān)注了xx列表的模板可以通用些,這樣它也可以被用來(lái)顯示被關(guān)注的oo列表。模板接收用戶、頁(yè)面標(biāo)題、分頁(yè)鏈接使用的斷點(diǎn)(路由名稱)、分頁(yè)對(duì)象和結(jié)果列表。
foolowed_by斷點(diǎn)幾乎完全一樣。唯一區(qū)別就是用戶列表是從user.followed關(guān)系取得的。模板參數(shù)也需要相應(yīng)進(jìn)行調(diào)整。
followers.html模板包含兩列,分別在左側(cè)顯示用戶名稱和他們的頭像,在右側(cè)顯示flask-moment時(shí)間戳。你可以從github倉(cāng)庫(kù)獲取源代碼查看詳細(xì)實(shí)現(xiàn)。
注下面是我自己的實(shí)現(xiàn)代碼:
#followed-by路由
@main.route('/followed-by/<username>')
@login_required
def followed_by(username):
user = User.query.filter_by(username=username).first()
if user is None:
flash('Invalid user.')
return redirect(url_for('.index'))
page = request.args.get('page', 1, type=int)
pagination = user.followed.paginate(page,\
per_page=current_app.config['FLASKY_FOLLOWERS_PER_PAGE'],\
error_out=False)
follows = [{'user': item.followed, 'timestamp': item.timestamp}
for item in pagination.items]
return render_template('followers.html', user=user, title="Followed by ",\
endpoint='.followed_by', pagination=pagination,follows=follows)
followers.html模板參考如下注意:由于我訪問(wèn)不了garava網(wǎng)站,所以把所有用戶頭像都硬編碼為一個(gè)固定值。你可以參考前面的代碼,調(diào)用user模型中的頭像生成函數(shù)來(lái)使用自定義頭像。
:
{% extends "base.html" %}
{% import "_macros.html" as macros %}
{% block title %}Flasky - {{ title }} {{ user.username }}{% endblock %}
{% block page_content %}
<div class="page-header">
<h1>{{ title }} {{ user.username }}</h1>
</div>
<table class="table table-hover followers">
<thead><tr><th>User</th><th>Since</th></tr></thead>
{% for follow in follows %}
{% if follow.user != user %}
<tr>
<td>
<a href="{{ url_for('.user', username = follow.user.username) }}">
<img class="img-rounded profile-thumbnail" src="{{ url_for('static',filename='headimg.png') }}">
<div style="margin-left:40px;">{{ follow.user.username }}</div>
</a>
</td>
<td>{{ moment(follow.timestamp).format('L') }}</td>
</tr>
{% endif %}
{% endfor %}
</table>
<div class="pagination">
{{ macros.pagination_widget(pagination, endpoint, username = user.username) }}
</div>
{% endblock %}
使用數(shù)據(jù)庫(kù)join查詢關(guān)注的文章
目前,程序的首頁(yè)倒序(從最近到最久遠(yuǎn))顯示了數(shù)據(jù)庫(kù)里所有的文章。完成以下代碼功能后,就可以允許用戶選擇只顯示自己關(guān)注的那些用戶的文章。
加載關(guān)注用戶的所有文章的直觀思路是:首先獲取那些被關(guān)注的用戶列表,然后逐個(gè)獲取其文章并存入一個(gè)獨(dú)立列表中。當(dāng)然這個(gè)方法擴(kuò)展性并不好,獲取聯(lián)合列表的耗時(shí)會(huì)隨著數(shù)據(jù)量的增長(zhǎng)同步變大,諸如分頁(yè)之類的操作就會(huì)變得沒(méi)有效率。提升性能的方法就是使用一個(gè)查詢完成獲取這個(gè)文章列表。
用來(lái)完成這個(gè)的數(shù)據(jù)庫(kù)操作被稱為“join”。一個(gè)join操作將根據(jù)指定條件在兩個(gè)或更多表中查找數(shù)據(jù),生成一個(gè)聯(lián)合數(shù)據(jù)集,并將數(shù)據(jù)插入一個(gè)臨時(shí)表。(譯注:這個(gè)數(shù)據(jù)集包含指定的各表字段,而非單一表中的字段。你可以參看sql語(yǔ)法
)。你可以通過(guò)一個(gè)例子來(lái)更好的理解join的工作方式。
表12-1是一個(gè)帶有三個(gè)用戶的users表
Table 12-1. users table
id username
1 john
2 susan
3 david
表12-2 是帶有一些文章的posts表
Table 12-2. Posts table
id author_id body
1 2 Blog post by susan
2 1 Blog post by john
3 3 Blog post by david
4 1 Second blog post by john
最后表12-3是誰(shuí)關(guān)注誰(shuí)。這個(gè)表你可以看到j(luò)ohn關(guān)注了david,susan關(guān)注john,david沒(méi)有關(guān)注任何人。
Table 12-3. Follows table
follower_id followed_id
1 3
2 1
2 3
要獲得susan關(guān)注的文章列表,必須合并posts和follows兩個(gè)表。首先過(guò)濾follows表只保留關(guān)注者susan的記錄,在表中就是最后兩行。然后,凡是posts表中author_id等于followed_id的數(shù)據(jù)被提取出來(lái)插入新創(chuàng)建的臨時(shí)join表中,這樣操作選擇了susan關(guān)注的用戶的文章就更有效率些。表12-4顯示了join操作的結(jié)果,用來(lái)進(jìn)行join操作的列被標(biāo)注了星號(hào)*。
Table12-4 合并后的表
id author_id* body follower_id followed_id*
2 1 Blog post by john 2 1
3 3 Blog post by david 2 3
4 1 Second blog post by john 2 1
這個(gè)表僅體現(xiàn)了susan觀注的用戶所撰寫的文章。Flask-SQLAlchemy要像表述那樣執(zhí)行查詢操作是相當(dāng)復(fù)雜的:
return db.session.query(Post).select_from(Follow).\
filter_by(follower_id=self.id).\
join(Post, Follow.followed_id == Post.author_id)
以前我們所見(jiàn)的查詢都是從模型的query屬性來(lái)執(zhí)行,但在這里這個(gè)模式就不適合了,因?yàn)榇颂幉樵冃枰祷豴osts數(shù)據(jù),但我們要完成的第一個(gè)操作是對(duì)follows表進(jìn)行過(guò)濾。所以我們使用了更基礎(chǔ)的模式來(lái)執(zhí)行這個(gè)查詢。為了更好地理解,我們把它分成幾個(gè)獨(dú)立的部分來(lái)解釋下。
+ db.session.query(Post),指明這是一個(gè)將返回Post對(duì)象的查詢
+ select_from(Follow),從Follow模型開(kāi)始一個(gè)查詢
+ filter_by(follower_id=self.id),使用follower用戶來(lái)過(guò)濾follows表(譯注:去除非susan的數(shù)據(jù))
+ join(Post,follow,followed_id==Post.author_id),把filter_by()后的結(jié)果與Post對(duì)象進(jìn)行join操作。
可以通過(guò)交換過(guò)濾器和join的順序把這個(gè)查詢簡(jiǎn)化為:
return Post.query.join(Follow, Follow.followed_id == Post.author_id).filter \
(Follow.follower_id == self.id)
首先執(zhí)行join操作,我們?cè)購(gòu)腜ost.query開(kāi)始查詢——這樣就只需要添加兩個(gè)過(guò)濾器:join()和filter()。但這是一樣的嗎?看起來(lái)先執(zhí)行join然后進(jìn)行過(guò)濾操作可能會(huì)導(dǎo)致更多的操作。但實(shí)際上這兩個(gè)查詢是等價(jià)的。SQLAlchemy首先收集所有的過(guò)濾器然后用最有效率的方式生成查詢。這兩個(gè)查詢的原生SQL結(jié)構(gòu)是一致的。添加到Post模型的最終版本查詢?nèi)缋?2-7所示:
Example 12-7. app/models.py: Obtain followed posts
class User(UserMixin,db.Model):
# ...
@property
def followed_posts(self):
return Post.query.join(Follow, Follow.followed_id == Post.author_id)\
.filter(Follow.follower_id == self.id)
注意:followed_post()方法被定義成了一個(gè)屬性,所以調(diào)用時(shí)不需要加上()
。這樣一來(lái)所有關(guān)系的語(yǔ)法就統(tǒng)一起來(lái)了。
Join的相關(guān)知識(shí)可能難以理解,我想你需要在shell中多練習(xí)例子的代碼來(lái)幫助你加深理解。
在首頁(yè)顯示關(guān)注用戶的文章
首頁(yè)現(xiàn)在允許用戶選擇顯示所有用戶的文章還是僅顯示自己關(guān)注用戶的文章。例子12-8展示了它是如何實(shí)現(xiàn)的:
Example 12-8. app/main/views.py: Show all or followed posts
@app.route('/', methods = ['GET', 'POST'])
def index():
# ...
show_followed = False
if current_user.is_authenticated():
show_followed = bool(request.cookies.get('show_followed', ''))
if show_followed:
query = current_user.followed_posts
else:
query = Post.query
pagination = query.order_by(Post.timestamp.desc()).paginate(page,\
per_page=current_app.config['FLASKY_POSTS_PER_PAGE'],\
error_out=False)
posts = pagination.items
return render_template('index.html',form=form,posts=posts,\
show_followed=show_followed, pagination=pagination)
在cookie中存儲(chǔ)了一個(gè)名為show_followed的字符串,如果非空,則只顯示關(guān)注的文章。Cookie以request.cookies的字典格式被存儲(chǔ)在請(qǐng)求對(duì)象中。這個(gè)cookie字符串可以被轉(zhuǎn)換成Boolean(布爾)類型,基于其值,程序?qū)⒃O(shè)置一個(gè)本地的query變量來(lái)查詢以獲取全部或篩選后的文章清單。要顯示所有的文章,就使用最頂層的查詢Post.query。當(dāng)列表被限制到關(guān)注者的時(shí)候,就會(huì)調(diào)用最后添加的User.followed_posts屬性。存儲(chǔ)在query本地變量中的這個(gè)查詢隨后進(jìn)行分頁(yè),像以前那樣其結(jié)果被發(fā)送給模板。
在兩個(gè)新的路由中設(shè)置了show_followed的cookie,如例子12-9所示:
Example 12-9. app/main/views.py: Selection of all or followed posts
@main.route('/all')
@login_required
def show_all():
resp = make_response(redirect(url_for('.index')))
resp.set_cookie('show_followed', '', max_age=30*24*60*60)
return resp
@main.route('/followed')
@login_required
def show_followed():
resp = make_response(redirect(url_for('.index')))
resp.set_cookie('show_followed', '1', max_age=30*24*60*60)
return resp
在首頁(yè)模板中添加上這兩個(gè)路由的鏈接。當(dāng)點(diǎn)擊時(shí),將為show_followed cookie設(shè)置一個(gè)值,并重新定向回到首頁(yè)。
Cookies只能在response對(duì)象中設(shè)置,所以這兩個(gè)路由必須手動(dòng)調(diào)用make_response來(lái)創(chuàng)建response對(duì)象,而不是讓Flask來(lái)做。
set_cookie()函數(shù)把cookie的名稱和值作為開(kāi)頭的兩個(gè)參數(shù)。max_age選項(xiàng)參數(shù)則指定了cookie超時(shí)過(guò)期的秒數(shù)。如果沒(méi)有設(shè)置該參數(shù),則cookie將在瀏覽器窗口關(guān)閉之后立即過(guò)期失效。在本例中,我們?cè)O(shè)置了過(guò)期時(shí)間是30天,這樣瀏覽器將“記住”該設(shè)置,即使用戶幾天都沒(méi)有登錄過(guò)系統(tǒng)。
修改模板中文章列表部分,在其頂部添加兩個(gè)導(dǎo)航標(biāo)簽,用以調(diào)用/all
或者/followed
路由來(lái)設(shè)置session值。你可以從Github庫(kù)中找到詳細(xì)的代碼(譯注:此處未列出
)。圖12-4就是更改后的首頁(yè)。
譯注:我的模板修改代碼:
<ul class="nav nav-tabs" role="tablist">
<li role="presentation"><a href="{{url_for('main.show_all')}}">All Posts</a></li>
<li role="presentation"><a href="{{url_for('main.show_followed')}}">Followed Posts</a></li>
</ul>
如果你嘗試點(diǎn)擊切換到“關(guān)注的文章”,你會(huì)注意到你自己的文章并沒(méi)有出現(xiàn)這列表中。這當(dāng)然是對(duì)的,因?yàn)椴荒荜P(guān)注自己嘛!
實(shí)際上即使是查詢代碼確實(shí)是按照我們的設(shè)計(jì)思路正常運(yùn)行,大多數(shù)人還是希望能同時(shí)看到自己的文章。實(shí)際上這并不困難,只要在創(chuàng)建注冊(cè)用戶的時(shí)候,直接把他們標(biāo)注為自己的關(guān)注者就可以了。這個(gè)小改動(dòng)如例子12-10所示:
Example 12-10. app/models.py: Make users their own followers on construction
class User(UserMixin, db.Model):
# ...
def __init__(self, **kwargs):
# ...
self.follow(self)
不幸的是,你的數(shù)據(jù)庫(kù)里可能有寫用戶早已存在而當(dāng)時(shí)并沒(méi)有關(guān)注自己。如果數(shù)據(jù)庫(kù)規(guī)模不大并且易于重建的話,最好是刪除并重建;但如果不行,那么正確的方法是添加一個(gè)函數(shù)來(lái)修復(fù)已存在的用戶比較好。請(qǐng)看例子12-11:
Example 12-11. app/models.py: Make users their own followers
class User(UserMixin, db.Model):
# ...
@staticmethod
def add_self_follows():
for user in User.query.all():
if not user.is_following(user):
user.follow(user)
db.session.add(user)
db.session.commit()
# ...
現(xiàn)在你就可以從shell中運(yùn)行這一代碼進(jìn)行數(shù)據(jù)庫(kù)升級(jí)了:
(venv) $ python manage.py shell
>>> User.add_self_follows()
創(chuàng)建一個(gè)函數(shù)來(lái)更新數(shù)據(jù)庫(kù)是一個(gè)常見(jiàn)的技術(shù),通過(guò)腳本升級(jí)比手動(dòng)修改數(shù)據(jù)庫(kù)更安全,所以這一技術(shù)在升級(jí)已經(jīng)發(fā)布的程序是很常見(jiàn)。在第17章中你將看到這一函數(shù)和其他類似的被合并到一個(gè)發(fā)布腳本中。
這樣一來(lái)程序可用性更好了,但也同時(shí)導(dǎo)致了不良“并發(fā)癥”。在用戶屬性頁(yè)中顯示的關(guān)注者和被關(guān)注者計(jì)數(shù)都增長(zhǎng)了“1”——直接的辦法就是在模板顯示的時(shí)候直接把計(jì)數(shù)減一即可:
修改user.html:
{{ user.followers.count() - 1 }}
和{{ user.followed.count() -1 }}
。
同樣的,關(guān)注和被關(guān)注者列表中也必須進(jìn)行調(diào)整,清除掉“自己”——在模板中使用條件判斷即可完成。最后,檢查關(guān)注者計(jì)數(shù)的單元測(cè)試代碼也必須進(jìn)行修改,以去除對(duì)自關(guān)注的統(tǒng)計(jì)。(譯注:如果你自己無(wú)法完成,可以去github參考作者提供的代碼)
ps: 這一段原文有幾處小瑕疵,作者在12-7后的幾個(gè)例子中修改user模型的文件位置標(biāo)注錯(cuò)誤,標(biāo)成了
app/models/user.py
,12-7的User(dm.Model)缺少了UserMixin。實(shí)際代碼是沒(méi)有問(wèn)題的。
在下一章,我們將實(shí)現(xiàn)用戶評(píng)論子系統(tǒng)——社交程序中的另一個(gè)重要功能。