标签归档:Flask 入门教程

《Flask 入门教程》第三版发布

最近手里堆积的事情越来越多,两本书的进度被日常工作挤压,于是非常想有一些小小的进展来缓解焦虑。刚好有读者来反馈《Flask 入门教程》不兼容最新版本 Flask 的问题,所以就花了一天时间来更新这个教程。

发布上个版本已经是 2019 年的事情了,这次针对 Flask 最新的 2.1.3 版本做了改写,同时不再兼容 Python 2.7 版本。优化了前面几章的一些内容,后面的章节需要改动的地方不多,就没有认真去更新。另外还顺便用 MkDocs 和 Netlify 把教程重新部署到了 tutorial.helloflask.com 上,后续会废弃掉 GitBook。

过去三年里,这本电子书大概给我带来了五百块的收入(主页上有一个自愿点击的「付费支持¥10」的按钮)。虽然不多,但是时不时收到到账提醒和鼓励的话还是很开心的。感谢每一位付款的读者!

当然,给我带来更大成就感的是看到读者分享他/她通过阅读这个教程写的程序,这是一种给别人带来成就感的成就感——元成就感。

《Flask 入门教程》第二版发布

Flask 入门教程》第二版发布了!新版本主要有以下变化(详见 commit 列表):

  • 去掉了 Pipenv 的介绍,改为使用 venv/virtualenv+pip
  • 所有提示和注意段落使用引用样式标注
  • 修正了大量笔误
  • 调整前言和后记内容
  • 调整部分措辞

访问本书主页下载 PDF 或在线阅读。

《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 入门教程》第 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

进阶提示