翻譯
Implementing User Comments with SQLAlchemy
?
保持 Web 應用程式用戶參與的最基本的方法之一是給他們一個寫評論的空間,現在,幾乎所有的東西都有第三方服務,評論也不例外,Disqus 和 Facebook 是很受歡迎的服務,允許你將評論嵌入到你的網站中,
?
但是如果你不想使用外部服務怎么辦?在本文中,我將向你展示如何使用 SQLAlchemy ORM 和它所支持的任何資料庫引擎在 Python 中實作評論,我將從一個非常簡單的方法開始,然后將繼續討論一些支持多級回復的高級實作,
?
評論服務的問題
雖然把你的評論轉移到外部服務很誘人,但是有很多原因可以解釋為什么你不想這么做,這些服務嵌入到你的頁面中的 UI 通常不是很靈活,因此它可能不適合你的站點布局,此外,你的一些用戶可能會覺得奇怪,即使他們擁有你的 Web 應用程式的帳戶,他們也需要創建其他服務的第二個帳戶來寫評論,
我還聽到許多其他開發者提到的一個合理的擔憂是,你并不擁有出現在你網站上的評論,而且如果你決定不使用你現在的供應商,或者更糟糕的是供應商關閉而導致無法使用,那么匯出這些資料可能會有困難,
還有一個安全方面的問題,你可能覺得把用戶的資訊交給這些經常受到黑客攻擊的大公司是不安全的,就在幾天前,Disqus 宣布遭遇了資料泄露,
基本評論系統
如果你不是很挑剔,你可以很容易地創建一個基本的評論系統解決方案,下面是可以完成這項作業的基本 SQLAlchemy 模型:
from datetime import datetime
class Comment(db.Model):
id = db.Column(db.Integer, primary_key=True)
text = db.Column(db.String(140))
author = db.Column(db.String(32))
timestamp = db.Column(db.DateTime(), default=datetime.utcnow, index=True)
使用這個簡單的模型,您可以跟蹤評論串列,快速免責宣告: 如果你習慣于單獨使用 SQLAlchemy,那么你將無法識別我上面使用的 db 實體,為了方便起見,本文中的所有示例都使用了構建在 SQLAlchemy 之上的 Flask-SQLAlchemy 擴展,并從 db 資料庫實體公開所有 SQLAlchemy 屬性,如果您正在使用 SQLAlchemy 而沒有使用 Flask 擴展,那么你需要做一些小的更改,以便從它們的原生 SQLAlchemy 模塊中匯入所有附加到 db 的屬性,
要添加新的評論,只需創建一個新的 Comment 實體并將其寫入資料庫:
comment = Comment(text='Hello, world!', author='alice')
db.session.add(comment)
db.session.commit()
?
注意,我并不擔心 timestamp 欄位,因為在模型定義中,默認情況下它獲取當前 UTC 時間,歸功于自動時間戳,我可以有效地檢索所有按日期升序或降序排序的評論:
# oldest comments first
for comment in Comment.query.order_by(Comment.timestamp.asc()):
print('{}: {}'.format(comment.author, comment.text))
# newest comments first
for comment in Comment.query.order_by(Comment.timestamp.desc()):
print('{}: {}'.format(comment.author, comment.text))
要將此解決方案與應用程式集成,你可能需要將 author 欄位更改為 User 模型中的外鍵,而不僅僅是字串,如果你在許多不同的頁面上接受評論,你可能還需要添加一個額外的欄位,將每條評論鏈接到應用程式的頁面,然后允許你通過該欄位的檢索每個頁面的評論,這實際上就是我在這個博客的評論中選擇的實作,
這個 gist 提供了該技術的一個簡單而完整的實作,
實作評論回復
如果你只需要一個簡單的評論串列,那么上一節中的簡單實作應該可以很好地完成這項作業, 但如果這還不夠呢?
對于許多應用程式,你可能希望用戶能夠回復其他用戶的評論,然后將所有這些鏈接的評論分層顯示,信不信由你,這在關系資料庫里是極其困難的,
有兩種相當知名的實作解決了以關系形式表示樹結構的問題,但不幸的是,它們都有嚴重的局限性,首先我會向你們描述他們,以及他們的問題,然后我會告訴你們我自己的解決方案,雖然也有一些局限性,但是不會像他們那么糟糕,
鄰接表
第一種方法叫做 鄰接表,實際上實作起來非常簡單,其想法是在 Comment 模型中添加一列,用于跟蹤每條評論的父評論, 如果每個評論都與其父評論有關系,那么您可以弄清楚整個樹結構,
?
對于這個模型,你會得到這樣的東西:
class Comment(db.Model):
id = db.Column(db.Integer, primary_key=True)
text = db.Column(db.String(140))
author = db.Column(db.String(32))
timestamp = db.Column(db.DateTime(), default=datetime.utcnow, index=True)
parent_id = db.Column(db.Integer, db.ForeignKey('comment.id'))
replies = db.relationship(
'Comment', backref=db.backref('parent', remote_side=[id]),
lazy='dynamic')
我在這里所做的是在上面使用的模型中添加了一個自參考的一對多關系,因為現在每條評論都有一個 parent_id 外鍵,我就可以輕松地找到給定評論的直接回復,只需要查找parent_id 為該評論的所有評論,
例如,假設我想表示下面的評論線索:
alice: hello1
bob: reply11
susan: reply111
susan: reply12
bob: hello2
alice: reply21
添加具有上述結構的評論的代碼如下所示:
c1 = Comment(text='hello1', author='alice')
c2 = Comment(text='hello2', author='bob')
c11 = Comment(text='reply11', author='bob', parent=c1)
c12 = Comment(text='reply12', author='susan', parent=c1)
c111 = Comment(text='reply111', author='susan', parent=c11)
c21 = Comment(text='reply21', author='alice', parent=c2)
db.session.add_all([c1, c2, c11, c12, c111, c21])
db.session.commit()
到目前為止,這一切都相當容易,當你需要以適合展示的方式檢索評論時,問題就來了,實際上沒有查詢可以以正確的線索順序檢索這些評論,唯一的方法是遞回查詢,以下代碼使用遞回查詢將評論線索列印到具有適當縮進的終端:
def display_comment(comment, level=0):
print('{}{}: {}'.format(' ' * level, comment.author, comment.text))
for reply in comment.replies:
display_comment(reply, level + 1)
for comment in Comment.query.filter_by(parent=None).order_by(Comment.timestamp.asc()):
display_comment(comment)
最下面的 for 回圈檢索所有頂級評論(那些沒有父評論的評論),然后對每個評論在 display_comment() 函式中遞回檢索它們的回復,
這種解決方案效率極低,如果有一個包含 100 條評論的評論線索,那么在獲得頂級評論的之后,需要發出 100 個額外的資料庫查詢來重構整個樹,如果你想對你的評論分頁,你唯一能做的就是給頂級的評論分頁,你不能真正對整體評論線索進行分頁,
因此,雖然這個解決方案非常優雅,但在實踐中,除非資料集很小,否則無法真正使用它,在這個 gist 中,你可以看到該技術的完整實作,
嵌套集合
第二種技術稱為 嵌套集合,這是一個相當復雜的解決方案,它向表中添加了兩列,稱為 left 和 right,以及第三個可選 level 列,所有列都存盤編號,并用于描述樹結構的遍歷順序,當你向下看的時候,你把數字順序的分配給 left 欄位,當你向上看的時候,你把它們分配給 right 欄位,這種編號的結果是,沒有回復的評論的 left和 right 是連續的,level 跟蹤每個評論有多少級父母,
例如,上面的評論線索會給出 left 、right 和 level 的值:
alice: hello1 left: 1 right: 8 level: 0
bob: reply11 left: 2 right: 5 level: 1
susan: reply111 left: 3 right: 4 level: 2
susan: reply12 left: 6 right: 7 level: 1
bob: hello2 left: 9 right: 12 level: 0
alice: reply21 left: 10 right: 11 level: 1
譯者注:
按層級依次往下走:alice: hello1 -> bob: reply11 -> susan: reply111
left 依次為 1,2,3,此時走到層級盡頭,再依次往上走按層級依次往上走
susan: reply111 -> bob: reply11
right 依次為 4,5,此時 bob: reply11 同一層級還有 susan: reply12,在依次往下走按層級依次往下走:bob: reply11 -> susan: reply12
left 依次為 6,此時走到層級盡頭,再依次往上走按層級依次往上走:susan: reply12 -> alice: hello1
right 依次為 7,8
使用這種結構,如果你想獲得給定評論的回復,你需要做的就是查找所有 left 大于父方 left ,right 小于父方 right 的評論,例如,alice 的 top post 的孩子是那些 left > 1 和 right < 8 的,第二行中 bob 的 post 的孩子是那些 left > 2 和 right < 5 的,如果按照 left 的順序對結果進行排序,就可以按照正確的線索順序得到結果,然后可以使用 level 來確定在網頁上呈現結果時要使用的縮進,這種方法相對于鄰接表的最大優點是,你可以通過單個資料庫查詢獲得擁有正確線索順序的所有評論,甚至可以使用分頁來獲得線索的子集,
你可能會認為這實際上是一個非常聰明的解決方案,可以很好地解決這個問題,但是你是否考慮過將這三個數字分配給每個評論的演算法是什么樣的?這就是這個解決方案的問題所在,每次添加新評論時,評論表中可能有很大一部分必須用新的左右值進行更新,當使用鄰接表時,插入代價低廉,查詢代價高昂,對于嵌套集合,情況恰恰相反,插入代價高昂,查詢代價低廉,
我自己從來沒有實作過這個演算法,所以我沒有現成的示例代碼向你展示它的外觀,但是如果希望看到一個真實的實作, django-mptt 專案是一個很好的例子,它與 Django ORM 一起作業,從上面的例子中你可以猜到查詢是相當簡單的,但是插入新評論所需的邏輯是復雜且效率極低,因為可能需要更新大量評論,具體取決于新評論在樹中的插入位置,只有在插入不常見且查詢頻繁的情況下,此解決方案才有意義,
跳出框框思考
不幸的是,上述解決方案都不能很好地滿足我的需求,我提出了一種完全不同的方法,它同時具有高效的插入和查詢,但作為交換,它還有其他不那么嚴格的限制,
這個解決方案添加了一列文本型別,我將它命名為 path:
class Comment(db.Model):
id = db.Column(db.Integer, primary_key=True)
text = db.Column(db.String(140))
author = db.Column(db.String(32))
timestamp = db.Column(db.DateTime(), default=datetime.utcnow, index=True)
path = db.Column(db.Text, index=True)
每條評論在插入時都會被分配一個唯一的遞增值,這與每個評論獲得資料庫自動遞增 id 的方式幾乎相同, 所以第一個評論得到 1,第二個得到 2,依此類推, 頂級評論的路徑內容就是這個計數器的值, 但是對于回復,路徑設定為父路徑,并在末尾附加計數器, 使用與上述示例相同的評論層次結構,以下可能是按照隨機順序輸入的評論,并為其分配了路徑值:
alice: hello1 path: '1'
bob: reply11 path: '1.2'
bob: hello2 path: '3'
susan: reply12 path: '1.4'
susan: reply111 path: '1.2.5'
alice: reply21 path: '3.6'
為清楚起見,我在每個路徑組件之間插入了一個句點,但在實際實作中并不是必需的, 現在,如果我在這個表上運行一個按路徑對行進行排序的查詢,我會得到正確的線索順序, 并且要知道每個評論需要的縮進級別,我可以查看路徑有多少個組件,
alice: hello1 path: '1' <-- top-level
bob: reply11 path: '1.2' <-- second-level
susan: reply111 path: '1.2.5' <-- third-level
susan: reply12 path: '1.4' <-- second-level
bob: hello2 path: '3' <-- top-level
alice: reply21 path: '3.6' <-- second-level
?
使用此方法插入新評論相當方便, 我只需要有一種方法來生成一個唯一且不斷增加的值來分配給新評論,例如,我可以使用資料庫分配的 id, 我還需要知道評論的父級,以便我可以在創建子評論的 path 時使用它的 path,
?
查詢也很方便,通過在 path 列上添加索引,我可以非常有效地按照正確的線索順序獲取評論,只需按照path 進行排序即可,我還可以對串列進行分頁,
那么,如果這一切都那么好,那么壞訊息是什么呢? 看看上面例子中的 path 分配,看看你是否能發現其局限性,
?
你找到了嗎? 你認為這個系統支持多少條評論? 按照我構建這個例子的方式,你的評論不能超過 10 條(或者實際上是 9 條,除非你從 0 開始計數), 僅當 path 欄位中使用的數字具有相同的位數(在本例中只有一位)時,按 path 排序才有效, 一旦出現 10,排序就會中斷,因為我使用的是字串,所以 10 在 1 和 2 之間而不是在 9 之后排序,
?
那么解決方案是什么呢? 讓我們為 path 中的每個組件分配兩位數:
alice: hello1 path: '01'
bob: reply11 path: '01.02'
susan: reply111 path: '01.02.05'
susan: reply12 path: '01.04'
bob: hello2 path: '03'
alice: reply21 path: '03.06'
如果我小心地對每個組件進行右對齊和零填充,現在我最多可以添加 99 條評論, 但是,這仍然很有限,所以你可能想要使用更多的數字而不是兩位數, 例如,如果您使用六位數字,則在遇到問題之前,你最多可以獲得一百萬條評論, 如果你發現你使用的位數已接近極限,您可以將評論離線進行維護,用更多的數字重新生成路徑,然后你就可以恢復正常了,
這個實作其實并沒有那么糟糕,我決定將此解決方案與鄰接串列選項結合起來,因為這為我提供了一種簡單有效的方法來獲取給定評論的父級(我可以不使用鄰接串列并從 path 欄位中提取 parent id, 但這似乎過于復雜), 我將評論的插入邏輯封裝在 Comment 模型中的 save() 方法中,以便可以從應用程式的任何部分輕松呼叫它,下面是更新后的模型,包括重新引入的鄰接表、save() 方法以及新增的 level() 方法,該方法回傳任何評論的縮進級別:
class Comment(db.Model):
_N = 6
id = db.Column(db.Integer, primary_key=True)
text = db.Column(db.String(140))
author = db.Column(db.String(32))
timestamp = db.Column(db.DateTime(), default=datetime.utcnow, index=True)
path = db.Column(db.Text, index=True)
parent_id = db.Column(db.Integer, db.ForeignKey('comment.id'))
replies = db.relationship(
'Comment', backref=db.backref('parent', remote_side=[id]),
lazy='dynamic')
def save(self):
db.session.add(self)
db.session.commit()
prefix = self.parent.path + '.' if self.parent else ''
self.path = prefix + '{:0{}d}'.format(self.id, self._N)
db.session.commit()
def level(self):
return len(self.path) // self._N - 1
_N 類變數存盤我用于每個組件的位數, 在本例中,我將其設定為 6,它最多支持一百萬條評論, 為了獲得在路徑中使用的唯一且自動遞增的數字,我只是盜用了資料庫分配的 id,因此我必須將評論保存兩次, 首先我保存它讓資料庫分配 id,此時沒有設定 path,然后第二次設定 path, 兩次保存評論并不理想,但考慮到我獲得的所有好處,我認為這是一個很好的妥協, 如果你想出一種不同的方法來生成自動遞增的數字,也可以避免雙重保存,但這需要非常仔細的設計以避免競爭條件,所以我決定堅持使用雙重保存解決方案,
?
在這個實作中,我在組件之間使用了點分隔符,但這并不是真正需要的, 我將它們留在那里是因為它使path 更具可讀性,但是如果你更喜歡節省空間,則完全可以不包含句點并將 path 變成一個壓縮的數字序列,
level() 方法非常容易實作,通過獲取 path 屬性的長度并將其除以每個組件中的位數, 當將這些評論按線索呈現時,此方法對于生成正確的縮進非常有用,
?
下面你可以看到我如何用上面的例子中使用的結構插入評論,基本上,我不得不停止直接參考資料庫會話,而是呼叫 save ()來保存每條評論:
c1 = Comment(text='hello1', author='alice')
c2 = Comment(text='hello2', author='bob')
c11 = Comment(text='reply11', author='bob', parent=c1)
c12 = Comment(text='reply12', author='susan', parent=c1)
c111 = Comment(text='reply111', author='susan', parent=c11)
c21 = Comment(text='reply21', author='alice', parent=c2)
for comment in [c1, c2, c11, c12, c111, c21]:
comment.save()
下面是我如何使用正確的縮進將評論列印到終端:
for comment in Comment.query.order_by(Comment.path):
print('{}{}: {}'.format(' ' * comment.level(), comment.author, comment.text))
?
這個實作的完整且可運行的例子就是這個 gist,
可能的改進
我認為這個解決方案是非常好的,但是根據應用程式的不同,你可能會發現需要稍微調整一下來達到你想要的效果,
?
正如我在上面提到的,這個解決方案可以管理一組評論,不幸的是,這并不是那么有用,因為大多數應用程式都有很多頁面,用戶可以在上面寫評論,為了能夠檢索應用于單個頁面的評論,你需要做的就是向 Comment 模型添加另一列,該列鏈接到應該顯示評論的頁面,例如,在博客應用程式中,這可能是 post id 的外鍵,這個 id 需要被復制到所有的評論中,包括回復,這樣你就可以運行一個類似于下面的查詢:
for comment in Comment.query.filter_by(post_id=post.id).order_by(Comment.path.asc()):
print('{}{}: {}'.format(' ' * comment.level(), comment.author, comment.text))
save() 方法可以將 post_id 欄位從父級復制到子級評論中,這樣你就不必一直手動復制這些 ID,
這個解決方案的另一個限制是,它只能按照頂級評論的順序檢索評論,從最舊的到最新的,對于許多應用程式,你可能希望將頂級評論從最新的到最舊的進行排序,同時仍然在每個父評論下按照線索順序保留所有的回復,在其他情況下,用戶可能會投票贊成或反對頂級評論,而你希望首先顯示投票最多的評論,
?
要實作這些替代排序策略,你必須使用額外的列, 如果你希望能夠按頂級評論的時間戳排序,你只需添加一個 thread_timestamp 列,該列在每個回復中都復制了頂級評論的時間戳,save() 方法可以將這個時間戳從父級傳遞給子級,這樣就不會成為管理這個額外列的負擔, 然后你可以按時間戳以及path 進行排序,來保留回復的順序:
for comment in Comment.query.order_by(Comment.thread_timestamp.desc(), Comment.path.asc()):
print('{}{}: {}'.format(' ' * comment.level(), comment.author, comment.text))
?
如果你想按用戶對頂級評論的投票進行排序,解決方案類似, 你必須使用 thread_votes 列而不是 thread_timestamp, 為了使這個解決方案起作用,你仍需要在與父評論關聯的所有回復中復制此列的值, 如果你想首先顯示投票最多的頂級評論,你可以執行以下操作:
for comment in Comment.query.order_by(Comment.votes.desc(), Comment.path.asc()):
print('{}{}: {}'.format(' ' * comment.level(), comment.author, comment.text))
?
然而,投票解決方案有一個轉折點, 用戶會對頂級評論進行投票贊成或反,因此每次頂級評論收到投票時,新的投票分數不僅需要寫在頂級評論上,還需要寫在所有回復上,以確保保持正確的線索排序, 你可以分兩步進行更新,首先獲取子項串列,然后更新所有子項的投票分數:
class Comment(db.Model):
def change_vote(vote):
for comment in Comment.query.filter(Comment.path.like(self.path + '%')):
self.thread_vote = vote
db.session.add(self)
db.session.commit()
?
如果你更喜歡更高效的東西,你可以通過繞過 ORM 的 update() 呼叫來實作,
總結
我希望這是一個有用的概述,可以幫助您為應用程式的注釋平臺找到最佳解決方案,正如我在上面指出的,我有一個關于扁平注釋、鄰接表和基于注釋路徑的最后解決方案的示例代碼的要點,你使用不同的解決方案嗎?我很想知道,所以請在下面的評論中告訴我,
?
我希望這是一個有用的概述,可以幫助你找到應用程式評論平臺的最佳解決方案, 正如我上面指出的,我有一個 gist 示例代碼,其中包含用于扁平評論、鄰接串列和基于評論路徑的解決方案, 你是否使用了不同的解決方案? 我想知道,所以請在下面的評論中告訴我,
本文由博客一文多發平臺 OpenWrite 發布!
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/288754.html
標籤:Python
