教程
# 1.5.1 项目布局
创建并进入项目文件夹:
$ mkdir flask-tutorial
$ cd flask-tutorial
2
接下来按照安装简介 设置一个 Python 虚拟环境,然后为项目安装 Flask 。
本教程假定项目文件夹名称为 flask-tutorial ,本教程中代码块的顶端的文件名是基于该文件夹的相对 名称。
一个最简单的 Flask 应用可以是单个文件。
Listing 1: hello.py
from flask import Flask
app = Flask(__name__)
@app.route('/')
def hello():
return 'Hello, World!'
2
3
4
5
6
7
然而,当项目越来越大的时候,把所有代码放在单个文件中就有点不堪重负了。Python 项目使用 包来管理代 码,把代码分为不同的模块,然后在需要的地方导入模块。本教程也会按这一方式管理代码。 教程项目包含如下内容:
flaskr/,一个包含应用代码和文件的Python包。tests/,一个包含测试模块的文件夹。venv/,一个Python虚拟环境,用于安装Flask和其他依赖的包。- 告诉
Python如何安装项目的安装文件。 - 版本控制配置,如
git。不管项目大小,应当养成使用版本控制的习惯。 - 项目需要的其他文件。
最后,项目布局如下:
/home/user/Projects/flask-tutorial
├── flaskr/
│ ├── __init__.py
│ ├── db.py
│ ├── schema.sql
│ ├── auth.py
│ ├── blog.py
│ ├── templates/
│ │ ├── base.html
│ │ ├── auth/
│ │ │ ├── login.html
│ │ │ └── register.html
│ │ └── blog/
│ │ ├── create.html
│ │ ├── index.html
│ │ └── update.html
│ └── static/
│ └── style.css
├── tests/
│ ├── conftest.py
│ ├── data.sql
│ ├── test_factory.py
│ ├── test_db.py
│ ├── test_auth.py
│ └── test_blog.py
├── venv/
├── setup.py
└── MANIFEST.in
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
如果使用了版本控制,那么应当忽略运行项目时产生的临时文件以及编辑代码时编辑器产生的临时文件。忽 略文件的基本原则是:不是你自己写的文件就可以忽略。举例来说,假设使用 git 来进行版本控制,那么使用 .gitignore 来设置应当忽略的文件,.gitignore 文件应当与下面类似:
Listing 2: .gitignore
venv/
*.pyc
__pycache__/
instance/
.pytest_cache/
.coverage
htmlcov/
dist/
build/
*.egg-info/
2
3
4
5
6
7
8
9
10
11
12
13
# 1.5.2 应用设置
一个 Flask 应用是一个Flask 类的实例。应用的所有东西(例如配置和 URL )都会和这个实例一起注册。
创建一个 Flask 应用最粗暴直接的方法是在代码的最开始创建一个全局Flask 实例。前面的“Hello, World!” 示例就是这样做的。有的情况下这样做是简单和有效的,但是当项目越来越大的时候就会有些力不从心了。
可以在一个函数内部创建Flask 实例来代替创建全局实例。这个函数被称为 应用工厂。所有应用相关的配 置、注册和其他设置都会在函数内部完成,然后返回这个应用。
应用工厂
写代码的时候到了!创建 flaskr 文件夹并且文件夹内添加 __init__.py 文件。__init__.py 有两个作 用:一是包含应用工厂;二是告诉 Python flaskr 文件夹应当视作为一个包。
$ mkdir flaskr
Listing 3: flaskr/init.py
import os
from flask import Flask
def create_app(test_config=None):
# create and configure the app
app = Flask(__name__, instance_relative_config=True)
app.config.from_mapping(
SECRET_KEY='dev',
DATABASE=os.path.join(app.instance_path, 'flaskr.sqlite'),
)
if test_config is None:
# load the instance config, if it exists, when not testing
app.config.from_pyfile('config.py', silent=True)
else:
# load the test config if passed in
app.config.from_mapping(test_config)
# ensure the instance folder exists
try:
os.makedirs(app.instance_path)
except OSError:
pass
# a simple page that says hello
@app.route('/hello')
def hello():
return 'Hello, World!'
return app
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
create_app 是一个应用工厂函数,后面的教程中会用到。这个看似简单的函数其实已经做了许多事情。
app = Flask(__name__, instance_relative_config=True)创建Flask实例。__name__是当前Python模块的名称。应用需要知道在哪里设置路径,使用__name__是一个方 便的方法。instance_relative_config=True告诉应用配置文件是相对于instance folder的相对路径。实 例文件夹在flaskr包的外面,用于存放本地数据(例如配置密钥和数据库),不应当提交到版本 控制系统。
app.config.from_mapping()设置一个应用的缺省配置:
SECRET_KEY是被Flask和扩展用于保证数据安全的。在开发过程中,为了方便可以设置为'dev',但是在发布的时候应当使用一个随机值来重载它。DATABASE SQLite数据库文件存放在路径。它位于 Flask 用于存放实例的app.instance_path之内。下一节会更详细地学习数据库的东西。
app.config.from_pyfile()使用config.py中的值来重载缺省配置,如果config.py存在的 话。例如,当正式部署的时候,用于设置一个正式的SECRET_KEY
test_config也会被传递给工厂,并且会替代实例配置。这样可以实现测试和开发的配置分离, 相互独立。
os.makedirs()可以确保app.instance_path存在。Flask不会自动创建实例文件夹,但是必须确 保创建这个文件夹,因为SQLite数据库文件会被保存在里面。@app.route()创建一个简单的路由,这样在继续教程下面的内容前你可以先看看应用如何运行的。 它创建了URL /hello和一个函数之间的关联。这个函数会返回一个响应,即一个'Hello, World!'字符串。
运行应用
现在可以通过使用 flask 命令来运行应用。在终端中告诉 Flask 你的应用在哪里,然后在开发模式下运行应 用。请记住,现在还是应当在最顶层的 flask-tutorial 目录下,不是在 flaskr 包里面。
开发模式下,当页面出错的时候会显示一个交互调试器,并且当你修改代码保存后会重启服务器。在学习本 教程的过程中,你可以一直让它保持运行,只需要刷新页面就可以了。
$ export FLASK_APP=flaskr
$ export FLASK_ENV=development
$ flask run
2
3
> set FLASK_APP=flaskr
> set FLASK_ENV=development
> flask run
2
3
> $env:FLASK_APP = "flaskr"
> $env:FLASK_ENV = "development"
> flask run
2
3
// Make sure to add code blocks to your code group
可以看到类似如下输出内容:
* Serving Flask app "flaskr"
* Environment: development
* Debug mode: on
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
* Restarting with stat
* Debugger is active!
* Debugger PIN: 855-212-761
2
3
4
5
6
7
在浏览器中访问 http://127.0.0.1:5000/hello ,就可以看到“Hello, World!”信息。恭喜你,Flask 网络应用成功 运行了!
# 1.5.3 定义和操作数据库
应用使用一个 SQLite 数据库来储存用户和博客内容。Python 内置了 SQLite 数据库支持,相应的模块为 sqlite3 。
使用 SQLite 的便利性在于不需要单独配置一个数据库服务器,并且 Python 提供了内置支持。但是当并发请 求同时要写入时,会比较慢一点,因为每个写操作是按顺序进行的。小应用没有问题,但是大应用可能就需 要考虑换成别的数据库了。
本教程不会详细讨论 SQL 。如果你不是很熟悉 SQL ,请先阅读 SQLite 文档中的 相关内容 。
连接数据库
当使用 SQLite 数据库(包括其他多数数据库的 Python 库)时,第一件事就是创建一个数据库的连接。所有 查询和操作都要通过该连接来执行,完事后该连接关闭。
在网络应用中连接往往与请求绑定。在处理请求的某个时刻,连接被创建。在发送响应之前连接被关闭。
Listing 4: flaskr/db.py
import sqlite3
import click
from flask import current_app, g
from flask.cli import with_appcontext
def get_db():
if 'db' not in g:
g.db = sqlite3.connect(
current_app.config['DATABASE'],
detect_types=sqlite3.PARSE_DECLTYPES
)
g.db.row_factory = sqlite3.Row
return g.db
def close_db(e=None):
db = g.pop('db', None)
if db is not None:
db.close()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
g 是一个特殊对象,独立于每一个请求。在处理请求过程中,它可以用于储存可能多个函数都会用到的数据。 把连接储存于其中,可以多次使用,而不用在同一个请求中每次调用 get_db 时都创建一个新的连接。
current_app 是另一个特殊对象,该对象指向处理请求的 Flask 应用。这里使用了应用工厂,那么在其 余的代码中就不会出现应用对象。当应用创建后,在处理一个请求时,get_db 会被调用。这样就需要使 用current_app 。
sqlite3.connect() 建立一个数据库连接,该连接指向配置中的 DATABASE 指定的文件。这个文件现在 还没有建立,后面会在初始化数据库的时候建立该文件。
sqlite3.Row 告诉连接返回类似于字典的行,这样可以通过列名称来操作数据。
close_db 通过检查 g.db 来确定连接是否已经建立。如果连接已建立,那么就关闭连接。以后会在应用工 厂中告诉应用 close_db 函数,这样每次请求后就会调用它。
创建表
在 SQLite 中,数据储存在 表和 列中。在储存和调取数据之前需要先创建它们。Flaskr 会把用户数据储存在 user 表中,把博客内容储存在 post 表中。下面创建一个文件储存用于创建空表的 SQL 命令:
Listing 5: flaskr/schema.sql
DROP TABLE IF EXISTS user;
DROP TABLE IF EXISTS post;
CREATE TABLE user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL
);
CREATE TABLE post (
id INTEGER PRIMARY KEY AUTOINCREMENT,
author_id INTEGER NOT NULL,
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
title TEXT NOT NULL,
body TEXT NOT NULL,
FOREIGN KEY (author_id) REFERENCES user (id)
);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
在 db.py 文件中添加 Python 函数,用于运行这个 SQL 命令:
Listing 6: flaskr/db.py
def init_db():
db = get_db()
with current_app.open_resource('schema.sql') as f:
db.executescript(f.read().decode('utf8'))
@click.command('init-db')
@with_appcontext
def init_db_command():
"""Clear the existing data and create new tables."""
init_db()
click.echo('Initialized the database.')
2
3
4
5
6
7
8
9
10
11
open_resource() 打开一个文件,该文件名是相对于 flaskr 包的。这样就不需要考虑以后应用具体部署 在哪个位置。get_db 返回一个数据库连接,用于执行文件中的命令。
click.command() 定义一个名为 init-db 命令行,它调用 init_db 函数,并为用户显示一个成功的消 息。更多关于如何写命令行的内容请参阅 doc:/cli 。
在应用中注册
close_db 和 init_db_command 函数需要在应用实例中注册,否则无法使用。然而,既然我们使用了工 厂函数,那么在写函数的时候应用实例还无法使用。代替地,我们写一个函数,把应用作为参数,在函数中 进行注册。
Listing 7: flaskr/db.py
def init_app(app):
app.teardown_appcontext(close_db)
app.cli.add_command(init_db_command)
2
3
app.teardown_appcontext() 告诉 Flask 在返回响应后进行清理的时候调用此函数。 app.cli.add_command() 添加一个新的可以与 flask 一起工作的命令。
在工厂中导入并调用这个函数。在工厂函数中把新的代码放到函数的尾部,返回应用代码的前面。
Listing 8: flaskr/__init__.py
def create_app():
app = ...
# existing code omitted
from . import db
db.init_app(app)
return app
2
3
4
5
6
初始化数据库文件
现在 init-db 已经在应用中注册好了,可以与 flask 命令一起使用了。使用的方式与前一页的 run 命令 类似。
笔记
如果你还在运行着前一页的服务器,那么现在要么停止该服务器,要么在新的终端中运行这个命令。 如果是新的终端请记住在进行项目文件夹并激活环境,参见安装 。同时还要像前一页所述设置 FLASK_APP 和 FLASK_ENV 。
运行 init-db 命令:
$ flask init-db
Initialized the database.
2
现在会有一个 flaskr.sqlite 文件出现在项目所在文件夹的 instance 文件夹中。
# 1.5.4 蓝图和视图
视图是一个应用对请求进行响应的函数。Flask 通过模型把进来的请求 URL 匹配到对应的处理视图。视图返 回数据,Flask 把数据变成出去的响应。Flask 也可以反过来,根据视图的名称和参数生成 URL 。
创建蓝图
Blueprint 是一种组织一组相关视图及其他代码的方式。与把视图及其他代码直接注册到应用的方式不同, 蓝图方式是把它们注册到蓝图,然后在工厂函数中把蓝图注册到应用。
Flaskr 有两个蓝图,一个用于认证功能,另一个用于博客帖子管理。每个蓝图的代码都在一个单独的模块中。 使用博客首先需要认证,因此我们先写认证蓝图。
Listing 9: flaskr/auth.py
import functools
from flask import (
Blueprint, flash, g, redirect, render_template, request, session, url_for
)
from werkzeug.security import check_password_hash, generate_password_hash
from flaskr.db import get_db
bp = Blueprint('auth', __name__, url_prefix='/auth')
2
3
4
5
6
7
8
9
10
11
这里创建了一个名称为 'auth' 的Blueprint 。和应用对象一样,蓝图需要知道是在哪里定义的,因此把 __name__ 作为函数的第二个参数。url_prefix 会添加到所有与该蓝图关联的 URL 前面。 使用app.register_blueprint() 导入并注册蓝图。新的代码放在工厂函数的尾部返回应用之前。
Listing 10: flaskr/__init__.py
def create_app():
app = ...
# existing code omitted
from . import auth
app.register_blueprint(auth.bp)
return app
2
3
4
5
6
认证蓝图将包括注册新用户、登录和注销视图。
第一个视图:注册
当用访问 /auth/register URL 时,register 视图会返回用于填写注册内容的表单的 HTML 。当用户 提交表单时,视图会验证表单内容,然后要么再次显示表单并显示一个出错信息,要么创建新用户并显示登 录页面。
现在只是编写视图代码,在下一页会编写生成 HTML 表单的模板。
Listing 11: flaskr/auth.py
@bp.route('/register', methods=('GET', 'POST'))
def register():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
db = get_db()
error = None
if not username:
error = 'Username is required.'
elif not password:
error = 'Password is required.'
if error is None:
try:
db.execute(
"INSERT INTO user (username, password) VALUES (?, ?)",
(username, generate_password_hash(password)),
)
db.commit()
except db.IntegrityError:
error = f"User {username} is already registered."
else:
return redirect(url_for("auth.login"))
flash(error)
return render_template('auth/register.html')
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
这个 register 视图做了以下工作:
@bp.route关联了URL/register和register视图函数。当Flask收到一个指向/auth/ register的请求时就会调用register视图并把其返回值作为响应。- 如果用户提交了表单,那么
request.method将会是'POST'。这咱情况下会开始验证用户的输入内 容。 request.form是一个特殊类型的dict,其映射了提交表单的键和值。表单中,用户将会输入其username和password。- 验证
username和password不为空。 - 如果验证成功,就把新用户的数据插入数据库。
db.execute使用了带有?占位符的SQL查询语句。占位符可以代替后面的元组参数中相应的 值。使用占位符的好处是会自动帮你转义输入值,以抵御SQL注入攻击。- 因为安全原因,不能把密码明文储存在数据库中。而是应当使用
generate_password_hash()生成安全的哈希值,再把哈希值储存到数据库中。因为查询修改了数据,所以要使用meth:db.commit()保存修改。 - 如果用户名已存在,会产生一个
sqlite3.IntegrityError错误,应当将该错误作为一个验证 错误显示给用户。
- 用户数据保存后将转到登录页面。
url_for()根据登录视图的名称生成相应的URL。与写固定的URL相比,这样做的好处是如果以后需要修改该视图相应的URL,那么不用修改所有涉及到URL的代码。 redirect() 为生成的 URL 生成一个重定向响应。 - 如果验证失败,那么会向用户显示一个出错信息。
flash()用于储存在渲染模块时可以调用的信息。 - . 当 用 户 最 初 访 问
auth/register时, 或 者 注 册 出 错 时, 应 用 显 示 一 个 注 册 表 单。render_template()会渲染一个包含HTML的模板。你会在教程的下一节学习如何写这个模板。
登录
这个视图和上述 register 视图原理相同。
Listing 12: flaskr/auth.py
@bp.route('/login', methods=('GET', 'POST'))
def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
db = get_db()
error = None
user = db.execute(
'SELECT * FROM user WHERE username = ?', (username,)
).fetchone()
if user is None:
error = 'Incorrect username.'
elif not check_password_hash(user['password'], password):
error = 'Incorrect password.'
if error is None:
session.clear()
session['user_id'] = user['id']
return redirect(url_for('index'))
flash(error)
return render_template('auth/login.html')
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
与 register 有以下不同之处:
- 首先需要查询用户并存放在变量中,以备后用。
fetchone()根据查询返回一个记录行。如果查询没有结果,则返回None。后面还用到fetchall(),它返回包括所有结果的列表。 check_password_hash()以相同的方式哈希提交的密码并安全的比较哈希值。如果匹配成功,那么 密码就是正确的。session是一个dict,它用于储存横跨请求的值。当验证成功后,用户的id被储存于一个新的会 话中。会话数据被储存到一个向浏览器发送的cookie中,在后继请求中,浏览器会返回它。Flask会安 全对数据进行 签名以防数据被篡改。
现在用户的 id 已被储存在session 中,可以被后续的请求使用。请每个请求的开头,如果用户已登录,那 么其用户信息应当被载入,以使其可用于其他视图。
Listing 13: flaskr/auth.py
@bp.before_app_request
def load_logged_in_user():
user_id = session.get('user_id')
if user_id is None:
g.user = None
else:
g.user = get_db().execute(
'SELECT * FROM user WHERE id = ?', (user_id,)
).fetchone()
2
3
4
5
6
7
8
9
bp.before_app_request() 注 册 一 个 在 视 图 函 数 之 前 运 行 的 函 数, 不 论 其 URL 是 什 么。 load_logged_in_user 检查用户 id 是否已经储存在session 中,并从数据库中获取用户数据,然 后储存在g.user 中。g.user 的持续时间比请求要长。如果没有用户 id ,或者 id 不存在,那么 g.user 将 会是 None 。
注销
注销的时候需要把用户 id 从session 中移除。然后 load_logged_in_user 就不会在后继请求中载入用 户了。
Listing 14: flaskr/auth.py
@bp.route('/logout')
def logout():
session.clear()
return redirect(url_for('index'))
2
3
4
在其他视图中验证
用户登录以后才能创建、编辑和删除博客帖子。在每个视图中可以使用 装饰器来完成这个工作。
Listing 15: flaskr/auth.py
def login_required(view):
@functools.wraps(view)
def wrapped_view(**kwargs):
if g.user is None:
return redirect(url_for('auth.login'))
return view(**kwargs)
return wrapped_view
2
3
4
5
6
7
装饰器返回一个新的视图,该视图包含了传递给装饰器的原视图。新的函数检查用户是否已载入。如果已载 入,那么就继续正常执行原视图,否则就重定向到登录页面。我们会在博客视图中使用这个装饰器。
端点和 URL
url_for() 函数根据视图名称和发生成 URL 。视图相关联的名称亦称为 端点,缺省情况下,端点名称与视 图函数名称相同。
例如,前文被加入应用工厂的 hello() 视图端点为 'hello' ,可以使用 url_for('hello') 来连接。如 果视图有参数,后文会看到,那么可使用 url_for('hello', who='World') 连接。
当使用蓝图的时候,蓝图的名称会添加到函数名称的前面。上面的 login 函数的端点为 'auth.login' , 因为它已被加入 'auth' 蓝图中。
# 1.5.5 模板
应用已经写好验证视图,但是如果现在运行服务器的话,无论访问哪个 URL ,都会看到一个 TemplateNotFound 错误。这是因为视图调用了render_template() ,但是模板还没有写。模板文件会储存在 flaskr 包内的 templates 文件夹内。
模板是包含静态数据和动态数据占位符的文件。模板使用指定的数据生成最终的文档。Flask 使用 Jinja 模板 库来渲染模板。
在教程的应用中会使用模板来渲染显示在用户浏览器中的 HTML 。在 Flask 中,Jinja 被配置为 自动转义 HTML 模板中的任何数据。即渲染用户的输入是安全的。任何用户输入的可能出现歧意的字符,如 < 和 > , 会被 转义,替换为 安全的值。这些值在浏览器中看起来一样,但是没有副作用。
Jinja 看上去并且运行地很像 Python 。Jinja 语句与模板中的静态数据通过特定的分界符分隔。任何位于 {{ 和 }} 这间的东西是一个会输出到最终文档的静态式。{% 和 %} 之间的东西表示流程控制语句,如 if 和 for 。 与 Python 不同,代码块使用分界符分隔,而不是使用缩进分隔。因为代码块内的静态文本可以会改变缩进。
基础布局
应用中的每一个页面主体不同,但是基本布局是相同的。每个模板会 扩展同一个基础模板并重载相应的小 节,而不是重写整个 HTML 结构。
Listing 16: flaskr/templates/base.html
<!DOCTYPE html>
<title>{% block title %}{% endblock %} - Flaskr</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
<nav>
<h1>Flaskr</h1>
<ul>
{% if g.user %}
<li><span>{{ g.user['username'] }}</span></li>
<li><a href="{{ url_for('auth.logout') }}">Log Out</a> {% else %}</li>
<li><a href="{{ url_for('auth.register') }}">Register</a></li>
<li>
<a href="{{ url_for('auth.login') }}">Log In</a>
{% endif %}
</li>
</ul>
</nav>
<section class="content">
<header>{% block header %}{% endblock %}</header>
{% for message in get_flashed_messages() %}
<div class="flash">{{ message }}</div>
{% endfor %} {% block content %}{% endblock %}
</section>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
g 在模板中自动可用。根据 g.user 是否被设置(在 load_logged_in_user 中进行),要么显示用户名和 注销连接,要么显示注册和登录连接。url_for() 也是自动可用的,可用于生成视图的 URL ,而不用手动 来指定。
在标题下面,正文内容前面,模板会循环显示get_flashed_messages() 返回的每个消息。在视图中使 用flash() 来处理出错信息,在模板中就可以这样显示出出来。
模板中定义三个块,这些块会被其他模板重载。
{% block title %}会改变显示在浏览器标签和窗口中的标题。{% block header %}类似于 title ,但是会改变页面的标题。{% block content %}是每个页面的具体内容,如登录表单或者博客帖子。
其他模板直接放在 templates 文件夹内。为了更好地管理文件,属于某个蓝图的模板会被放在与蓝图同名 的文件夹内。
注册
Listing 17: flaskr/templates/auth/register.html
{% extends 'base.html' %} {% block header %}
<h1>{% block title %}Register{% endblock %}</h1>
{% endblock %} {% block content %}
<form method="post">
<label for="username">Username</label>
<input name="username" id="username" required />
<label for="password">Password</label>
<input type="password" name="password" id="password" required />
<input type="submit" value="Register" />
</form>
{% endblock %}
2
3
4
5
6
7
8
9
10
11
{% extends 'base.html' %} 告诉 Jinja 这个模板基于基础模板,并且需要替换相应的块。所有替换的 内容必须位于 {% block %} 标签之内。
一个实用的模式是把 {% block title %} 放在 {% block header %} 内部。这里不但可以设置 title 块,还可以把其值作为 header 块的内容,一举两得。
input 标记使用了 required 属性。这是告诉浏览器这些字段是必填的。如果用户使用不支持这个属性的 旧版浏览器或者不是浏览器的东西创建的请求,那么你还是要在视图中验证输入数据。总是在服务端中完全 验证数据,即使客户端已经做了一些验证,这一点非常重要。
登录
本模板除了标题和提交按钮外与注册模板相同。
Listing 18: flaskr/templates/auth/login.html
{% extends 'base.html' %} {% block header %}
<h1>{% block title %}Log In{% endblock %}</h1>
{% endblock %} {% block content %}
<form method="post">
<label for="username">Username</label>
<input name="username" id="username" required />
<label for="password">Password</label>
<input type="password" name="password" id="password" required />
<input type="submit" value="Log In" />
</form>
{% endblock %}
2
3
4
5
6
7
8
9
10
11
注册一个用户
现在验证模板已写好,你可以注册一个用户了。请确定服务器还在运行(如果没有请使用 flask run ),然 后访问 http://127.0.0.1:5000/auth/register 。
在不填写表单的情况,尝试点击“Register”按钮,浏览器会显示出错信息。尝试在 register.html 中删除 required 属性后再次点击“Register”按钮。页面会重载并显示来自于视图中的flash() 的出错信息,而 不是浏览器显示出错信息。
填写用户名和密码后会重定向到登录页面。尝试输入错误的用户名,或者输入正常的用户名和错误的密码。 如果登录成功,那么会看到一个出错信息,因为还没有写登录后要转向的 index 视图。
# 1.5.6 静态文件
验证视图和模板已经可用了,但是看上去很朴素。可以使用一些 CSS 给 HTML 添加点样式。样式不会改变, 所以应当使用 静态文件,而不是模板。
Flask 自动添加一个 static 视图,视图使用相对于 flaskr/static 的相对路径。base.html 模板已经使 用了一个 style.css 文件连接:
{{ url_for('static', filename='style.css') }}
除了 CSS ,其他类型的静态文件可以是 JavaScript 函数文件或者 logo 图片。它们都放置于 flaskr/static 文件夹中,并使用 url_for('static', filename='...') 引用。
本教程不专注于如何写 CSS ,所以你只要复制以下内容到 flaskr/static/style.css 文件:
Listing 19: flaskr/static/style.css
html { font-family: sans-serif; background: #eee; padding: 1rem; }
body { max-width: 960px; margin: 0 auto; background: white; }
h1 { font-family: serif; color: #377ba8; margin: 1rem 0; }
a { color: #377ba8; }
hr { border: none; border-top: 1px solid lightgray; }
nav { background: lightgray; display: flex; align-items: center; padding: 0 0.5rem; }
nav h1 { flex: auto; margin: 0; }
nav h1 a { text-decoration: none; padding: 0.25rem 0.5rem; }
nav ul { display: flex; list-style: none; margin: 0; padding: 0; }
nav ul li a, nav ul li span, header .action { display: block; padding: 0.5rem; }
.content { padding: 0 1rem 1rem; }
.content > header { border-bottom: 1px solid lightgray; display: flex; align-items:␣
,→flex-end; }
.content > header h1 { flex: auto; margin: 1rem 0 0.25rem 0; }
.flash { margin: 1em 0; padding: 1em; background: #cae6f6; border: 1px solid #377ba8;␣
,→}
.post > header { display: flex; align-items: flex-end; font-size: 0.85em; }
.post > header > div:first-of-type { flex: auto; }
.post > header h1 { font-size: 1.5em; margin-bottom: 0; }
.post .about { color: slategray; font-style: italic; }
.post .body { white-space: pre-line; }
.content:last-child { margin-bottom: 0; }
.content form { margin: 1em 0; display: flex; flex-direction: column; }
.content label { font-weight: bold; margin-bottom: 0.5em; }
.content input, .content textarea { margin-bottom: 1em; }
.content textarea { min-height: 12em; resize: vertical; }
input.danger { color: #cc2f2e; }
input[type=submit] { align-self: start; min-width: 10em; }
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
你可以在 示例代码 找到一个排版不紧凑的 style.css 。 访问 http://127.0.0.1:5000/auth/login ,页面如下所示。

关于 CSS 的更多内容参见 Mozilla 的文档 。改动静态文件后需要刷新页面。如果刷新没有作用,请清除浏览 器的缓存。
# 1.5.7 博客蓝图
博客蓝图与验证蓝图所使用的技术一样。博客页面应当列出所有的帖子,允许已登录用户创建帖子,并允许 帖子作者修改和删除帖子。
当你完成每个视图时,请保持开发服务器运行。当你保存修改后,请尝试在浏览器中访问 URL ,并进行测 试。
蓝图
定义蓝图并注册到应用工厂。
Listing 20: flaskr/blog.py
from flask import (
Blueprint, flash, g, redirect, render_template, request, url_for
)
from werkzeug.exceptions import abort
from flaskr.auth import login_required
from flaskr.db import get_db
bp = Blueprint('blog', __name__)
2
3
4
5
6
7
8
9
10
11
使用app.register_blueprint() 在工厂中导入和注册蓝图。将新代码放在工厂函数的尾部,返回应用 之前。
Listing 21: flaskr/__init__.py
def create_app():
app = ...
# existing code omitted
from . import blog
app.register_blueprint(blog.bp)
app.add_url_rule('/', endpoint='index')
return app
2
3
4
5
6
7
与验证蓝图不同,博客蓝图没有 url_prefix 。因此 index 视图会用于 / ,create 会用于 /create ,以 此类推。博客是 Flaskr 的主要功能,因此把博客索引作为主索引是合理的。
但是,下文的 index 视图的端点会被定义为 blog.index 。一些验证视图会指定向普通的 index 端 点。我们使用app.add_url_rule() 关联端点名称 'index' 和 / URL ,这样 url_for('index') 或 url_for('blog.index') 都会有效,会生成同样的 / URL 。
在其他应用中,可能会在工厂中给博客蓝图一个 url_prefix 并定义一个独立的 index 视图,类似前文中 的 hello 视图。在这种情况下 index 和 blog.index 的端点和 URL 会有所不同。
索引
索引会显示所有帖子,最新的会排在最前面。为了在结果中包含 user 表中的作者信息,使用了一个 JOIN 。
Listing 22: flaskr/blog.py
@bp.route('/')
def index():
db = get_db()
posts = db.execute(
'SELECT p.id, title, body, created, author_id, username'
' FROM post p JOIN user u ON p.author_id = u.id'
' ORDER BY created DESC'
).fetchall()
return render_template('blog/index.html', posts=posts)
2
3
4
5
6
7
8
9
10
Listing 23: flaskr/templates/blog/index.html
{% extends 'base.html' %} {% block header %}
<h1>{% block title %}Posts{% endblock %}</h1>
{% if g.user %}
<a class="action" href="{{ url_for('blog.create') }}">New</a>
{% endif %} {% endblock %} {% block content %} {% for post in posts %}
<article class="post">
<header>
<div>
<h1>{{ post['title'] }}</h1>
<div class="about">
by {{ post['username'] }} on {{ post['created'].strftime(,→'%Y-%m-%d')
}}
</div>
</div>
{% if g.user['id'] == post['author_id'] %}
<a class="action" href="{{ url_for('blog.update',id=post['id']) }}">Edit</a>
{% endif %}
</header>
<p class="body">{{ post['body'] }}</p>
</article>
{% if not loop.last %}
<hr />
{% endif %} {% endfor %} {% endblock %}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
当用户登录后,header 块添加了一个指向 create 视图的连接。当用户是博客作者时,可以看到一个“Edit ”连接,指向 update 视图。loop.last 是一个 Jinja for 循环 内部可用的特殊变量,它用于在每个博客帖 子后面显示一条线来分隔帖子,最后一个帖子除外。
创建
create 视图与 register 视图原理相同。要么显示表单,要么发送内容已通过验证且内容已加入数据库, 或者显示一个出错信息。
先前写的 login_required 装饰器用在了博客视图中,这样用户必须登录以后才能访问这些视图,否则会 被重定向到登录页面。
Listing 24: flaskr/blog.py
@bp.route('/create', methods=('GET', 'POST'))
@login_required
def create():
if request.method == 'POST':
title = request.form['title']
body = request.form['body']
error = None
if not title:
error = 'Title is required.'
if error is not None:
flash(error)
else:
db = get_db()
db.execute(
'INSERT INTO post (title, body, author_id)'
' VALUES (?, ?, ?)',
(title, body, g.user['id'])
)
db.commit()
return redirect(url_for('blog.index'))
return render_template('blog/create.html')
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Listing 25: flaskr/templates/blog/create.html
{% extends 'base.html' %} {% block header %}
<h1>{% block title %}New Post{% endblock %}</h1>
{% endblock %} {% block content %}
<form method="post">
<label for="title">Title</label>
<input name="title" id="title" value="{{ request.form['title'] }}" required />
<label for="body">Body</label>
<textarea name="body" id="body">{{ request.form['body'] }} </textarea>
<input type="submit" value="Save" />
</form>
{% endblock %}
2
3
4
5
6
7
8
9
10
11
更新
update 和 delete 视图都需要通过 id 来获取一个 post ,并且检查作者与登录用户是否一致。为避免重 复代码,可以写一个函数来获取 post ,并在每个视图中调用它。
Listing 26: flaskr/blog.py
def get_post(id, check_author=True):
post = get_db().execute(
'SELECT p.id, title, body, created, author_id, username'
' FROM post p JOIN user u ON p.author_id = u.id'
' WHERE p.id = ?',
(id,)
).fetchone()
if post is None:
abort(404, f"Post id {id} doesn't exist.")
if check_author and post['author_id'] != g.user['id']:
abort(403)
return post
2
3
4
5
6
7
8
9
10
11
12
abort() 会引发一个特殊的异常,返回一个 HTTP 状态码。它有一个可选参数,用于显示出错信息,若不使 用该参数则返回缺省出错信息。404 表示“未找到”,403 代表“禁止访问”。(401 表示“未授权”,但是我们重定向到登录页面来代替返回这个状态码)
check_author 参数的作用是函数可以用于在不检查作者的情况下获取一个 post 。这主要用于显示一个 独立的帖子页面的情况,因为这时用户是谁没有关系,用户不会修改帖子。
Listing 27: flaskr/blog.py
@bp.route('/<int:id>/update', methods=('GET', 'POST'))
@login_required
def update(id):
post = get_post(id)
if request.method == 'POST':
title = request.form['title']
body = request.form['body']
error = None
if not title:
error = 'Title is required.'
if error is not None:
flash(error)
else:
db = get_db()
db.execute(
'UPDATE post SET title = ?, body = ?'
' WHERE id = ?',
(title, body, id)
)
db.commit()
return redirect(url_for('blog.index'))
return render_template('blog/update.html', post=post)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
和所有以前的视图不同,update 函数有一个 id 参数。该参数对应路由中的 。一个真正的 URL 类似 /1/update 。Flask 会捕捉到 URL 中的 1 ,确保其为一个 int ,并将其作为 id 参数传递给视图。如 果没有指定 int: 而是仅仅写了 ,那么将会传递一个字符串。要生成一个指向更新页面的 URL ,需要 传递 id 参数给url_for() :url_for('blog.update', id=post['id']) 。前文的 index.html 文 件中同样如此。
create 和 update 视图看上去是相似的。主要的不同之处在于 update 视图使用了一个 post 对象和一个 UPDATE 查询代替了一个 INSERT 查询。作为一个明智的重构者,可以使用一个视图和一个模板来同时完成 这两项工作。但是作为一个初学者,把它们分别处理要清晰一些。
Listing 28: flaskr/templates/blog/update.html
{% extends 'base.html' %} {% block header %}
<h1>{% block title %}Edit "{{ post['title'] }}"{% endblock %}</h1>
{% endblock %} {% block content %}
<form method="post">
<label for="title">Title</label>
<input
name="title"
id="title"
value="{{ request.form['title'] or post['title'] }}"
required
/>
<label for="body">Body</label>
<textarea name="body" id="body">
{{ request.form['body'] or post['body'] }}</textarea
>
<input type="submit" value="Save" />
</form>
<hr />
<form action="{{ url_for('blog.delete', id=post['id']) }}" method="post">
<input
class="danger"
type="submit"
value="Delete"
onclick="return confirm('Are you sure?');"
/>
</form>
{% endblock %}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
这个模板有两个表单。第一个提交已编辑过的数据给当前页面(/<id>/update )。另一个表单只包含一个 按钮。它指定一个 action 属性,指向删除视图。这个按钮使用了一些 JavaScript 用以在提交前显示一个确 认对话框。
参数
用于选择在表单显示什么数据。当表单还 未提交时,显示原 post 数据。但是,如果提交了非法数据,然后需要显示这些非法数据以便于用户修改时, 就显示 request.form 中的数据。request 是又一个自动在模板中可用的变量。
删除
删除视图没有自己的模板。删除按钮已包含于 update.html 之中,该按钮指向/<id>/delete URL 。既 然没有模板,该视图只处理 POST 方法并重定向到 index 视图。
Listing 29: flaskr/blog.py
@bp.route('/<int:id>/delete', methods=('POST',))
@login_required
def delete(id):
get_post(id)
db = get_db()
db.execute('DELETE FROM post WHERE id = ?', (id,))
db.commit()
return redirect(url_for('blog.index'))
2
3
4
5
6
7
8
恭喜,应用写完了!花点时间在浏览器中试试这个应用吧。然而,构建一个完整的应用还有一些工作要做。
# 1.5.8 项目可安装化
项目可安装化是指创建一个项目 发行文件,以使用项目可以安装到其他环境,就像在你的项目中安装 Flask 一样。这样可以使你的项目如同其他库一样进行部署,可以使用标准的 Python 工具来管理项目。
可安装化还可以带来如下好处,这些好处在教程中可以不太明显或者初学者可能没注意到:
- 现在,
Python和Flask能够理解如何flaskr包,是因为你是在项目文件夹中运行的。可安装化后,可 以从任何地方导入项目并运行。 - 可以和其他包一样管理项目的依赖,即使用
pip install yourproject.whl来安装项目并安装相 关依赖。 - 测试工具可以分离测试环境和开发环境。
描述项目
setup.py 文件描述项目及其从属的文件。
Listing 30: setup.py
from setuptools import find_packages, setup
setup(
name='flaskr',
version='1.0.0',
packages=find_packages(),
include_package_data=True,
zip_safe=False,
install_requires=[
'flask',],
)
2
3
4
5
6
7
8
9
10
packages 告诉 Python 包所包括的文件夹(及其所包含的 Python 文件)。find_packages() 自动找到这 些文件夹,这样就不用手动写出来。为了包含其他文件夹,如静态文件和模板文件所在的文件夹,需要设置 include_package_data 。Python 还需要一个名为 MANIFEST.in 文件来说明这些文件有哪些。
Listing 31: MANIFEST.in
include flaskr/schema.sql
graft flaskr/static
graft flaskr/templates
global-exclude *.pyc
2
3
4
这告诉 Python 复制所有 static 和 templates 文件夹中的文件,schema.sql 文件,但是排除所有字节 文件。
安装项目
使用 pip 在虚拟环境中安装项目。
$ pip install -e .
这个命令告诉 pip 在当前文件夹中寻找 setup.py 并在 编辑或 开发模式下安装。编辑模式是指当改变本地 代码后,只需要重新项目。比如改变了项目依赖之类的元数据的情况下。
可以通过 pip list 来查看项目的安装情况。
$ pip list
Package Version Location
-------------- --------- ----------------------------------
click 6.7
Flask 1.0
flaskr 1.0.0 /home/user/Projects/flask-tutorial
itsdangerous 0.24
Jinja2 2.10
MarkupSafe 1.0
pip 9.0.3
setuptools 39.0.1
Werkzeug 0.14.1
wheel 0.30.0
2
3
4
5
6
7
8
9
10
11
12
13
至此,没有改变项目运行的方式,FLASK_APP 还是被设置为 flaskr ,还是使用 flask run 运行应用。不 同的是可以在任何地方运行应用,而不仅仅是在 flask-tutorial 目录下。
# 1.5.9 测试覆盖
为应用写单元测试可以检查代码是否按预期执行。Flask 提供了测试客户端,可以模拟向应用发送请求并返 回响应数据。
应当尽可能多地进行测试。函数中的代码只有在函数被调用的情况下才会运行。分支中的代码,如 if 块中 的代码,只有在符合条件的情况下才会运行。测试应当覆盖每个函数和每个分支。
越接近 100% 的测试覆盖,越能够保证修改代码后不会出现意外。但是 100% 测试覆盖不能保证应用没有错 误。通常,测试不会覆盖用户如何在浏览器中与应用进行交互。尽管如此,在开发过程中,测试覆盖仍然是 非常重要的。
我们使用 pytest 和 coverage 来进行测试和衡量代码。先安装它们:
$ pip install pytest coverage
配置和固件
测试代码位于 tests 文件夹中,该文件夹位于 flaskr 包的 旁边,而不是里面。tests/conftest.py 文 件包含名为 fixtures (固件)的配置函数。每个测试都会用到这个函数。测试位于 Python 模块中,以 test_ 开头,并且模块中的每个测试函数也以 test_ 开头。
每个测试会创建一个新的临时数据库文件,并产生一些用于测试的数据。写一个 SQL 文件来插入数据。
Listing 32: tests/data.sql
INSERT INTO user (username, password)
VALUES
('test', 'pbkdf2:sha256:50000$TCI4GzcX$0de171a4f4dac32e3364c7ddc7c14f3e2fa61f2d17574483f7ffbb431b4acb2f'),
('other', 'pbkdf2:sha256:50000$kJPKsz6N$d2d4784f1b030a9761f5ccaeeaca413f27f2ecb76d6168407af962ddce849f79');
INSERT INTO post (title, body, author_id, created)
VALUES
('test title', 'test' || x'0a' || 'body', 1, '2018-01-01 00:00:00');
2
3
4
5
6
7
8
app 固件会调用工厂并为测试传递 test_config 来配置应用和数据库,而不使用本地的开发配置。
Listing 33: tests/conftest.py
import os
import tempfile
import pytest
from flaskr import create_app
from flaskr.db import get_db, init_db
with open(os.path.join(os.path.dirname(__file__), 'data.sql'), 'rb') as f:
_data_sql = f.read().decode('utf8')
@pytest.fixture
def app():
db_fd, db_path = tempfile.mkstemp()
app = create_app({
'TESTING': True,
'DATABASE': db_path,
})
with app.app_context():
init_db()
get_db().executescript(_data_sql)
yield app
os.close(db_fd)
os.unlink(db_path)
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def runner(app):
return app.test_cli_runner()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
tempfile.mkstemp() 创建并打开一个临时文件,返回该文件描述符和路径。DATABASE 路径被重载,这 样它会指向临时路径,而不是实例文件夹。设置好路径之后,数据库表被创建,然后插入数据。测试结束后,临时文件会被关闭并删除。
TESTING 告诉 Flask 应用处在测试模式下。Flask 会改变一些内部行为以方便测试。其他的扩展也可以使用 这个标志方便测试。
client 固件调用app.test_client() 由 app 固件创建的应用对象。测试会使用客户端来向应用发送请 求,而不用启动服务器。
runner 固件类似于 client 。app.test_cli_runner() 创建一个运行器,可以调用应用注册的 Click 命 令。
Pytest 通过匹配固件函数名称和测试函数的参数名称来使用固件。例如下面要写 test_hello 函数有一个 client 参数。Pytest 会匹配 client 固件函数,调用该函数,把返回值传递给测试函数。
工厂
工厂本身没有什么好测试的,其大部分代码会被每个测试用到。因此如果工厂代码有问题,那么在进行其他 测试时会被发现。
唯一可以改变的行为是传递测试配置。如果没传递配置,那么会有一些缺省配置可用,否则配置会被重载。
Listing 34: tests/test_factory.py
from flaskr import create_app
def test_config():
assert not create_app().testing
assert create_app({'TESTING': True}).testing
def test_hello(client):
response = client.get('/hello')
assert response.data == b'Hello, World!'
2
3
4
5
6
7
8
在本教程开头的部分添加了一个 hello 路由作为示例。它返回“Hello, World!”,因此测试响应数据是否匹 配。
数据库
在一个应用环境中,每次调用 get_db 都应当返回相同的连接。退出环境后,连接应当已关闭。
Listing 35: tests/test_db.py
import sqlite3
import pytest
from flaskr.db import get_db
def test_get_close_db(app):
with app.app_context():
db = get_db()
assert db is get_db()
with pytest.raises(sqlite3.ProgrammingError) as e:
db.execute('SELECT 1')
assert 'closed' in str(e.value)
2
3
4
5
6
7
8
9
10
11
12
init-db 命令应当调用 init_db 函数并输出一个信息。
Listing 36: tests/test_db.py
def test_init_db_command(runner, monkeypatch):
class Recorder(object):
called = False
def fake_init_db():
Recorder.called = True
monkeypatch.setattr('flaskr.db.init_db', fake_init_db)
result = runner.invoke(args=['init-db'])
assert 'Initialized' in result.output
assert Recorder.called
2
3
4
5
6
7
8
9
10
这个测试使用 Pytest’s monkeypatch 固件来替换 init_db 函数。前文写的 runner 固件用于通过名称调 用 init-db 命令。
验证
对于大多数视图,用户需要登录。在测试中最方便的方法是使用客户端制作一个 POST 请求发送给 login 视 图。与其每次都写一遍,不如写一个类,用类的方法来做这件事,并使用一个固件把它传递给每个测试的客 户端。
Listing 37: tests/conftest.py
class AuthActions(object):
def __init__(self, client):
self._client = client
def login(self, username='test', password='test'):
return self._client.post(
'/auth/login',
data={'username': username, 'password': password}
)
def logout(self):
return self._client.get('/auth/logout')
@pytest.fixture
def auth(client):
return AuthActions(client)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
通过 auth 固件,可以在调试中调用 auth.login() 登录为 test 用户。这个用户的数据已经在 app 固件 中写入了数据。
register 视图应当在 GET 请求时渲染成功。在 POST 请求中,表单数据合法时,该视图应当重定向到登录 URL ,并且用户的数据已在数据库中保存好。数据非法时,应当显示出错信息。
Listing 38: tests/test_auth.py
import pytest
from flask import g, session
from flaskr.db import get_db
def test_register(client, app):
assert client.get('/auth/register').status_code == 200
response = client.post(
'/auth/register', data={'username': 'a', 'password': 'a'}
)
assert 'http://localhost/auth/login' == response.headers['Location']
with app.app_context():
assert get_db().execute(
"SELECT * FROM user WHERE username = 'a'",
).fetchone() is not None
@pytest.mark.parametrize(('username', 'password', 'message'), (
('', '', b'Username is required.'),
('a', '', b'Password is required.'),
('test', 'test', b'already registered'),
))
def test_register_validate_input(client, username, password, message):
response = client.post(
'/auth/register',
data={'username': username, 'password': password}
)
assert message in response.data
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
client.get() 制作一个 GET 请求并由 Flask 返回Response 对象。类似的 client.post() 制作一个 POST 请求,转换 data 字典为表单数据。
为了测试页面是否渲染成功,制作一个简单的请求,并检查是否返回一个 200 OK status_code 。如果渲 染失败,Flask 会返回一个 500 Internal Server Error 代码。
当注册视图重定向到登录视图时,headers 会有一个包含登录 URL 的 Location 头部。
data 以字节方式包含响应的身体。如果想要检测渲染页面中的某个值,请在 data 中检测。字节值只能与 字节值作比较,如果想比较文本,请使用 get_data(as_text=True) 。
pytest.mark.parametrize 告诉 Pytest 以不同的参数运行同一个测试。这里用于测试不同的非法输入和 出错信息,避免重复写三次相同的代码。
login 视图的测试与 register 的非常相似。后者是测试数据库中的数据,前者是测试登录之后session 应当包含 user_id 。
Listing 39: tests/test_auth.py
def test_login(client, auth):
assert client.get('/auth/login').status_code == 200
response = auth.login()
assert response.headers['Location'] == 'http://localhost/'
with client:
client.get('/')
assert session['user_id'] == 1
assert g.user['username'] == 'test'
@pytest.mark.parametrize(('username', 'password', 'message'), (
('a', 'test', b'Incorrect username.'),
('test', 'a', b'Incorrect password.'),
))
def test_login_validate_input(auth, username, password, message):
response = auth.login(username, password)
assert message in response.data
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
在 with 块中使用 client ,可以在响应返回之后操作环境变量,比如session 。通常,在请求之外操作 session 会引发一个异常。
logout 测试与 login 相反。注销之后,session 应当不包含 user_id 。
Listing 40: tests/test_auth.py
def test_logout(client, auth):
auth.login()
with client:
auth.logout()
assert 'user_id' not in session
2
3
4
5
博客
所有博客视图使用之前所写的 auth 固件。调用 auth.login() ,并且客户端的后继请求会登录为 test 用 户。
index 索引视图应当显示已添加的测试帖子数据。作为作者登录之后,应当有编辑博客的连接。
当测试 index 视图时,还可以测试更多验证行为。当没有登录时,每个页面显示登录或注册连接。当登录之 后,应当有一个注销连接。
Listing 41: tests/test_blog.py
import pytest
from flaskr.db import get_db
def test_index(client, auth):
response = client.get('/')
assert b"Log In" in response.data
assert b"Register" in response.data
auth.login()
response = client.get('/')
assert b'Log Out' in response.data
assert b'test title' in response.data
assert b'by test on 2018-01-01' in response.data
assert b'test\nbody' in response.data
assert b'href="/1/update"' in response.data
2
3
4
5
6
7
8
9
10
11
12
13
14
用户必须登录后才能访问 create 、update 和 delete 视图。帖子作者才能访问 update 和 delete 。否 则返回一个 403 Forbidden 状态码。如果要访问 post 的 id 不存在,那么 update 和 delete 应当返回 404 Not Found 。
Listing 42: tests/test_blog.py
@pytest.mark.parametrize('path', (
'/create',
'/1/update',
'/1/delete',
))
def test_login_required(client, path):
response = client.post(path)
assert response.headers['Location'] == 'http://localhost/auth/login'
def test_author_required(app, client, auth):
# change the post author to another user
with app.app_context():
db = get_db()
db.execute('UPDATE post SET author_id = 2 WHERE id = 1')
db.commit()
auth.login()
# current user can't modify other user's post
assert client.post('/1/update').status_code == 403
assert client.post('/1/delete').status_code == 403
# current user doesn't see edit link
assert b'href="/1/update"' not in client.get('/').data
@pytest.mark.parametrize('path', (
'/2/update',
'/2/delete',
))
def test_exists_required(client, auth, path):
auth.login()
assert client.post(path).status_code == 404
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
对于 GET 请求,create 和 update 视图应当渲染和返回一个 200 OK 状态码。当 POST 请求发送了合法数 据后,create 应当在数据库中插入新的帖子数据,update 应当修改数据库中现存的数据。当数据非法时, 两者都应当显示一个出错信息。
Listing 43: tests/test_blog.py
def test_create(client, auth, app):
auth.login()
assert client.get('/create').status_code == 200
client.post('/create', data={'title': 'created', 'body': ''})
with app.app_context():
db = get_db()
count = db.execute('SELECT COUNT(id) FROM post').fetchone()[0]
assert count == 2
def test_update(client, auth, app):
auth.login()
assert client.get('/1/update').status_code == 200
client.post('/1/update', data={'title': 'updated', 'body': ''})
with app.app_context():
db = get_db()
post = db.execute('SELECT * FROM post WHERE id = 1').fetchone()
assert post['title'] == 'updated'
@pytest.mark.parametrize('path', (
'/create',
'/1/update',
))
def test_create_update_validate(client, auth, path):
auth.login()
response = client.post(path, data={'title': '', 'body': ''})
assert b'Title is required.' in response.data
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
delete 视图应当重定向到索引 URL ,并且帖子应当从数据库中删除。
Listing 44: tests/test_blog.py
def test_delete(client, auth, app):
auth.login()
response = client.post('/1/delete')
assert response.headers['Location'] == 'http://localhost/'
with app.app_context():
db = get_db()
post = db.execute('SELECT * FROM post WHERE id = 1').fetchone()
assert post is None
2
3
4
5
6
7
8
9
运行测试
额外的配置可以添加到项目的 setup.cfg 文件。这些配置不是必需的,但是可以使用测试更简洁明了。
Listing 45: setup.cfg
[tool:pytest]
testpaths = tests
[coverage:run]
branch = True
source =
flaskr
2
3
4
5
6
7
使用 pytest 来运行测试。该命令会找到并且运行所有测试。
$ pytest
========================= test session starts ==========================
platform linux -- Python 3.6.4, pytest-3.5.0, py-1.5.3, pluggy-0.6.0
rootdir: /home/user/Projects/flask-tutorial, inifile: setup.cfg
collected 23 items
tests/test_auth.py ........ [ 34%]
tests/test_blog.py ............ [ 86%]
tests/test_db.py .. [ 95%]
tests/test_factory.py .. [100%]
====================== 24 passed in 0.64 seconds =======================
2
3
4
5
6
7
8
9
10
如果有测试失败,pytest 会显示引发的错误。可以使用 pytest -v 得到每个测试的列表,而不是一串点。 可以使用 coverage 命令代替直接使用 pytest 来运行测试,这样可以衡量测试覆盖率。
$ coverage run -m pytest
在终端中,可以看到一个简单的覆盖率报告:
$ coverage report
Name Stmts Miss Branch BrPart Cover
------------------------------------------------------
flaskr/__init__.py 21 0 2 0 100%
flaskr/auth.py 54 0 22 0 100%
flaskr/blog.py 54 0 16 0 100%
flaskr/db.py 24 0 4 0 100%
------------------------------------------------------
TOTAL 153 0 44 0 100%
2
3
4
5
6
7
8
9
还可以生成 HTML 报告,可以看到每个文件中测试覆盖了哪些行:
$ coverage html
这个命令在 htmlcov 文件夹中生成测试报告,然后在浏览器中打开 htmlcov/index.html 查看。
# 1.5.10 部署产品
本文假设你要把应用部署到一个服务器上。本文只是给出如何创建发行文件并进行安装的概览,但是不会具 体讨论使用哪种服务器或者软件。你可以在用于开发的电脑中设置一个新的虚拟环境,以便于尝试下面的内 容。但是建议不要用于部署一个真正的公开应用。以多种不同方式部署应用的列表参见部署方式 。
构建和安装
当需要把应用部署到其他地方时,需要构建一个发行文件。当前 Python 的标准发行文件是 wheel 格式的,扩 展名为 .whl 。先确保已经安装好 wheel 库:
$ pip install wheel
用 Python 运行 setup.py 会得到一个命令行工具,以使用构建相关命令。bdist_wheel 命令会构建一个 wheel 发行文件。
$ python setup.py bdist_wheel
构建的文件为 dist/flaskr-1.0.0-py3-none-any.whl 。文件名由项目名称、版本号和一些关于项目 安装要求的标记组成,形如:{project name}-{version}-{python tag}-{abi tag}-{platform tag} 。
复制这个文件到另一台机器,创建一个新的虚拟环境 ,然后用 pip 安装这个文件。
$ pip install flaskr-1.0.0-py3-none-any.whl
Pip 会安装项目和相关依赖。
既然这是一个不同的机器,那么需要再次运行 init-db 命令,在实例文件夹中创建数据库。
$ export FLASK_APP=flaskr
$ flask init-db
2
> set FLASK_APP=flaskr
> flask init-db
2
> $env:FLASK_APP = "flaskr"
> flask init-db
2
// Make sure to add code blocks to your code group
当 Flask 探测到它已被安装(不在编辑模式下),它会与前文不同,使用 venv/var/flaskr-instance 作 为实例文件夹。
配置密钥
在教程开始的时候给了SECRET_KEY 一个缺省值。在产品中我们应当设置一些随机内容。否则网络攻击者就 可以使用公开的 'dev' 键来修改会话 cookie ,或者其他任何使用密钥的东西。 可以使用下面的命令输出一个随机密钥:
$ python -c 'import os; print(os.urandom(16))'
b'_5#y2L"F4Q8z\n\xec]/'
2
3
在实例文件夹创建一个 config.py 文件。工厂会读取这个文件,如果该文件存在的话。提制生成的值到该 文件中。
Listing 46: venv/var/flaskr-instance/config.py
SECRET_KEY = b'_5#y2L"F4Q8z\n\xec]/'
其他必须的配置也可以写入该文件中。Flaskr 只需要 SECRET_KEY 即可。
运行产品服务器
当运行公开服务器而不是进行开发的时候,应当不使用内建的开发服务器(flask run )。开发服务器由 Werkzeug 提供,目的是为了方便开发,但是不够高效、稳定和安全。
替代地,应当选用一个产品级的 WSGI 服务器。例如,使用 Waitress 。首先在虚拟环境中安装它:
$ pip install waitress
需要把应用告知 Waitree ,但是方式与 flask run 那样使用 FLASK_APP 不同。需要告知 Waitree 导入并调 用应用工厂来得到一个应用对象。
$ waitress-serve --call 'flaskr:create_app'
Serving on http://0.0.0.0:8080
2
3
以多种不同方式部署应用的列表参见部署方式 。使用 Waitress 只是一个示例,选择它是因为它同时支持 Windows 和 Linux 。还有其他许多 WSGI 服务器和部署选项可供选择。
# 1.5.11 继续开发!
通过教程您已经学到了许多 Flask 和 Python 的概念。回顾一下教程,并比较每一步的代码有何变化。比较你 的项目与 示例项目 ,可能会发现有较大的区别,蹒跚学步,很自然。 Flask 远不止教程所涉及的这些内容,但是您已经可以开始网络应用开发了。请阅读快速上手 ,对 Flask 的功 能有个大致了解,然后深入学习文档。Flask 在幕后使用了 Jinja 、Click 、Werkzeug 和 ItsDangerous ,它们也有 各自的文档。Flask 还有许多功能强大的扩展 ,比如数据库扩展或者表单验证扩展等等,你一定会感兴趣的。 如果要继续开发 Flaskr 项目,建议尝试以下内容:
- 点击帖子标题,显示帖子详细页面。
- 喜欢或者不喜欢一个帖子。
- 评论。
- 标记。点击标记显示所有带有该标记的帖子。
- 一个可以过滤标题的搜索框。
- 帖子可以上传图片。
- 帖子支持用
Markdown撰写。 - 一个新帖子的
RSS源。
祝你开心并写出令人惊叹的应用! 本教程中我们将会创建一个名为 Flaskr 的具备基本功能的博客应用。应用用户可以注册、登录、发贴和编辑 或者删除自己的帖子。可以打包这个应用并且安装到其他电脑上。

本文假设你已经熟悉 Python 。不熟悉?那么建议先从学习或者复习 Python 文档的 官方教程 入手。 本教程不会涵盖 Flask 的所有内容,其目的是提供一个良好的起点。如果想了解 Flask 能够做什么,可以通 过快速上手 作一个大概的了解,想深入了解的话那就只有仔细阅读所有文档了。本教程只会涉及 Flask 和 Python 。在实际项目中可以通过使用扩展 或者其他的库,达到事半功倍的效果。

Flask 是非常灵活的,不需要使用任何特定的项目或者代码布局。但是对于初学者,使用结构化的方法是有益 无害的,亦即本教程会有一点样板的意思。本教程可以让初学者避免一些常见的陷阱,并且完成后的应用可 以方便的扩展。一旦熟悉了 Flask 之后就可以跳出这个结构,充分享受 Flask 的灵活性。

如果在学习教程过程中需要比较项目代码与最终结果的差异,那么可以在 Flask 官方资源库的示例 中 (opens new window)找到完 成的教程项目代码。