标签归档:Flask

《Flask Web 开发实战》第二次重印

最近要进行第二次重印,花了整整两天时间整理勘误。接下来的计划是,在第二次重印的书上市前,只在晚上处理论坛问题,尽量不在 IM 上回答和书上错误无关的提问(耗费心力,沟通成本非常高),也不再更新勘误页面。等到第二次重印的书上市,做下面几件事:

  • 写一篇《Flask Web 开发实战 2019 补丁》,汇总所有额外的项目源码变动,第三方工具和库的新版变化,常见的错误和常见问题等。
  • 和豆瓣协商,再为购买旧版本的读者更新一次文件;同时了解微信读书、多看、掌阅、京东、当当几个电子书平台的更新情况。
  • 再集中清理一次盗版文件。
  • 处理 Albumy 现存的两个 bug。
  • 整理一遍所有勘误,更新勘误页面。

2019/4/15 更新:

收到出版社的新版样书,也就是说,1-3 版本的书已经上市销售,目前哪里可以买到还不清楚(估计是京东自营)。

(使用 HTML、JavaScript、Flask 或 Nginx)为丢失的图片显示默认图片

当在 HTML 页面上显示图片时,如果图片不存在,我们通常需要显示一个默认图片。

假设我们的图片路径在 /imgs 下,默认图片为 /imgs/default.jpg,下面是一些常见的处理方法示例。

Solution 1: HTML / JavaScript

最简单的,你可以使用 <img> 元素的 onerror 属性来设置默认图片:

<img src="/imgs/cat.jpg" onerror="this.src='/imgs/default.jpg'">

类似的,你也可以使用 JavaScript(jQuery)监听 img 元素的 error 事件(当图片地址无效时浏览器会触发这个事件):

$('img').on("error", function() {
  $(this).attr('src', '/imgs/default.jpg');  // 替换为默认图片
});

Solution 2: Flask

除此之外,你也可以在服务器端处理,以 Flask 为例,你可以写一个自定义视图函数来加载图片:

import os
from flask import send_from_directory

# ...

@app.route('/img/<path:filename>')
def get_image(filename):
    img_path = os.path.join(images_path, filename)  # 获取图片路径

    if not os.path.exists(img_path):  # 判断图片文件是否存在
        return send_from_directory(os.path.join(images_path, '/imgs/default.jpg'))
    return send_from_directory(img_path)

在模板里,使用这个视图函数获取图片 URL:

<img src="{{ url_for('get_image', filename='imgs/' + image.filename) }}" >

Solution 3: Nginx

在生产环境下,出于性能的考虑,我们通常会使用 Web 服务器(比如 Nginx / Apache) 来服务(serve)静态文件。以 Nginx 为例,你可以使用 try_files 指令来设置默认图片:

location /imgs/ {
    try_files $uri /imgs/default.jpg;
}

附注:本文改写自我的SO 回答

《Flask 入门教程》后记

恭喜,你已经完成了整个 Flask 入门教程。不出意外的话,你也编写了你的第一个 Web 程序,并把它部署到了互联网上。这是一件值得纪念的事,它可以作为你的编程学习之路上的一个小小的里程碑。继续加油!

留言 & 打卡

如果你完成了这个教程,可以在 HelloFlask 论坛上的这个帖子留言打卡,欢迎分享你的心得体会和经验总结。如果你对这本书有什么建议,也可以在这里进行留言反馈。

讨论与求助

如果你想和其他同学交流 Flask、Web 开发等相关话题,或是在学习中遇到了问题,想要寻求帮助,下面是一些好去处:

付费支持

本书采取自愿付费原则,价格为 10 元。如果你愿意,可以通过付费来支持我,让我有更多的时间和动力写作 Flask 教程和文章。你可以通过支付宝账号 withlihui@gmail.com 转账,或是扫描下面的二维码付款。

支付宝二维码 / 微信二维码

qr_alipayqr_wechat

进阶阅读

说来惭愧,在这本教程几乎每一章的结尾,我都会提到《Flask Web 开发实战》,每次写到这里,我都觉得自己好像在写“问候家明”。所以,最合适的进阶读物我已经推荐过很多次了。除了这本书,其他的进阶读物如下:

未完待续

你喜欢这本书以及这本书的写作模式吗?

如果有足够的人喜欢的话,或许我会考虑写一本包含 Flask 进阶知识的《Flask 进阶教程》。按照设想,在这个进阶教程里,这个 Watchlist 程序变成一个支持多人注册和使用的简化版豆瓣电影 / IMDb。同时介绍各类常用扩展的使用和 Flask 上下文、蓝本等进阶知识。

再或者,这个教程或许会升级为一本完整的书,使用类似的编写模式,引入一个更加丰富有趣的程序,包含优化后的入门知识和 Flask 进阶内容。

如果你期待这样一本进阶教程 / 书的出现,欢迎让我知道。你可以在打卡 & 留言贴发布留言,或是直接发邮件(withlihui@gmail.com)告诉我。

《Flask 入门教程》小挑战

经过本书的学习,你应该有能力独立开发一个简单的 Web 程序了。所以这里有一个小挑战:为你的 Watchlist 添加一个留言板功能,效果类似 SayHello

下面是一些编写提示:

  • 编写表示留言的模型类,更新数据库表
  • 创建留言页面的模板
  • 在模板中添加留言表单
  • 添加显示留言页面的视图函数
  • 在显示留言页面的视图函数编写处理表单的代码
  • 生成一些虚拟数据进行测试
  • 编写单元测试
  • 更新到部署后的程序
  • 可以参考 SayHello 源码

如果在完成这个挑战的过程中遇到了困难,可以在 HelloFlask 论坛发起讨论(设置帖子分类为“Flask 入门教程”)。除此之外,你可以在后记查看更多讨论的去处。

《Flask 入门教程》第 11 章:部署上线

在这个教程的最后一章,我们将会把程序部署到互联网上,让网络中的其他所有人都可以访问到。

Web 程序通常有两种部署方式:传统部署和云部署。传统部署指的是在使用物理主机或虚拟主机上部署程序,你通常需要在一个 Linux 系统上完成所有的部署操作;云部署则是使用其他公司提供的云平台,这些平台为你设置好了底层服务,包括 Web 服务器、数据库等等,你只需要上传代码并进行一些简单设置即可完成部署。这一章我们会介绍使用云平台 PythonAnywhere 来部署程序。

部署前的准备

对于某些配置,生产环境下需要使用不同的值。为了让配置更加灵活,我们把需要在生成环境下使用的配置改为优先从环境变量中读取,如果没有读取到,则使用默认值:

app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev')
app.config['SQLALCHEMY_DATABASE_URI'] = prefix + os.path.join(os.path.dirname(app.root_path), os.getenv('DATABASE_FILE', 'data.db'))

以第一个配置变量为例,os.getenv('SECRET_KEY', 'dev') 表示读取系统环境变量 SECRET_KEY 的值,如果没有获取到,则使用 dev

注意 像密钥这种敏感信息,保存到环境变量中要比直接写在代码中更加安全。

对于第二个配置变量,我们仅改动了最后的数据库文件名。在示例程序里,因为我们部署后将继续使用 SQLite,所以只需要为生产环境设置不同的数据库文件名,否则的话,你可以像密钥一样设置优先从环境变量读取整个数据库 URL。

在部署程序时,我们不会使用 Flask 内置的开发服务器运行程序,因此,对于写到 .env 文件的环境变量,我们需要手动使用 python-dotenv 导入。下面在项目根目录创建一个 wsgi.py 脚本,在这个脚本中加载环境变量,并导入程序实例以供部署时使用:

wsgi.py:手动设置环境变量并导入程序实例

import os

from dotenv import load_dotenv

dotenv_path = os.path.join(os.path.dirname(__file__), '.env')
if os.path.exists(dotenv_path):
    load_dotenv(dotenv_path)

from watchlist import app

这两个环境变量的具体定义,我们将在远程服务器环境创建新的 .env 文件写入。

最后让我们把改动提交到 Git 仓库,并推送到 GitHub 上的远程仓库:

$ git add .
$ git commit -m "Ready to deploy"
$ git push

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

使用 PythonAnywhere 部署程序

首先访问注册页面注册一个免费账户。注册时填入的用户名将作为你的程序域名的子域部分,以及分配给你的 Linux 用户名。比如,如果你的用户名为 greyli,最终为你分配的程序域名就是 http://greyli.pythonanywhere.com/ 。

注册完成后会有一个简单的教程,你可以跳过,也可以跟着了解一下基本用法。管理面板主页如下所示:

管理面板主页

导航栏包含几个常用的链接,可以打开其他面板:

  • Consoles(控制台):可以打开 Bash、Python Shell、MySQL 等常用的控制台
  • Files(文件):创建、删除、编辑、上传文件,你可以在这里直接修改代码
  • Web:管理 Web 程序
  • Tasks(任务):创建计划任务
  • Databases(数据库):设置数据库,免费账户可以使用 MySQL

这些链接对应页面的某些功能也可以直接在管理面板主页打开。

我们需要先来创建一个 Web 程序,你可以点击导航栏的 Web 链接,或是主页上的“Open Web tab”按钮打开 Web 面板:

Web 面板

点击“Add a new web app”按钮创建 Web 程序,第一步提示升级账户后可以自定义域名,我们直接点击“Next”按钮跳到下一步:

自定义域名

这一步选择 Web 框架,为了获得更灵活的控制,选择手动设置(Manual configuration):

选择 Web 框架

接着选择你想使用的 Python 版本:

选择 Python 版本

最后点击“Next”按钮即可完成创建 Web 程序流程:

结束创建 Web 程序流程

接下来我们需要进行一系列程序初始化操作,最后再回到 Web 面板进行具体的设置。

初始化程序运行环境

我们首先要考虑把代码上传到 PythonAnywhere 的服务器上。上传代码一般有两种方式:

  • 从 GitHub 拉取我们的程序
  • 在本地将代码存储为压缩文件,然后在 Files 标签页上传压缩包

因为我们的代码已经推送到 GitHub 上,这里将采用第一种方式。首先通过管理面板主页的“Bash”按钮或是 Consoles 面板下的“Bash”链接创建一个命令行会话:

打开新的命令行会话

在命令行下输入下面的命令:

$ pip3 install --user pipenv  # 安装 Pipenv
$ git clone https://github.com/greyli/watchlist  # 注意替换 Git 仓库地址
$ cd watchlist  # 切换进程序仓库

这会把程序代码克隆到 PythonAnywhere 为你分配的用户目录中,路径即 /home/你的 PythonAnywhere 用户名/你的参仓库名,比如 /home/greyli/watchlist

注意替换 git clone 命令后的 Git 地址,将 greyli 替换为你的 GitHub 用户名,将 watchlist 替换为你的仓库名称。

提示 如果你使用 Python 2.7,那么需要使用 pip 来执行安装 Pipenv 的命令;打开 Python Shell 时使用 python 命令,而不是 python3。

提示 如果你在 GitHub 上的仓库类型为私有仓库,那么需要将 PythonAnywhere 服务器的 SSH 密钥添加到 GitHub 账户中,具体参考第 1 章“设置 SSH 密钥”小节。

下面我们在项目根目录创建 .env 文件,并写入生产环境下需要设置的两个环境变量。其中,密钥(SECRET_KEY)的值是随机字符串,我们可以使用 uuid 模块来生成:

$ python3
>>> import uuid
>>> uuid.uuid4().hex
'3d6f45a5fc12445dbac2f59c3b6c7cb1'

复制生成的随机字符备用,接着创建 .env 文件:

$ nano .env

写入设置密钥和数据库名称的环境变量:

SECRET_KEY=3d6f45a5fc12445dbac2f59c3b6c7cb1
DATABASE_FILE=data-prod.db

最后安装依赖并执行初始化操作:

$ pipenv install  # 创建虚拟环境并安装依赖
$ pipenv shell  # 激活虚拟环境
$ flask initdb  # 初始化数据库
$ flask admin  # 创建管理员账户

先不要关闭这个标签页,后面我们还要在这里执行一些命令。点击右上角的菜单按钮,并在浏览器的新标签页打开 Web 面板。

设置并启动程序

代码部分我们已经设置完毕,接下来进行一些简单设置就可以启动程序了。

代码

回到 Web 标签页,先来设置 Code 部分的配置:

代码配置

点击源码(Source code)和工作目录(Working directory)后的路径并填入项目根目录,目录规则为“/home/用户名/项目文件夹名”。

点击 WSGI 配置文件(WSGI configuration file)后的链接打开编辑页面,删掉这个文件内的所有内容,填入下面的代码:

import sys

path = '/home/watchlist/watchlist'
if path not in sys.path:
    sys.path.append(path)

from wsgi import app as application

完成后点击绿色的 Save 按钮或按下 Ctrl+S 保存修改,点击右上角的菜单按钮返回 Web 面板。

PythonAnywhere 会自动从这个文件里导入名称为 application 的程序实例,所以我们从项目目录的 wsgi 模块中导入程序实例 app,并将名称映射为 application

虚拟环境

为了让程序正确运行,我们需要在 Virtualenv 部分填入虚拟环境文件夹的路径:

虚拟环境配置

使用 Pipenv 时,你可以在项目根目录下使用下面的命令获取当前项目对应的虚拟环境文件夹路径(返回前面打开的命令行会话输入下面的命令):

$ pipenv --venv

复制输出的路径,点击 Virtualenv 部分的红色字体链接,填入并保存。

静态文件

静态文件可以交给 PythonAnywhere 设置的服务器来处理,这样会更高效。要让 PythonAnywhere 处理静态文件,我们只需要在 Static files 部分指定静态文件 URL 和对应的静态文件文件夹目录,如下所示:

静态文件配置

注意更新目录中的用户名和项目文件夹名称。

启动程序

一切就绪,点击绿色的重载按钮即可让配置生效:

重载程序

现在访问你的程序网址“https://用户名.pythonanywhere.com”(Web 面板顶部的链接),比如https://greyli.pythonanywhere.com 即可访问程序。

最后还要注意的是,免费账户需要每三个月点击一次黄色的激活按钮(在过期前你会收到提醒邮件):

激活程序

更新部署后的程序

当你需要更新程序时,流程和部署类似。在本地完成更新,确保程序通过测试后,将代码推送到 GitHub 上的远程仓库。登录到 PythonAnywhere,打开一个命令行会话(Bash),切换到项目目录,使用 git pull 命令从远程仓库拉取更新:

$ cd watchlist
$ git pull

然后你可以执行一些必要的操作,比如安装新的依赖等等。最后在 Web 面板点击绿色的重载(Reload)按钮即可完成更新。

本章小结

程序部署上线以后,你可以考虑继续为它开发新功能,也可以从零编写一个新的程序。虽然本书即将接近尾声,但你的学习之路才刚刚开始,因为本书只是介绍了 Flask 入门所需的基础知识,你还需要进一步学习。在后记中,你可以看到进一步学习的推荐读物。接下来,有一个挑战在等着你。

进阶提示

  • 因为 PythonAnywhere 支持在线管理文件、编辑代码、执行命令,你可以在学习编程的过程中使用它来在线开发 Web 程序。
  • PythonAnywhere 的 Web 面板还有一些功能设置:Log files 部分可以查看你的程序日志,Traffic 部分显示了你的程序访问流量情况,Security 部分可以为你的程序程序开启强制启用 HTTPS 和密码保护。
  • 《Flask Web 开发实战》 第 14 章详细介绍了部署 Flask 程序的两种方式,传统部署和云部署。
  • 本书主页 & 相关资源索引:http://helloflask.com/tutorial

《Flask 入门教程》第 10 章:组织你的代码

虽然我们的程序开发已经完成,但随着功能的增多,把所有代码放在 app.py 里会让后续的开发和维护变得麻烦。这一章,我们要对项目代码进行一次重构,让项目组织变得更加合理。

Flask 对项目结构没有固定要求,你可以使用单脚本,也可以使用包。这一章我们会学习使用包来组织程序。

先来看看我们目前的项目文件结构:

├── .flaskenv
├── Pipfile
├── Pipfile.lock
├── app.py
├── static
│   ├── favicon.ico
│   ├── images
│   │   ├── avatar.png
│   │   └── totoro.gif
│   └── style.css
├── templates
│   ├── 400.html
│   ├── 404.html
│   ├── 500.html
│   ├── base.html
│   ├── edit.html
│   ├── index.html
│   ├── login.html
│   └── settings.html
└── test_watchlist.py

使用包组织代码

我们会创建一个包,然后把 app.py 中的代码按照类别分别放到多个模块里。下面是我们需要执行的一系列操作(这些操作你也可以使用文件管理器或编辑器完成):

$ mkdir watchlist  # 创建作为包的文件夹
$ mv static templates watchlist  # 把 static 和 templates 文件夹移动到 watchlist 文件夹内
$ cd watchlist  # 切换进包目录
$ touch __init__.py views.py errors.py models.py commands.py  # 创建多个模块

我们把这个包称为程序包,包里目前包含的模块和作用如下表所示:

模块 作用
__init__.py 包构造文件,创建程序实例
views.py 视图函数
errors.py 错误处理函数
models.py 模型类
commands.py 命令函数

提示 除了包构造文件外,其他的模块文件名你可以自由修改,比如 views.py 也可以叫 routes.py。

创建程序实例,初始化扩展的代码放到包构造文件里(__init__.py),如下所示:

import os
import sys

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager

# ...

app = Flask(__name__)
app.config['SECRET_KEY'] = 'dev'
# 注意更新这里的路径,把 app.root_path 添加到 os.path.dirname() 中
# 以便把文件定位到项目根目录
app.config['SQLALCHEMY_DATABASE_URI'] = prefix + os.path.join(os.path.dirname(app.root_path), 'data.db')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

db = SQLAlchemy(app)
login_manager = LoginManager(app)

@login_manager.user_loader
def load_user(user_id):
	from watchlist.models import User
	user = User.query.get(int(user_id))
	return user

login_manager.login_view = 'login'

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

from watchlist import views, errors, commands

在构造文件中,为了让视图函数、错误处理函数和命令函数注册到程序实例上,我们需要在这里导入这几个模块。但是因为这几个模块同时也要导入构造文件中的程序实例,为了避免循环依赖(A 导入 B,B 导入 A),我们把这一行导入语句放到构造文件的结尾。同样的,load_user() 函数和 inject_user() 函数中使用的模型类也在函数内进行导入。

其他代码则按照分类分别放到各自的模块中,这里不再给出具体代码,你可以参考源码仓库。在移动代码之后,注意添加并更新导入语句,比如使用下面的导入语句来导入程序实例和扩展对象:

from watchlist import app, db

使用下面的导入语句来导入模型类:

from watchlist.models import User, Movie

以此类推。

组织模板

模块文件夹 templates 下包含了多个模板文件,我们可以创建子文件夹来更好的组织它们。下面的操作创建了一个 errors 子文件夹,并把错误页面模板都移动到这个 errors 文件夹内(这些操作你也可以使用文件管理器或编辑器完成):

$ cd templates  # 切换到 templates 目录
$ mkdir errors  # 创建 errors 文件夹
$ mv 400.html 404.html 500.html errors  # 移动错误页面模板到 errors 文件夹

因为错误页面放到了新的路径,所以我们需要修改代码中的 3 处模板文件路径,以 404 错误处理函数为例:

@app.errorhandler(400)
def bad_request(e):
    return render_template('errors/400.html'), 400

单元测试

你也可以将测试文件拆分成多个模块,创建一个 tests 包来存储这些模块。但是因为目前的测试代码还比较少,暂时不做改动,只需要更新导入语句即可:

from watchlist import app, db
from watchlist.models import Movie, User
from watchlist.commands import forge, initdb

因为要测试的目标改变,测试时的 --source 选项的值也要更新为包的名称 watchlist

$ coverage run --source=watchlist test_watchlist.py

提示 你可以创建配置文件来预先定义 --source 选项,避免每次执行命令都给出这个选项,具体可以参考文档配置文件章节

现在的测试覆盖率报告会显示包内的多个文件的覆盖率情况:

$ coverage report
Name                    Stmts   Miss  Cover
-------------------------------------------
watchlist\__init__.py      25      1    96%
watchlist\commands.py      35      1    97%
watchlist\errors.py         8      2    75%
watchlist\models.py        16      0   100%
watchlist\views.py         77      2    97%
-------------------------------------------
TOTAL                     161      6    96%

启动程序

因为我们使用包来组织程序,不再是 Flask 默认识别的 app.py,所以在启动开发服务器前需要使用环境变量 FLASK_APP 来给出程序实例所在的模块路径。因为我们的程序实例在包构造文件内,所以直接写出包名称即可。在 .flaskenv 文件中添加下面这行代码:

FLASK_APP=watchlist

最终的项目文件结构如下所示:

├── .flaskenv
├── Pipfile
├── Pipfile.lock
├── test_watchlist.py
└── watchlist  # 程序包
    ├── __init__.py
    ├── commands.py
    ├── errors.py
    ├── models.py
    ├── static
    │   ├── favicon.ico
    │   ├── images
    │   │   ├── avatar.png
    │   │   └── totoro.gif
    │   └── style.css
    ├── templates
    │   ├── base.html
    │   ├── edit.html
    │   ├── errors
    │   │   ├── 400.html
    │   │   ├── 404.html
    │   │   └── 500.html
    │   ├── index.html
    │   ├── login.html
    │   └── settings.html
    └── views.py

本章小结

对我们的程序来说,这样的项目结构已经足够了。但对于大型项目,你可以使用蓝本和工厂函数来进一步组织程序。结束前,让我们提交代码:

$ git add .
$ git commit -m "Orignize application with package"
$ git push

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

进阶提示

  • 蓝本类似于子程序的概念,借助蓝本你可以把程序不同部分的代码分离开(比如按照功能划分为用户认证、管理后台等多个部分),即对程序进行模块化处理。每个蓝本可以拥有独立的子域名、URL 前缀、错误处理函数、模板和静态文件。
  • 工厂函数就是创建程序的函数。在工厂函数内,我们先创建程序实例,并在函数内完成初始化扩展、注册视图函数等一系列操作,最后返回可以直接运行的程序实例。工厂函数可以接受配置名称作为参数,在内部加载对应的配置文件,这样就可以实现按需创建加载不同配置的程序实例,比如在测试时调用工厂函数创建一个测试用的程序实例。
  • 《Flask Web 开发实战》 第 7 章介绍了使用包组织程序,第 8 章介绍了大型项目结构以及如何使用蓝本和工厂函数组织程序。
  • 本书主页 & 相关资源索引:http://helloflask.com/tutorial

《Flask 入门教程》第 9 章:测试

在此之前,每次为程序添加了新功能,我们都要手动在浏览器里访问程序进行测试。除了测试新添加的功能,你还要确保旧的功能依然正常工作。在功能复杂的大型程序里,如果每次修改代码或添加新功能后手动测试所有功能,那会产生很大的工作量。另一方面,手动测试并不可靠,重复进行测试操作也很枯燥。

基于这些原因,为程序编写自动化测试就变得非常重要。

注意 为了便于介绍,本书统一在这里介绍关于测试的内容。在实际的项目开发中,你应该在开发每一个功能后立刻编写相应的测试,确保测试通过后再开发下一个功能。

单元测试

单元测试指对程序中的函数等独立单元编写的测试,它是自动化测试最主要的形式。这一章我们将会使用 Python 标准库中的测试框架 unittest 来编写单元测试,首先通过一个简单的例子来了解一些基本概念。假设我们编写了下面这个函数:

def sayhello(to=None):
    if to:
        return 'Hello, %s!' % to
    return 'Hello!'

下面是我们为这个函数编写的单元测试:

import unittest

from module_foo import sayhello


class SayHelloTestCase(unittest.TestCase):  # 测试用例

    def setUp(self):  # 测试固件
        pass

    def tearDown(self):  # 测试固件
        pass

    def test_sayhello(self):  # 第 1 个测试
        rv = sayhello()
        self.assertEqual(rv, 'Hello!')
       
    def test_sayhello_to_somebody(self)  # 第 2 个测试
        rv = sayhello(to='Grey')
		self.assertEqual(rv, 'Hello, Grey!')


if __name__ == '__main__':
    unittest.main()

测试用例继承 unittest.TestCase 类,在这个类中创建的以 test_ 开头的方法将会被视为测试方法。

内容为空的两个方法很特殊,它们是测试固件,用来执行一些特殊操作。比如 setUp() 方法会在每个测试方法执行前被调用,而 tearDown() 方法则会在每一个测试方法执行后被调用(注意这两个方法名称的大小写)。

如果把执行测试方法比作战斗,那么准备弹药、规划战术的工作就要在 setUp() 方法里完成,而打扫战场则要在 tearDown()方法里完成。

每一个测试方法(名称以 test_ 开头的方法)对应一个要测试的函数 / 功能 / 使用场景。在上面我们创建了两个测试方法,test_sayhello() 方法测试 sayhello() 函数,test_sayhello_to_somebody() 方法测试传入参数时的 sayhello() 函数。

在测试方法里,我们使用断言方法来判断程序功能是否正常。以第一个测试方法为例,我们先把 sayhello() 函数调用的返回值保存为 rv 变量(return value),然后使用 self.assertEqual(rv, 'Hello!') 来判断返回值内容是否符合预期。如果断言方法出错,就表示该测试方法未通过。

下面是一些常用的断言方法:

  • assertEqual(a, b)
  • assertNotEqual(a, b)
  • assertTrue(x)
  • assertFalse(x)
  • assertIs(a, b)
  • assertIsNot(a, b)
  • assertIsNone(x)
  • assertIsNotNone(x)
  • assertIn(a, b)
  • assertNotIn(a, b)

这些方法的作用从方法名称上基本可以得知。

假设我们把上面的测试代码保存到 test_sayhello.py 文件中,通过执行 python test_sayhello.py 命令即可执行所有测试,并输出测试的结果、通过情况、总耗时等信息。

测试 Flask 程序

回到我们的程序,我们在项目根目录创建一个 test_watchlist.py 脚本来存储测试代码,我们先编写测试固件和两个简单的基础测试:

test_watchlist.py:测试固件

import unittest

from app import app, db, Movie, User


class WatchlistTestCase(unittest.TestCase):

    def setUp(self):
        # 更新配置
        app.config.update(
            TESTING=True,
            SQLALCHEMY_DATABASE_URI='sqlite:///:memory:'
        )
        # 创建数据库和表
        db.create_all()
        # 创建测试数据,一个用户,一个电影条目
        user = User(name='Test', username='test')
        user.set_password('123')
        movie = Movie(title='Test Movie Title', year='2019')
        # 使用 add_all() 方法一次添加多个模型类实例,传入列表
        db.session.add_all([user, movie])
        db.session.commit()

        self.client = app.test_client()  # 创建测试客户端
        self.runner = app.test_cli_runner()  # 创建测试命令运行器

    def tearDown(self):
        db.session.remove()  # 清除数据库会话
        db.drop_all()  # 删除数据库表
    
    # 测试程序实例是否存在
    def test_app_exist(self):
        self.assertIsNotNone(app)

    # 测试程序是否处于测试模式
    def test_app_is_testing(self):
        self.assertTrue(app.config['TESTING'])

某些配置,在开发和测试时通常需要使用不同的值。在 setUp() 方法中,我们更新了两个配置变量的值,首先将 TESTING 设为 True 来开启测试模式,这样在出错时不会输出多余信息;然后将 SQLALCHEMY_DATABASE_URI 设为 'sqlite:///:memory:',这会使用 SQLite 内存型数据库,不会干扰开发时使用的数据库文件。你也可以使用不同文件名的 SQLite 数据库文件,但内存型数据库速度更快。

接着,我们调用 db.create_all() 创建数据库和表,然后添加测试数据到数据库中。在 setUp() 方法最后创建的两个类属性分别为测试客户端和测试命令运行器,前者用来模拟客户端请求,后者用来触发自定义命令,下一节会详细介绍。

在 tearDown() 方法中,我们调用 db.session.remove() 清除数据库会话并调用 db.drop_all() 删除数据库表。测试时的程序状态和真实的程序运行状态不同,所以需要调用 db.session.remove() 来确保数据库会话被清除。

测试客户端

app.test_client() 返回一个测试客户端对象,可以用来模拟客户端(浏览器),我们创建类属性 self.client 来保存它。对它调用 get() 方法就相当于浏览器向服务器发送 GET 请求,调用 post() 则相当于浏览器向服务器发送 POST 请求,以此类推。下面是两个发送 GET 请求的测试方法,分别测试 404 页面和主页:

test_watchlist.py:测试固件

class WatchlistTestCase(unittest.TestCase):
    # ...
    # 测试 404 页面
    def test_404_page(self):
        response = self.client.get('/nothing')  # 传入目标 URL
        data = response.get_data(as_text=True)
        self.assertIn('Page Not Found - 404', data)
        self.assertIn('Go Back', data)
        self.assertEqual(response.status_code, 404)  # 判断响应状态码
    
    # 测试主页
    def test_index_page(self):
        response = self.client.get('/')
        data = response.get_data(as_text=True)
        self.assertIn('Test\'s Watchlist', data)
        self.assertIn('Test Movie Title', data)
        self.assertEqual(response.status_code, 200)

调用这类方法返回包含响应数据的响应对象,对这个响应对象调用 get_data() 方法并把 as_text 参数设为 True 可以获取 Unicode 格式的响应主体。我们通过判断响应主体中是否包含预期的内容来测试程序是否正常工作,比如 404 页面响应是否包含 Go Back,主页响应是否包含标题 Test's Watchlist。

接下来,我们要测试数据库操作相关的功能,比如创建、更新和删除电影条目。这些操作对应的请求都需要登录账户后才能发送,我们先编写一个用于登录账户的辅助方法:

test_watchlist.py:测试辅助方法

class WatchlistTestCase(unittest.TestCase):
    # ...
    # 辅助方法,用于登入用户
    def login(self):
        self.client.post('/login', data=dict(
            username='test',
            password='123'
        ), follow_redirects=True)

在 login() 方法中,我们使用 post() 方法发送提交登录表单的 POST 请求。和 get() 方法类似,我们需要先传入目标 URL,然后使用 data 关键字以字典的形式传入请求数据(字典中的键为表单 <input> 元素的 name 属性值),作为登录表单的输入数据;而将 follow_redirects 参数设为 True 可以跟随重定向,最终返回的会是重定向后的响应。

下面是测试创建、更新和删除条目的测试方法:

test_watchlist.py:测试创建、更新和删除条目

class WatchlistTestCase(unittest.TestCase):
    # ...
    # 测试创建条目
    def test_create_item(self):
        self.login()
        
        # 测试创建条目操作
        response = self.client.post('/', data=dict(
            title='New Movie',
            year='2019'
        ), follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertIn('Item created.', data)
        self.assertIn('New Movie', data)
        
        # 测试创建条目操作,但电影标题为空
        response = self.client.post('/', data=dict(
            title='',
            year='2019'
        ), follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertNotIn('Item created.', data)
        self.assertIn('Invalid input.', data)
        
        # 测试创建条目操作,但电影年份为空
        response = self.client.post('/', data=dict(
            title='New Movie',
            year=''
        ), follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertNotIn('Item created.', data)
        self.assertIn('Invalid input.', data)

    # 测试更新条目
    def test_update_item(self):
        self.login()
        
        # 测试更新页面
        response = self.client.get('/movie/edit/1')
        data = response.get_data(as_text=True)
        self.assertIn('Edit item', data)
        self.assertIn('Test Movie Title', data)
        self.assertIn('2019', data)
        
        # 测试更新条目操作
        response = self.client.post('/movie/edit/1', data=dict(
            title='New Movie Edited',
            year='2019'
        ), follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertIn('Item updated.', data)
        self.assertIn('New Movie Edited', data)
        
        # 测试更新条目操作,但电影标题为空
        response = self.client.post('/movie/edit/1', data=dict(
            title='',
            year='2019'
        ), follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertNotIn('Item updated.', data)
        self.assertIn('Invalid input.', data)
        
        # 测试更新条目操作,但电影年份为空
        response = self.client.post('/movie/edit/1', data=dict(
            title='New Movie Edited Again',
            year=''
        ), follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertNotIn('Item updated.', data)
        self.assertNotIn('New Movie Edited Again', data)
        self.assertIn('Invalid input.', data)

    # 测试删除条目
    def test_delete_item(self):
        self.login()
        
        response = self.client.post('/movie/delete/1', follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertIn('Item deleted.', data)
        self.assertNotIn('Test Movie Title', data)

在这几个测试方法中,大部分的断言都是在判断响应主体是否包含正确的提示消息和电影条目信息。

登录、登出和认证保护等功能的测试如下所示:

test_watchlist.py:测试认证相关功能

class WatchlistTestCase(unittest.TestCase):
    # ...
    # 测试登录保护
    def test_login_protect(self):
        response = self.client.get('/')
        data = response.get_data(as_text=True)
        self.assertNotIn('Logout', data)
        self.assertNotIn('Settings', data)
        self.assertNotIn('<form method="post">', data)
        self.assertNotIn('Delete', data)
        self.assertNotIn('Edit', data)

    # 测试登录
    def test_login(self):
        response = self.client.post('/login', data=dict(
            username='test',
            password='123'
        ), follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertIn('Login success.', data)
        self.assertIn('Logout', data)
        self.assertIn('Settings', data)
        self.assertIn('Delete', data)
        self.assertIn('Edit', data)
        self.assertIn('<form method="post">', data)
        
        # 测试使用错误的密码登录
        response = self.client.post('/login', data=dict(
            username='test',
            password='456'
        ), follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertNotIn('Login success.', data)
        self.assertIn('Invalid username or password.', data)
        
        # 测试使用错误的用户名登录
        response = self.client.post('/login', data=dict(
            username='wrong',
            password='123'
        ), follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertNotIn('Login success.', data)
        self.assertIn('Invalid username or password.', data)
        
        # 测试使用空用户名登录
        response = self.client.post('/login', data=dict(
            username='',
            password='123'
        ), follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertNotIn('Login success.', data)
        self.assertIn('Invalid input.', data)
        
        # 测试使用空密码登录
        response = self.client.post('/login', data=dict(
            username='test',
            password=''
        ), follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertNotIn('Login success.', data)
        self.assertIn('Invalid input.', data)

    # 测试登出
    def test_logout(self):
        self.login()

        response = self.client.get('/logout', follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertIn('Goodbye.', data)
        self.assertNotIn('Logout', data)
        self.assertNotIn('Settings', data)
        self.assertNotIn('Delete', data)
        self.assertNotIn('Edit', data)
        self.assertNotIn('<form method="post">', data)
    
    # 测试设置
    def test_settings(self):
        self.login()
        
        # 测试设置页面
        response = self.client.get('/settings')
        data = response.get_data(as_text=True)
        self.assertIn('Settings', data)
        self.assertIn('Your Name', data)

        # 测试更新设置
        response = self.client.post('/settings', data=dict(
            name='Grey Li',
        ), follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertIn('Settings updated.', data)
        self.assertIn('Grey Li', data)

        # 测试更新设置,名称为空
        response = self.client.post('/settings', data=dict(
            name='',
        ), follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertNotIn('Settings updated.', data)
        self.assertIn('Invalid input.', data)

测试命令

除了测试程序的各个视图函数,我们还需要测试自定义命令。app.test_cli_runner() 方法返回一个命令运行器对象,我们创建类属性 self.runner 来保存它。通过对它调用 invoke() 方法可以执行命令,传入命令函数对象,或是使用 args 关键字直接给出命令参数列表。invoke() 方法返回的命令执行结果对象,它的 output 属性返回命令的输出信息。下面是我们为各个自定义命令编写的测试方法:

test_watchlist.py:测试自定义命令行命令

# 导入命令函数
from app import app, db, Movie, User, forge, initdb


class WatchlistTestCase(unittest.TestCase):
    # ...
    # 测试虚拟数据
    def test_forge_command(self):
        result = self.runner.invoke(forge)
        self.assertIn('Done.', result.output)
        self.assertNotEqual(Movie.query.count(), 0)

    # 测试初始化数据库
    def test_initdb_command(self):
        result = self.runner.invoke(initdb)
        self.assertIn('Initialized database.', result.output)

    # 测试生成管理员账户
    def test_admin_command(self):
        db.drop_all()
        db.create_all()
        result = self.runner.invoke(args=['admin', '--username', 'grey', '--password', '123'])
        self.assertIn('Creating user...', result.output)
        self.assertIn('Done.', result.output)
        self.assertEqual(User.query.count(), 1)
        self.assertEqual(User.query.first().username, 'grey')
        self.assertTrue(User.query.first().validate_password('123'))

    # 测试更新管理员账户
    def test_admin_command_update(self):
        # 使用 args 参数给出完整的命令参数列表
        result = self.runner.invoke(args=['admin', '--username', 'peter', '--password', '456'])
        self.assertIn('Updating user...', result.output)
        self.assertIn('Done.', result.output)
        self.assertEqual(User.query.count(), 1)
        self.assertEqual(User.query.first().username, 'peter')
        self.assertTrue(User.query.first().validate_password('456'))

在这几个测试中,大部分的断言是在检查执行命令后的数据库数据是否发生了正确的变化,或是判断命令行输出(result.output)是否包含预期的字符。

运行测试

最后,我们在程序结尾添加下面的代码:

if __name__ == '__main__':
    unittest.main()

使用下面的命令执行测试:

$ python test_watchlist.py
...............
----------------------------------------------------------------------
Ran 15 tests in 2.942s

OK

如果测试出错,你会看到详细的错误信息,进而可以有针对性的修复对应的程序代码,或是调整测试方法。

测试覆盖率

为了让让程序更加强壮,你可以添加更多、更完善的测试。那么,如何才能知道程序里有哪些代码还没有被测试?整体的测试覆盖率情况如何?我们可以使用 Coverage.py 来检查测试覆盖率,首先安装它(添加 --dev 参数将它作为开发依赖安装):

$ pipenv install coverage --dev

使用下面的命令执行测试并检查测试覆盖率:

$ coverage run --source=app test_watchlist.py

因为我们只需要检查程序脚本 app.py 的测试覆盖率,所以使用 --source 选项来指定要检查的模块或包。

最后使用下面的命令查看覆盖率报告:

$ coverage report
Name     Stmts   Miss  Cover
----------------------------
app.py     146      5    97%

从上面的表格可以看出,一共有 146 行代码,没测试到的代码有 5 行,测试覆盖率为 97%。

你还可以使用 coverage html 命令获取详细的 HTML 格式的覆盖率报告,它会在当前目录生成一个 htmlcov 文件夹,打开其中的 index.html 即可查看覆盖率报告。点击文件名可以看到具体的代码覆盖情况,如下图所示:

覆盖率报告

同时在 .gitignore 文件后追加下面两行,忽略掉生成的覆盖率报告文件:

htmlcov/
.coverage

本章小结

通过测试后,我们就可以准备上线程序了。结束前,让我们提交代码:

$ git add .
$ git commit -m "Add unit test with unittest"
$ git push

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

进阶提示

我的书终于重印了

盼了几个月,《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。

《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