第12章:关注-Flask Web 开发(第2版)

社交型网站都允许用户与其他用户进行关联。这种关联关系在不同的应用中有不同的叫法:关注、朋友、联系人、关联、好友 等,但它们的实质是类似的,都需要在数据库里存储用户之间的定向连接,以便维护用户之间的关联关系。

本章你将学习到如何给我们的 Flasky 项目加上关注功能。用户可以关注其他用户,并在首页中对博文进行过滤,只显示已关注用户的博文。

再谈数据库关系

第5章讨论过,数据库可以通过 关系(relationship) 对两条记录进行连接。一对多 是最常见的关系类型,表示一条记录和多条记录相连接。为了实现这种类型的关系,“多”侧的元素具有指向“一”侧上的连接元素的外键。目前这个应用里有两个一对多关系:角色与用户以及用户与博文。

从一对多类型的关系可以派生出更多的关联关系。从“多”的角度看,一对多就成了多对一关系。一对一是简化版的一对多关系,这种关系里,“多”的这边也只有一个元素。唯一不能通过一对多模型的简单变化实现的关系类型是多对多关系,这种关系的两边都有多个元素。

多对多关系

一对多、多对一和一对一关系中,都至少有一方只有一个实体,所以只要通过这个实体的外键就能实现关联。但是,当两边都有多个实体时,这种关系要如何实现?

考虑一个多对多关系的典型例子:学生和他们所学课程的数据库。很显然,你不能在学生表(students) 表添加一个指向课程(classes)的外键,因为学生可能会有多个课程——使用一个外键无法满足此需求。同样的,你也不能在课程表里添加一个指向学生表的外键,因为每门课程都有多个学生。关系的两边都需要一个外键列表(a list of foreign keys)。

此问题的解决方法是,在数据库里添加被称为关联表(assoction table)的第三张表。现在,多对多关系可以分解为原始表和关联表的两个一对多关系。

本例中,我们使用 registrations(注册) 作为关联表。该表中每一条记录代表一个学生注册的一门课程。

多对多关系的查询分两步。首先,获取某个学生所学习的所有课程,使用一对多关系,在学生(students)和注册( registrations)之间进行查询,查询结果是一个列表;然后,以多对一的方向遍历课程(classess)和注册(registrations)之间的一对多关系,检索出学生注册过的课程。类似的,要获取某个课程有哪些学生注册了,需要先获取课程与注册之间的记录,然后遍历获取与之相关的学习。

这听起来很复杂,幸运的是 SQLAlchemy 让这些操作变得跟简单的关联关系一样容易。上述多对多关系,可以这样进行定义:

# 使用 Flask-SQLAlchemy 定义多对多关联关系

# 关联表
registrations = db.Table('registrations',
    db.Column('student_id', db.Integer, db.ForeignKey('students.id')),
    db.Column('class_id', db.Integer, db.ForeignKey('classes.id'))
)

# 学生
class Student(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String)
    classes = db.relationship('Class',
                              secondary=registrations,
                              backref=db.backref('students', lazy='dynamic'),
                              lazy='dynamic')

# 课程
class Class(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String)

和一对多一样,依然使用 db.relationship() 来定义关系,不同之处在于,在多对多关系中,必须通过 secondary 指定关联表。关系可以在两个类中的任何一个类里定义,用 backref 参数来定义另一端暴露的属性。关联表被定义成了简单表,而不是模型类。SQLAlchemy 会在内部管理这个表。Student 里的 classes 使用列表语义,即是说,可以像使用列表那样来使用该属性。这让多对多的处理变得非常简单。假设有一个学生 s 和一门课程 c,以下代码用于实现学生注册课程:

>>> s.classes.append(c)
>>> db.session.add(s)

无论是获取学生已注册的课程,还是获取课程中有哪些学生注册,都非常简单:

>>> s.classes.all()
>>> c.students.all()

Class 模型类的 students 之所以能用,是因为在 Student 模型类中,定义关系的时候,通过 backref 指定了该属性。注意,这个关系中的 backref 包含了一些扩展信息,lazy='dynamic' 属性表示,关系两边都返回一个可进行额外操作的 query 对象。

如果学生 s 决定不参加课程 c 了,可以使用下面的代码进行移除:

>>> s.classes.remove(c)

自引用关系

多对多关系可以用来为用户关注另一用户进行建模,但却有一个问题。在学生和课程的例子中,有两个非常明确的实体通过关联表连接在一起。但在表示用户之间的关注时,却只有一个实体——只有用户实体。

关联关系的两边都是同个一表,称为自引用(self-referential)。本例中,关系左边的的用户,称为 关注者(follower);关系右边的用户,称为 被关注者(followed)。从概念上讲,自引用和普通的关联关系没有区别,只是要花点时间去理解。

本例中,我们将关联表命名为 follows(关注)。表中每一条记录表示一个用户关注的另一个用户。

高级多对多关系

如前例所示,通过配置自引用多对多关系,数据库可以表示用户之间的关注 —— 但有一个限制。处理多对多关系时最常见的需要是存储适用于两个实体之间连接的附加数据。对于关注关系,将用户开始关注其他用户的日期存储起来可能很有用,因为这样可以按照时间顺序显示关注者列表。唯一可以存储该信息地方,就是在关联表中,但是之前演示的学生和课程之间的关联表是一个由 SQLAlchemy 内部维护的简单表。

要将自定义的信息存储关联表中,必须把关联表变成应用程序能访问的普通模型类。

# 示例 12-1. app/models.py: 使用模型类定义“关注”关联表
class Follow(db.Model):
    __tablename__ = 'follows'
    follower_id = db.Column(db.Integer, db.ForeignKey('users.id'),
                            primary_key=True)
    followed_id = db.Column(db.Integer, db.ForeignKey('users.id'),
                            primary_key=True)
    timestamp = db.Column(db.DateTime, default=datetime.utcnow)

多对多关系必须将关系的左右两边分解为两个基本的一对多关系,这些关系必须定义为标准的关系。

# 示例 12-2. app/models.py: 使用两个一对多关系实现一个多对多关系
class User(UserMixin, db.Model):
    # ...
    followed = db.relationship('Follow',
                               foreign_keys=[Follow.follower_id],
                               backref=db.backref('follower', lazy='joined'),
                               lazy='dynamic',
                               cascade='all, delete-orphan')
    followers = db.relationship('Follow',
                                foreign_keys=[Follow.followed_id],
                                backref=db.backref('followed', lazy='joined'),
                                lazy='dynamic',
                                cascade='all, delete-orphan')

在这里,关注者和被关注者之间的关系被分别定义为单个的一对多关系。请注意,必须在每个关系中通过可选参数 foreign_keys 来指定使用哪个外键,以消除外键之间的歧义。db.backref 指定的是对 Follow 模型的反向引用,而不是指定两个关系的关联。反向引用中的 lazy 指定为 joined,这种模式会导致相关对象立即从关联查询中加载。比如,假设某用户关注了100个其他用户,调用 user.followed.all() 后将返回含有100个 Follow 实例的列表,每一个实例的 followedfollowers 属性都反向引用相关的用户。lazy='joined' 实现了在一次查询里,获取所有相关数据的能力。如果是 lazy='select'(默认值),则需要额外发起100次查询,才能把用户关注的100个其他用户查询出来。

关系两边的 User 使用的是 lazy='dynamic',这意味着不会立即返回所有相关数据,而是返回一个可以使用过滤器的 query 对象。

cascade(级联) 参数指定了,对关系中的父对象进行操作时,其对应的子对象会有什么影响。该参数的默认规则是,当一个对象被添加到数据库会话中时,通过关系关联的任何对象也应自动添加到会话中。这个默认规则适用于大部分情况,但对于自引用的多对多关系来说并不适用。默认规则是,删除对象时将任何连接到它的相关对象的外键设置为 NULL 值。但对于关联表而言,正确的行为是删除指向已删除记录的条目,以有效地销毁连接。这就是设置 delete-orphan(删除孤儿) 的原因。

注意,cascade 的值通过由逗号分隔的选项给定。这多少会让人感觉迷惑。all 选项表示除了 delete-orphan 之外的所有行为。为了设置 delete-orphan,我们使用 all, delete-orphan

现在应用程序需要使用两个一对多关系来实现多对多功能。由于这些操作需要经常重复,所以在 User 模型中为所有可能的操作创建辅助方法是一个好主意。

# 示例 12-3. app/models.py: 关注功能的辅助方法
class User(db.Model):
    # ...
    def follow(self, user):
        if not self.is_following(user):
            f = Follow(follower=self, followed=user)
            db.session.add(f)

    def unfollow(self, user):
        f = self.followed.filter_by(followed_id=user.id).first()
        if f:
            db.session.delete(f)

    def is_following(self, user):
        if user.id is None:
            return False
        return self.followed.filter_by(
            followed_id=user.id).first() is not None

    def is_followed_by(self, user):
        if user.id is None:
            return False
        return self.followers.filter_by(
            follower_id=user.id).first() is not None

follow() 方法手动插入一个 Follow 对象到关联表中,它将关注者和被关注者进行连接,并给应用程序提供设置自定义字段的机会。两个用户通过 Follow 类的两个参数建立连接,之后跟往常一样,将它们保存到数据库中。unfollow() 方法通过 followed 关系找到两个相关的 Follow 实例,然后只需要简单的删除这个 Follow 实例,就可以解除两个用户的关注关系。is_following()is_followed_by() 从一对多关系的左边或右边查找对应的用户,如果找到了,就返回 True

如果你从 Github 克隆了示例代码,可以使用 git checkout 12a 签出本示例。签出后,请运行 flask db upgrade 进行数据库更新

在用户资料页面显示关注者

在用户资料页面里,如果当前查看这个页面的用户并没有关注用户资料所属的用户,则应显示一个“关注”按钮;反之,则应显示一个“取消关注”按钮。如果能加上“关注数”及“被关注数”、关注列表和被关注列表、并在相应的用户资料页上显示“关注了你”就更好了。

<!-- 示例 12-4. app/templates/user.html: 使用关注功能增加用户资料页面-->
{% if current_user.can(Permission.FOLLOW) and user != current_user %}
    {% if not current_user.is_following(user) %}
    <a href="{{ url_for('.follow', username=user.username) }}"
        class="btn btn-primary">关注</a>
    {% else %}
    <a href="{{ url_for('.unfollow', username=user.username) }}"
        class="btn btn-default">取消关注</a>
    {% endif %}
{% endif %}
<a href="{{ url_for('.followers', username=user.username) }}">
    已关注: <span class="badge">{{ user.followers.count() }}</span>
</a>
<a href="{{ url_for('.followed_by', username=user.username) }}">
    被关注: <span class="badge">{{ user.followed.count() }}</span>
</a>
{% if current_user.is_authenticated and user != current_user and
    user.is_following(current_user) %}
| <span class="label label-default">关注了你</span>
{% endif %}

这个模板中使用了4个新的端点。当用户点击其他用户资料上的“关注”时,触发 /follow/<username> 路由。

# 示例 12-5. app/main/views.py: “关注”的路由和视图函数
@main.route('/follow/<username>')
@login_required
@permission_required(Permission.FOLLOW)
def follow(username):
    user = User.query.filter_by(username=username).first()
    if user is None:
        flash('非法用户.')
        return redirect(url_for('.index'))
    if current_user.is_following(user):
        flash('你已经关注过该用户了.')
        return redirect(url_for('.user', username=username))
    current_user.follow(user)
    db.session.commit()
    flash('你已成功关注 %s.' % username)
    return redirect(url_for('.user', username=username))

这个视图函数最终调用的是 User 模型类中定义的 follow() 辅助方法。使用类似的方法,可以轻松地实现 /unfollow/<username>

当用户在其他用户的资料页点击“已关注”的统计数据时,触发 /followers/<username> 路由。

# 示例 12-6. app/main/views.py: “已关注”路由和视图函数
@main.route('/followers/<username>')
def followers(username):
    user = User.query.filter_by(username=username).first()
    if user is None:
        flash('非法用户.')
        return redirect(url_for('.index'))
    page = request.args.get('page', 1, type=int)
    pagination = user.followers.paginate(
        page, per_page=current_app.config['FLASKY_FOLLOWERS_PER_PAGE'],
        error_out=False)
    follows = [{'user': item.follower, 'timestamp': item.timestamp}
               for item in pagination.items]
    return render_template('followers.html', user=user, title="已关注",
                           endpoint='.followers', pagination=pagination,
                           follows=follows)

“被关注”与“已关注”几乎一样,唯一不同的是,它调用的是 followed 关系。相关的模板也非常容易编写,详情请通过git仓库查看。

如果你从 Github 克隆了示例代码,可以使用 git checkout 12b 签出本示例。

使用数据库的关联查询获取已关注用户的博文

应用程序的首页,目前显示的是所有用户发表的博文,并按发布的时间的倒序进行排序。随着关注功能的完成,需要在该页面加入只显示已关注用户发表的博文功能。

显而易见的方式是,首先获得已关注用户的列表,然后从每个用户那里获取博文并将它们分类到单个列表中。显然,这种方法不能很好地扩展; 获得这个组合列表所付出的代价将随着数据库的增长而增长,并且诸如分页等操作不能有效地完成。此问题通常称为“N + 1问题”,因为以这种方式处理数据库需要发出N + 1个数据库查询,其中N是第一个查询返回的结果数。获得良好性能的关键在于,无论数据库有多大,都只发送一条查询来获取博文。

数据库中的 join(关联查询) 可以实现这一操作。关联操作需要两个或多个表,并将满足给定条件的所有行进行组合。生成的组合行插入到作为连接结果的临时表中。解释关联查询的最好方式是通过实例。

下表显示了 users 表中的三条示例数据。

id username
1 john
2 susan
3 david

下表显示了与这些用户相关的存储在 posts 表中的博文示例数据。

id author_id body
1 2 Blog post by susan
2 1 Blog post by john
3 3 Blog post by david
4 1 Second blog post by john

下表显示 follows 表中的关注示例数据。(john关注了david,susan关注了john和david)

follower_id followed_id
1 3
2 1
2 3

为了获取用户 susan 已关注的所有用户发表的博文,postsfollows 表的记录必须进行合并。首先,对 follows 表的记录进行过滤,保证只获取关注者为 susan 的记录;然后,一个关联查询的临时表被创建,其中包含了 posts 和过滤后的 follows 组合而成的记录。这些记录只有 posts 表里的 author_id 和过滤后的 followsfollowed_id 相等的记录。最终呈现出来的便是 susan 已关注的所有用户发表的博文列表。

下表列出了此关联查询的最终结果,带 * 的字段表示执行关联操作的字段。

id author_id* body follower_id followed_id*
2 1 Blog post by john 2 1
3 3 Blog post by david 2 3
4 1 Second blog post by john 2 1

【wrdll 注】此例的原生 SQL 语句如下:

SELECT p.id AS id, p.author_id, p.body, f.follower_id, f.followed_id
FROM posts AS p
INNER JOIN follows AS f
ON p.author_id = f.followed_id
WHERE f.follower_id = 2

使用 Flask-SQLAlchemy 执行上述的关联查询的操作相当复杂:

return db.session.query(Post).select_from(Follow).\
    filter_by(follower_id=self.id).\
    join(Post, Follow.followed_id == Post.author_id)

之前所有的查询都是通过模型类的 query 发起的。这个方法无法满足本需求,因为本次查询是需要在返回 posts 里的行之前,对 follows 进行过滤。为了充分理解这个查询,下面对该查询的各部分进行分解讲述。

  • db.session.query(Post) 指定这将是一个返回 Post 对象的查询
  • select_from(Follow) 指定从 Follow 模型开始查询
  • filter_by(follower_id=self.id) 使用当前对象的 idfollows 表的 follower_id 字段进行过滤
  • join(Post, Follow.followed_id == Post.author_id)filter_by() 的结果和 Post 对象进行合并

此查询的 filterjoin 的位置可以进行交换:

return Post.query.join(Follow, Follow.followed_id == Post.author_id)\
    .filter(Follow.follower_id == self.id)

这意味着,首先,关联查询可以由模型类的 query 来发起,如例中的 Post.query。之后,可以通过 joinfilter 来把两个过滤条件应用到查询中。看上去这个查询好像是先进行关联查询,然后再对数据进去过滤。其实,它和上例是一样的。SQLAlchemy 会把所有过滤条件进行收集,然后再生成最高性能的 query 对象。这两个例子生成的原生 SQL 语句其实是一样的。可以通过打印 query 的字符串形式来显示对应的 SQL 语句,如:print(str(query))

【wrdll 注】通过配置SQLALCHEMY_ECHO = True 选项,可以让 Flask-SQLAlchemy 自动显示每次操作生成的 SQL。

最终的查询将添加到 Post 模型类中,如示例12-7所示。

# 示例 12-7. app/models.py: 获取被关注者发表的博文
class User(db.Model):
    # ...
    @property
    def followed_posts(self):
        return Post.query.join(Follow, Follow.followed_id == Post.author_id)\
            .filter(Follow.follower_id == self.id)

followed_posts() 方法被定义成了属性,所以在使用时不需要带上 (),这是在数据库关系中的惯例。

如果你从 Github 克隆了示例代码,可以使用 git checkout 12c 签出本示例。

关联查询有点让人头疼,所以你可能需要在 Shell 中加强练习。

在首页中显示被关注者的博文

# 示例 12-8. app/main/views.py: 显示所有博文或只显示被关注者的博文
@main.route('/', methods = ['GET', 'POST'])
def index():
    # ...
    show_followed = False
    if current_user.is_authenticated:
        show_followed = bool(request.cookies.get('show_followed', ''))
    if show_followed:
        query = current_user.followed_posts
    else:
        query = Post.query
    pagination = query.order_by(Post.timestamp.desc()).paginate(
        page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'],
        error_out=False)
    posts = pagination.items
    return render_template('index.html', form=form, posts=posts,
                           show_followed=show_followed, pagination=pagination)

通过 Cookie show_followed 来保存首页的显示方式,如果为 True 则只显示已关注用户的博文;否则显示所有博文。这个 Cookie 在两个新路由中设置。

# 示例 12-9. app/main/views.py: 所有博文或已关注用户博文的选择
@main.route('/all')
@login_required
def show_all():
    resp = make_response(redirect(url_for('.index')))
    resp.set_cookie('show_followed', '', max_age=30*24*60*60)  # 30 days
    return resp

@main.route('/followed')
@login_required
def show_followed():
    resp = make_response(redirect(url_for('.index')))
    resp.set_cookie('show_followed', '1', max_age=30*24*60*60)  # 30 days
    return resp

如果你从 Github 克隆了示例代码,可以使用 git checkout 12d 签出本示例。

当显示已关注用户博文的时候,你会发现一个问题:没有显示自己发表的博文。这是自然的,因为自己和自己并没有关注关系。然而,很多用户希望在看到朋友博文的时候,自己的博文也能一并显示。

实现这个功能很简单的,只需要在创建用户的时候,将自己设为自己的关注者。

# 示例 12-10. app/models.py: 在创建用户的时候,将自己设为自己的关注者
class User(UserMixin, db.Model):
    # ...
    def __init__(self, **kwargs):
        # ...
        self.follow(self)

不幸的是,那些已存在于数据库里的用户并未受此影响。如果数据量小的话,可以手动对这些数据进行修改。但这种费时费力还易错的工作,显然交给计算机来做更合适。我们增加一个辅助方法,用于实现将所有用户设置为自己的关注者。

# 示例 12-11. app/models.py: 将用户设置为自己的关注者
class User(UserMixin, db.Model):
    # ...
    @staticmethod
    def add_self_follows():
        for user in User.query.all():
            if not user.is_following(user):
                user.follow(user)
                db.session.add(user)
                db.session.commit()
    # ...

然后在 Shell 中调用该方法:

(venv) $ flask shell
>>> User.add_self_follows()

这种技术经常用于为已部署到生产环境中的项目进行数据库更新。第17章中,你将学习到如何将此类函数加入到部署脚本中。

将自己设置为关注者之后,有几个地方要进行微调。一是显示“已关注”和“被关注”的数量要减一,{{ user.followers.count() - 1 }}{{ user.followed.count() - 1 }};还有就是在显示“已关注”和“被关注”用户的页面中,要把自己隐藏起来。

如果你从 Github 克隆了示例代码,可以使用 git checkout 12e 签出本示例。

下一章将完整实现社交网站的另一个非常重要的功能:用户评论。

本文并非《Flask Web Development(2nd edition)》的全文翻译。而只是在阅读该书时,对自认为重点的内容进行记录以及思考。由于水平有限,本文所述内容难免出现不足或错误。请通过购买正版图书来进行更系统地学习,原书地址。本站将依照本系列文章录制视频教程,届时本文未作记录的部分以及项目实际开发中所需要的知识都会在视频教程中进行补充。

flask Flask Web Development(2nd edition) 2018-03-28 13:31 1210269