Flask Web DEvelopment翻译11

第十二章 关注者

社交类web程序允许用户与其它用户联络。程序把这些关系称之为关注者,朋友,人脉,熟人或者粉丝……不管名称怎么变化,这一功能实际一样,就是保持追踪用户之间的定向连接并在数据库查询中使用这些连接。
  在本章,你将学到如何实现Flasky的关注功能,用户将能够关注其他用户并在首页选择性显示自己关注的用户的博客列表。

重新审视数据库关系

我们曾在第五章讨论过,数据库使用关系在数据记录之间建立连接。一对多关系就是最常见的一种关系类型——一条记录与多条记录相关联。为了实现这种关系类型,“”侧的元素拥有一个外键指向"”侧的那个元素。在当前状态下的例程包含了两个一对多关系:一个是连接了角色(一)和用户(多)列表,而另一个则是连接着用户(一)和他所写的文章(多)列表。
  其他大多数关系可以认为是一对多关系的演变。多对一是一对多关系的反向视图,一对一关系就是简化版的一对多,多侧简化成了只有一个元素。唯一不能这么理解的关系就是多对多关系,它两侧都是一个元素列表。这种关系我们将在下节进行描述。

多对多关系

一对多,多对一和一对一都是有一边是一个实体,所以相关记录通过一个外键来指向"一"这侧。但是,怎么实现两边都是多的关系呢
  让我们来考虑下经典的多对多的例子:一个学生数据表和他们上的课程数据表。很明显,你不能在学生表中添加一个外键指向课程表,因为一个学生可能选了多门课程——一个外键是不够的。同样的,也不能在课程表中添加指向学生的外键,因为该课程不止一个学生会选。两边都需要一个外键列表
  解决办法就是在数据库里添加第三个表,我们称之为关联表。现在多对多关系可以被分解到两个一对多的关系,从原来两个原始表分解出第三个关联表。图12-1展示了学生和课程间的多对多关系的表现。

Flask Web DEvelopment翻译11_第1张图片
Paste_Image.png

  本例中的关联表被称为registrations(注册表)。表中的每一行对应一个独立的注册信息:描述了某1学生选择了某1课程。
  对多对多关系的查询需要执行两个步骤。为了获取某个学生选课的课程列表,你得先从学生和注册的一对多关系开始(1学生多注册),获取该学生的注册列表。然后,反转课程和注册之间的一对多关系,从多对一的方向(1注册多课程)来获取与注册列表对应的课程列表。同样的,要列出某课程中的所有学生,你先从课程开始获取该课程所有的注册列表,然后的得到与这些注册相关联的所有学生列表。
  横跨两种关系来获取查询结果听起来很困难,但对于一个像上面例子中的简单关系来说,SQLAlchemy能完成大部分的工作。下列代码就实现了图12-1中的多对多关系( 由于排版的关系,下列代码可能需要你进行重新处理缩进换行):

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会内部管理这个表。
  classes关系使用语义列表,它使以这种方式处理多对多关系非常简单。给定一个学生 S 和一个课程 C ,把学生注册到课程的代码就是:

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

查询学生S选择的课程列表和课程C下面所有的学生同样简单:

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

课程模型中有效的学生关系是在db.backref()参数中定义的,注意在这个关系中,backref参数也被扩展为含有lazy='dynamic'属性,所以两边都会返回一个能接受过滤器的查询。
  如果学生 S 决定不选课程 C 了,你可以更新数据库:

>>> s.classes.remove(c)
自引用关系

多对多关系可以被用在规范用户关注其他用户上,但这里有个问题。在学生和课程的例子中,有两个非常明显的被定义的实体(学生和课程)通过关联表被链接在一起。但是,在我们描述用户关注另外一个用户的时候,并没有第二个实体——两边都是指向用户实体自己。
  两边都属于同一个表的关系我们称之为自引用关系。本例中,关系的左侧实体是被称为“关注者(粉丝)”的用户,右侧的也是用户,但应该称之为“被关注者(博主)”。从概念上来说,自引用关系和普通的关系并没有什么不同,但理解起来确实不容易。图12-2显示了数据库中的自引用关系图,它描述了用户关注其他用户。

Flask Web DEvelopment翻译11_第2张图片
Paste_Image.png

  这个关系中的中间关联表称之为follows。这个表中的每一行记录着一个用户关注另外一个用户。图中左侧的一对多关系中,通过follows表关联到的多侧的用户是关注者。而图右侧的一对多关系中,通过follows表关联到的多侧非人用户则是被关注者。

高级多对多关系

利用像上文例子中自引用多对多关系配置,数据库能描述关注者,但也有一个限制。当使用多对多关系时,一个常见要求是存储附加数据用来在两个实体间提供链接。对于关注者的关系,保存一个用户开始关注另外一个的时间是很有用的,因为可以据此生成按时间排序的关注者列表。唯一能存储这一信息的地方就是关联表,但在类似于前面显示的学生和课程的实现中,这个关联表是一个完全由SQLAlchemy管理的内部表(不是模型,无法使用自定义数据)。
  为了能够在关系中使用自定义数据,关联表必须提升为一个明确的模型以便于程序能访问到。例子12-1展示了新的关联表,由Follow模型定义。

Example 12-1. app/models/user.py: The follows association table as a model
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)

SQLAlchemy不能透明地使用关联表,因此就无法让程序访问其中的自定义字段。多对多关系必须从左边和右边分解成两个基本的一对多关系,且须定义成标准关系。如例子12-2:

Example 12-2. app/models/user.py: A many-to-many relationship implemented as two one-to-many relationships
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。lazy模式会立即从join查询中加载相关对象。例如,如果一个用户关注了100个其他用户,调用user.followed.all()将返回一个100个follow实例的列表,其中每个实例的关注者(follower)被关注(followed)的回溯引用都指向各自对应的一个用户。lazy='joined'模式允许使用一个查询完成所有上述操作。如果lazy被设置为默认的值'select',关注者和被关注的用户就将会延迟加载:当第一次访问follower或followed的时候,才会使用单独查询加载用户——这就意味着要获得完整的这100个被关注者的名单需要额外100次数据库查询才能完成。
  这两个关系中User端的lazy参数有不同的要求。在这一侧设置lazy并将返回侧的数据,这里lazy就使用了dynamic模式,所以关系的属性返回一个查询对象而不是直接返回记录,这样一来我们可以在查询执行之前添加可选过滤器。
  cascade参数配置了父类的一个动作如何传播给其子对象的。cascade选项的一个例子就是,规定当一个对象被添加到数据库会话时,任何通过关系与之相关的对象也应该被自动添加到会话中。默认的cascade选项足以应对大部分情况,但但在多对多的关系中这一默认选项就不能很好的工作了。默认的cascade会在一个对象被删除时,把所有指向它的对象的外键统统设为空值(null)。但对以一个关联表来说,正确的操作应该是在一条数据被删除后,也删除所有指向它的关联数据实体(而不是外键设空值),以此来删除关联。这正好是delete-orphan传播选项可以做的。

指定给cascade的是一个逗号分隔的选项列表,这有点绕,但实际是名为all的选项包含了除了delete-orphan外所有的级联选项。使用all+delete-orphan将启用默认传播选项,并删除孤儿数据(失联的无效数据,无法被引用也没有引用别的)。

现在程序需要运行两个一对多关系来实现多对多关系功能。由于有需要经常重复使用的功能,在User模型中为所有的操作可能创建一个辅助方法是个不错的主意。控制这个关系的四个新方法如例子12-3所示:

Example 12-3. app/models/user.py: Followers helper methods
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):
    return self.followed.filter_by(followed_id=user.id).first() is not None
def is_followed_by(self, user):
    return self.followers.filter_by(follower_id=user.id).first() is not None

follow()方法手动在关联表中插入Follow实例,来链接关注者和被关注者,为程序提供设置自定义字段的机会。这两个链接起来的用户是在Follow实例的构造函数中被手动加入,然后把该实例对象添加到数据库会话。注意并不需要手动设置timestamp字段因为它的默认值被定义为当前日期和时间。unfollow()方法使用followed关系定位链接了被关注者和关注者的follow实例。要解除两个用户之间的链接,只需简单的删除该follow对象即可。is_followed()和is_followd_by()方法在左侧和右侧的一对多关系中查找指定用户,并在找到后返回True。
  这个功能的数据库部分已经完成了,你可以尝试为它编写一个测试单元。

属性页上的关注者

如果查看某用户属性页的浏览者不是该用户的关注者的话,该属性页面需要显示一个"Follow"按钮,或者如果是关注者就应该显示一个"Unfollow"按钮。在合适的时候,这里显示关注数量和被关注数量,显示关注者列表和粉丝列表,或显示一个“Follows you”的标志,这都是不错的。属性页的变化请参见例子12-4,图12-3显示了其样式。

Example 12-4. app/templates/user.html: Follower enhancements to the user profile header
{% if current_user.can(Permission.FOLLOW) and user != current_user %}
    {% if not current_user.is_following(user) %}
    Follow
    {% else %}
    Unfollow
    {% endif %}
{% endif %}

Followers: {{ user.followers.count() }}


Following: {{ user.followed.count() }}

{% if current_user.is_authenticated() and user != current_user and
    user.is_following(current_user) %}
| Follows you
{% endif %}

图12-3

Flask Web DEvelopment翻译11_第3张图片
Paste_Image.png

  在模板的这些变化中,定义了四个新的端点(endpoint)。 /follow/路由将在一个用户在别的用户属性页上点击"follow"按钮时被调用。其实现如例子12-5所示:

Example 12-5. app/main/views.py: Follow route and view function
@main.route('/follow/')
@login_required
@permission_required(Permission.FOLLOW)#注:注意导入引用
def follow(username):
    user = User.query.filter_by(username=username).first()
    if user is None:
        flash('Invalid user.')
        return redirect(url_for('.index'))
    if current_user.is_following(user):
        flash('You are already following this user.')
        return redirect(url_for('.user', username=username))
    current_user.follow(user)
    flash('You are now following %s.' % username)
    return redirect(url_for('.user', username=username))

这个视图函数加载了要关注的用户,确认其合法并尚未被当前的登录用户关注,然后调用User模型中的follow()辅助函数来实现链接。/unfollow/路由也是以类似的方式实现。(下面是自写代码,你也可以参考作者的github源代码)

@main.route('/unfollow/')#注:自写,原无。
@login_required
@permission_required(Permission.FOLLOW)
def unfollow(username):
    user=User.query.filter_by(username=username).first()
    if user is None:
        flash(u'无效的用户!')
        return redirect(url_for('.index'))
    if current_user.is_following(user):
        current_user.unfollow(user)
        flash(u'你已取消对 %s 的关注' % username)
        return redirect(url_for('.user',username=username))

当浏览者点击关注者数量(followers)的时候,就会调用/followers/路由函数,这一实现如例子12-6所示(这里还需要在config.py中设置一下FLASKY_FOLLOWERS_PER_PAGE分页变量):

Example 12-6. app/main/views.py: Followers route and view function
@main.route('/followers/')
@login_required
def followers(username):
    user = User.query.filter_by(username=username).first()
    if user is None:
        flash('Invalid user.')
        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="Followers of",\
            endpoint='.followers', pagination=pagination,follows=follows)

这个函数加载并验证被请求的用户,然后使用11章的分页技术对该用户的关注者进行分页处理。因为对关注者的查询将返回一个Follow实例列表,我们把它转换成为便于显示的follows列表,其中每个实体只拥有user和timestamp两个字段。
  用来显示关注了xx列表的模板可以通用些,这样它也可以被用来显示被关注的oo列表。模板接收用户、页面标题、分页链接使用的断点(路由名称)、分页对象和结果列表。
  foolowed_by断点几乎完全一样。唯一区别就是用户列表是从user.followed关系取得的。模板参数也需要相应进行调整。
  followers.html模板包含两列,分别在左侧显示用户名称和他们的头像,在右侧显示flask-moment时间戳。你可以从github仓库获取源代码查看详细实现。

注下面是我自己的实现代码:

#followed-by路由
@main.route('/followed-by/')
@login_required
def followed_by(username):
    user = User.query.filter_by(username=username).first()
    if user is None:
        flash('Invalid user.')
        return redirect(url_for('.index'))
    page = request.args.get('page', 1, type=int)
    pagination = user.followed.paginate(page,\
        per_page=current_app.config['FLASKY_FOLLOWERS_PER_PAGE'],\
        error_out=False)
    follows = [{'user': item.followed, 'timestamp': item.timestamp}
        for item in pagination.items]
    return render_template('followers.html', user=user, title="Followed by ",\
        endpoint='.followed_by', pagination=pagination,follows=follows)

followers.html模板参考如下注意:由于我访问不了garava网站,所以把所有用户头像都硬编码为一个固定值。你可以参考前面的代码,调用user模型中的头像生成函数来使用自定义头像。

{% extends "base.html" %}
{% import "_macros.html" as macros %}
{% block title %}Flasky - {{ title }} {{ user.username }}{% endblock %}
{% block page_content %}


    {% for follow in follows %}
    {% if follow.user != user %}
    
    {% endif %}
    {% endfor %}
UserSince
{{ follow.user.username }}
{{ moment(follow.timestamp).format('L') }}
{% endblock %}

使用数据库join查询关注的文章

目前,程序的首页倒序(从最近到最久远)显示了数据库里所有的文章。完成以下代码功能后,就可以允许用户选择只显示自己关注的那些用户的文章。
  加载关注用户的所有文章的直观思路是:首先获取那些被关注的用户列表,然后逐个获取其文章并存入一个独立列表中。当然这个方法扩展性并不好,获取联合列表的耗时会随着数据量的增长同步变大,诸如分页之类的操作就会变得没有效率。提升性能的方法就是使用一个查询完成获取这个文章列表。
  用来完成这个的数据库操作被称为“join”。一个join操作将根据指定条件在两个或更多表中查找数据,生成一个联合数据集,并将数据插入一个临时表。(译注:这个数据集包含指定的各表字段,而非单一表中的字段。你可以参看sql语法)。你可以通过一个例子来更好的理解join的工作方式。
表12-1是一个带有三个用户的users表

Table 12-1. users table
id username
1  john
2  susan
3  david

表12-2 是带有一些文章的posts表

Table 12-2. Posts table
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

最后表12-3是谁关注谁。这个表你可以看到john关注了david,susan关注john,david没有关注任何人。

Table 12-3. Follows table
follower_id  followed_id
1            3
2            1
2            3

要获得susan关注的文章列表,必须合并posts和follows两个表。首先过滤follows表只保留关注者susan的记录,在表中就是最后两行。然后,凡是posts表中author_id等于followed_id的数据被提取出来插入新创建的临时join表中,这样操作选择了susan关注的用户的文章就更有效率些。表12-4显示了join操作的结果,用来进行join操作的列被标注了星号*。

Table12-4 合并后的表
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

这个表仅体现了susan观注的用户所撰写的文章。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),使用follower用户来过滤follows表(译注:去除非susan的数据)
+ join(Post,follow,followed_id==Post.author_id),把filter_by()后的结果与Post对象进行join操作。

可以通过交换过滤器和join的顺序把这个查询简化为:

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

首先执行join操作,我们再从Post.query开始查询——这样就只需要添加两个过滤器:join()和filter()。但这是一样的吗?看起来先执行join然后进行过滤操作可能会导致更多的操作。但实际上这两个查询是等价的。SQLAlchemy首先收集所有的过滤器然后用最有效率的方式生成查询。这两个查询的原生SQL结构是一致的。添加到Post模型的最终版本查询如例子12-7所示:

Example 12-7. app/models.py: Obtain followed posts
class User(UserMixin,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_post()方法被定义成了一个属性,所以调用时不需要加上()。这样一来所有关系的语法就统一起来了。
  Join的相关知识可能难以理解,我想你需要在shell中多练习例子的代码来帮助你加深理解。

在首页显示关注用户的文章

首页现在允许用户选择显示所有用户的文章还是仅显示自己关注用户的文章。例子12-8展示了它是如何实现的:

Example 12-8. app/main/views.py: Show all or followed posts
@app.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的字符串,如果非空,则只显示关注的文章。Cookie以request.cookies的字典格式被存储在请求对象中。这个cookie字符串可以被转换成Boolean(布尔)类型,基于其值,程序将设置一个本地的query变量来查询以获取全部或筛选后的文章清单。要显示所有的文章,就使用最顶层的查询Post.query。当列表被限制到关注者的时候,就会调用最后添加的User.followed_posts属性。存储在query本地变量中的这个查询随后进行分页,像以前那样其结果被发送给模板。
  在两个新的路由中设置了show_followed的cookie,如例子12-9所示:

Example 12-9. app/main/views.py: Selection of all or followed posts
@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)
    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)
    return resp

在首页模板中添加上这两个路由的链接。当点击时,将为show_followed cookie设置一个值,并重新定向回到首页。
  Cookies只能在response对象中设置,所以这两个路由必须手动调用make_response来创建response对象,而不是让Flask来做。
  set_cookie()函数把cookie的名称和值作为开头的两个参数。max_age选项参数则指定了cookie超时过期的秒数。如果没有设置该参数,则cookie将在浏览器窗口关闭之后立即过期失效。在本例中,我们设置了过期时间是30天,这样浏览器将“记住”该设置,即使用户几天都没有登录过系统。
  修改模板中文章列表部分,在其顶部添加两个导航标签,用以调用/all或者/followed路由来设置session值。你可以从Github库中找到详细的代码(译注:此处未列出)。图12-4就是更改后的首页。

Flask Web DEvelopment翻译11_第4张图片
Paste_Image.png

译注:我的模板修改代码:


如果你尝试点击切换到“关注的文章”,你会注意到你自己的文章并没有出现这列表中。这当然是对的,因为不能关注自己嘛!
  实际上即使是查询代码确实是按照我们的设计思路正常运行,大多数人还是希望能同时看到自己的文章。实际上这并不困难,只要在创建注册用户的时候,直接把他们标注为自己的关注者就可以了。这个小改动如例子12-10所示:

Example 12-10. app/models.py: Make users their own followers on construction
class User(UserMixin, db.Model):
# ...
def __init__(self, **kwargs):
    # ...
    self.follow(self)

不幸的是,你的数据库里可能有写用户早已存在而当时并没有关注自己。如果数据库规模不大并且易于重建的话,最好是删除并重建;但如果不行,那么正确的方法是添加一个函数来修复已存在的用户比较好。请看例子12-11:

Example 12-11. app/models.py: Make users their own followers
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) $ python manage.py shell
>>> User.add_self_follows()

创建一个函数来更新数据库是一个常见的技术,通过脚本升级比手动修改数据库更安全,所以这一技术在升级已经发布的程序是很常见。在第17章中你将看到这一函数和其他类似的被合并到一个发布脚本中。
  这样一来程序可用性更好了,但也同时导致了不良“并发症”。在用户属性页中显示的关注者和被关注者计数都增长了“1”——直接的办法就是在模板显示的时候直接把计数减一即可:

修改user.html:{{ user.followers.count() - 1 }}{{ user.followed.count() -1 }}

同样的,关注和被关注者列表中也必须进行调整,清除掉“自己”——在模板中使用条件判断即可完成。最后,检查关注者计数的单元测试代码也必须进行修改,以去除对自关注的统计。(译注:如果你自己无法完成,可以去github参考作者提供的代码)

ps: 这一段原文有几处小瑕疵,作者在12-7后的几个例子中修改user模型的文件位置标注错误,标成了app/models/user.py,12-7的User(dm.Model)缺少了UserMixin。实际代码是没有问题的。

在下一章,我们将实现用户评论子系统——社交程序中的另一个重要功能。

<<第十一章 博客文章 第十三章 用户评论>>

你可能感兴趣的:(Flask Web DEvelopment翻译11)