【翻译】使用 SQLAlchemy 实现用户评论

528 阅读18分钟

翻译 Implementing User Comments with SQLAlchemy

保持 Web 应用程序用户参与的最基本的方法之一是给他们一个写评论的空间。现在,几乎所有的东西都有第三方服务,评论也不例外。DisqusFacebook 是很受欢迎的服务,允许你将评论嵌入到你的网站中。 ​

但是如果你不想使用外部服务怎么办?在本文中,我将向你展示如何使用 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 中,你可以看到该技术的完整实现。

嵌套集合

第二种技术称为 嵌套集合。这是一个相当复杂的解决方案,它向表中添加了两列,称为 leftright,以及第三个可选 level 列。所有列都存储编号,并用于描述树结构的遍历顺序。当你向下看的时候,你把数字顺序的分配给 left 字段,当你向上看的时候,你把它们分配给 right 字段。这种编号的结果是,没有回复的评论的 leftright 是连续的。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 示例代码,其中包含用于扁平评论、邻接列表和基于评论路径的解决方案。 你是否使用了不同的解决方案? 我想知道,所以请在下面的评论中告诉我。

PythonCN-sub.png