我的书终于重印了

盼了几个月,《Flask Web 开发实战》终于重印了。为什么这么期待重印?当然是因为重印可以修正书中的错误!假如你经常写博客的话,你可以想象一下这样的场景,你在昨天发布的一篇文章里发现大量的错误,但是却没法更新文章,而你的读者还在不停的找出更多的错误……而面对一本已经付印的书,除了感到愧疚,努力整理勘误,告诉读者阅读勘误外,你什么也做不了。这种感觉真是太糟糕了!

这次重印的 1-2 版本主要有下面这些变化:

  • 修正了 1-1 版本 11 月前已知的所有勘误。
  • 更新前言开始介绍 Flask 时的 GitHub 仓库 stars 和 watchers 数量。
  • 前言最后添加了初稿被删掉的后记(稍作修改)。
  • 在前言、附录、封底的作者简介下面添加了勘误页面网址。
  • 完善了多处内容,包括为新版本 Pipenv 和 PyCharm 的变化添加说明,去掉附录中过时的项目等,具体见可改进实现文件
  • 修正了 42 处断行连字符错误。
  • 比 1-1 的封面更好看,封面文字的颜色变淡了,纸张变白了。

尽管修正了这么多的错误,1-2 版本的勘误表仍然还有不少内容。不过,我相信,如果还有下一次重印,那么大部分错误都将得到修正。希望这一天早点来到!

顺便说一句,据出版社编辑的消息,电子书文件已经相应更新,如果没有接收到推送,可以联系各平台客服手动更新文件。

出版社寄了两本 1-2 版本的样书,稍后会在知乎专栏 Hello, Flask! 送出,也算是稍稍填补一下我的愧疚。

HelloFlask 论坛上线

很早的时候就想弄一个 Flask 论坛,但一直没有时间,最近终于下定决定完成这件事。

简单对比了目前比较流行的论坛程序,发现 Flarum 的界面最符合我的审美,功能也不差。不过安装的时候花了很大功夫,各种出错,最后实在不想继续下去。何况 Flarum 目前还没有发布稳定版,可以再等一等,最终决定先用 Discourse。安装 Discourse 的过程倒是很顺利,大部分时间都花在了安装完成后的设置和主题调整上。

这个 HelloFlask 论坛目前已经开始试运行,网址是 discuss.helloflask.com,欢迎对 Flask、Python、Web 开发等话题感兴趣的朋友加入。

它将会用来替代下面这些程序和群组的大部分功能:

  • HelloFlask Google+ 群组
  • HelloFlask QQ 群
  • HelloFlask Telegram 群组
  • HelloFlask GitHub 仓库
  • Flask-China GitHub 仓库

如果你遇到了自己无法解决的技术问题,请在这个论坛发帖子,而不是直接通过邮件、私信等途径联系我。

Tips:

  • 注册时请尽量不要使用 QQ 邮箱(验证邮件会被拦截,需要手动到“收信记录”里取回),建议使用 GitHub 登录。
  • 因为部署在 DigitalOcean 上,如果无法访问可以尝试使用 VPN。

博客改版

可以自由控制博客的样式,大概是独立博客最显著的优点之一。个人博客像是自己的房子,你拆墙打洞,想怎么弄都行,而其他社交网站和博客平台则像是精装修的出租屋,一切都是房东说了算。昨天花了很长时间给博客动了一次大手术。一直觉得传统博客的两栏布局很多余,边栏在所有页面都会显示,干扰正常的阅读。而主边栏包含文章摘要,每页只能显示很少的文章,阅读起来也不够方便。单从阅读的角度看,单栏布局+全部文章列表才是最合理的设计。

这次的调整对博客各个方面都进行了简化,尽可能多的去掉页面上的噪音(干扰信息):

  • 去掉所有页面的边栏。
  • 随着边栏一起去掉的还有边栏上的“Unfortunately not Flask powered”徽章、站点统计信息、日历挂件、文章分类、月度归档、标签和近期文章。
  • 因为没有了边栏,除了文章正文页面,外部不再有分类、标签和月度归档的入口,搜索框放到了博客页面底部。
  • 去掉了背景图片。
  • 博客主页使用年度归档文章列表,跟摘要说再见了。
  • 所有插件的语言改为中文。
  • 去掉导航栏的想法分类,以后短想法也采用标准文章格式。
  • 页面宽度缩减到 860px。 优化了评论框的宽度,修复了响应式问题。
  • 简化了相关文章扩展的样式,文章标题去掉粗体显示;提示文字使用 `<p>`,而不是 `<h3>`,上方添加一个分割线。

这次调整后,未来大概很长一段时间里都不会再有大变化了,除非是更换博客引擎(以前考虑过换用静态博客引擎,但是因为旧文章不方便迁移,所以迟迟没有动手)。未来考虑会在细节上再进行一些优化:

  • 去掉“相关文章”插件。
  • 去掉“图片弹窗”插件。
  • 去掉 Google 统计的代码,似乎对我没什么用。
  • 个人主页使用和博客页面统一的样式。

2018 年总结

和去年一样,又为写书花掉了一整年,这一年最大的感受就是累。上半年因为拖稿很焦虑,写完后又因为内容写太多了(700 页),审稿审到想吐,这也导致成书有不少笔误……出版后轻松不少,但生活开始充满了各种各样的杂事:推广新书,整理源码,打击盗版,回复读者提问,整理勘误,更新网站。几个月里,我的角色不停的在销售专员、客服、售后、技术顾问之间换来换去,以至于都快忘了我只能拿到 9% 的版税。而到手的一万多稿费,买了新的电脑和手机后就花的差不多了。反正我是再也不会写那么厚的书了,我发誓。

话虽这么说,我倒挺喜欢这种生活方式,有种做手工艺人的感觉,而且书写完了会有很大的满足感和成就感。

成果

这一年大概有下面这些值得记录的成果:

时间花销

这一年仍然没有进行时间统计,大概的时间花销如下:

  • 1~5 月:写作,最终在五月底定稿
  • 6 月:完善书相关的项目源码、追加多次书稿修改
  • 7 月:部署 5 个项目的在线 Demo、上线网站 helloflask.com
  • 8月:为书发售做准备、写多篇文章
  • 9 月:书发售、写文章、推广书、送书
  • 10 月:写文章、处理盗版、整理勘误、回复读者提问
  • 11 月:准备并参加 PyCon、处理盗版、整理勘误、回复读者提问
  • 12 月:做外包项目、写《Flask 入门教程》、处理盗版、整理勘误、回复读者提问

其他

和去年一样,除了翻过几本技术书外,今年几乎没有看什么书。电影的话,留下印象的只有最近去看的龙猫和无双,而音乐一直在重复旧的播放列表。这一年运动也严重不足,长时间使用电脑导致手腕和手指不太舒服(翻了下统计数据,上半年每天鼠标点击的次数在 2000~4000 左右,击键次数在 15000~30000 左右),期待意念键盘和鼠标早日发明出来。因为写作占用了大量时间,技术进步并不大,学习的内容也都局限在 Flask 和 Web 两个领域,明年需要扩展学习的范围。

2019 年做什么?大概是恢复正常的生活节奏,清理掉过度收集的信息,再学一点新东西(或许再写本书 :p)。

慎用 OneTab 扩展

新年第一天就发生了一件让人沮丧的事情,在电脑和浏览器都没有出现异常的情况下,OneTab(一个用来管理浏览器标签的扩展)保存的 3000 多个标签页突然全都不见了。这个问题也许和最近的一次 Chrome 自动更新有关(没错,就是那个界面非常难看而且不允许恢复旧样式的版本)。

在网上搜索后才发现有那么多人有同样的经历,在 Reddit 和 Stack Overflow 上有大量的帖子在讨论如何恢复丢失的标签数据。而在 Chrome 商店也可以看到很多一星评价,它们大都是这样开头的:“I love this extension until I lost all my saved tabs suddenly…”。

真是荒唐,一个用来保存标签页的工具,却没有提供一个可靠的存储机制。从大量评论来看,维护者是不负责任的,从来没有回复过反馈信息,官方文档也没有任何相关说明。OneTab 将数据存储在浏览器的本地存储中,文件位置大概在 AppData\Local\Google\Chrome\User Data\Default\Local Storage\ 目录下,而浏览器有可能会重写这里的文件。旧版本使用 SQLite 存储数据,新版本换成了 LevelDB,数据保存在 leveldb 目录下。或许可以尝试从这个目录下的文件导出数据,但暂时不清楚是否还存储了其他扩展的数据,尝试了几个工具,没能打开文件。因为不想为它浪费更多的时间,就此作罢。

这个扩展不值得信任,对于正在使用这个扩展的朋友,建议每次存档标签页以后都手动导出到本地文件进行备份或改用其他同类工具(比如 Tab OutlinerQlearly)。扩展本身提供了导出的功能,但导出的数据为纯文本,会丢失时间戳、分组名称、锁定和加星状态,也有人建议将 OneTab 页面作为网页保存来备份,或是使用网盘同步 leveldb 目录。

虽然是件坏事,但也有积极的一面。保存了几千个待读的标签页本身就说明了处理信息方式的不合理。这些“觉得有用但是目前没有时间处理”的标签页,在过一段时间后重新来看,大部分都变得没用了,不会再去浏览。尽管我一再提醒自己尽快处理掉这些积攒的标签页,但是却迟迟没有动身,直到 OneTab “自我了结”。这次数据丢失可以看做一个改变信息处理方式的契机,我决定停用这个扩展,尝试养成每晚清空标签页的习惯,并试着建立一个更好的信息处理机制。

忽然想起来,在使用 OneTab 之前积攒的大量书签还没有处理,而 Pocket 里面还躺着几百个待读的网页……


Update 2019/10/23

换了新电脑后又开始继续用 OneTab 了(同时搭配 Tab Outliner),不过这次采取了备份措施,每次点完存档后都会手动导出标签页信息到网盘同步的文本文档里。打开大量标签页直到电脑死机、存档大量标签页但不再回顾处理的坏习惯还没有改掉。目前标签页数量 963。

Update 2020/11/24

弃用 OneTab 大概有半年了,目前在用 Tab Outliner,免费版也支持备份数据到 Google 硬盘(不过需要手动备份)。同时也开始定期清理用不到的标签页,不过此前用 OneTab 备份在 TXT 文件里等待整理的几千个标签页也许再也不会打开了。

《Flask 入门教程》第 7 章:表单

在 HTML 页面里,我们需要编写表单来获取用户输入。一个典型的表单如下所示:

<form method="post">  <!-- 指定提交方法为 POST -->
    <label for="name">名字</label>
    <input type="text" name="name" id="name"><br>  <!-- 文本输入框 -->
    <label for="occupation">职业</label>
    <input type="text" name="occupation" id="occupation"><br>  <!-- 文本输入框 -->
    <input type="submit" name="submit" value="登录">  <!-- 提交按钮 -->
</form>

编写表单的 HTML 代码有下面几点需要注意:

  • 在 <form> 标签里使用 method 属性将提交表单数据的 HTTP 请求方法指定为 POST。如果不指定,则会默认使用 GET 方法,这会将表单数据通过 URL 提交,容易导致数据泄露,而且不适用于包含大量数据的情况。
  • <input> 元素必须要指定 name 属性,否则无法提交数据,在服务器端,我们也需要通过这个 name 属性值来获取对应字段的数据。

提示 填写输入框标签文字的 <label> 元素不是必须的,只是为了辅助鼠标用户。当使用鼠标点击标签文字时,会自动激活对应的输入框,这对复选框来说比较有用。for 属性填入要绑定的 <input> 元素的 id 属性值。

创建新条目

创建新条目可以放到一个新的页面来实现,也可以直接在主页实现。这里我们采用后者,首先在主页模板里添加一个表单:

templates/index.html:添加创建新条目表单

<p>{{ movies|length }} Titles</p>
<form method="post">
    Name <input type="text" name="title" autocomplete="off" required>
    Year <input type="text" name="year" autocomplete="off" required>
    <input class="btn" type="submit" name="submit" value="Add">
</form>

在这两个输入字段中,autocomplete 属性设为 off 来关闭自动完成(按下输入框不显示历史输入记录);另外还添加了 required 标志属性,如果用户没有输入内容就按下了提交按钮,浏览器会显示错误提示。

两个输入框和提交按钮相关的 CSS 定义如下:

/* 覆盖某些浏览器对 input 元素定义的字体 */
input[type=submit] {
    font-family: inherit;
}

input[type=text] {
    border: 1px solid #ddd;
}

input[name=year] {
    width: 50px;
}

.btn {
    font-size: 12px;
    padding: 3px 5px;
    text-decoration: none;
    cursor: pointer;
    background-color: white;
    color: black;
    border: 1px solid #555555;
    border-radius: 5px;
}

.btn:hover {
    text-decoration: none;
    background-color: black;
    color: white;
    border: 1px solid black;
}

接下来,我们需要考虑如何获取提交的表单数据。

处理表单数据

默认情况下,当表单中的提交按钮被按下,浏览器会创建一个新的请求,默认发往当前 URL(在 <form> 元素使用 action 属性可以自定义目标 URL)。

因为我们在模板里为表单定义了 POST 方法,当你输入数据,按下提交按钮,一个携带输入信息的 POST 请求会发往根地址。接着,你会看到一个 405 Method Not Allowed 错误提示。这是因为处理根地址请求的 index 视图默认只接受 GET 请求。

提示 在 HTTP 中,GET 和 POST 是两种最常见的请求方法,其中 GET 请求用来获取资源,而 POST 则用来创建 / 更新资源。我们访问一个链接时会发送 GET 请求,而提交表单通常会发送 POST 请求。

为了能够处理 POST 请求,我们需要修改一下视图函数:

@app.route('/', methods=['GET', 'POST'])

在 app.route() 装饰器里,我们可以用 methods 关键字传递一个包含 HTTP 方法字符串的列表,表示这个视图函数处理哪种方法类型的请求。默认只接受 GET 请求,上面的写法表示同时接受 GET 和 POST 请求。

两种方法的请求有不同的处理逻辑:对于 GET 请求,返回渲染后的页面;对于 POST 请求,则获取提交的表单数据并保存。为了在函数内加以区分,我们添加一个 if 判断:

app.py:创建电影条目

from flask import request, url_for, redirect, flash

# ...

@app.route('/', methods=['GET', 'POST'])
def index():
    if request.method == 'POST':  # 判断是否是 POST 请求
        # 获取表单数据
        title = request.form.get('title')  # 传入表单对应输入字段的 name 值
        year = request.form.get('year')
        # 验证数据
        if not title or not year or len(year) > 4 or len(title) > 60:
            flash('Invalid input.')  # 显示错误提示
            return redirect(url_for('index'))  # 重定向回主页
        # 保存表单数据到数据库
        movie = Movie(title=title, year=year)  # 创建记录
        db.session.add(movie)  # 添加到数据库会话
        db.session.commit()  # 提交数据库会话
        flash('Item Created.')  # 显示成功创建的提示
        return redirect(url_for('index'))  # 重定向回主页

    user = User.query.first()
    movies = Movie.query.all()
    return render_template('index.html', user=user, movies=movies)

在 if 语句内,我们编写了处理表单数据的代码,其中涉及 3 个新的知识点,下面来一一了解。

请求对象

Flask 会在请求触发后把请求信息放到 request 对象里,你可以从 flask 包导入它:

from flask import request

因为它在请求触发时才会包含数据,所以你只能在视图函数内部调用它。它包含请求相关的所有信息,比如请求的路径(request.path)、请求的方法(request.method)、表单数据(request.form)、查询字符串(request.args)等等。

在上面的 if 语句中,我们首先通过 request.method 的值来判断请求方法。在 if 语句内,我们通过 request.form 来获取表单数据。request.form 是一个特殊的字典,用表单字段的 name 属性值可以获取用户填入的对应数据:

if request.method == 'POST':
    title = request.form.get('title')
    year = request.form.get('year')

flash 消息

在用户执行某些动作后,我们通常在页面上显示一个提示消息。最简单的实现就是在视图函数里定义一个包含消息内容的变量,传入模板,然后在模板里渲染显示它。因为这个需求很常用,Flask 内置了相关的函数。其中 flash() 函数用来在视图函数里向模板传递提示消息,get_flashed_messages() 函数则用来在模板中获取提示消息。

flash() 的用法很简单,首先从 flask 包导入 flash 函数:

from flask import flash

然后在视图函数里调用,传入要显示的消息内容:

flash('Item Created.')

flash() 函数在内部会把消息存储到 Flask 提供的 session 对象里。session 用来在请求间存储数据,它会把数据签名后存储到浏览器的 Cookie 中,所以我们需要设置签名所需的密钥:

app.config['SECRET_KEY'] = 'dev'  # 等同于 app.secret_key = 'dev'

提示 这个密钥的值在开发时可以随便设置。基于安全的考虑,在部署时应该设置为随机字符,且不应该明文写在代码里, 在部署章节会详细介绍。

下面在基模板(base.html)里使用 get_flashed_messages() 函数获取提示消息并显示:

<!-- 插入到页面标题上方 -->
{% for message in get_flashed_messages() %}
	<div class="alert">{{ message }}</div>
{% endfor %}
<h2>...</h2>

alert 类为提示消息增加样式:

.alert {
    position: relative;
    padding: 7px;
    margin: 7px 0;
    border: 1px solid transparent;
    color: #004085;
    background-color: #cce5ff;
    border-color: #b8daff;
    border-radius: 5px;
}

通过在 <input> 元素内添加 required 属性实现的验证(客户端验证)并不完全可靠,我们还要在服务器端追加验证:

if not title or not year or len(year) > 4 or len(title) > 60:
    flash('Invalid input.')  # 显示错误提示
    return redirect(url_for('index'))
# ...
flash('Item Created.')  # 显示成功创建的提示

提示 在真实世界里,你会进行更严苛的验证,比如对数据去除首尾的空格。一般情况下,我们会使用第三方库(比如 WTForms)来实现表单数据的验证工作。

如果输入的某个数据为空,或是长度不符合要求,就显示错误提示“Invalid input.”,否则显示成功创建的提示“Item Created.”。

重定向响应

重定向响应是一类特殊的响应,它会返回一个新的 URL,浏览器在接受到这样的响应后会向这个新 URL 再次发起一个新的请求。Flask 提供了 redirect() 函数来快捷生成这种响应,传入重定向的目标 URL 作为参数,比如 redirect('http://helloflask.com')

根据验证情况,我们发送不同的提示消息,最后都把页面重定向到主页,这里的主页 URL 均使用 url_for() 函数生成:

if not title or not year or len(year) > 4 or len(title) > 60:
    flash('Invalid title or year!')  
    return redirect(url_for('index'))  # 重定向回主页
flash('Movie Created!')
return redirect(url_for('index'))  # 重定向回主页

编辑条目

编辑的实现和创建类似,我们先创建一个用于显示编辑页面和处理编辑表单提交请求的视图函数:

app.py:编辑电影条目

@app.route('/movie/edit/<int:movie_id>', methods=['GET', 'POST'])
def edit(movie_id):
    movie = Movie.query.get_or_404(movie_id)

    if request.method == 'POST':  # 处理编辑表单的提交请求
        title = request.form['title']
        year = request.form['year']
        
        if not title or not year or len(year) > 4 or len(title) > 60:
            flash('Invalid input.')
            return redirect(url_for('edit', movie_id=movie_id))  # 重定向回对应的编辑页面
        
        movie.title = title  # 更新标题
        movie.year = year  # 更新年份
        db.session.commit()  # 提交数据库会话
        flash('Item Updated.')
        return redirect(url_for('index'))  # 重定向回主页
    
    return render_template('edit.html', movie=movie)  # 传入被编辑的电影记录

这个视图函数的 URL 规则有一些特殊,如果你还有印象的话,我们在第 2 章的《实验时间》部分曾介绍过这种 URL 规则,其中的 <int:movie_id> 部分表示 URL 变量,而 int 则是将变量转换成整型的 URL 变量转换器。在生成这个视图的 URL 时,我们也需要传入对应的变量,比如 url_for('edit', movie_id=2) 会生成 /movie/edit/2。

movie_id 变量是电影条目记录在数据库中的主键值,这个值用来在视图函数里查询到对应的电影记录。查询的时候,我们使用了 get_or_404() 方法,它会返回对应主键的记录,如果没有找到,则返回 404 错误响应。

为什么要在最后把电影记录传入模板?既然我们要编辑某个条目,那么必然要在输入框里提前把对应的数据放进去,以便于进行更新。在模板里,通过表单 <input> 元素的 value 属性即可将它们提前写到输入框里。完整的编辑页面模板如下所示:

templates/edit.html:编辑页面模板

{% extends 'base.html' %}

{% block content %}
<h3>Edit item</h3>
<form method="post">
    Name <input type="text" name="title" autocomplete="off" required value="{{ movie.title }}">
    Year <input type="text" name="year" autocomplete="off" required value="{{ movie.year }}">
    <input class="btn" type="submit" name="submit" value="Update">
</form>
{% endblock %}

最后在主页每一个电影条目右侧都添加一个指向该条目编辑页面的链接:

index.html:编辑电影条目的链接

<span class="float-right">
    <a class="btn" href="{{ url_for('edit', movie_id=movie.id) }}">Edit</a>
    ...
</span>

点击某一个电影条目的编辑按钮打开的编辑页面如下图所示:

编辑电影条目

删除条目

因为不涉及数据的传递,删除条目的实现更加简单。首先创建一个视图函数执行删除操作,如下所示:

app.py:删除电影条目

@app.route('/movie/delete/<int:movie_id>', methods=['POST'])  # 限定只接受 POST 请求
def delete(movie_id):
    movie = Movie.query.get_or_404(movie_id)  # 获取电影记录
    db.session.delete(movie)  # 删除对应的记录
    db.session.commit()  # 提交数据库会话
    flash('Item Deleted.')
    return redirect(url_for('index'))  # 重定向回主页

为了安全的考虑,我们一般会使用 POST 请求来提交删除请求,也就是使用表单来实现(而不是创建删除链接):

index.html:删除电影条目表单

<span class="float-right">
    ...
    <form class="inline-form" method="post" action="{{ url_for('delete', movie_id=movie.id) }}">
        <input class="btn" type="submit" name="delete" value="Delete" onclick="return confirm('Are you sure?')">
    </form>
    ...
</span>

为了让表单中的删除按钮和旁边的编辑链接排成一行,我们为表单元素添加了下面的 CSS 定义:

.inline-form {
    display: inline;
}

最终的程序主页如下图所示:

添加表单和操作按钮后的主页

本章小结

本章我们完成了程序的主要功能:添加、编辑和删除电影条目。结束前,让我们提交代码:

$ git add .
$ git commit -m "Create, edit and delete item by form"
$ git push

提示 你可以在 GitHub 上查看本书示例程序的对应 commit:84e766f。在后续的 commit 里,我们为另外两个常见的 HTTP 错误:400(Bad Request) 和 500(Internal Server Error) 错误编写了错误处理函数和对应的模板,前者会在请求格式不符要求时返回,后者则会在程序内部出现任意错误时返回(关闭调试模式的情况下)。

进阶提示

  • 从上面的代码可以看出,手动验证表单数据既麻烦又不可靠。对于复杂的程序,我们一般会使用集成了 WTForms 的扩展 Flask-WTF 来简化表单处理。通过编写表单类,定义表单字段和验证器,它可以自动生成表单对应的 HTML 代码,并在表单提交时验证表单数据,返回对应的错误消息。更重要的,它还内置了 CSRF(跨站请求伪造) 保护功能。你可以阅读 Flask-WTF 文档和 Hello, Flask! 专栏上的表单系列文章了解具体用法。
  • CSRF 是一种常见的攻击手段。以我们的删除表单为例,某恶意网站的页面中内嵌了一段代码,访问时会自动发送一个删除某个电影条目的 POST 请求到我们的程序。如果我们访问了这个恶意网站,就会导致电影条目被删除,因为我们的程序没法分辨请求发自哪里。解决方法通常是在表单里添加一个包含随机字符串的隐藏字段,在提交时通过对比这个字段的值来判断是否是用户自己发送的请求。在我们的程序中没有实现 CSRF 保护。
  • 使用 Flask-WTF 时,表单类在模板中的渲染代码基本相同,你可以编写宏来渲染表单字段。如果你使用 Bootstap,那么扩展 Bootstrap-Flask 内置了多个表单相关的宏,可以简化渲染工作。
  • 你可以把删除按钮的行内 JavaScript 代码改为事件监听函数,写到单独的 JavaScript 文件里。
  • 《Flask Web 开发实战》第 4 章介绍了表单处理的各个方面,包括表单类的编写和渲染、错误消息显示、自定义错误消息语言、文件和多文件上传、富文本编辑器等等。
  • 本书主页 & 相关资源索引:http://helloflask.com/tutorial

《Flask 入门教程》第 6 章:模板优化

这一章我们会继续完善模板,学习几个非常实用的模板编写技巧,为下一章实现创建、编辑电影条目打下基础。

自定义错误页面

为了引出相关知识点,我们首先要为 Watchlist 编写一个错误页面。目前的程序中,如果你访问一个不存在的 URL,比如 /hello,Flask 会自动返回一个 404 错误响应。默认的错误页面非常简陋,如下图所示:

默认的 404 错误页面

在 Flask 程序中自定义错误页面非常简单,我们先编写一个 404 错误页面模板,如下所示:

templates/404.html:404 错误页面模板

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>{{ user.name }}'s Watchlist</title>
    <link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" type="text/css">
</head>
<body>
    <h2>
        <img alt="Avatar" class="avatar" src="{{ url_for('static', filename='images/avatar.png') }}">
        {{ user.name }}'s Watchlist
    </h2>
    <ul class="movie-list">
        <li>
            Page Not Found - 404
            <span class="float-right">
                <a href="{{ url_for('index') }}">Go Back</a>
            </span>
        </li>
    </ul>
    <footer>
        <small>&copy; 2018 <a href="http://helloflask.com/tutorial">HelloFlask</a></small>
	</footer>
</body>
</html>

接着使用 app.errorhandler() 装饰器注册一个错误处理函数,它的作用和视图函数类似,当 404 错误发生时,这个函数会被触发,返回值会作为响应主体返回给客户端:

app.py:404 错误处理函数

@app.errorhandler(404)  # 传入要处理的错误代码
def page_not_found(e):  # 接受异常对象作为参数
    user = User.query.first()
    return render_template('404.html', user=user), 404  # 返回模板和状态码

提示 和我们前面编写的视图函数相比,这个函数返回了状态码作为第二个参数,普通的视图函数之所以不用写出状态码,是因为默认会使用 200 状态码,表示成功。

这个视图返回渲染好的错误模板,因为模板中使用了 user 变量,这里也要一并传入。现在访问一个不存在的 URL,会显示我们自定义的错误页面:

自定义 404 错误页面

编写完这部分代码后,你会发现两个问题:

  • 错误页面和主页都需要使用 user 变量,所以在对应的处理函数里都要查询数据库并传入 user 变量。因为每一个页面都需要获取用户名显示在页面顶部,如果有更多的页面,那么每一个对应的视图函数都要重复传入这个变量。
  • 错误页面模板和主页模板有大量重复的代码,比如 <head> 标签的内容,页首的标题,页脚信息等。这种重复不仅带来不必要的工作量,而且会让修改变得更加麻烦。举例来说,如果页脚信息需要更新,那么每个页面都要一一进行修改。

显而易见,这两个问题有更优雅的处理方法,下面我们来一一了解。

模板上下文处理函数

对于多个模板内都需要使用的变量,我们可以使用 app.context_processor 装饰器注册一个模板上下文处理函数,如下所示:

app.py:模板上下文处理函数

@app.context_processor
def inject_user():  # 函数名可以随意修改
    user = User.query.first()
    return dict(user=user)  # 需要返回字典,等同于return {'user': user}

这个函数返回的变量(以字典键值对的形式)将会统一注入到每一个模板的上下文环境中,因此可以直接在模板中使用。

现在我们可以删除 404 错误处理函数和主页视图函数中的 user 变量定义,并删除在 render_template() 函数里传入的关键字参数:

@app.context_processor
def inject_user():
    user = User.query.first()
    return dict(user=user)


@app.errorhandler(404)
def page_not_found(e):
    return render_template('404.html'), 404


@app.route('/')
def index():
    movies = Movie.query.all()
    return render_template('index.html', movies=movies)

同样的,后面我们创建的任意一个模板,都可以在模板中直接使用 user 变量。

使用模板继承组织模板

对于模板内容重复的问题,Jinja2 提供了模板继承的支持。这个机制和 Python 类继承非常类似:我们可以定义一个父模板,一般会称之为基模板(base template)。基模板中包含完整的 HTML 结构和导航栏、页首、页脚都通用部分。在子模板里,我们可以使用 extends 标签来声明继承自某个基模板。

基模板中需要在实际的子模板中追加或重写的部分则可以定义成块(block)。块使用 block 标签创建, {% block 块名称 %}作为开始标记,{% endblock %} 或 {% endblock 块名称 %} 作为结束标记。通过在子模板里定义一个同样名称的块,你可以向基模板的对应块位置追加或重写内容。

编写基础模板

下面是新编写的基模板 base.html:

templates/base.html:基模板

<!DOCTYPE html>
<html lang="en">
<head>
    {% block head %}
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{ user.name }}'s Watchlist</title>
    <link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" type="text/css">
    {% endblock %}
</head>
<body>
    <h2>
        <img alt="Avatar" class="avatar" src="{{ url_for('static', filename='images/avatar.png') }}">
        {{ user.name }}'s Watchlist
    </h2>
    <nav>
        <ul>
            <li><a href="{{ url_for('index') }}">Home</a></li>
        </ul>
    </nav>
    {% block content %}{% endblock %}
    <footer>
        <small>&copy; 2018 <a href="http://helloflask.com/tutorial">HelloFlask</a></small>
	</footer>
</body>
</html>

在基模板里,我们添加了两个块,一个是包含 <head></head> 内容的 head 块,另一个是用来在子模板中插入页面主体内容的 content 块。在复杂的项目里,你可以定义更多的块,方便在子模板中对基模板的各个部分插入内容。另外,块的名字没有特定要求,你可以自由修改。

在编写子模板之前,我们先来看一下基模板中的两处新变化。

第一处,我们添加了一个新的 <meta> 元素,这个元素会设置页面的视口,让页面根据设备的宽度来自动缩放页面,让移动设备拥有更好的浏览体验:

<meta name="viewport" content="width=device-width, initial-scale=1.0">

第二处,新的页面添加了一个导航栏:

<nav>
    <ul>
        <li><a href="{{ url_for('index') }}">Home</a></li>
    </ul>
</nav>

导航栏对应的 CSS 代码如下所示:

nav ul {
    list-style-type: none;
    margin: 0;
    padding: 0;
    overflow: hidden;
    background-color: #333;
}

nav li {
    float: left;
}

nav li a {
    display: block;
    color: white;
    text-align: center;
    padding: 8px 12px;
    text-decoration: none;
}

nav li a:hover {
    background-color: #111;
}

编写子模板

创建了基模板后,子模板的编写会变得非常简单。下面是新的主页模板(index.html):

templates/index.html:继承基模板的主页模板

{% extends 'base.html' %}

{% block content %}
<p>{{ movies|length }} Titles</p>
<ul class="movie-list">
    {% for movie in movies %}
    <li>{{ movie.title }} - {{ movie.year }}
        <span class="float-right">
            <a class="imdb" href="https://www.imdb.com/find?q={{ movie.title }}" target="_blank" title="Find this movie on IMDb">IMDb</a>
        </span>
    </li>
    {% endfor %}
</ul>
<img alt="Walking Totoro" class="totoro" src="{{ url_for('static', filename='images/totoro.gif') }}" title="to~to~ro~">
{% endblock %}

第一行使用 extends 标签声明扩展自模板 base.html,可以理解成“这个模板继承自 base.html“。接着我们定义了 content块,这里的内容会插入到基模板中 content 块的位置。

提示 默认的块重写行为是覆盖,如果你想向父块里追加内容,可以在子块中使用 super() 声明,即 {{ super() }}

404 错误页面的模板类似,如下所示:

templates/index.html:继承基模板的 404 错误页面模板

{% extends 'base.html' %}

{% block content %}
<ul class="movie-list">
    <li>
        Page Not Found - 404
        <span class="float-right">
            <a href="{{ url_for('index') }}">Go Back</a>
        </span>
    </li>
</ul>
{% endblock %}

添加 IMDb 链接

在主页模板里,我们还为每一个电影条目右侧添加了一个 IMDb 链接:

<span class="float-right">
    <a class="imdb" href="https://www.imdb.com/find?q={{ movie.title }}" target="_blank" title="Find this movie on IMDb">IMDb</a>
</span>

这个链接的 href 属性的值为 IMDb 搜索页面的 URL,搜索关键词通过查询参数 q 传入,这里传入了电影的标题。

对应的 CSS 定义如下所示:

.float-right {
    float: right;
}

.imdb {
    font-size: 12px;
    font-weight: bold;
    color: black;
    text-decoration: none;
    background: #F5C518;
    border-radius: 5px;
    padding: 3px 5px;
}

现在,我们的程序主页如下所示:

添加导航栏和 IMDb 链接

本章小结

本章我们主要学习了 Jinja2 的模板继承机制,去掉了大量的重复代码,这让后续的模板编写工作变得更加轻松。结束前,让我们提交代码:

$ git add .
$ git commit -m "Add base template and error template"
$ git push

提示 你可以在 GitHub 上查看本书示例程序的对应 commit:3bca489

进阶提示

  • 本章介绍的自定义错误页面是为了引出两个重要的知识点,因此并没有着重介绍错误页面本身。这里只为 404 错误编写了自定义错误页面,对于另外两个常见的错误 400 错误和 500 错误,你可以自己试着为它们编写错误处理函数和对应的模板。
  • 因为示例程序的语言和电影标题使用了英文,所以电影网站的搜索链接使用了 IMDb,对于中文,你可以使用豆瓣电影或时光网。以豆瓣电影为例,它的搜索链接为 https://movie.douban.com/subject_search?search_text=关键词,对应的 href 属性即 https://movie.douban.com/subject_search?search_text={{ movie.title }}
  • 因为基模板会被所有其他页面模板继承,如果你在基模板中使用了某个变量,那么这个变量也需要使用模板上下文处理函数注入到模板里。
  • 本书主页 & 相关资源索引:http://helloflask.com/tutorial

《Flask 入门教程》第 5 章:数据库

大部分程序都需要保存数据,所以不可避免要使用数据库。用来操作数据库的数据库管理系统(DBMS)有很多选择,对于不同类型的程序,不同的使用场景,都会有不同的选择。在这个教程中,我们选择了属于关系型数据库管理系统(RDBMS)的 SQLite,它基于文件,不需要单独启动数据库服务器,适合在开发时使用,或是在数据库操作简单、访问量低的程序中使用。

使用 SQLAlchemy 操作数据库

为了简化数据库操作,我们将使用 SQLAlchemy——一个 Python 数据库工具(ORM,即对象关系映射)。借助 SQLAlchemy,你可以通过定义 Python 类来表示数据库里的一张表(类属性表示表中的字段 / 列),通过对这个类进行各种操作来代替写 SQL 语句。这个类我们称之为模型类,类中的属性我们将称之为字段

Flask 有大量的第三方扩展,这些扩展可以简化和第三方库的集成工作。我们下面将使用一个叫做 Flask-SQLAlchemy 的官方扩展来集成 SQLAlchemy。

首先使用 Pipenv 安装它:

$ pipenv install flask-sqlalchemy

大部分扩展都需要执行一个“初始化”操作。你需要导入扩展类,实例化并传入 Flask 程序实例:

from flask_sqlalchemy import SQLAlchemy  # 导入扩展类

app = Flask(__name__)

db = SQLAlchemy(app)  # 初始化扩展,传入程序实例 app

设置数据库 URI

为了设置 Flask、扩展或是我们程序本身的一些行为,我们需要设置和定义一些配置变量。Flask 提供了一个统一的接口来写入和获取这些配置变量:Flask.config 字典。配置变量的名称必须使用大写,写入配置的语句一般会放到扩展类实例化语句之前。

下面写入了一个 SQLALCHEMY_DATABASE_URI 变量来告诉 SQLAlchemy 数据库连接地址:

import os

# ...

app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////' + os.path.join(app.root_path, 'data.db')

注意 这个配置变量的最后一个单词是 URI,而不是 URL。

对于这个变量值,不同的 DBMS 有不同的格式,对于 SQLite 来说,这个值的格式如下:

sqlite:////数据库文件的绝对地址

数据库文件一般放到项目根目录即可,app.root_path 返回程序实例所在模块的路径(目前来说,即项目根目录),我们使用它来构建文件路径。数据库文件的名称和后缀你可以自由定义,一般会使用 .db、.sqlite 和 .sqlite3 作为后缀。

另外,如果你使用 Windows 系统,上面的 URI 前缀部分需要写入三个斜线(即 sqlite:///)。在本书的示例程序代码里,做了一些兼容性处理,另外还新设置了一个配置变量,实际的代码如下:

import os
import sys

from flask import Flask

WIN = sys.platform.startswith('win')
if WIN:  # 如果是 Windows 系统,使用三个斜线
    prefix = 'sqlite:///'
else:  # 否则使用四个斜线
    prefix = 'sqlite:////'

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = prefix + os.path.join(app.root_path, 'data.db')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False  # 关闭对模型修改的监控

如果你固定在某一个操作系统上进行开发,部署时也使用相同的操作系统,那么可以不用这么做,直接根据你的需要写出前缀即可。

提示 你可以访问 Flask 文档的配置页面查看 Flask 内置的配置变量;同样的,在 Flask-SQLAlchemy 文档的配置页面可以看到 Flask-SQLAlchemy 提供的配置变量。

创建数据库模型

在 Watchlist 程序里,目前我们有两类数据要保存:用户信息和电影条目信息。下面分别创建了两个模型类来表示这两张表:

app.py:创建数据库模型

class User(db.Model):  # 表名将会是 user(自动生成,小写处理)
    id = db.Column(db.Integer, primary_key=True)  # 主键
    name = db.Column(db.String(20))  # 名字


class Movie(db.Model):  # 表名将会是 movie
    id = db.Column(db.Integer, primary_key=True)  # 主键
    title = db.Column(db.String(60))  # 电影标题
    year = db.Column(db.String(4))  # 电影年份

模型类的编写有一些限制:

  • 模型类要声明继承 db.Model
  • 每一个类属性(字段)要实例化 db.Column,传入的参数为字段的类型,下面的表格列出了常用的字段类。
  • 在 db.Column() 中添加额外的选项(参数)可以对字段进行设置。比如,primary_key 设置当前字段是否为主键。除此之外,常用的选项还有 nullable(布尔值,是否允许为空值)、index(布尔值,是否设置索引)、unique(布尔值,是否允许重复值)、default(设置默认值)等。

常用的字段类型如下表所示:

字段类 说明
db.Integer 整型
db.String (size) 字符串,size 为最大长度,比如 db.String(20)
db.Text 长文本
db.DateTime 时间日期,Python datetime 对象
db.Float 浮点数
db.Boolean 布尔值

创建数据库表

模型类创建后,还不能对数据库进行操作,因为我们还没有创建表和数据库文件。下面在 Python Shell 中创建了它们:

$ flask shell
>>> from app import db
>>> db.create_all()

打开文件管理器,你会发现项目根目录下出现了新创建的数据库文件 data.db。这个文件不需要提交到 Git 仓库,我们在 .gitignore 文件最后添加一行新规则:

*.db

如果你改动了模型类,想重新生成表模式,那么需要先使用 db.drop_all() 删除表,然后重新创建:

>>> db.drop_all()
>>> db.create_all()

注意这会一并删除所有数据,如果你想在不破坏数据库内的数据的前提下变更表的结构,需要使用数据库迁移工具,比如集成了 Alembic 的 Flask-Migrate 扩展。

提示 上面打开 Python Shell 使用的是 flask shell命令,而不是 python。使用这个命令启动的 Python Shell 激活了“程序上下文”,它包含一些特殊变量,这对于某些操作是必须的(比如上面的 db.create_all()调用)。请记住,后续的 Python Shell 都会使用这个命令打开。

和 flask shell类似,我们可以编写一个自定义命令来自动执行创建数据库表操作:

import click

@app.cli.command()  # 注册为命令
@click.option('--drop', is_flag=True, help='Create after drop.')  # 设置选项
def initdb(drop):
    """Initialize the database."""
    if drop:  # 判断是否输入了选项
        db.drop_all()
    db.create_all()
    click.echo('Initialized database.')  # 输出提示信息

默认情况下,函数名称就是命令的名字,现在执行 flask initdb 命令就可以创建数据库表:

$ flask initdb

使用 --drop 选项可以删除表后重新创建:

$ flask initdb --drop

创建、读取、更新、删除

在前面打开的 Python Shell 里,我们来测试一下常见的数据库操作。你可以跟着示例代码来操作,也可以自由练习。

创建

下面的操作演示了如何向数据库中添加记录:

>>> from app import User, Movie  # 导入模型类
>>> user = User(name='Grey Li')  # 创建一个 User 记录
>>> m1 = Movie(title='Leon', year='1994')  # 创建一个 Movie 记录
>>> m2 = Movie(title='Mahjong', year='1996')  # 再创建一个 Movie 记录
>>> db.session.add(user)  # 把新创建的记录添加到数据库会话
>>> db.session.add(m1)
>>> db.session.add(m2)
>>> db.session.commit()  # 提交数据库会话,只需要在最后调用一次即可

提示 在实例化模型类的时候,我们并没有传入 id 字段(主键),因为 SQLAlchemy 会自动处理这个字段。

最后一行 db.session.commit() 很重要,只有调用了这一行才会真正把记录提交进数据库,前面的 db.session.add() 调用是将改动添加进数据库会话(一个临时区域)中。

读取

通过对模型类的 query 属性调用可选的过滤方法和查询方法,我们就可以获取到对应的单个或多个记录(记录以模型类实例的形式表示)。查询语句的格式如下:

<模型类>.query.<过滤方法(可选)>.<查询方法>

下面是一些常用的过滤方法:

过滤方法 说明
filter() 使用指定的规则过滤记录,返回新产生的查询对象
filter_by() 使用指定规则过滤记录(以关键字表达式的形式),返回新产生的查询对象
order_by() 根据指定条件对记录进行排序,返回新产生的查询对象
group_by() 根据指定条件对记录进行分组,返回新产生的查询对象

下面是一些常用的查询方法:

查询方法 说明
all() 返回包含所有查询记录的列表
first() 返回查询的第一条记录,如果未找到,则返回None
get(id) 传入主键值作为参数,返回指定主键值的记录,如果未找到,则返回None
count() 返回查询结果的数量
first_or_404() 返回查询的第一条记录,如果未找到,则返回404错误响应
get_or_404(id) 传入主键值作为参数,返回指定主键值的记录,如果未找到,则返回404错误响应
paginate() 返回一个Pagination对象,可以对记录进行分页处理

下面的操作演示了如何从数据库中读取记录,并进行简单的查询:

>>> from app import Movie  # 导入模型类
>>> movie = Movie.query.first()  # 获取 Movie 模型的第一个记录(返回模型类实例)
>>> movie.title  # 对返回的模型类实例调用属性即可获取记录的各字段数据
'Leon'
>>> movie.year
'1994'
>>> Movie.query.all()  # 获取 Movie 模型的所有记录,返回包含多个模型类实例的列表
[, ]
>>> Movie.query.count()  # 获取 Movie 模型所有记录的数量
2
>>> Movie.query.get(1)  # 获取主键值为 1 的记录
<Movie 1>
>>> Movie.query.filter_by(title='Mahjong').first()  # 获取 title 字段值为 Mahjong 的记录
<Movie 2>
>>> Movie.query.filter(Movie.title=='Mahjong').first()  # 等同于上面的查询,但使用不同的过滤方法
<Movie 2>

提示 我们在说 Movie 模型的时候,实际指的是数据库中的 movie 表。表的实际名称是模型类的小写形式(自动生成),如果你想自己指定表名,可以定义 __tablename__ 属性。

对于最基础的 filter() 过滤方法,SQLAlchemy 支持丰富的查询操作符,具体可以访问文档相关页面查看。除此之外,还有更多的查询方法、过滤方法和数据库函数可以使用,具体可以访问文档的 Query API 部分查看。

更新

下面的操作更新了 Movie 模型中主键为 2 的记录:

>>> movie = Movie.query.get(2)
>>> movie.title = 'WALL-E'  # 直接对实例属性赋予新的值即可
>>> movie.year = '2008'
>>> db.session.commit()  # 注意仍然需要调用这一行来提交改动

删除

下面的操作删除了 Movie 模型中主键为 1 的记录:

>>> movie = Movie.query.get(1)
>>> db.session.delete(movie)  # 使用 db.session.delete() 方法删除记录,传入模型实例
>>> db.session.commit()  # 提交改动

在程序里操作数据库

经过上面的一番练习,我们可以在 Watchlist 里进行实际的数据库操作了。

在主页视图读取数据库记录

因为设置了数据库,负责显示主页的 index 可以从数据库里读取真实的数据:

@app.route('/')
def index():
    user = User.query.first()  # 读取用户记录
    movies = Movie.query.all()  # 读取所有电影记录
    return render_template('index.html', user=user, movies=movies)

在 index 视图中,原来传入模板的 name 变量被 user 实例取代,模板 index.html 中的两处 name 变量也要相应的更新为 user.name 属性:

{{ user.name }}'s Watchlist

生成虚拟数据

因为有了数据库,我们可以编写一个命令函数把虚拟数据添加到数据库里。下面是用来生成虚拟数据的命令函数:

import click

@app.cli.command()
def forge():
    """Generate fake data."""
    db.create_all()
    
    # 全局的两个变量移动到这个函数内
    name = 'Grey Li'
    movies = [
        {'title': 'My Neighbor Totoro', 'year': '1988'},
        {'title': 'Dead Poets Society', 'year': '1989'},
        {'title': 'A Perfect World', 'year': '1993'},
        {'title': 'Leon', 'year': '1994'},
        {'title': 'Mahjong', 'year': '1996'},
        {'title': 'Swallowtail Butterfly', 'year': '1996'},
        {'title': 'King of Comedy', 'year': '1999'},
        {'title': 'Devils on the Doorstep', 'year': '1999'},
        {'title': 'WALL-E', 'year': '2008'},
        {'title': 'The Pork of Music', 'year': '2012'},
    ]
    
    user = User(name=name)
    db.session.add(user)
    for m in movies:
        movie = Movie(title=m['title'], year=m['year'])
        db.session.add(movie)
    
    db.session.commit()
    click.echo('Done.')

现在执行 flask forge 命令就会把所有虚拟数据添加到数据库里:

$ flask forge

本章小结

本章我们学习了使用 SQLAlchemy 操作数据库,后面你会慢慢熟悉相关的操作。结束前,让我们提交代码:

$ git add .
$ git commit -m "Add database support with Flask-SQLAlchemy"
$ git push

提示 你可以在 GitHub 上查看本书示例程序的对应 commit:4d2442a

进阶提示

  • 在生产环境,你可以更换更合适的 DBMS,因为 SQLAlchemy 支持多种 SQL 数据库引擎,通常只需要改动非常少的代码。
  • 我们的程序只有一个用户,所以没有将 User 表和 Movie 表建立关联。访问 Flask-SQLAlchemy 文档的”声明模型“章节可以看到相关内容
  • 《Flask Web 开发实战》第 5 章详细介绍了 SQLAlchemy 和 Flask-Migrate 的使用,第 8 章和第 9 章引入了更复杂的模型关系和查询方法。
  • 阅读 SQLAlchemy 官方文档和教程详细了解它的用法。注意我们在这里使用 Flask-SQLAlchemy 来集成它,所以用法和单独使用 SQLAlchemy 有一些不同。作为参考,你可以同时阅读 Flask-SQLAlchemy 官方文档 。
  • 本书主页 & 相关资源索引:http://helloflask.com/tutorial

《Flask 入门教程》第 4 章:使用静态文件

静态文件(static files)和我们的模板概念相反,指的是内容不需要动态生成的文件。比如图片、CSS 文件和 JavaScript 脚本等。

在 Flask 中,我们需要创建一个 static 文件夹来保存静态文件,它应该和程序模块、templates 文件夹在同一目录层级,所以我们在项目根目录创建它:

$ mkdir static

生成静态文件 URL

在 HTML 文件里,引入这些静态文件需要给出资源所在的 URL。为了更加灵活,这些文件的 URL 可以通过 Flask 提供的 url_for() 函数来生成。

在第 2 章的最后,我们学习过 url_for() 函数的用法,传入端点值(视图函数的名称)和参数,它会返回对应的 URL。对于静态文件,需要传入的端点值是 static,同时使用filename 参数来传入相对于 static 文件夹的文件路径。

假如我们在 static 文件夹的根目录下面放了一个 foo.jpg 文件,下面的调用可以获取它的 URL:

<img src="{{ url_for('static', filename='foo.jpg') }}">

花括号部分的调用会返回 /static/foo.jpg

提示 在 Python 脚本里,url_for() 函数需要从 flask 包中导入,而在模板中则可以直接使用,因为 Flask 把一些常用的函数和对象添加到了模板上下文(环境)里。

添加 Favicon

Favicon(favourite icon) 是显示在标签页和书签栏的网站头像。你需要准备一个 ICO、PNG 或 GIF 格式的图片,大小一般为 16×16、32×32、48×48 或 64×64 像素。把这个图片放到 static 目录下,然后像下面这样在 HTML 模板里引入它:

templates/index.html:引入 Favicon

<head>
    ...
    <link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
</head>

保存后刷新页面,即可在浏览器标签页上看到这个图片。

添加图片

为了让页面不那么单调,我们来添加两个图片:一个是显示在页面标题旁边的头像,另一个是显示在页面底部的龙猫动图。我们在 static 目录下面创建一个子文件夹 images,把这两个图片都放到这个文件夹里:

$ cd static
$ mkdir images

创建子文件夹并不是必须的,这里只是为了更好的组织同类文件。同样的,如果你有多个 CSS 文件,也可以创建一个 css 文件夹来组织他们。下面我们在页面模板中添加这两个图片,注意填写正确的文件路径:

templates/index.html:添加图片

<h2>
    <img alt="Avatar" src="{{ url_for('static', filename='images/avatar.png') }}">
    {{ name }}'s Watchlist
</h2>
...
<img alt="Walking Totoro" src="{{ url_for('static', filename='images/totoro.gif') }}">

提示 这两张图片你可以自己替换为任意的图片(注意更新文件名),也可以在示例程序的 GitHub 仓库下载。

添加 CSS

虽然添加了图片,但页面还是非常简陋,因为我们还没有添加 CSS 定义。下面在 static 目录下创建一个 CSS 文件 style.css,内容如下:

static/style.css:定义页面样式

/* 页面整体 */
body {
    margin: auto;
    max-width: 580px;
    font-size: 14px;
    font-family: Helvetica, Arial, sans-serif;
}

/* 页脚 */
footer {
    color: #888;
    margin-top: 15px;
    text-align: center;
    padding: 10px;
}

/* 头像 */
.avatar {
    width: 40px;
}

/* 电影列表 */
.movie-list {
    list-style-type: none;
    padding: 0;
    margin-bottom: 10px;
    box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16), 0 2px 10px 0 rgba(0, 0, 0, 0.12);
}

.movie-list li {
    padding: 12px 24px;
    border-bottom: 1px solid #ddd;
}

.movie-list li:last-child {
    border-bottom:none;
}

.movie-list li:hover {
    background-color: #f8f9fa;
}

/* 龙猫图片 */
.totoro {
    display: block;
    margin: 0 auto;
    height: 100px;
}

接着在页面的 <head> 标签内引入这个 CSS 文件:

templates/index.html:引入 CSS 文件

<head>
    ...
    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" type="text/css">
</head>

最后要为对应的元素设置 class 属性值,以便和对应的 CSS 定义关联起来:

templates/index.html:添加 class 属性

<h2>
    <img alt="Avatar" class="avatar" src="{{ url_for('static', filename='images/avatar.png') }}">
    {{ name }}'s Watchlist
</h2>
...
<ul class="movie-list">
    ...
</ul>
<img alt="Walking Totoro" class="totoro" src="{{ url_for('static', filename='images/totoro.gif') }}">

最终的页面如下图所示(你可以自由修改 CSS 定义,我已经尽力了):

4-1

本章小结

主页现在基本成型了,接下来我们会慢慢完成程序的功能。结束前,让我们提交代码:

$ git add .
$ git commit -m "Add static files"
$ git push

提示 你可以在 GitHub 上查看本书示例程序的对应 commit:e51c579

进阶提示

《Flask 入门教程》第 3 章:模板

在一般的 Web 程序里,访问一个地址通常会返回一个包含各类信息的 HTML 页面。因为我们的程序是动态的,页面中的某些信息需要根据不同的情况来进行调整,比如对登录和未登录用户显示不同的信息,所以页面需要在用户访问时根据程序逻辑动态生成。

我们把包含变量和运算逻辑的 HTML 或其他格式的文本叫做模板,执行这些变量替换和逻辑计算工作的过程被称为渲染,这个工作由我们这一章要学习使用的模板渲染引擎——Jinja2 来完成。

按照默认的设置,Flask 会从程序实例所在模块同级目录的 templates 文件夹中寻找模板,我们的程序目前存储在项目根目录的 app.py 文件里,所以我们要在项目根目录创建这个文件夹:

$ mkdir templates

模板基本语法

在社交网站上,每个人都有一个主页,借助 Jinja2 就可以写出一个通用的模板:

<h1>{{ username }}的个人主页</h1>
{% if bio %}
    <p>{{ bio }}</p>  {# 这里的缩进只是为了可读性,不是必须的 #}
{% else %}
    <p>自我介绍为空。</p>
{% endif %}  {# 大部分 Jinja 语句都需要声明关闭 #}

Jinja2 的语法和 Python 大致相同,你在后面会陆续接触到一些常见的用法。在模板里,你需要添加特定的定界符将 Jinja2 语句和变量标记出来,下面是三种常用的定界符:

  • {{ ... }} 用来标记变量。
  • {% ... %} 用来标记语句,比如 if 语句,for 语句等。
  • {# ... #} 用来写注释。

模板中使用的变量需要在渲染的时候传递进去,具体我们后面会了解。

编写主页模板

我们先在 templates 目录下创建一个 index.html 文件,作为主页模板。主页需要显示电影条目列表和个人信息,代码如下所示:

templates/index.html:主页模板

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>{{ name }}'s Watchlist</title>
</head>
<body>
    <h2>{{ name }}'s Watchlist</h2>
    {# 使用 length 过滤器获取 movies 变量的长度 #}
    <p>{{ movies|length }} Titles</p>
    <ul>
        {% for movie in movies %}  {# 迭代 movies 变量 #}
        <li>{{ movie.title }} - {{ movie.year }}</li>  {# 等同于 movie['title'] #}
        {% endfor %}  {# 使用 endfor 标签结束 for 语句 #}
    </ul>
    <footer>
        <small>&copy; 2018 <a href="http://helloflask.com/tutorial">HelloFlask</a></small>
	</footer>
</body>
</html>

为了方便对变量进行处理,Jinja2 提供了一些过滤器,语法形式如下:

{{ 变量|过滤器 }}

左侧是变量,右侧是过滤器名。比如,上面的模板里使用 length 过滤器来获取 movies 的长度,类似 Python 里的 len() 函数。

提示 访问 http://jinja.pocoo.org/docs/2.10/templates/#list-of-builtin-filters 查看所有可用的过滤器。

准备虚拟数据

为了模拟页面渲染,我们需要先创建一些虚拟数据,用来填充页面内容:

app.py:定义虚拟数据

name = 'Grey Li'
movies = [
    {'title': 'My Neighbor Totoro', 'year': '1988'},
    {'title': 'Dead Poets Society', 'year': '1989'},
    {'title': 'A Perfect World', 'year': '1993'},
    {'title': 'Leon', 'year': '1994'},
    {'title': 'Mahjong', 'year': '1996'},
    {'title': 'Swallowtail Butterfly', 'year': '1996'},
    {'title': 'King of Comedy', 'year': '1999'},
    {'title': 'Devils on the Doorstep', 'year': '1999'},
    {'title': 'WALL-E', 'year': '2008'},
    {'title': 'The Pork of Music', 'year': '2012'},
]

渲染主页模板

使用 render_template() 函数可以把模板渲染出来,必须传入的参数为模板文件名(相对于 templates 根目录的文件路径),这里即 'index.html'。为了让模板正确渲染,我们还要把模板内部使用的变量通过关键字参数传入这个函数,如下所示:

app.py:返回渲染好的模板作为响应

from flask import Flask, render_template

# ...

@app.route('/')
def index():
    return render_template('index.html', name=name, movies=movies)

为了更好的表示这个视图函数的作用,我们把原来的函数名 hello 改为 index,意思是“索引”,即主页。

在传入 render_template() 函数的关键字参数中,左边的 movies 是模板中使用的变量名称,右边的 movies 则是该变量指向的实际对象。这里传入模板的 name 是字符串,movies 是列表,但能够在模板里使用的不只这两种 Python 数据结构,你也可以传入元组、字典、函数等。

render_template() 函数在调用时会识别并执行 index.html 里所有的 Jinja2 语句,返回渲染好的模板内容。在返回的页面中,变量会被替换为实际的值(包括定界符),语句(及定界符)则会在执行后被移除(注释也会一并移除)。

现在访问 http://localhost:5000/ 看到的程序主页如下图所示:

3-1

本章小结

这一章我们编写了一个简单的主页。结束前,让我们提交代码:

$ git add .
$ git commit -m "Add index page"
$ git push

提示 你可以在 GitHub 上查看本书示例程序的对应 commit:8537d98

进阶提示

  • 使用 Faker 可以实现自动生成虚拟数据,它支持丰富的数据类型,比如时间、人名、地名、随机字符等等……
  • 除了过滤器,Jinja2 还在模板中提供了一些测试器、全局函数可以使用;除此之外,还有更丰富的控制结构支持,有一些我们会在后面学习到,更多的内容则可以访问 Jinja2 文档学习。
  • 如果你是《Flask Web 开发实战》的读者,模板相关内容可以在第 3 章《模板》找到,Faker 相关内容可以在第 7 章找到。
  • 本书主页 & 相关资源索引:http://helloflask.com/tutorial

Flask 全局错误处理(一个函数处理所有 HTTP 错误)

在 Flask 程序中,使用 app.errorhandler() 装饰器可以注册错误处理函数,传入 HTTP 错误状态码或是特定的异常类:

@app.errorhandler(404)
def error_404(e):
    return '404 Error', 404

如果发生 404 错误,就会触发这个函数获取返回值作为响应主体。

通常我们会为不同的 HTTP 错误编写各自的的错误处理函数,以便返回不同的响应。如果你愿意的话,我们也可以编写一个统一的错误处理函数,这个函数会处理所有的 HTTP 错误和一般异常,只需要在装饰器内传入 Exception 类即可:

@app.errorhandler(Exception)
def all_exception_handler(e):
    return 'Error', 500

现在所有的 HTTP 错误都会触发这个函数。你也可以在函数中对错误进行分类处理:

@app.errorhandler(Exception)
def all_exception_handler(e):
    # 对于 HTTP 异常,返回自带的错误描述和状态码
    # 这些异常类在 Werkzeug 中定义,均继承 HTTPException 类
    if isinstance(e, HTTPException):
        return e.desciption, e.code
    return 'Error', 500  # 一般异常

如果你使用 Flask 0.12 版本,则需要参考这个 SO 回答重写相关方法。

附注一些关于错误处理的小知识:

  • 对于一般的程序异常(比如 NameError),如果没有特定的异常处理函数,默认都会触发 500 错误处理函数。
  • 开启调试模式的时候,500 错误会显示错误调试页面。
  • 500 错误发生时传入错误处理函数的是真正的异常对象,不是 Werkzeug 内置的 HTTP 异常类。
  • 内置的 HTTP 异常类的 description 和 code 属性分别返回错误描述和状态码。