共计 10054 个字符,预计需要花费 26 分钟才能阅读完成。
翻译
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 示例代码,其中蕴含用于扁平评论、邻接列表和基于评论门路的解决方案。你是否应用了不同的解决方案?我想晓得,所以请在上面的评论中通知我。