这是我用 Python 的轻量级框架 Flask 编写web程序时的一些经验,我将它记录在此,本篇是一系列文章中的第八篇。
这系列教程的目的是开发一个功能完善的,但没有创意的微博应用,我决定称之为微博客。
下面是这个系列中已经发布的文章的目录:
- 第一篇: Hello, World!
- 第二篇: 模板
- 第三篇: Web 表单
- 第四篇: 数据库
- 第五篇: 用户登陆
- 第六篇: 个人资料和头像
- 第七篇: 单元测试
- 第八篇: 关注、联系人和好友(本文)
- 第九篇: 分页
- 第十篇: 全文搜索
- 第十一篇: 电子邮件
- 第十二篇: 整合
简要
我们的微博客正在一点一点的成长,到目前为止我们已经接触了很大一部分我们必须要在应用中实现的主题。
今天我们接着在数据库上面做文章。我们应用中不同的用户可以选择他想关注的其他用户,所以数据库必须记录谁关注了谁。所有社交化应用都有这个功能。只是它们叫不同的名字而已,比如关注,粉丝,朋友,伙伴,慕名崇拜者等等。其他一些网站用这个想法来实现被允许和被禁止的用户列表。我们将称它为关注,但不管名字是神马,实现是一样的。
设计“关注”功能
在编码之前,让我们想想我们想要从这个功能中获取什么。
让我们从最明显的开始,我们希望我们的用户能简单的维护粉丝名单。
从另一面看,我们希望不同的用户互相知道这个粉丝名单。
我们也希望能有一个方法去查询是否一个用户正在关注谁,或者被谁关注。
用户将电机其他用户个人页面的“关注”按钮来关注其他用户。同样的,他们也可以点击“取消关注”按钮来停止关注一个用户。
最后一点需要的是我们可以轻松从数据库为给定用户的粉丝查询所有微博文章。
所以,如果你认为这是一个快速简单的文章,再想想吧。
数据库关联
我说过我们希望得到所有用户的关注列表和粉丝列表。不幸的是,关系型数据库没有list属性的字段,我们只有记录表和这些记录之间的联系。
我们已经在数据库中有了一张表来记录用户,我们还没做的是提出一个适当的关系类型来模拟关注/粉丝链接。现在是时候复习一下三种数据库关系类型了。
One-to-many(一对多)
我们已经在之前的数据库章节中看到过了一对多的关系类型。这儿有个图解:
这个关系类型中,两个实体users和posts关联。我们说一个用户可以有很多条微博,而一条固定微博只能是由一个用户发的。在数据库中这种关系类型通过在“多”的一方添加“一”的一方的外键来表示。在上面的例子中外键就是将user_id添加到posts表中。这个字段标明了不同微博和它在用户表中用户的关联。
user_id清晰的提供了用户和所发微博的直接关系,但是反过来肿么样呢?让这种关系类型变得更加有用,我们需要获取给定用户的所有微博。结果是posts表中的user_id已经足够回答这个问题,如果数据库有索引那么它将支持更有效率的查询方法例如“当user_id为xxoo是检索所有微博”。
Many-to-many(多对多)
多对多关系类型有点复杂。比如一个例子,想象一个拥有学生和老师的数据库。我们可说一个学生可以有多个老师,一个老师可以有多个学生。这就像是两个重叠的一对多关系。
这种数据库关系下我们可以查询数据库获得教一个学生的所有老师的列表,以及一个老师的课程下所有学生的列表。但它实现起来比较棘手,它不能通过在已存在的表中增加外键来实现。
想要实现多对多关系需要增加一个叫association的辅助表。这儿有个数据库如何查找学生和老师的例子:
它是轻而易举的,包含两个外键的association表能有效地解决很多类型的查询,例如:
- 学生S的老师是谁?
- CC老师的的学生是谁?
- CC老师有多少学生?
- 学生S有多少老师?
- CC老师教学生S吗?
- 学生S选CC老师的课了吗?
One-to-one(一对一)
一对一是一对多的特例。表现形式也相似,约束是阻止“多”的一方有超过一个链接到“一”的一方。
在有些情况下,这种关系类型是灰常有用的,它和其他两种关系不同,因为任何时候一个表里的一条数据映射到另一个表中时,它可以证明两个表是否能合并成一个表。
展现关注和粉丝
从上面的关系中我们很容易就能决定使用多对多关系作为合适的数据关系,因为一个用户可以关注很多用户,一个用户也可以被很多用户关注。但有一点拧巴的是,我们希望展示用户关注其他用户,但我们只有用户这一个角色(不像学生老师有两种角色)。所以我们用什么来当多对多关系中的第二个实体呢?
好吧,第二个实体还是用户。这种一个实体的实例映射到同一个实体的另一个实例的关系被叫做self-referential relationship(自我指涉关系),这正是我们需要的。
这是我们多对多关系的图解:
follwers表示我们的association表。外键都指向用户表,因为我们是映射用户到用户。这个表中的每个记录代表了一个关注用户和一个被关注用户。就像学生和老师的那个例子,这样的设定也能回答所有关注者和悲观者者之间的问题,简单又灵活。
数据库模型
稍微改变一下我们的数据库模型。我们从增加followers表开始(file app/models.py):
followers = db.Table('followers', db.Column('follower_id', db.Integer, db.ForeignKey('user.id')), db.Column('followed_id', db.Integer, db.ForeignKey('user.id')) )
这是从我们上面的图解中直接翻译的结果。注意我们没有像users和posts表那样说明这个表作为模型。因为这是一个除了外键没有任何数据的辅助表,我们使用Flask-SQLAlchemy中低级的APIs创建这个表而不实用associated模型。
下面我们在用户表中定义多对多关系(file app/models.py):
class User(db.Model): id = db.Column(db.Integer, primary_key = True) nickname = db.Column(db.String(64), unique = True) email = db.Column(db.String(120), index = True, unique = True) role = db.Column(db.SmallInteger, default = ROLE_USER) posts = db.relationship('Post', backref = 'user', lazy = 'dynamic') about_me = db.Column(db.String(140)) last_seen = db.Column(db.DateTime) followed = db.relationship('User', secondary = followers, primaryjoin = (followers.c.follower_id == id), secondaryjoin = (followers.c.followed_id == id), backref = db.backref('followers', lazy = 'dynamic'), lazy = 'dynamic')
设定这种数据关系是重大的改变,而且必须来解释一下。就像之前章节中我们设定一对多关系一样,我们使用db.relationship方法来定义数据库关系。我们将映射一个用户实例到同一个用户的另一个实例,所以按照惯例我们定义一对映射用户中左边的用户正在关注右边的用户。
我们定义左侧为关注实体的数据类型,因为当我们从左侧查询数据类型时,我们将得到粉丝列表。让我们一个一个看看db.relationship()方法的参数:
- ‘User’是这种关系类型的右侧实体(左侧实体是父类)。由于我们定义了自我指涉数据类型,所以我们两侧使用同样的类。
- secondary指明了这种数据关系要用到的association表
- primaryjoin指明了通过association表映射到左侧实体(粉丝)的条件。注意因为followers表不是一个模型,所以这儿需要一些稍微奇怪的语法。
- secondaryjoin指明了通过association表映射到右侧实体(正在关注的用户)的条件。
- backref定义了从右侧实体怎么样进行访问。我们说对于一个给定的用户,当进行followed查询时,返回所有右侧的用户,这些用户都在左侧有目标用户。反过来,当进行follower查询时,将返回所有的左侧用户,这些用户同样在右侧有目标用户。附加的lazy参数指明了查询的执行模式。dynamic模式建立的查询只有在特殊的请求下才运行。这对于性能问题很有用,而且因为我们可以得到这个查询并且修改它在它执行前。关于这个还有很多。
- lazy和backref参数里的lazy是相似的,但和上面的相反,这个适合于经常查询。
如果的内容不好懂,别失望(译者注:反正我是没太懂). 我们将立刻使用这些查询, 然后一切就变得清晰了。
由于我们升级了数据库,所以现在我们需要产生一个新的迁移:
./db_migrate.py
这样我们就完成了数据库的改变。但我们还有很大量的编码工作要做。
关注与取消关注
为了增强可重用性,我们将在User模型里实现follow和unfollow方法而不是在view里直接实现它。这种方法我们能在真实的应用中使用(通过view方法调用它)而且对我们的单元测试有好处。原理是,它可以在逻辑上更好的和view还有深层次的模型分离,因为这样可以简化测试。你希望你的view方法足够简单,因为它复杂了会很难实现自动化测试。
以下是添加删除关系的代码,在User 模型里定义方法(fileapp/models.py):
class User(db.Model): #... def follow(self, user): if not self.is_following(user): self.followed.append(user) return self def unfollow(self, user): if self.is_following(user): self.followed.remove(user) return self def is_following(self, user): return self.followed.filter(followers.c.followed_id == user.id).count() > 0
得益于sqlalchemy在幕后做了大量的工作,我们的代码写起来很简单。我们只需要添加,删除关系,sqlalchemy会帮我们管理关系表。
follow和unfollow方法要在执行成功是返回一个user对象,失败的时候返回None,user对象会被添加到数据库会话,并提交。
is_following方法只有一行代码却做了很多事情. 我们递交获取followed关系的请求,它返回所有以我们的user为follower的(follower, followed)元组, 以followed user为条件进行过滤. 上述方法是可行的,因为followed关系有一个懒惰模式的动态属性,我们得到的是请求执行之前请求对象而不是请求的结果.
过滤操作返回的是更改后、依然没有被执行的请求. 当我们在这条请求上调用count()方法时,请求才会被执行并返回查询到的记录数目. 若返回的记录数目大于等于1,这俩用户之间存在联系;相反,则没有联系.
测试
Let's write a test for our unit testing framework that exercises all that we have built so far (filetests.py):的
让我们在已经建立起来的单元测试框架里继续写测试(filetests.py):
class TestCase(unittest.TestCase): #... def test_follow(self): u1 = User(nickname = 'john', email = 'john@example.com') u2 = User(nickname = 'susan', email = 'susan@example.com') db.session.add(u1) db.session.add(u2) db.session.commit() assert u1.unfollow(u2) == None u = u1.follow(u2) db.session.add(u) db.session.commit() assert u1.follow(u2) == None assert u1.is_following(u2) assert u1.followed.count() == 1 assert u1.followed.first().nickname == 'susan' assert u2.followers.count() == 1 assert u2.followers.first().nickname == 'john' u = u1.unfollow(u2) assert u != None db.session.add(u) db.session.commit() assert u1.is_following(u2) == False assert u1.followed.count() == 0 assert u2.followers.count() == 0
测试写完之后,我们可以通过下面的命令运行整个单元测试套件。
./tests.py
And if everything works it should say that all our tests pass.
如果所有的正常工作,说明通过了测试
数据库查询
我们目前的数据库模型支持在开始罗列的大多数需求,缺少的部分也是最难的部分。索引页需要显示被当前登录用户follow的人所发布的所有帖子,因此我们需要这样一个请求能返回这些帖子。
目前我们能做的、最明显的方式是发出一条请求,以得到被follow的用户列表。然后给列表中的每个用户发送一条请求,以得到他们的帖子。得到所有用户的的帖子后,将帖子组合成以时间排序的列表。听起来还不错?好吧,不是真的。
这种处理方式存在很多问题,如果一个用户关注了上千人会是怎样的情况?我们需要执行一千次数据库的查询只是为了收集所有关注用户的帖子,现在我们在内存中存放了一千条记录列表,我们需要对他们进行合并和排序。另一个问题,考虑我们的首页将要使用分页方式,每页显示50条信息,点击下一页和前一页显示另外的50条。如果我们想要按照时间顺序显示帖子,我们如何知道所有关注用户的最后发表的50篇帖子,除非我们获取到所有的帖子然后再进行排序。这实际上是一个不支持扩展的可怕的实现方式。
虽然这种收集和排序能得到想要的结果,但是非常低效。这种工作是关系型数据库所擅长的,数据库索引可以让查询和排序更加高效。
所以我们真正想要的是通过单个的数据库查询得到我们想要的结果,同时让数据库找出什么样的方式是我们获取数据最高效的方式。
好了,不卖关子了,这里有个查询可以实现我们想要的。不幸的是,这仍然是一个需要加到User模型里面加载的方法(fileapp/models.py):
class User(db.Model): #... def followed_posts(self): return Post.query.join(followers, (followers.c.followed_id == Post.user_id)).filter(followers.c.follower_id == self.id).order_by(Post.timestamp.desc())
让我们尝试分块解释下这个查询,它分为三部分:join,filter和order_by。
联合
要了解的联合操作,让我们来看一个例子。让我们假设我们有一个User表包含以下内容:
User | |
---|---|
id | nickname |
1 | john |
2 | susan |
3 | mary |
4 | david |
有一些额外的字段的表,上面没有显示,这只是个简化的例子。
比方说,我们的追随者关联表显示,用户“john”是追随用户“susan”和“david”,用户“susan”是追对“mary”并且用户“mary”是追随“david”。表示上述的数据是这样的:
followers | |
---|---|
follower_id | followed_id |
1 | 2 |
1 | 4 |
2 | 3 |
3 | 4 |
Post | ||
---|---|---|
id | text | user_id |
1 | post from susan | 2 |
2 | post from mary | 3 |
3 | post from david | 4 |
4 | post from john | 1 |
这里我们为了保持这个例子的简单,省略了一些子段。
下面是我们查询的join部分,独立与其他的查询:
Post.query.join(followers, (followers.c.followed_id == Post.user_id))
join 操作调用在post表,有两个参数, 第一个是要连接的另一个表,我们的followers表,第二个参数是连接(join) 查询的条件。
join操作实现的是创建一个临时表,数据根据指定条件从Post和followers表合并得到。在这个例子中,我们使用followers的follower_id字段和Post的user_id字段做匹配。
执行这个合并操作,我们从Post表(左链接)得到每条记录,并且把记录添加到匹配条件的follwers表(右链接)对应字段。如果不匹配,帖子记录不被返回。
例子中合并后的结果在这张临时表中如下:
Post | followers | |||
---|---|---|---|---|
id | text | user_id | follower_id | followed_id |
1 | post from susan | 2 | 1 | 2 |
2 | post from mary | 3 | 2 | 3 |
3 | post from david | 4 | 1 | 4 |
3 | post from david | 4 | 2 | 4 |
注意,在合并后user_id=1的帖子被移除了,这是因为在follwers表中没有follower_id=1的记录。同时也要注意user_id=4出现两次,因为followers表中有两条对应followed_id=4的记录。
Filters(过滤)
join操作给我们列出了一个用户关注的所有用户的帖子,没有指定具体的哪个关注用户。我们可能只对这个列表的子集感兴趣,我们需要获取一个关注的指定用户的帖子。
因此我们需要过滤下我们关注的用户,filter的查询部分如下:
filter(followers.c.follower_id == self.id)
Post | followers | |||
---|---|---|---|---|
id | text | user_id | follower_id | followed_id |
1 | post from susan | 2 | 1 | 2 |
3 | post from david | 4 | 1 | 4 |
记住这个查询被定义在Post类中,因此即使我们最终使用的临时表不符合任何一个模型,然而帖子的所需结果都被包含在这个临时表中,没有因为join操作添加额外的字段。
Sorting(排序)
流程的最后一步是按照我们的条件进行排序,排序部分的查询如下:
order_by(Post.timestamp.desc())
这里我们按照时间戳降序排序,所以第一条记录应该是最后发表的帖子。
有一个小细节,我们可以优化下我们的查询,当用户查看他们关注的帖子时,他们也可能想同时看到自己的帖子,因此,在结果中包含个人帖子信息将使我们的查询看起来更友好。
和原来一样,这里有个简单的方式实现,不需要做任何修改!我们只需要确定每个用户在数据库关注者里面添加上自己,这将为我们解决这个小问题。
总结一下我们关于查询的长篇大论吧,让我们来为查询写个单元测试 (tests.py):
#... from datetime import datetime, timedelta from app.models import User, Post #... class TestCase(unittest.TestCase): #... def test_follow_posts(self): # make four users u1 = User(nickname = 'john', email = 'john@example.com') u2 = User(nickname = 'susan', email = 'susan@example.com') u3 = User(nickname = 'mary', email = 'mary@example.com') u4 = User(nickname = 'david', email = 'david@example.com') db.session.add(u1) db.session.add(u2) db.session.add(u3) db.session.add(u4) # make four posts utcnow = datetime.utcnow() p1 = Post(body = "post from john", author = u1, timestamp = utcnow + timedelta(seconds = 1)) p2 = Post(body = "post from susan", author = u2, timestamp = utcnow + timedelta(seconds = 2)) p3 = Post(body = "post from mary", author = u3, timestamp = utcnow + timedelta(seconds = 3)) p4 = Post(body = "post from david", author = u4, timestamp = utcnow + timedelta(seconds = 4)) db.session.add(p1) db.session.add(p2) db.session.add(p3) db.session.add(p4) db.session.commit() # setup the followers u1.follow(u1) # john follows himself u1.follow(u2) # john follows susan u1.follow(u4) # john follows david u2.follow(u2) # susan follows herself u2.follow(u3) # susan follows mary u3.follow(u3) # mary follows herself u3.follow(u4) # mary follows david u4.follow(u4) # david follows himself db.session.add(u1) db.session.add(u2) db.session.add(u3) db.session.add(u4) db.session.commit() # check the followed posts of each user f1 = u1.followed_posts().all() f2 = u2.followed_posts().all() f3 = u3.followed_posts().all() f4 = u4.followed_posts().all() assert len(f1) == 3 assert len(f2) == 2 assert len(f3) == 2 assert len(f4) == 1 assert f1 == [p4, p2, p1] assert f2 == [p3, p2] assert f3 == [p4, p3] assert f4 == [p4]这个测试有一大部分是配置代码,实际上测试非常短小。我们先检查下每个用户关注帖子的返回数量和预期的是否一致,接着检查下返回的帖子是否正确,帖子的顺序是否和预期一样(注意我们按照时间顺序排列帖子,保证我们总是使用相同的排序方式)。
注意followed_posts()方法的用法,这个方法返回一个查询对象,而不是结果集,这就如同lazy=''dynamic'的关系。通常来说返回一个查询对象而不是结果集是一个很好的点子,因为这给调用者在执行查询前提供了添加更多查询条件的机会。
在查询对象中有几个方法触发查询的执行。我们看到的count()运行查询时返回的是一个结果集的数量(不关注实际结果的内容);我们也经常使用first()来返回结果集的第一条记录,在这个测试中我们使用了all()方法来获取所有结果集的一个数组。
可能性的改进
现在我们已经实现了‘关注’的所有功能,但是我们还可以来优化下我们的设计,使他更灵活些。
所有的社交网站,我们喜欢的和不喜欢的都以同样的方式推送给我们,但是他们也可以通过更多的一些操作来控制分享的信息。
比如,我们没有提供获取用户阻止信息的支持。这将给我们的查询增加一层复杂性,因为我们不仅要获取我们关注用户的帖子,还要过滤出那些关注者不让我们看到的帖子。你想如何实现呢?简单!用一个多对多自关联的关系来记录谁阻止谁,在查询中再加一个join+filter来返回关注的帖子。
在社交网站上另一个常用的功能是能够对关注者进行分组,并且可以针对某一个特殊的组分享信息。这也靠增加关联关系和增加查询的复杂性来实现的。
我们在微博客中不添加这个功能,但是如果大家有足够的兴趣的话我很乐意再写一篇关于这个主题的文章。写下你的评论,让我了解下吧!
梳理下收尾
今儿我们取得了很明显的进步,虽然我们已经解决了关于数据库设置和查询的所有问题,我们还没有在应用程序中加上我们的新功能。
幸运的是,你不需要在程序做什么改变,我们只需要在合适的时机让视图函数和模板调用User模型里新的方法就可以了,因此,在结束这个会话前我们来修改下吧!
做自己的粉丝
我们决定把所有用户标记为自己的粉丝,这样他们就能在帖子动态里面看到自己的帖子了。
我们要修改的地方在用户的账号设置那里,after_login处理之后 (file 'app/views.py'):
@oid.after_login def after_login(resp): if resp.email is None or resp.email == "": flash('Invalid login. Please try again.') redirect(url_for('login')) user = User.query.filter_by(email = resp.email).first() if user is None: nickname = resp.nickname if nickname is None or nickname == "": nickname = resp.email.split('@')[0] user = User(nickname = nickname, email = resp.email, role = ROLE_USER) db.session.add(user) db.session.commit() # make the user follow him/herself db.session.add(user.follow(user)) db.session.commit() remember_me = False if 'remember_me' in session: remember_me = session['remember_me'] session.pop('remember_me', None) login_user(user, remember = remember_me) return redirect(request.args.get('next') or url_for('index'))
关注和取消关注的链接
下一步,我们在为一个用户定义关注和取消关注的视图方法 (fileapp/views.py):
@app.route('/follow/<nickname>') def follow(nickname): user = User.query.filter_by(nickname = nickname).first() if user == None: flash('User ' + nickname + ' not found.') return redirect(url_for('index')) if user == g.user: flash('You can\'t follow yourself!') return redirect(url_for('user', nickname = nickname)) u = g.user.follow(user) if u is None: flash('Cannot follow ' + nickname + '.') return redirect(url_for('user', nickname = nickname)) db.session.add(u) db.session.commit() flash('You are now following ' + nickname + '!') return redirect(url_for('user', nickname = nickname)) @app.route('/unfollow/<nickname>') def unfollow(nickname): user = User.query.filter_by(nickname = nickname).first() if user == None: flash('User ' + nickname + ' not found.') return redirect(url_for('index')) if user == g.user: flash('You can\'t unfollow yourself!') return redirect(url_for('user', nickname = nickname)) u = g.user.unfollow(user) if u is None: flash('Cannot unfollow ' + nickname + '.') return redirect(url_for('user', nickname = nickname)) db.session.add(u) db.session.commit() flash('You have stopped following ' + nickname + '.') return redirect(url_for('user', nickname = nickname))
这些应该是不言自明的,但是注意这里如何在程序里做错误处理的,阻止意外错误的发生并且试图在错误发生时为用户提供一个简单明了的信息。
我们有了视图方法,现在把他们和模板结合起来吧。关注和取消关注的链接将在每个用户的配置里面体现(fileapp/templates/user.html):
<!-- extend base layout --> {% extends "base.html" %} {% block content %} <table> <tr valign="top"> <td><img src="{{user.avatar(128)}}"></td> <td> <h1>User: {{user.nickname}}</h1> {% if user.about_me %}<p>{{user.about_me}}</p>{% endif %} {% if user.last_seen %}<p><i>Last seen on: {{user.last_seen}}</i></p>{% endif %} <p>{{user.followers.count()}} followers | {% if user.id == g.user.id %} <a href="{{url_for('edit')}}">Edit your profile</a> {% elif not g.user.is_following(user) %} <a href="{{url_for('follow', nickname = user.nickname)}}">Follow</a> {% else %} <a href="{{url_for('unfollow', nickname = user.nickname)}}">Unfollow</a> {% endif %} </p> </td> </tr> </table> <hr> {% for post in posts %} {% include 'post.html' %} {% endfor %} {% endblock %}
在‘Edit’链接那一行显示了用户的粉丝数量,其次是三种可能存在的链接:
- 如果用户空间属于当前登录用户,那么“Edit”可见。
- 另一种情况,假如当前没有关注这个用户,会显示“Follow”链接。
- 已关注的用户就会显示“Unfollow”链接。
现在,你可以运行你的应用,创建一些用户用不同的OpenId账号,尝试下following和unfollowing用户。
还需要做的事,在首页上展示关注(followed)的posts,但是,在我们去实现之前,我们错过了一些重要的东西。所以,只能等到下一章去做了。
结语
今天,我们实现了应用的核心。数据库的关系和查询时相当复杂的,所以,如果你有任何的问题,欢迎在回复中提出你的问题。
在接下来的教程中,我们会看看分页的实现, 最后,我们会posts从数据库显示到我们的应用
对于那些都懒的打字的懒的复制粘贴的同学,下面是最新的microblog 应用
一如往常,flask 的虚拟开发环境,数据库不包括在这,可以看看先前的文章如何配置开发环境。
在本系列的下一篇文章中,我希望再次见到你。
感谢你的阅读!