在这节我们实现的功能比较复杂,就是实现用户"关注"和"取消关注"的功能。
一个用户可以关注多个其他的用户,一个用户也可以被其他多个用户所关注,这样看的话,在数据库中显然是多对多的关系。但是这有一个问题。我们想要表示用户关注其他用户,因为我们只有用户。我们应该使用什么作为多对多关系的第二个表(实体)?这种关系的第二个表也是用户。我们现在来建这张表,表名是followers。在这张表中我们只设置了两个字段(follower_id 和 followered_id),这两个字段作为外键都关联到user表中。下面我们来开始逐步实现。
一、建立数据模型
1.添加followers表(model.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 来创建没有使用关联模式。
2.在user表中定义一个多对多的关系
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), unique = True)
posts = db.relationship('Post', backref = 'author', 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 函数来定义关系。我们将连接 User 实例到其它 User 实例,换一种通俗的话来说,在这种关系下连接的一对用户,左边的用户是关注着右边的用户。因为我们定义左边的用户为 followed,当我们从左边用户查询这种关系的时候,我们将会得到被关注用户的列表。让我们一个一个来解释下 db.relationship() 中的所有参数:
(1)‘User’ 是这种关系中的右边的表(实体)(左边的表/实体是父类)。因为定义一个自我指向的关系,我们在两边使用同样的类。
(2)secondary 指明了用于这种关系的辅助表。
(3)primaryjoin 表示辅助表中连接左边实体(发起关注的用户)的条件。注意因为 followers 表不是一个模式,获得字段名的语法有些怪异。
(4)secondaryjoin 表示辅助表中连接右边实体(被关注的用户)的条件。
(5)backref 定义这种关系将如何从右边实体进行访问。当我们做出一个名为 followed 的查询的时候,将会返回所有跟左边实体联系的右边的用户。当我们做出一个名为 followers 的查询的时候,将会返回一个所有跟右边联系的左边的用户。lazy 指明了查询的模式。dynamic 模式表示直到有特定的请求才会运行查询,这是对性能有很好的考虑。
(6)lazy 是与 backref 中的同样名称的参数作用是类似的,但是这个是应用于常规查询。
运行迁移脚本:python db_migrate.py
二、添加和移除关注者
为了使得代码具有可重用性,我们将会在 User 模型中实现 follow 和 unfollow 函数,而不是在视图函数中。这种方式不仅可以让这个功能应用于真实的应用也能在单元测试中测试。原则上,从视图函数中移除应用程序的逻辑到数据模型中是一种好的方式。你们必须要保证视图函数尽可能简单,因为它能难被自动化测试。
我们在model.py 的User模型中加入:
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 在底层做了很多的工作。我们只是从 followed 关系中添加或者移除了表项,sqlalchemy 为我们管理辅助表。
follow 和 unfollow 方法是定义成当它们成功的话返回一个对象或者失败的时候返回 None。当返回一个对象的时候,这个对象必须被添加到数据库并且提交。
is_following 方法在一行代码中做了很多。我们做了一个 followed 关系查询,这个查询返回所有当前用户作为关注者的 (follower, followed) 对。
三、测试
下面我们在测试模块中测试一下:(test.py)
def test_follow(self):
from model import User
u1 = User(nickname='john', email='[email protected]')
u2 = User(nickname='susan', email='[email protected]')
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
四、数据库查询
我们的数据库模型已经能够支持大部分我们列出来的需求。我们缺少的实际上是最难的。我们的首页将会显示登录用户所有关注者撰写的 blog,因为我们需要一个返回这些 blog 的查询。
最明了的解决方式就是查询给定的关注者用户的列表,这也是我们目前可以做到的。接着对每一个返回的用户去查询他的或者她的 blog。一旦我们完成所有的查询工作,我们把它们整合到一个列表中然后排序。听起来不错?实际上不是。
这种方法其实问题很大。当一个用户拥有上千个关注者的话会发生些什么?我们需要执行上千次甚至更多的数据库查询,并且在内存中我们需要维持一个数据量很大的 blog 的列表,接着还要排序。不知道这些做完,要花上多久的时间?
这种收集以及排序的工作需要在其它的地方完成,我们只要使用结果就行。这类的工作其实就是关系型数据库擅长。数据库有索引,因此允许以一种高效地方式去查询以及排序。
所以我们真正想要的是要拿出一个单一的数据库查询,表示我们想要得到什么样的信息,然后我们让数据库弄清楚什么是最有效的方式来为我们获取数据。
下面这种查询可以实现上述的要求,这个单行的代码又被我们添加到 User 模型(文件model.py):
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())
下面我们来一一解释一下这个方法做了哪些工作:
1.连接
为了理解一个连接操作做了什么,让我们看看例子。假设我们有一个如下内容的 User 表:
只为了简化例子,表里面还有一些额外的字段没有显示。
比如说,我们的 followers 辅助表中表示用户 “john” 关注着 用户 “susan” 以及 “david”,用户 “susan” 关注着 “mary” 以及 用户 “mary” 关注着 “david”。表示上述的数据是这样的:
最后,我们的 Post 表中,每一个用户有一篇 blog:
这里再次申明为了使得例子显得简单,我们忽略了一些字段。
下面是我们的查询的连接部分的,独立于其余的查询:
Post.query.join(followers,
(followers.c.followed_id == Post.user_id))
在 Post 表中调用了 join 操作。这里有两个参数,第一个是其它的表,我们的 followers 表。第二参数就是连接的条件。
连接操作所做的就是创建一个数据来自于 Post 和 followers 表的临时新的表,根据给定条件进行整合。
在这个例子中,我们要 followers 表中的字段 followed_id 与 Post 表中的字段 user_id 相匹配。
为了演示整合的过程,我们从 Post 表中取出所有记录,从 followers 表中取出符合条件的记录插入在后边。如果没有匹配的话,Post 表中的记录就会被移除。
我们例子中这个临时表的连接的结果如下:
注:Post 表中的 user_id=1 记录被移除了,因为在 followers 表中没有 followed_id=1 的记录。
2.过滤
连接操作给我们被某人关注的用户的 blog 的列表,但是没有指出谁是关注者。我们仅仅对这个列表的子集感兴趣,我们只需要被某一特定用户关注的用户的 blog 列表。因此我们过滤这个表格,查询的过滤操作是:
filter(followers.c.follower_id == self.id)
注意查询是在我们目标用户的内容中执行,因为这是 User 类的一个方法,self.id 就是我们感兴趣的用户的 id。因此在我们的例子中,如果我们感兴趣的用户的 id 是 id=1,那么我们会得到另一个临时表:
3.排序
order_by(Post.timestamp.desc())
在这里,我们要说的结果应该按照 timestamp 字段按降序排列,这样的第一个结果将是最近的 blog。
这里还有一个小问题需要我们改善我们的查询操作。当用户阅读他们关注者的 blog 的时候,他们可能也想看到自己的 blog。因此最好把用户自己的 blog 也包含进查询结果中。
其实这不需要做任何改变。我们只需要把自己添加为自己的关注者。
五、成为自己的关注者
在 after_login 中处理 OpenID 的时候就设置自己成为自己的关注者(microblog.py)
@oid.after_login
def after_login(resp):
if resp.email is None or resp.email == "":
flash('Invalid login. Please try again.')
return redirect(url_for('login'))
user = model.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]
nickname = model.User.make_unique_nickname(nickname)
user = model.User(nickname=nickname, email=resp.email)
db.session.add(user)
db.session.commit()
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( url_for('index'))
六、关注以及取消关注的连接
1.定义关注及其取消关注用户的视图函数(microblog.py)
@app.route('/follow/<nickname>')
@login_required
def follow(nickname):
from model import User
user = User.query.filter_by(nickname=nickname).first()
if user is None:
flash('User %s not found.' % nickname)
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>')
@login_required
def unfollow(nickname):
from model import User
user = User.query.filter_by(nickname=nickname).first()
if user is None:
flash('User %s not found.' % nickname)
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))
if __name__ == '__main__':
app.debug=True
app.run()
2.修改模板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 %}
在编辑一行上,我们会显示关注者的用户数目,后面可能会跟随三种可能的链接:
(1)如果用户属于登录状态,“编辑” 链接会显示。
(2)否则,如果用户不是关注者,“关注” 链接会显示
(3)否则,一个 “取消关注” 将会显示。