翻译自: Implementing User Comments with SQLAlchemy

首先SQLAlchemy读音是/ˈsiːkwəl/ /ˈælkəmi/,alchemy的意思是炼金术,改变事物的魔力

一个在你的web应用中保持用户参与的基本方式是让他们可以写评论。现在有一些第三方服务提供了这些评论功能。Disqus和Facebook是可以在你的站点中提供评论最受欢迎的服务。

但是如果你不想使用外部服务呢。在这篇文章中,我将向你展示我是如何使用Python来实现评论服务的,通过使用SQLAlchemy ORM(Object Relational Mapping对象关系映射)和其支持的任何数据库引擎。我会以最简单的开始,随后会讨论一些支持多层回复的高级实现。

评论服务的问题

虽然将评论服务转交给外部服务是吸引人的,但是还有很多为什么你不会这样做的原因。这些服务嵌入到你的页面上的用户接口通常不是太灵活,因此可能在你的页面上看起来不舒服。可能虽然已经有了你自己站点的账户,但是还需要在第三方服务上注册账户才能使用评论功能。

还有一个比较重要的考虑是许多开发者注意到这些评论是不会被自己拥有的,但是一个潜在需求是希望将这些数据导出,你会决定不使用这个服务而使用其他服务吗?或者更糟糕的是这些提供商自己破产了怎么办?

另外还有安全层面上的问题。你可能不会信任你的用户的信息放在这些经常被黑客攻击的大公司里。就在几天前,Disqus声明其经历了数据泄漏。

基本的评论平台

下面这个是最简单的方式实现评论(看别人的代码有助于纠正自己的一些不好做法= =):

1
2
3
4
5
6
7
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)

这样你就可以保持一个评论列表。为了添加一个评论,你可以简单的创建一个Comment实例,然后将其写入到数据库中。

1
2
3
comment = Comment(text='Hello, world!', author='alice')
db.session.add(comment)
db.session.commit()

我不用关系timestamp字段,因为我在模型定义中已经给其赋了默认值当前的utc时间。这样我可以按照时间顺序对评论进行排序了。

1
2
3
4
5
6
7
# 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模型的外键。如果你在多个页面接收评论,你也需要添加一个额外的字段,将评论链接到所属的页面上,然后你可以根绝页面地址将评论取出来。

实现评论的回复

在大多数的应用中,需要对某个评论进行回复,然后以分层的方式展示所有的关联评论。不过这种在关系型数据库中不容易实现。

有两种比较有名的实现处理了在关系型中展示树结构的问题,但是两者都有限制。

Adjacency Lists

其主要想法是在Comment模型中添加一列来追踪每个评论的父评论。如果每个评论都有指向父评论的关系,这样你就可以取出所有的树结构了。当parent为None的时候就是顶层评论。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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)
# 父节点的ID,插入的时候第一级的评论该字段是None
# 一个外键,对应的是Comment.id
parent_id = db.Column(db.Integer, db.ForeignKey('comment.id'))
# 链接外键的关系,使用字段名为parent,一对多关系
# 反向关系,这样就可以使用这个反向关系获取外键对应的评论数据
replies = db.relationship(
'Comment', backref=db.backref('parent', remote_side=[id]),
lazy='dynamic')

这里我添加的是一个自引用的一对多关系模型。因为每个评论现在都有parent_id外键,因此我可以很容易的找到给定一个评论的直接回复,只用找到所有parent是这条评论的所有评论就可以了。

比如,我想展示下面的评论:

1
2
3
4
5
6
alice: hello1
bob: reply11
susan: reply111
susan: reply12
bob: hello2
alice: reply21

添加上述结构的代码如下:

1
2
3
4
5
6
7
8
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()

到目前为止,一切都很简单。但是当你想以适合表现的方式获取数据的时候就有问题了。想以正确的评论顺序获取数据基本上是不可能的。唯一一种方式是通过递归查询。下面的代码通过递归查询来打印出了评论的合适顺序。

1
2
3
4
5
6
7
8
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()递归的去获取它们的子评论。

那么这样做就是非常低效的。如果一个评论底下有上百层子评论,你就要进行上百次额外的数据库查询。如果你想给你的评论分页,你唯一能做的是将顶层的评论分页,而不能将评论线作为整体进行分页。

尽管这个方法很优雅,但是在实际中一般不采用这种方法。

Nested Sets

第二种技术叫做嵌套集合。这个方法比较复杂,它给数据库添加了两列,一列叫left一列是right,第三个可选的列叫做level。所有的列都存储数字,用来描述树的遍历顺序。当你沿着树向下走的时候,给left字段赋予连续的数字,向上走给right字段赋予连续的值。这个方法的结果是,没有回复的评论的left和right将有连续的数字。level列存储了每个评论有的父节点的层数。

1
2
3
4
5
6
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

在这种结构下,如果你想获取一个评论下的回复,只需要查找所有的评论中left比这个父节点的left大,right比父节点的right小的记录。然后如果你按照left排序,你将会得到正确的评论顺序,并且你可以使用level来确定如果在web页面上正确的渲染。这种方法相对于adjacency lists来说是,你可以在一次查询中获取正确的顺序,甚至可以使用分页获取子集。

你也许会觉得这是一个解决问题很好的方法,但是你有没有想过这个算法是如何给每个评论赋值的?这就是这个方法的问题所在。每次一个新的评论添加进来后,整个评论表几乎都要进行更新left和right值。使用adjacency lists的时候,插入是很容易的,但是查询非常低效。当使用嵌套集合的时候是相反的,插入是很低效的,但是查询很高效。

跳出来想

这里有一个新的方法,添加了一个text类型的列,我将其命名为path:

1
2
3
4
5
6
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,以此类推。最上层的评论的path值就是这个计数器的值。但是对于回复,path将会被设置为父节点的path后面加上计数器的值。使用上面的评论例子,下面是对于它们随机插入的时候,path被赋的值。

1
2
3
4
5
6
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'

这种方法插入的时候要直到父节点是哪个。

为了澄清,我在每个path中间都插入了句点,但是在实际实现中是不必要的。如果我现在按照path排序来查询这个表,我会得到正确的评论顺序。并且可以通过path中有多少个数字组成就直到其是第几个层级。

1
2
3
4
5
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

插入的时候也很容易,只需要生成一个独一无二并且是自增长的数字给新的评论,比如我可以使用database的id列。我还需要直到这个评论的父节点,这样我就可以使用它的path字段,然后生成孩子评论的path字段了。

查询也很容易。通过在path列加入索引,我可以非常高效的以正确的顺序得到评论,只需要对path排序。并且我还可以对评论列表分页。

那么看起来很棒吧,有什么限制呢?你认为这个系统可以支持多少个评论呢。以这种方式的评论系统,你不能拥有超过10条评论(或者说是9,因为你没有从0开始)。通过path排序,因为是字符串而不是数字比较,所以会出现问题,比如10和2比较,其实2应该在前面,但是字符串比较并不是这样。所以比较的时候位数必须一样。

那么如果给path的每个组件分配2位呢?

1
2
3
4
5
6
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条评论了。当你发现会到达极限的时候,可以线下维护评论,重新使用更多的位数来生成paths。

另外,可以和上述的adjacency list结合起来,可以以一种简单和高效的方式来获取给定评论的父节点。我封装了插入逻辑为save()方法,这样我就可以非常容易的调用了。并且level()方法返回任何评论的层级。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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()
# 需要存储两次,因为第一次要获取id
# 如果其他实现计数器,可能导致竞争条件
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.split('.')) - 1
return len(self.path) // self._N - 1

下面是一个例子:

1
2
3
4
5
6
7
8
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()

下面就可以在终端以正确的缩进打印了

1
2
for comment in Comment.query.order_by(Comment.path):
print('{}{}: {}'.format(' ' * comment.level(), comment.author, comment.text))

可能的改进

如果有多个页面,就需要在Comment模型中加入一个字段表示哪个页面的,比如在博客应用中,需要加入一个外键指向post的id。id需要复制到所有的评论中,包括回复,当然你可以在插入的时候将父评论的post id复制到子节点中。查询如下:

1
2
for comment in Comment.query.filter_by(post_id=post.id).order_by(Comment.path.asc()):
print('{}{}: {}'.format(' ' * comment.level(), comment.author, comment.text))

还有的就是需要根据不同的方式排序父评论,需要再加一个列。比如你想以父评论的时间来排序,你就可以添加一个thread_timestamp列,可以给所有对应的回复加上父节点的时间。save()方法可以将父评论的时间传递给子评论,这样你就可以按照这个时间排序了。

1
2
for comment in Comment.query.order_by(Comment.thread_timestamp.desc(), Comment.path.asc()):
print('{}{}: {}'.format(' ' * comment.level(), comment.author, comment.text))

如果要按照点赞数来排序,可以增加一个thread_votes列,与上面同理:

1
2
for comment in Comment.query.order_by(Comment.votes.desc(), Comment.path.asc()):
print('{}{}: {}'.format(' ' * comment.level(), comment.author, comment.text))

但是改变点赞数的时候就比较麻烦,要给所有的回复更新点赞数

1
2
3
4
5
6
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()

当然也可以直接使用update调用,而不是ORM。