第九章:分页

本文翻译自The Flask Mega-Tutorial Part IX: Pagination

这是Flask Mega-Tutorial系列的第九部分,我将告诉你如何对数据列表进行分页。

第八章我已经做了几个数据库更改,以支持在社交网络非常流行的“粉丝”机制。 有了这个功能,接下来我准备好删除一开始就使用的模拟用户动态了。 在本章中,应用将开始接受来自用户的动态更新,并将其发布到网站首页和个人主页。

本章的GitHub链接为:Browse, Zip, Diff.

发布用户动态

让我们从简单的事情开始吧。 首页需要有一个表单,用户可以在其中键入新动态。 我创建一个表单类:

class PostForm(FlaskForm):
    post = TextAreaField('Say something', validators=[
        DataRequired(), Length(min=1, max=140)])
    submit = SubmitField('Submit')

然后,我将该表单添加到网站首页的模板中:

{% extends "base.html" %}

{% block content %}
    <h1>Hi, {{ current_user.username }}!</h1>
    <form action="" method="post">
        {{ form.hidden_tag() }}
        <p>
            {{ form.post.label }}<br>
            {{ form.post(cols=32, rows=4) }}<br>
            {% for error in form.post.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>{{ form.submit() }}</p>
    </form>
    {% for post in posts %}
    <p>
    {{ post.author.username }} says: <b>{{ post.body }}</b>
    </p>
    {% endfor %}
{% endblock %}

模板中的变更和处理以前的表单类似。最后的部分是将表单处理逻辑添加到视图函数中:

from app.forms import PostForm
from app.models import Post

@app.route('/', methods=['GET', 'POST'])
@app.route('/index', methods=['GET', 'POST'])
@login_required
def index():
    form = PostForm()
    if form.validate_on_submit():
        post = Post(body=form.post.data, author=current_user)
        db.session.add(post)
        db.session.commit()
        flash('Your post is now live!')
        return redirect(url_for('index'))
    posts = [
        {
            'author': {'username': 'John'},
            'body': 'Beautiful day in Portland!'
        },
        {
            'author': {'username': 'Susan'},
            'body': 'The Avengers movie was so cool!'
        }
    ]
    return render_template("index.html", title='Home Page', form=form,
                           posts=posts)

我们来一个个地解读该视图函数的变更:

  • 导入PostPostForm

  • 关联到index视图函数的两个路由都新增接受POST请求,以便视图函数处理接收的表单数据

  • 处理表单的逻辑会为post表插入一条新的数据

  • 模板新增接受form对象,以便渲染文本输入框

在继续之前,我想提一些与Web表单处理相关的重要内容。 请注意,在处理表单数据后,我通过发送重定向到主页来结束请求。 我可以轻松地跳过重定向,并允许函数继续向下进入模板渲染部分,因为这已经是主页视图函数了。

那么,为什么重定向呢? 通过重定向来响应Web表单提交产生的POST请求是一种标准做法。 这有助于缓解在Web浏览器中执行刷新命令的烦恼。 当你点击刷新键时,所有的网页浏览器都会重新发出最后的请求。 如果带有表单提交的POST请求返回一个常规的响应,那么刷新将重新提交表单。 因为这不是预期的行为,所以浏览器会要求用户确认重复的提交,但是大多数用户却很难理解浏览器询问的内容。不过,如果一个POST请求被重定向响应,浏览器现在被指示发送GET请求来获取重定向中指定的页面,所以现在最后一个请求不再是'POST'请求了, 刷新命令就能以更可预测的方式工作。

这个简单的技巧叫做Post/Redirect/Get模式。 它避免了用户在提交网页表单后无意中刷新页面时插入重复的动态。

展示用户动态

如果你还记得,我创建过几条模拟的用户动态,展示在主页已经有一段时间了。 这些模拟对象是在index视图函数中显式创建的一个简单的Python列表:

    posts = [
        { 
            'author': {'username': 'John'}, 
            'body': 'Beautiful day in Portland!' 
        },
        { 
            'author': {'username': 'Susan'}, 
            'body': 'The Avengers movie was so cool!' 
        }
    ]

但是现在我在User模型中有了followed_posts()方法,它可以返回给定用户希望看到的用户动态的查询结果集。 所以现在我可以用真正的用户动态替换模拟的用户动态:

@app.route('/', methods=['GET', 'POST'])
@app.route('/index', methods=['GET', 'POST'])
@login_required
def index():
    # ...
    posts = current_user.followed_posts().all()
    return render_template("index.html", title='Home Page', form=form,
                           posts=posts)

User类的followed_posts方法返回一个SQLAlchemy查询对象,该对象被配置为从数据库中获取用户感兴趣的用户动态。 在这个查询中调用all()会触发它的执行,返回值是包含所有结果的列表。 所以我最终得到了一个与我迄今为止一直使用的模拟用户动态非常相似的结构。 它们非常接近,模板甚至不需要改变。

更容易地发现和关注用户

相信你已经留意到了,应用没有一个很好的途径来让用户可以找到其他用户进行关注。实际上,现在根本没有办法在页面上查看到底有哪些用户存在。我将会使用少量简单的变更来解决这个问题。

我将会创建一个新的“发现”页面。该页面看起来像是主页,但是却不是只显示已关注用户的动态,而是展示所有用户的全部动态。新增的发现视图函数如下:

@app.route('/explore')
@login_required
def explore():
    posts = Post.query.order_by(Post.timestamp.desc()).all()
    return render_template('index.html', title='Explore', posts=posts)

你有没有注意到这个视图函数中的奇怪之处? render_template()引用了我在应用的主页面中使用的index.html模板。 这个页面与主页非常相似,所以我决定重用这个模板。 但与主页不同的是,在发现页面不需要一个发表用户动态表单,所以在这个视图函数中,我没有在模板调用中包含form参数。

要防止index.html模板在尝试呈现不存在的Web表单时崩溃,我将添加一个条件,只在传入表单参数后才会呈现该表单:

{% extends "base.html" %}

{% block content %}
    <h1>Hi, {{ current_user.username }}!</h1>
    {% if form %}
    <form action="" method="post">
        ...
    </form>
    {% endif %}
    ...
{% endblock %}

该页面也需要添加到导航栏中:

        <a href="{{ url_for('explore') }}">Explore</a>

还记得我在第六章中介绍的用于个人主页渲染用户动态的_post.html子模板吗? 这是一个包含在个人主页模板中的小模板,它独立于其他模板,因此也可以被这些模板调用。 我现在要做一个小小的改进,将用户动态作者的用户名显示为一个链接:

    <table>
        <tr valign="top">
            <td><img src="{{ post.author.avatar(36) }}"></td>
            <td>
                <a href="{{ url_for('user', username=post.author.username) }}">
                    {{ post.author.username }}
                </a>
                says:<br>{{ post.body }}
            </td>
        </tr>
    </table>

然后在主页和发现页中使用这个子模板来渲染用户动态:

    ...
    {% for post in posts %}
        {% include '_post.html' %}
    {% endfor %}
    ...

子模板期望存在一个名为post的变量,才能正常工作。该变量是上层模板中通过循环产生的。

通过这些细小的变更,应用的用户体验得到了大大的提升。现在,用户可以访问发现页来查看陌生用户的动态,并通过这些用户动态来关注用户,而需要的操作仅仅是点击用户名跳转到其个人主页并点击关注链接。令人叹为观止!对吧?

此时,我建议你在应用上再次尝试一下这个功能,以便体验最后的用户接口的完善。

用户动态的分页

应用看起来更完善了,但是在主页显示所有用户动态迟早会出问题。如果一个用户有成千上万条关注的用户动态时,会发生什么?你可以想象得到,管理这么大的用户动态列表将会变得相当缓慢和低效。

为了解决这个问题,我会将用户动态进行分页。这意味着一开始显示的只是所有用户动态的一部分,并提供链接来访问其余的用户动态。Flask-SQLAlchemy的paginate()方法原生就支持分页。例如,我想要获取用户关注的前20个动态,我可以将all()结束调用替换成如下的查询:

>>> user.followed_posts().paginate(1, 20, False).items

Flask-SQLAlchemy的所有查询对象都支持paginate方法,需要输入三个参数来调用它:

  • 从1开始的页码

  • 每页的数据量

  • 错误处理布尔标记,如果是True,当请求范围超出已知范围时自动引发404错误。如果是False,则会返回一个空列表。

paginate方法返回一个Pagination的实例。其items属性是请求内容的数据列表。Pagination实例还有一些其他用途,我会在之后讨论。

现在想想如何在index()视图函数展现分页呢。我先来给应用添加一个配置项,以表示每页展示的数据列表长度吧。

class Config(object):
    # ...
    POSTS_PER_PAGE = 3

存储这些应用范围的“可控机关”到配置文件是一个好主意,因为这样我调整时只需去一个地方。 在最终的应用中,每页显示的数据将会大于三,但是对于测试而言,使用小数字很方便。

接下来,我需要决定如何将页码并入到应用URL中。 一个相当常见的方法是使用查询字符串参数来指定一个可选的页码,如果没有给出则默认为页面1。 以下是一些示例网址,显示了我将如何实现这一点:

要访问查询字符串中给出的参数,我可以使用Flask的request.args对象。 你已经在第五章中看到了这种方法,我用Flask-Login实现了用户登录的可以包含一个next查询字符串参数的URL。

给主页和发现页的视图函数添加分页的代码变更如下:

@app.route('/', methods=['GET', 'POST'])
@app.route('/index', methods=['GET', 'POST'])
@login_required
def index():
    # ...
    page = request.args.get('page', 1, type=int)
    posts = current_user.followed_posts().paginate(
        page, app.config['POSTS_PER_PAGE'], False)
    return render_template('index.html', title='Home', form=form,
                           posts=posts.items)

@app.route('/explore')
@login_required
def explore():
    page = request.args.get('page', 1, type=int)
    posts = Post.query.order_by(Post.timestamp.desc()).paginate(
        page, app.config['POSTS_PER_PAGE'], False)
    return render_template("index.html", title='Explore', posts=posts.items)

通过这些更改,这两个路由决定了要显示的页码,可以从page查询字符串参数获得或是默认值1。然后使用paginate()方法来检索指定范围的结果。 决定页面数据列表大小的POSTS_PER_PAGE配置项是通过app.config对象中获取的。

请注意,这些更改非常简单,每次更改都只会影响很少的代码。 我试图在编写应用每个部分的时候,不做任何有关其他部分如何工作的假设,这使我可以编写更易于扩展和测试的且兼具模块化和健壮性的应用,并且不太可能失败或出现BUG。

来尝试下分页功能吧。 首先确保你有三条以上的用户动态。 在发现页面中更方便测试,因为该页面显示所有用户的动态。 你现在只会看到最近的三条用户动态。 如果你想看接下来的三条,请在浏览器的地址栏中输入http://localhost:5000/explore?page=2

分页导航

接下来的改变是在用户动态列表的底部添加链接,允许用户导航到下一页或上一页。 还记得我曾提到过paginate()的返回是Pagination类的实例吗? 到目前为止,我已经使用了此对象的items属性,其中包含为所选页面检索的用户动态列表。 但是这个分页对象还有一些其他的属性在构建分页链接时很有用:

  • has_next: 当前页之后存在后续页面时为真

  • has_prev: 当前页之前存在前置页面时为真

  • next_num: 下一页的页码

  • prev_num: 上一页的页码

有了这四个元素,我就可以生成上一页和下一页的链接并将其传入模板以渲染:

@app.route('/', methods=['GET', 'POST'])
@app.route('/index', methods=['GET', 'POST'])
@login_required
def index():
    # ...
    page = request.args.get('page', 1, type=int)
    posts = current_user.followed_posts().paginate(
        page, app.config['POSTS_PER_PAGE'], False)
    next_url = url_for('index', page=posts.next_num) \
        if posts.has_next else None
    prev_url = url_for('index', page=posts.prev_num) \
        if posts.has_prev else None
    return render_template('index.html', title='Home', form=form,
                           posts=posts.items, next_url=next_url,
                           prev_url=prev_url)

 @app.route('/explore')
 @login_required
 def explore():
    page = request.args.get('page', 1, type=int)
    posts = Post.query.order_by(Post.timestamp.desc()).paginate(
        page, app.config['POSTS_PER_PAGE'], False)
    next_url = url_for('explore', page=posts.next_num) \
        if posts.has_next else None
    prev_url = url_for('explore', page=posts.prev_num) \
        if posts.has_prev else None
    return render_template("index.html", title='Explore', posts=posts.items,
                          next_url=next_url, prev_url=prev_url)

这两个视图函数中的next_urlprev_url只有在该方向上存在一个页面时,才会被设置为由url_for()返回的URL。 如果当前页面位于用户动态集合的末尾或者开头,那么Pagination实例的has_nexthas_prev属性将为'False',在这种情况下,将设置该方向的链接为None

url_for()函数的一个有趣的地方是,你可以添加任何关键字参数,如果这些参数的名字没有直接在URL中匹配使用,那么Flask将它们设置为URL的查询字符串参数。

现在让我们把它们渲染在index.html模板上,就在用户动态列表的正下方:

    ...
    {% for post in posts %}
        {% include '_post.html' %}
    {% endfor %}
    {% if prev_url %}
    <a href="{{ prev_url }}">Newer posts</a>
    {% endif %}
    {% if next_url %}
    <a href="{{ next_url }}">Older posts</a>
    {% endif %}
    ...

主页和发现页都添加了分页链接。第一个链接标记为“Newer posts”,并指向前一页(请记住,我显示的用户动态按时间的倒序来排序,所以第一页是最新的内容)。 第二个链接标记为“Older posts”,并指向下一页的帖子。 如果这两个链接中的任何一个都是None,则通过条件过滤将其从页面中省略。

个人主页中的分页

主页分页已经完成,但是,个人主页中也有一个用户动态列表,其中只显示个人主页拥有者的动态。 为了保持一致,个人主页也应该实现分页,以匹配主页的分页样式。

我开始更新个人主页视图函数,其中仍然有一个模拟用户动态的列表。

@app.route('/user/<username>')
@login_required
def user(username):
    user = User.query.filter_by(username=username).first_or_404()
    page = request.args.get('page', 1, type=int)
    posts = user.posts.order_by(Post.timestamp.desc()).paginate(
        page, app.config['POSTS_PER_PAGE'], False)
    next_url = url_for('user', username=user.username, page=posts.next_num) \
        if posts.has_next else None
    prev_url = url_for('user', username=user.username, page=posts.prev_num) \
        if posts.has_prev else None
    return render_template('user.html', user=user, posts=posts.items,
                           next_url=next_url, prev_url=prev_url)

为了得到用户的动态列表,我利用了User模型中已经定义好的user.posts一对多关系。 我执行该查询并添加一个order_by()子句,以便我首先得到最新的用户动态,然后完全按照我对主页和发现页面中的用户动态所做的那样进行分页。 请注意,由url_for()函数生成的分页链接需要额外的username参数,因为它们指向个人主页,个人主页依赖用户名作为URL的动态组件。

最后,对user.html模板的更改与我在主页上所做的更改相同:

    ...
    {% for post in posts %}
        {% include '_post.html' %}
    {% endfor %}
    {% if prev_url %}
    <a href="{{ prev_url }}">Newer posts</a>
    {% endif %}
    {% if next_url %}
    <a href="{{ next_url }}">Older posts</a>
    {% endif %}

完成对分页功能的实验后,可以将POSTS_PER_PAGE配置项设置为更合理的值:

class Config(object):
    # ...
    POSTS_PER_PAGE = 25

Last updated