连续复制
一键复制
一键打包

安装

可扩展的框架

Flask 有 3 个主要依赖:

路由、调试和 Web 服务器网关接口(WSGI,Web server gateway interface)

子系统由 Werkzeug 提供;

模板系统由 Jinja2 提供;

命令行集成由 Click 提供。

这些依赖全都是 Flask 的开发者 Armin Ronacher 开发的

安装

进入项目目录,创建虚拟环境
python3 -m venv venv
使用虚拟环境
source venv/bin/activate

虚拟环境中的工作结束后,在命令提示符中输入 deactivate

还原当前终端会话的 PATH 环境变量,把命令提示符重置为最初的状态

安装 Flask 包
(venv) $ pip install flask

执行这个命令后,pip 不仅安装 Flask 自身,还会安装它的所有依赖

验证 Flask 是否正确安装

可以启动 Python 解释器,尝试导入 Flask

(venv) $ python

>>> import flask

>>>

如果没有看到错误提醒,那么恭喜你

应用的基本结构

初始化

所有 Flask 应用都必须创建一个应用实例。

Web 服务器使用一种名为 Web 服务器网关接口(WSGI,Web server gateway interface,读作“wiz-ghee”)的协议,把接收自客户端的所有请求都转交给这个对象处理。

应用实例是 Flask 类的对象,通常由下述代码创建:

from flask import Flask

app = Flask(__name__)

Flask 类的构造函数只有一个必须指定的参数,即应用主模块或包的名称。

在大多数应用中,Python 的 name 变量就是所需的值。

Flask 用这个参数确定应用的位置,进而找到应用中其他文件的位置,例如图像和模板。

路由和视图函数

路由

客户端(例如 Web 浏览器)把请求发送给 Web 服务器,Web 服务器再把请求发送给 Flask 应用实例。

应用实例需要知道对每个 URL 的请求要运行哪些代码,所以保存了一个 URL 到 Python 函数的映射关系。

处理 URL 和函数之间关系的程序称为路由。

app.route 装饰器 声明路由

在 Flask 应用中定义路由的最简便方式,是使用应用实例提供的 app.route 装饰器

把 index() 函数注册为应用根地址的处理程序

@app.route('/')
def index():
    return '<h1>Hello World!</h1>'

装饰器是 Python 语言的标准特性。惯常用法是把函数注册为事件处理程序,在特定事件发生时调用。

app.add_url_rule() 声明路由

这个方法最简单的形式接受 3 个参数:URL、端点名和视图函数

使用 app.add_url_rule() 方法注册 index() 函数

def index():
    return '<h1>Hello World!</h1>'

app.add_url_rule('/', 'index', index)
视图函数

index() 这样处理入站请求的函数称为视图函数。

如果应用部署在域名为 www.example.com 的服务器上,在浏览器中访问 http://www.example.com 后,会触发服务器执行 index() 函数。

这个函数的返回值称为响应,是客户端接收到的内容。

如果客户端是 Web 浏览器,响应就是显示给用户查看的文档。

视图函数返回的响应可以是包含 HTML 的简单字符串,也可以是复杂表单。

直接在 Python 源码文件中编写响应字符串的 HTML 代码会导致代码难以维护

动态路由

name 参数用于生成个性化的欢迎消息

@app.route('/user/<name>')
def user(name):
    return '<h1>Hello, {}!</h1>'.format(name)

路由 URL 中放在尖括号里的内容就是动态部分,

任何能匹配静态部分的 URL 都会映射到这个路由上。

调用视图函数时,Flask 会将动态部分作为参数传入函数。

在这个视图函数中,name 参数用于生成个性化的欢迎消息。

路由中的动态部分默认使用字符串,不过也可以是其他类型。

例如,路由 /user/<int:id> 只会匹配动态片段 id 为整数的 URL,例如 /user/123。

Flask 支持在路由中使用 string、int、float 和 path 类型。

path 类型是一种特殊的字符串,与 string 类型不同的是,它可以包含正斜线。

一个完整的应用

hello.py 应用脚本定义一个应用实例、一个路由和一个视图函数。

from flask import Flask

app = Flask(__name__)

@app.route('/')
def index():
    return '<h1>Hello World!</h1>'

Web开发服务器

flask run

Flask 应用自带 Web 开发服务器,通过 flask run 命令启动。

这个命令在 FLASK_APP 环境变量指定的 Python 脚本中寻找应用实例。

Linux 和 macOS

(venv) $ export FLASK_APP=hello.py
(venv) $ flask run
 * Serving Flask app "hello"
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

Windows

(venv) $ set FLASK_APP=hello.py
(venv) $ flask run
 * Serving Flask app "hello"
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

服务器启动后便开始轮询,处理请求。

直到按 Ctrl+C 键停止服务器,轮询才会停止。

如果在基 URL 后面再输入任何内容,应用将不知道如何处理,会向浏览器返回错误码 404

Flask 提供的 Web 服务器只适用于开发和测试

app.run()

Flask Web 开发服务器也可以通过编程的方式启动:调用 app.run() 方法

在没有 flask 命令的旧版 Flask 中,若想启动应用,要运行应用的主脚本。主脚本的尾部包含下述代码片段:

if __name__ == '__main__':
    app.run()

动态路由

包含动态路由的 Flask 应用

在浏览器中访问这个动态 URL 时,你会看到一条个性化的消息,包含你在 URL 中提供的名字

from flask import Flask

app = Flask(__name__)

@app.route('/')
def index():
    return '<h1>Hello World!</h1>'

@app.route('/user/<name>')
def user(name):
    return '<h1>Hello, {}!</h1>'.format(name)

访问 http://localhost:5000/user/Dave。应用会显示一个使用 name 动态参数生成的欢迎消息

调试模式

Flask 应用可以在调试模式中运行。

在这个模式下,开发服务器默认会加载两个便利的工具:重载器和调试器。

重载器

启用重载器后,Flask 会监视项目中的所有源码文件,发现变动时自动重启服务器。

在开发过程中运行启动重载器的服务器特别方便,

因为每次修改并保存源码文件后,服务器都会自动重启,让改动生效。

调试器

调试器是一个基于 Web 的工具,当应用抛出未处理的异常时,它会出现在浏览器中。

此时,Web 浏览器变成一个交互式栈跟踪,你可以在里面审查源码,在调用栈的任何位置计算表达式。

调试模式默认禁用。

若想启用,在执行 flask run 命令之前设定 FLASK_DEBUG=1 环境变量:

(venv) $ export FLASK_APP=hello.py
(venv) $ export FLASK_DEBUG=1
(venv) $ flask run

 * Serving Flask app "hello"
 * Forcing debug mode on
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 273-181-528

使用 app.run() 方法启动服务器时,不会用到 FLASK_APP 和 FLASK_DEBUG 环境变量。

若想以编程的方式启动调试模式,就使用 app.run(debug=True)。

客户端通过调试器能请求执行远程代码,因此可能导致生产服务器遭到攻击。

作为一种简单的保护措施,启动调试模式时可以要求输入 PIN 码,执行 flask run 命令时会打印在控制台中。

命令行选项

flask 命令支持一些选项。执行 flask --help,或者执行 flask

flask shell

flask shell 命令在应用的上下文中打开一个 Python shell 会话。

在这个会话中可以运行维护任务或测试,也可以调试问题。

flask run

作用是在 Web 开发服务器中运行应用。这个命令有多个参数:flask run --help

--host

--host 参数特别有用,它告诉 Web 服务器在哪个网络接口上监听客户端发来的连接。

默认情况下,Flask 的 Web 开发服务器监听 localhost 上的连接,因此服务器只接受运行服务器的计算机发送的连接。

下述命令让 Web 服务器监听公共网络接口上的连接,因此同一网络中的其他计算机发送的连接也能接收到:

flask run --host 0.0.0.0

现在,网络中的任何计算机都能通过 http://a.b.c.d:5000 访问 Web 服务器。

其中,a.b.c.d 是运行服务器的计算机的 IP 地址。

--reload、--no-reload、--debugger 和 --no-debugger

--reload、--no-reload、--debugger 和 --no-debugger 参数对调试模式进行细致的设置。

例如,启动调试模式后可以使用 --no-debugger 关闭调试器,但是应用还在调试模式中运行,而且重载器也启用了。

请求–响应循环

应用和请求上下文

Flask 从客户端收到请求时,要让视图函数能访问一些对象,这样才能处理请求。请求对象就是一个很好的例子,它封装了客户端发送的 HTTP 请求。

要想让视图函数能够访问请求对象,一种直截了当的方式是将其作为参数传入视图函数,不过这会导致应用中的每个视图函数都多出一个参数。

为了避免大量可有可无的参数把视图函数弄得一团糟,Flask 使用上下文临时把某些对象变为全局可访问。

from flask import request

@app.route('/')
def index():
    user_agent = request.headers.get('User-Agent')
    return '<p>Your browser is {}</p>'.format(user_agent)

在这个视图函数中我们把 request 当作全局变量使用。

事实上,request 不可能是全局变量。

试想,在多线程服务器中,多个线程同时处理不同客户端发送的不同请求时,每个线程看到的 request 对象必然不同。

Flask 使用上下文让特定的变量在一个线程中全局可访问,与此同时却不会干扰其他线程。

线程是可单独管理的最小指令集。进程经常使用多个活动线程,有时还会共享内存或文件句柄等资源。多线程 Web 服务器会创建一个线程池,再从线程池中选择一个线程处理接收到的请求。

在 Flask 中有两种上下文:应用上下文和请求上下文
变量名上下文说明
current_app应用上下文当前应用的应用实例
g应用上下文处理请求时用作临时存储的对象,每次请求都会重设这个变量
request请求上下文请求对象,封装了客户端发出的 HTTP 请求中的内容
session请求上下文用户会话,值为一个字典,存储请求之间需要“记住”的值

Flask 在分派请求之前激活(或推送)应用和请求上下文,请求处理完成后再将其删除。

应用上下文被推送后,就可以在当前线程中使用 current_app 和 g 变量。

类似地,请求上下文被推送后,就可以使用 request 和 session 变量。

如果使用这些变量时没有激活应用上下文或请求上下文,就会导致错误

应用上下文的使用方法
>>> from hello import app
>>> from flask import current_app
>>> current_app.name
Traceback (most recent call last):
...
RuntimeError: working outside of application context
>>> app_ctx = app.app_context()
>>> app_ctx.push()
>>> current_app.name
'hello'
>>> app_ctx.pop()

在这个例子中,没激活应用上下文之前就调用 current_app.name 会导致错误,但推送完上下文之后就可以调用了。

注意,获取应用上下文的方法是在应用实例上调用 app.app_context()。

请求分派
URL 映射

应用收到客户端发来的请求时,要找到处理该请求的视图函数。

为了完成这个任务,Flask 会在应用的 URL 映射中查找请求的 URL。

URL 映射是 URL 和视图函数之间的对应关系。

Flask 使用 app.route 装饰器或者作用相同的 app.add_url_rule() 方法构建映射。

查看 Flask 应用中的 URL 映射
(venv) $ python
>>> from hello import app
>>> app.url_map
Map([<Rule '/' (HEAD, OPTIONS, GET) -> index>,
 <Rule '/static/<filename>' (HEAD, OPTIONS, GET) -> static>,
 <Rule '/user/<name>' (HEAD, OPTIONS, GET) -> user>])

/ 和 /user/<name> 路由在应用中使用 app.route 装饰器定义。

/static/<filename> 路由是 Flask 添加的特殊路由,用于访问静态文件。

URL 映射中的 (HEAD, OPTIONS, GET) 是请求方法,由路由进行处理。HTTP 规范中规定,每个请求都有对应的处理方法,这通常表示客户端想让服务器执行什么样的操作。

Flask 为每个路由都指定了请求方法,这样即使不同的请求方法发送到相同的 URL 上时,也会使用不同的视图函数处理。

HEAD 和 OPTIONS 方法由 Flask 自动处理,因此可以这么说,在这个应用中,URL 映射中的 3 个路由都使用 GET 方法(表示客户端想请求信息,例如一个网页)

请求对象

Flask 通过上下文变量 request 对外开放请求对象。包含客户端发送的 HTTP 请求的全部信息

Flask 请求对象中最常用的属性和方法
属性或方法说明
form一个字典,存储请求提交的所有表单字段
args一个字典,存储通过 URL 查询字符串传递的所有参数
values一个字典, form 和 args 的合集
cookies一个字典,存储请求的所有 cookie
headers一个字典,存储请求的所有 HTTP 首部
files一个字典,存储请求上传的所有文件
get_data()返回请求主体缓冲的数据
get_json()返回一个 Python 字典,包含解析请求主体后得到的 JSON
blueprint处理请求的 Flask 蓝本的名称;蓝本在第 7 章介绍
endpoint处理请求的 Flask 端点的名称;Flask 把视图函数的名称用作路由端点的名称
methodHTTP 请求方法,例如 GET 或 POST
schemeURL 方案( http 或 https)
is_secure()通过安全的连接(HTTPS)发送请求时返回 True
host请求定义的主机名,如果客户端定义了端口号,还包括端口号
pathURL 的路径部分
query_stringURL 的查询字符串部分,返回原始二进制值
full_pathURL 的路径和查询字符串部分
url客户端请求的完整 URL
base_url同 url,但没有查询字符串部分
remote_addr客户端的 IP 地址
environ请求的原始 WSGI 环境字典
请求钩子

在处理请求之前或之后执行代码

在请求开始时,可能需要创建数据库连接或者验证发起请求的用户身份。

为了避免在每个视图函数中都重复编写代码,Flask 提供了注册通用函数的功能,注册的函数可在请求被分派到视图函数之前或之后调用。

请求钩子通过装饰器实现。Flask 支持以下 4 种钩子

before_request

注册一个函数,在每次请求之前运行。

before_first_request

注册一个函数,只在处理第一个请求之前运行。可以通过这个钩子添加服务器初始化任务。

after_request

注册一个函数,如果没有未处理的异常抛出,在每次请求之后运行。

teardown_request

注册一个函数,即使有未处理的异常抛出,也在每次请求之后运行。

共享数据

在请求钩子函数和视图函数之间共享数据一般使用上下文全局变量 g。

例如,before_request 处理程序可以从数据库中加载已登录用户,并将其保存到 g.user 中。

随后调用视图函数时,便可以通过 g.user 获取用户。

响应

Flask 调用视图函数后,会将其返回值作为响应的内容。

多数情况下,响应就是一个简单的字符串,作为 HTML 页面回送客户端。

状态码

HTTP 协议需要的不仅是作为请求响应的字符串。

HTTP 响应中一个很重要的部分是状态码,Flask 默认设为 200,表明请求已被成功处理。

如果视图函数返回的响应需要使用不同的状态码,可以把数字代码作为第二个返回值,添加到响应文本之后。

例如,下述视图函数返回 400 状态码,表示请求无效:

@app.route('/')
def index():
    return '<h1>Bad Request</h1>', 400
响应首部

视图函数返回的响应还可接受第三个参数,这是一个由 HTTP 响应首部组成的字典

make_response()

如果不想返回由 1 个、2 个或 3 个值组成的元组,Flask 视图函数还可以返回一个响应对象。

make_response() 函数可接受 1 个、2 个或 3 个参数(和视图函数的返回值一样),然后返回一个等效的响应对象。

有时我们需要在视图函数中生成响应对象,然后在响应对象上调用各个方法,进一步设置响应。下例创建一个响应对象,然后设置 cookie:

from flask import make_response

@app.route('/')
def index():
    response = make_response('<h1>This document carries a cookie!</h1>')
    response.set_cookie('answer', '42')
    return response
响应对象最常使用的属性和方法
属性或方法说明
status_codeHTTP 数字状态码
headers一个类似字典的对象,包含随响应发送的所有首部
set_cookie()为响应添加一个 cookie
delete_cookie()删除一个 cookie
content_length响应主体的长度
content_type响应主体的媒体类型
set_data()使用字符串或字节值设定响应
get_data()获取响应主体
重定向

响应有个特殊的类型,称为重定向。

这种响应没有页面文档,只会告诉浏览器一个新 URL,用以加载新页面。

重定向经常在 Web 表单中使用

重定向的状态码通常是 302,在 Location 首部中提供目标 URL。

重定向响应可以使用 3 个值形式的返回值生成,也可在响应对象中设定。

由于使用频繁,Flask 提供了 redirect() 辅助函数,用于生成这种响应:

redirect()
from flask import redirect

@app.route('/')
def index():
    return redirect('http://www.example.com')
abort()

一种特殊的响应由 abort() 函数生成,用于处理错误。

abort() 不会把控制权交还给调用它的函数,而是抛出异常

在下面这个例子中,如果 URL 中动态参数 id 对应的用户不存在,就返回状态码 404

from flask import abort

@app.route('/user/<id>')
def get_user(id):
    user = load_user(id)
    if not user:
        abort(404)
    return '<h1>Hello, {}</h1>'.format(user.name)

模板

开发出易于维护的应用,关键在于编写形式简洁且结构良好的代码

Flask 视图函数的两个完全独立的作用却被融合在了一起,这就产生了一个问题。

视图函数的作用很明确,即生成请求的响应

对最简单的请求来说,这就足够了,但很多情况下,请求会改变应用的状态,而这种变化就发生在视图函数中。

业务逻辑和表现逻辑

以用户在网站中注册新账户的过程为例。

用户在表单中输入电子邮件地址和密码,然后点击提交按钮。服务器接收到包含用户输入数据的请求,然后 Flask 把请求分派给处理注册请求的视图函数。这个视图函数需要访问数据库,添加新用户,然后生成响应回送浏览器,指明操作成功还是失败。这两个过程分别称为业务逻辑和表现逻辑。

把业务逻辑和表现逻辑混在一起会导致代码难以理解和维护

把表现逻辑移到模板中能提升应用的可维护性

模板 渲染

模板是包含响应文本的文件,其中包含用占位变量表示的动态部分,其具体值只在请求的上下文中才能知道。

使用真实值替换变量,再返回最终得到的响应字符串,这一过程称为渲染。

为了渲染模板,Flask 使用一个名为 Jinja2 的强大模板引擎。

Jinja2模板引擎

形式最简单的 Jinja2 模板就是一个包含响应文本的文件

templates/index.html:Jinja2 模板

<h1>Hello World!</h1>
使用变量

templates/user.html:Jinja2 模板

<h1>Hello, {{ name }}!</h1>
渲染模板 render_template()

默认情况下,Flask 在应用目录中的 templates 子目录里寻找模板

hello.py:渲染模板

from flask import Flask, render_template

# ...

@app.route('/')
def index():
    return render_template('index.html')

@app.route('/user/<name>')
def user(name):
    return render_template('user.html', name=name)

Flask 提供的 render_template() 函数把 Jinja2 模板引擎集成到了应用中。

这个函数的第一个参数是模板的文件名,随后的参数都是键 – 值对,表示模板中变量对应的具体值。

在这段代码中,第二个模板收到一个名为 name 的变量。

前例中的 name=name 是经常使用的关键字参数。

左边的 name 表示参数名,就是模板中使用的占位符;

右边的 name 是当前作用域中的变量,表示同名参数的值。

两侧使用相同的变量名是很常见,但不是强制要求。

变量

在模板中使用的 {{ name }} 结构表示一个变量,这是一种特殊的占位符,告诉模板引擎这个位置的值从渲染模板时使用的数据中获取。

Jinja2 能识别所有类型的变量,甚至是一些复杂的类型,例如列表、字典和对象

<p>A value from a dictionary: {{ mydict['key'] }}.</p>
<p>A value from a list: {{ mylist[3] }}.</p>
<p>A value from a list, with a variable index: {{ mylist[myintvar] }}.</p>
<p>A value from an object's method: {{ myobj.somemethod() }}.</p>
Jinja2变量过滤器

变量的值可以使用过滤器修改。过滤器添加在变量名之后,二者之间以竖线分隔。

下述模板把 name 变量的值变成首字母大写的形式:

Hello, {{ name|capitalize }}

Jinja2变量过滤器

过滤器名说明
safe渲染值时不转义
capitalize把值的首字母转换成大写,其他字母转换成小写
lower把值转换成小写形式
upper把值转换成大写形式
title把值中每个单词的首字母都转换成大写
trim把值的首尾空格删掉
striptags渲染之前把值中所有的 HTML 标签都删掉

完整的过滤器列表可在 Jinja2 文档(http://jinja.pocoo.org/docs/2.10/templates/#builtin-filters)中查看。

safe 过滤器

默认情况下,出于安全考虑,Jinja2 会转义所有变量。

例如,如果一个变量的值为 '<h1>Hello</h1>',Jinja2 会将其渲染成 '<h1>Hello</ h1>',

浏览器能显示这个 h1 元素,但不会解释它。

很多情况下需要显示变量中存储的 HTML 代码,这时就可使用 safe 过滤器。

千万别在不可信的值上使用 safe 过滤器,例如用户在表单中输入的文本。

控制结构

Jinja2 提供了多种控制结构,可用来改变模板的渲染流程

在模板中使用条件判断语句
{% if user %}
    Hello, {{ user }}!
{% else %}
    Hello, Stranger!
{% endif %}
使用 for 循环
<ul>
    {% for comment in comments %}
        <li>{{ comment }}</li>
    {% endfor %}
</ul>

宏类似于 Python 代码中的函数

{% macro render_comment(comment) %}
    <li>{{ comment }}</li>
{% endmacro %}

<ul>
    {% for comment in comments %}
        {{ render_comment(comment) }}
    {% endfor %}
</ul>

为了重复使用宏,可以把宏保存在单独的文件中,然后在需要使用的模板中导入:

{% import 'macros.html' as macros %}

<ul>
    {% for comment in comments %}
        {{ macros.render_comment(comment) }}
    {% endfor %}
</ul>

需要在多处重复使用的模板代码片段可以写入单独的文件,再引入所有模板中,以避免重复:

{% include 'common.html' %}
模板继承

重复使用代码的强大方式

类似于 Python 代码中的类继承

首先,创建一个名为 base.html 的基模板:

<html>
<head>
    {% block head %}
    <title>{% block title %}{% endblock %} - My Application</title>
    {% endblock %}
</head>
<body>
    {% block body %}
    {% endblock %}
</body>
</html>

基模板中定义的区块可在衍生模板中覆盖。

Jinja2 使用 block 和 endblock 指令在基模板中定义内容区块。

在本例中,我们定义了名为 head、title 和 body 的区块。

注意,title 包含在 head 中。

基模板的衍生模板:

{% extends "base.html" %}
{% block title %}Index{% endblock %}
{% block head %}
    {{ super() }}
    <style>
    </style>
{% endblock %}
{% block body %}
<h1>Hello, World!</h1>
{% endblock %}

extends 指令声明这个模板衍生自 base.html。

在 extends 指令之后,基模板中的 3 个区块被重新定义,模板引擎会将其插入适当的位置。

如果基模板和衍生模板中的同名区块中都有内容,衍生模板中的内容将显示出来。

在衍生模板的区块里可以调用 super(),引用基模板中同名区块里的内容。上例中的 head 区块就是这么做的。

使用Flask-Bootstrap集成Bootstrap

要想在应用中集成 Bootstrap,最直接的方法是根据 Bootstrap 文档中的说明对 HTML 模板进行必要的改动。

不过,这个任务使用 Flask 扩展处理要简单得多,而且相关的改动不会导致主逻辑凌乱不堪。

安装 Flask-Bootstrap
(venv) $ pip install flask-bootstrap
初始化 Flask-Bootstrap

Flask 扩展在创建应用实例时初始化

hello.py:初始化 Flask-Bootstrap

把应用实例作为参数传给构造函数

from flask_bootstrap import Bootstrap

# ...

bootstrap = Bootstrap(app)

初始化 Flask-Bootstrap 之后,就可以在应用中使用一个包含所有 Bootstrap 文件和一般结构的基模板。应用利用 Jinja2 的模板继承机制来扩展这个基模板

使用 Flask-Bootstrap 的模板

templates/user.html:使用 Flask-Bootstrap 的模板

{% extends "bootstrap/base.html" %}

{% block title %}Flasky{% endblock %}

{% block navbar %}
<div class="nav-bar navbar-inverse" role="navigation">

</div>
{% endblock %}

{% block content %}
<div class="container">
    <div class="page-header">
        <h1>Hello, {{ name }}!</h1>
    </div>
</div>
{% endblock %}

Jinja2 中的 extends 指令从 Flask-Bootstrap 中导入 bootstrap/base.html,从而实现模板继承。Flask-Bootstrap 的基模板提供了一个网页骨架,引入了 Bootstrap 的所有 CSS 和 JavaScript 文件。

上面这个 user.html 模板定义了 3 个区块,分别名为 title、navbar 和 content。这些区块都是基模板提供的,可在衍生模板中重新定义。title 区块的作用很明显,其中的内容会出现在渲染后的 HTML 文档头部,放在 <title> 标签中。navbar 和 content 这两个区块分别表示页面中的导航栏和主体内容。

在这个模板中,navbar 区块使用 Bootstrap 组件定义了一个简单的导航栏。content 区块中有个 <div> 容器,其中包含一个页头。之前版本中的欢迎消息,现在就放在这个页头里。

Flask-Bootstrap基模板中定义的区块

Flask-Bootstrap 的 base.html 模板还定义了很多其他区块,都可在衍生模板中使用

区块名说明
doc整个 HTML 文档
html_attribs<html> 标签的属性
html<html> 标签中的内容
head<head> 标签中的内容
title<title> 标签中的内容
metas一组 <meta> 标签
stylesCSS 声明
body_attribs<body> 标签的属性
super() 向已经有内容的块中添加新内容

很多区块都是 Flask-Bootstrap 自用的,如果直接覆盖可能会导致一些问题。

例如,Bootstrap 的 CSS 和 JavaScript 文件在 styles 和 scripts 区块中声明。

如果应用需要向已经有内容的块中添加新内容,必须使用 Jinja2 提供的 super() 函数。

例如,如果要在衍生模板中添加新的 JavaScript 文件,需要这么定义 scripts 区块:

{% block scripts %}
{{ super() }}
<script type="text/javascript" src="my-script.js"></script>
{% endblock %}

Web 表单

Flask 请求对象包含客户端在请求中发送的全部信息,

对包含表单数据的 POST 请求来说,用户填写的信息通过 request.form 访问。

尽管 Flask 的请求对象提供的信息足以处理 Web 表单,但有些任务很单调,而且要重复操作。比如,生成表单的 HTML 代码和验证提交的表单数据。

Flask-WTF

Flask-WTF 扩展可以把处理 Web 表单的过程变成一种愉悦的体验。

这个扩展对独立的 WTForms 包进行了包装,方便集成到 Flask 应用中。

安装

(venv) $ pip install flask-wtf

配置 Flask-WTF

Flask-WTF 无须在应用层初始化,但是它要求应用配置一个密钥。

密钥是一个由随机字符构成的唯一字符串,通过加密或签名以不同的方式提升应用的安全性。

Flask 使用这个密钥保护用户会话,以防被篡改。

每个应用的密钥应该不同,而且不能让任何人知道

配置 Flask-WTF

app = Flask(__name__)

app.config['SECRET_KEY'] = 'hard to guess string'

app.config 字典可用于存储 Flask、扩展和应用自身的配置变量。

使用标准的字典句法就能把配置添加到 app.config 对象中。

这个对象还提供了一些方法,可以从文件或环境中导入配置。
之后将介绍管理大型应用配置的合理方式。

Flask-WTF 之所以要求应用配置一个密钥,是为了防止表单遭到跨站请求伪造(CSRF,cross-site request forgery)攻击。恶意网站把请求发送到被攻击者已登录的其他网站时,就会引发 CSRF 攻击。Flask-WTF 为所有表单生成安全令牌,存储在用户会话中。令牌是一种加密签名,根据密钥生成。

为了增强安全性,密钥不应该直接写入源码,而要保存在环境变量中。这一技术在第 7 章介绍。

在视图函数中处理表单

视图函数 index() 有两个任务:一是渲染表单,二是接收用户在表单中填写的数据。

hello.py:使用 GET 和 POST 请求方法处理 Web 表单

@app.route('/', methods=['GET', 'POST'])
def index():
    name = None
    form = NameForm()
    if form.validate_on_submit():
        name = form.name.data
        form.name.data = ''
    return render_template('index.html', form=form, name=name)

app.route 装饰器中多出的 methods 参数告诉 Flask,在 URL 映射中把这个视图函数注册为 GET 和 POST 请求的处理程序。

如果没指定 methods 参数,则只把视图函数注册为 GET 请求的处理程序

局部变量 name 用于存放表单中输入的有效名字,如果没有输入,其值为 None。如上述代码所示,我们在视图函数中创建了一个 NameForm 实例,用于表示表单。提交表单后,如果数据能被所有验证函数接受,那么 validate_on_submit() 方法的返回值为 True,否则返回 False。这个函数的返回值决定是重新渲染表单还是处理表单提交的数据。

用户首次访问应用时,服务器会收到一个没有表单数据的 GET 请求,所以 validate_on_submit() 将返回 False。此时,if 语句的内容将被跳过,对请求的处理只是渲染模板,并传入表单对象和值为 None 的 name 变量作为参数。用户会看到浏览器中显示了一个表单。

用户提交表单后,服务器会收到一个包含数据的 POST 请求。validate_on_submit() 会调用名字字段上依附的 DataRequired() 验证函数。如果名字不为空,就能通过验证,validate_on_submit() 返回 True。现在,用户输入的名字可通过字段的 data 属性获取。在 if 语句中,把名字赋值给局部变量 name,然后再把 data 属性设为空字符串,清空表单字段。因此,再次渲染这个表单时,各字段中将没有内容。最后一行调用 render_template() 函数渲染模板,但这一次参数 name 的值为表单中输入的名字,因此会显示一个针对该用户的欢迎消息。

重定向和用户会话

前一版 hello.py 存在一个可用性问题。用户输入名字后提交表单,然后点击浏览器的刷新按钮,会看到一个莫名其妙的警告,要求在再次提交表单之前进行确认。

之所以出现这种情况,是因为刷新页面时浏览器会重新发送之前发送过的请求。

如果前一个请求是包含表单数据的 POST 请求,刷新页面后会再次提交表单。多数情况下,这并不是我们想执行的操作,因此浏览器才要求用户确认。

很多用户不理解浏览器发出的这个警告。鉴于此,最好别让 Web 应用把 POST 请求作为浏览器发送的最后一个请求。

Post / 重定向 /Get 模式

这种需求的实现方式是,使用重定向作为 POST 请求的响应,而不是使用常规响应。

重定向是一种特殊的响应,响应内容包含的是 URL,而不是 HTML 代码的字符串。

浏览器收到这种响应时,会向重定向的 URL 发起 GET 请求,显示页面的内容。

这个页面的加载可能要多花几毫秒,因为要先把第二个请求发给服务器。

除此之外,用户不会察觉到有什么不同。

现在,前一个请求是 GET 请求,所以刷新命令能像预期的那样正常运作了。

这个技巧称为 Post / 重定向 /Get 模式。

但这种方法又会引起另一个问题。应用处理 POST 请求时,可以通过 form.name.data 获取用户输入的名字,然而一旦这个请求结束,数据也就不见了。因为这个 POST 请求使用重定向处理,所以应用需要保存输入的名字,这样重定向后的请求才能获得并使用这个名字,从而构建真正的响应。

session 用户会话

应用可以把数据存储在用户会话中,以便在请求之间“记住”数据。

用户会话是一种私有存储,每个连接到服务器的客户端都可访问。

用户会话是请求上下文中的变量,名为 session,像标准的 Python 字典一样操作。

默认情况下,用户会话保存在客户端 cookie 中,使用前面设置的密钥加密签名。如果篡改了 cookie 的内容,签名就会失效,会话也将随之失效。

index() 视图函数的新版本,实现了重定向和用户会话

from flask import Flask, render_template, session, redirect, url_for

@app.route('/', methods=['GET', 'POST'])
def index():
    form = NameForm()
    if form.validate_on_submit():
        session['name'] = form.name.data
        return redirect(url_for('index'))
    return render_template('index.html', form=form, name=session.get('name'))

应用的前一个版本在局部变量 name 中存储用户在表单中输入的名字。这个变量现在保存在用户会话中,即 session['name'],所以在两次请求之间能记住输入的值。

现在,包含有效表单数据的请求最后会使视图函数调用 redirect() 函数。这是 Flask 提供的辅助函数,用于生成 HTTP 重定向响应。redirect() 函数的参数是重定向的 URL,这里使用的重定向 URL 是应用的根 URL,因此重定向响应本可以写得更简单一些,写成 redirect('/'),不过这里却使用了 Flask 提供的 URL 生成函数 url_for()(参见第 3 章)。

url_for() 函数的第一个且唯一必须指定的参数是端点名,即路由的内部名称。默认情况下,路由的端点是相应视图函数的名称。在这个示例中,处理根 URL 的视图函数是 index(),因此传给 url_for() 函数的名字是 index。

最后一处改动位于 render_template() 函数中,现在我们使用 session.get('name') 直接从会话中读取 name 参数的值。与普通的字典一样,这里使用 get() 获取字典中键对应的值,可以避免未找到键时抛出异常。如果指定的键不存在,则 get() 方法返回默认值 None。

闪现消息

请求完成后,有时需要让用户知道状态发生了变化,可以是确认消息、警告或者错误提醒。

一个典型例子是,用户提交有一项错误的登录表单后,服务器发回的响应重新渲染登录表单,并在表单上面显示一个消息,提示用户名或密码无效。

Flask 本身内置这个功能,flash() 函数可实现这种效果。

flash()

闪现消息

from flask import Flask, render_template, session, redirect, url_for, flash

@app.route('/', methods=['GET', 'POST'])
def index():
    form = NameForm()
    if form.validate_on_submit():
        old_name = session.get('name')
        if old_name is not None and old_name != form.name.data:
            flash('Looks like you have changed your name!')
        session['name'] = form.name.data
        return redirect(url_for('index'))
    return render_template('index.html',
        form = form, name = session.get('name'))

在这个示例中,每次提交的名字都会和存储在用户会话中的名字进行比较,而会话中存储的名字是前一次在这个表单中提交的数据。如果两个名字不一样,就会调用 flash() 函数,在发给客户端的下一个响应中显示一个消息。

仅调用 flash() 函数并不能把消息显示出来,应用的模板必须渲染这些消息。最好在基模板中渲染闪现消息,因为这样所有页面都能显示需要显示的消息。Flask 把 get_flashed_messages() 函数开放给模板,用于获取并渲染闪现消息,如示例 4-7 所示。

templates/base.html:渲染闪现消息

使用 Bootstrap 提供的 CSS alert 样式渲染警告消息

{% block content %}
<div class="container">
    {% for message in get_flashed_messages() %}
    <div class="alert alert-warning">
        <button type="button" class="close" data-dismiss="alert">&times;</button>
        {{ message }}
    </div>
    {% endfor %}

    {% block page_content %}{% endblock %}
</div>
{% endblock %}

闪现消息 get_flashed_messages()

这里使用了循环,因为在之前的请求循环中每次调用 flash() 函数时都会生成一个消息,所以可能有多个消息在排队等待显示。

get_flashed_messages() 函数获取的消息在下次调用时不会再次返回,因此闪现消息只显示一次,然后就消失了。

数据库

数据库按照一定规则保存应用的数据,应用再发起查询,取回所需的数据。

Web 应用最常使用基于关系模型的数据库,这种数据库也称为 SQL 数据库,因为它们使用结构化查询语言(SQL)。

不过近年来文档数据库和键 – 值对数据库成了流行的替代选择,这两种数据库合称 NoSQL 数据库。

SQL数据库

实体建模

关系型数据库把数据存储在表中,表为应用中不同的实体建模。

例如,订单管理应用的数据库中可能有 customers、products 和 orders 等表。

表中的列数是固定的,行数是可变的。

列定义表所表示的实体的数据属性。

例如,customers 表中可能有 name、address、phone 等列。

表中的行定义部分或所有列对应的真实数据。

主键

表中有个特殊的列,称为主键,其值为表中各行的唯一标识符

外键

表中还可以有称为外键的列,引用同一个表或不同表中某一行的主键。

关系

行之间的这种联系称为关系,这正是关系型数据库模型的基础。

实体 – 关系图

一个简单数据库的关系图。这个数据库中有两个表,分别存储用户和用户角色。

连接两个表的线代表两个表之间的关系。

关系型数据库示例

image

数据库结构的这种图示法称为实体 – 关系图。其中,方框表示数据库表,里面列出表的属性(或列)。

roles 表存储所有可用的用户角色,每个角色都使用一个唯一的 id 值(即表的主键)进行标识。

users 表存储用户,每个用户也有唯一的 id 值。除了 id 主键之外,roles 表中还有 name 列,users 表中还有 username 和 password 列。

users 表中的 role_id 列是外键。连接 roles.id 和 users.role_id 两列的线表示两个表之间的关系。

这条线两端的符号表明关系的基数。在 roles.id 一侧的短竖线表示“一个”,而 users.role_id 一侧的符号表示“多个”。二者一起构成一对多关系,即 roles 表中的各行可以对应于 user 表中的多行。

关系型数据库引擎为联结操作提供了必要的支持

NoSQL数据库

所有不符合上节所述的关系模型的数据库统称为 NoSQL 数据库。

NoSQL 数据库一般使用集合代替表,使用文档代替记录。

NoSQL 数据库更适合设计成如图 5-2 所示的结构。这是执行反规范化操作得到的结果,它减少了表的数量,却增加了数据重复量。

image

使用SQL还是NoSQL

SQL 数据库擅于用高效且紧凑的形式存储结构化数据。

这种数据库需要花费大量精力保证数据的一致性,需要考虑停电或硬件失效。

为了达到这种程度的可靠性,关系型数据库采用一种称为 ACID 的范式,即 atomicity(原子性)、consistency(一致性)、isolation(隔离性)和 durability(持续性)。

NoSQL 数据库放宽了对 ACID 的要求,从而获得性能上的优势。

Python数据库框架

Flask 并不限制你使用何种类型的数据库包

MySQL、Postgres、SQLite、Redis、MongoDB、CouchDB 或 DynamoDB

数据库抽象层代码包

例如 SQLAlchemy 和 MongoEngine。你可以使用这些抽象包直接处理高等级的 Python 对象,而不用处理如表、文档或查询语言之类的数据库实体。

易用性

如果直接比较数据库引擎和数据库抽象层,显然后者取胜。

抽象层,也称为对象关系映射器(ORM)或对象文档映射器(ODM),在用户不知不觉的情况下把高层的面向对象操作转换成低层的数据库指令。

性能

ORM 和 ODM 把对象业务转换成数据库业务时会有一定的损耗。

多数情况下,这种性能的降低微不足道,但也不一定都是如此。

一般情况下,ORM 和 ODM 对生产率的提升远远超过了这一丁点儿的性能降低,所以性能降低这个理由不足以说服用户完全放弃 ORM 和 ODM。

真正的关键点在于选择一个能直接操作低层数据库的抽象层,以防特定的操作需要直接使用数据库原生指令优化。

可移植性

考虑其是否能在你的开发平台和生产平台中使用

可移植性还针对 ORM 和 ODM。尽管有些框架只为一种数据库引擎提供抽象层,但其他框架可能做了更高层的抽象,支持不同的数据库引擎,而且都使用相同的面向对象接口。SQLAlchemy ORM 就是一个很好的例子,它支持很多关系型数据库引擎,包括流行的 MySQL、Postgres 和 SQLite。

Flask-SQLAlchemy

Flask-SQLAlchemy 是一个 Flask 扩展,简化了在 Flask 应用中使用 SQLAlchemy 的操作。SQLAlchemy 是一个强大的关系型数据库框架,支持多种数据库后台。SQLAlchemy 提供了高层 ORM,也提供了使用数据库原生 SQL 的低层功能。

安装
(venv) $ pip install flask-sqlalchemy

在 Flask-SQLAlchemy 中,数据库使用 URL 指定

FLask-SQLAlchemy数据库URL

数据库引擎URL
MySQLmysql://username:password@hostname/database
Postgrespostgresql://username:password@hostname/database
SQLite(Linux,macOS)sqlite:////absolute/path/to/database
SQLite(Windows)sqlite:///c:/absolute/path/to/database

在这些 URL 中,hostname 表示数据库服务所在的主机,可以是本地主机(localhost),也可以是远程服务器。

数据库服务器上可以托管多个数据库,因此 database 表示要使用的数据库名。

如果数据库需要验证身份,使用 username 和 password 提供数据库用户的凭据

SQLite 数据库没有服务器,因此不用指定 hostname、username 和 password。URL 中的 database 是磁盘中的文件名。

应用使用的数据库 URL 必须保存到 Flask 配置对象的 SQLALCHEMY_DATABASE_URI 键中。

Flask-SQLAlchemy 文档还建议把 SQLALCHEMY_TRACK_MODIFICATIONS 键设为 False,以便在不需要跟踪对象变化时降低内存消耗。

配置数据库
import os
from flask_sqlalchemy import SQLAlchemy

basedir = os.path.abspath(os.path.dirname(__file__))

app = Flask(__name__)

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

app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

db = SQLAlchemy(app)

db 对象是 SQLAlchemy 类的实例,表示应用使用的数据库,通过它可获得 Flask-SQLAlchemy 提供的所有功能。

定义模型

模型这个术语表示应用使用的持久化实体。

在 ORM 中,模型一般是一个 Python 类,类中的属性对应于数据库表中的列。

Flask-SQLAlchemy 创建的数据库实例为模型提供了一个基类以及一系列辅助类和辅助函数,可用于定义模型的结构。

定义 Role 和 User 模型
class Role(db.Model):
    __tablename__ = 'roles'
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(64), unique=True)

    def __repr__(self):
        return '<Role %r>' % self.name

class User(db.Model):
    __tablename__ = 'users'
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(64), unique=True, index=True)

    def __repr__(self):
        return '<User %r>' % self.username
tablename

类变量 tablename 定义在数据库中使用的表名。

如果没有定义 tablename ,Flask-SQLAlchemy 会使用一个默认名称,但默认的表名没有遵守流行的使用复数命名的约定,所以最好由我们自己来指定表名。

其余的类变量都是该模型的属性,定义为 db.Column 类的实例。

db.Column

db.Column 类构造函数的第一个参数是数据库列和模型属性的类型

常用的SQLAlchemy列类型

类型名Python类型说明
Integerint普通整数,通常是 32 位
SmallIntegerint取值范围小的整数,通常是 16 位
BigIntegerint 或 long不限制精度的整数
Floatfloat浮点数
Numericdecimal.Decimal定点数
Stringstr变长字符串
Textstr变长字符串,对较长或不限长度的字符串做了优化
Unicodeunicode变长 Unicode 字符串
UnicodeTextunicode变长 Unicode 字符串,对较长或不限长度的字符串做了优化
Booleanbool布尔值
Datedatetime.date日期
Timedatetime.time时间
DateTimedatetime.datetime日期和时间
Intervaldatetime.timedelta时间间隔
Enumstr一组字符串
PickleType任何 Python 对象自动使用 Pickle 序列化
LargeBinarystr二进制 blob

db.Column 的其余参数指定属性的配置选项

常用的SQLAlchemy列选项

选项名说明
primary_key如果设为 True,列为表的主键
uniquey如果设为 True,列不允许出现重复的值
indexy如果设为 True,为列创建索引,提升查询效率
nullabley如果设为 True,列允许使用空值;如果设为 False,列不允许使用空值
defaulty为列定义默认值
主键

Flask-SQLAlchemy 要求每个模型都定义主键,这一列经常命名为 id

repr()

虽然没有强制要求,但这两个模型都定义了 repr() 方法,返回一个具有可读性的字符串表示模型,供调试和测试时使用。

关系

关系型数据库使用关系把不同表中的行联系起来

角色到用户的一对多关系,因为一个角色可属于多个用户,而每个用户都只能有一个角色。

在数据库模型中定义关系

class Role(db.Model):
    # ...
    users = db.relationship('User', backref='role')

class User(db.Model):
    # ...
    role_id = db.Column(db.Integer, db.ForeignKey('roles.id'))
db.ForeignKey

关系使用 users 表中的外键连接两行。

添加到 User 模型中的 role_id 列被定义为外键,就是这个外键建立起了关系。

传给 db.ForeignKey() 的参数 'roles.id' 表明,这列的值是 roles 表中相应行的 id 值。

db.relationship

从“一”那一端可见,添加到 Role 模型中的 users 属性代表这个关系的面向对象视角。

对于一个 Role 类的实例,其 users 属性将返回与角色相关联的用户组成的列表(即“多”那一端)。

db.relationship() 的第一个参数表明这个关系的另一端是哪个模型。

如果关联的模型类在模块后面定义,可使用字符串形式指定。

db.relationship() 中的 backref 参数向 User 模型中添加一个 role 属性,从而定义反向关系。

通过 User 实例的这个属性可以获取对应的 Role 模型对象,而不用再通过 role_id 外键获取。

多数情况下,db.relationship() 都能自行找到关系中的外键,但有时却无法确定哪一列是外键。例如,如果 User 模型中有两个或以上的列定义为 Role 模型的外键,SQLAlchemy 就不知道该使用哪一列。如果无法确定外键,就要为 db.relationship() 提供额外的参数。

常用的SQLAlchemy关系选项

选项名说明
backref在关系的另一个模型中添加反向引用
primaryjoin明确指定两个模型之间使用的联结条件;只在模棱两可的关系中需要指定
lazy指定如何加载相关记录,可选值有 select(首次访问时按需加载)、immediate(源对象加载后就加载)、joined(加载记录,但使用联结)、subquery(立即加载,但使用子查询),noload(永不加载)和 dynamic(不加载记录,但提供加载记录的查询)
uselist如果设为 False,不使用列表,而使用标量值
order_by指定关系中记录的排序方式
secondary指定多对多关系中关联表的名称
secondaryjoinSQLAlchemy 无法自行决定时,指定多对多关系中的二级联结条件

一对一关系可以用前面介绍的一对多关系表示,但调用 db.relationship() 时要把 uselist 设为 False,把“多”变成“一”。

多对一关系也可使用一对多表示,对调两个表即可,或者把外键和 db.relationship() 都放在“多”这一侧。

最复杂的关系类型是多对多,需要用到第三张表,这个表称为关联表(或联结表)

数据库操作

创建表 db.create_all

Flask-SQLAlchemy 根据模型类创建数据库。

db.create_all() 函数将寻找所有 db.Model 的子类,然后在数据库中创建对应的表:

(venv) $ flask shell
>>> from hello import db
>>> db.create_all()

现在查看应用目录,你会发现有个名为 data.sqlite 的文件,文件名与配置中指定的一样。

如果数据库表已经存在于数据库中,那么 db.create_all() 不会重新创建或者更新相应的表。

删除 db.drop_all

如果修改模型后要把改动应用到现有的数据库中,这一行为会带来不便。

更新现有数据库表的蛮力方式是先删除旧表再重新创建:

遗憾的是,这个方法有个我们不想看到的副作用,它把数据库中原有的数据都销毁了

>>> db.drop_all()
>>> db.create_all()
插入行

创建一些角色和用户

>>> from hello import Role, User
>>> admin_role = Role(name='Admin')
>>> mod_role = Role(name='Moderator')
>>> user_role = Role(name='User')
>>> user_john = User(username='john', role=admin_role)
>>> user_susan = User(username='susan', role=user_role)
>>> user_david = User(username='david', role=user_role)

模型的构造函数接受的参数是使用关键字参数指定的模型属性初始值。

注意,role 属性也可使用,虽然它不是真正的数据库列,但却是一对多关系的高级表示。

新建对象时没有明确设定 id 属性,因为在多数数据库中主键由数据库自身管理。

现在这些对象只存在于 Python 中,还未写入数据库。因此,id 尚未赋值:

>>> print(admin_role.id)
None
>>> print(mod_role.id)
None
>>> print(user_role.id)
None
数据库会话管理 db.session

对数据库的改动通过数据库会话管理,在 Flask-SQLAlchemy 中,会话由 db.session 表示。

准备把对象写入数据库之前,要先将其添加到会话中:

>>> db.session.add(admin_role)
>>> db.session.add(mod_role)
>>> db.session.add(user_role)
>>> db.session.add(user_john)
>>> db.sessionadd(user_susan)
>>> db.session.add(user_david)

或者简写成:

>>> db.session.add_all([admin_role, mod_role, user_role,
...     user_john, user_susan, user_david])
写入数据库 commit

为了把对象写入数据库,我们要调用 commit() 方法提交会话:

>>> db.session.commit()

提交数据后再查看 id 属性,现在它们已经赋值了:

>>> print(admin_role.id)
1
>>> print(mod_role.id)
2
>>> print(user_role.id)
3

数据库会话 db.session 和 Flask session 对象没有关系。数据库会话也称为事务。

数据库会话能保证数据库的一致性。提交操作使用原子方式把会话中的对象全部写入数据库。

如果在写入会话的过程中发生了错误,那么整个会话都会失效。

如果你始终把相关改动放在会话中提交,就能避免因部分更新导致的数据库不一致。

回滚 db.session.rollback

数据库会话也可回滚。调用 db.session.rollback() 后,添加到数据库会话中的所有对象都将还原到它们在数据库中的状态。

修改行 add

在数据库会话上调用 add() 方法也能更新模型。

把 "Admin" 角色重命名为 "Administrator"

>>> admin_role.name = 'Administrator'
>>> db.session.add(admin_role)
>>> db.session.commit()
删除行 delete

数据库会话还有个 delete() 方法。下面这个例子把 "Moderator" 角色从数据库中删除:

删除与插入和更新一样,提交数据库会话后才会执行

>>> db.session.delete(mod_role)
>>> db.session.commit()
查询行

Flask-SQLAlchemy 为每个模型类都提供了 query 对象

all

最基本的模型查询是使用 all() 方法取回对应表中的所有记录:

>>> Role.query.all()
[<Role 'Administrator'>, <Role 'User'>]
>>> User.query.all()
[<User 'john'>, <User 'susan'>, <User 'david'>]
过滤器 filter_by

使用过滤器可以配置 query 对象进行更精确的数据库查询。下面这个例子查找角色为 "User" 的所有用户:

>>> User.query.filter_by(role=user_role).all()
[<User 'susan'>, <User 'david'>]
查看原生 SQL 查询语句

若想查看 SQLAlchemy 为查询生成的原生 SQL 查询语句,只需把 query 对象转换成字符串:

>>> str(User.query.filter_by(role=user_role))
'SELECT users.id AS users_id, users.username AS users_username,
users.role_id AS users_role_id \nFROM users \nWHERE :param_1 = users.role_id'
first

first() 方法只返回第一个结果,如果没有结果的话,则返回 None

>>> user_role = Role.query.filter_by(name='User').first()

filter_by() 等过滤器在 query 对象上调用,返回一个更精确的 query 对象。

多个过滤器可以一起调用,直到获得所需结果。

常用的SQLAlchemy查询过滤器

http://docs.sqlalchemy.org

过滤器说明
filter()把过滤器添加到原查询上,返回一个新查询
filter_by()把等值过滤器添加到原查询上,返回一个新查询
limit()使用指定的值限制原查询返回的结果数量,返回一个新查询
offset()偏移原查询返回的结果,返回一个新查询
order_by()根据指定条件对原查询结果进行排序,返回一个新查询
group_by()根据指定条件对原查询结果进行分组,返回一个新查询

在查询上应用指定的过滤器后,调用 all() 方法将执行查询,以列表的形式返回结果。

除了 all() 方法之外,还有其他方法能触发查询执行

常用的SQLAlchemy查询执行方法

方法说明
all()以列表形式返回查询的所有结果
first()返回查询的第一个结果,如果没有结果,则返回 None
first_or_404()返回查询的第一个结果,如果没有结果,则终止请求,返回 404 错误响应
get()返回指定主键对应的行,如果没有对应的行,则返回 None
get_or_404()返回指定主键对应的行,如果没找到指定的主键,则终止请求,返回 404 错误响应
count()返回查询结果的数量
paginate()返回一个 Paginate 对象,包含指定范围内的结果
关系查询

关系与查询的处理方式类似。

下面这个例子分别从关系的两端查询角色和用户之间的一对多关系:

>>> users = user_role.users
>>> users
[<User 'susan'>, <User 'david'>]
>>> users[0].role
<Role 'User'>

执行 user_role.users 表达式时,隐式的查询会调用 all() 方法,返回一个用户列表。

此时,query 对象是隐藏的,无法指定更精确的查询过滤器。

就这个示例而言,返回一个按照字母顺序排列的用户列表可能更好。

修改关系的设置,加入了 lazy='dynamic' 参数,从而禁止自动执行查询。

动态数据库关系

class Role(db.Model):
    # ...
    users = db.relationship('User', backref='role', lazy='dynamic')
    # ...

这样配置关系之后,user_role.users 将返回一个尚未执行的查询,因此可以在其上添加过滤器:

>>> user_role.users.order_by(User.username).all()
[<User 'david'>, <User 'susan'>]
>>> user_role.users.count()
2

在视图函数中操作数据库

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

def index():
    form = NameForm()
    if form.validate_on_submit():
        user = User.query.filter_by(username=form.name.data).first()
        if user is None:
            user = User(username=form.name.data)
            db.session.add(user)
            db.session.commit()
            session['known'] = False
        else:
            session['known'] = True
        session['name'] = form.name.data
        form.name.data = ''
        return redirect(url_for('index'))
    return render_template('index.html',
        form=form, name=session.get('name'),
        known=session.get('known', False))

在模板中定制欢迎消息

{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}

{% block title %}Flasky{% endblock %}

{% block page_content %}
<div class="page-header">
    <h1>Hello, {% if name %}{{ name }}{% else %}Stranger{% endif %}!</h1>
    {% if not known %}
    <p>Pleased to meet you!</p>
    {% else %}
    <p>Happy to see you again!</p>
    {% endif %}
</div>
{{ wtf.quick_form(form) }}
{% endblock %}

集成Python shell

flask shell 命令自动导入 数据库实例和模型

使用 app.shell_context_processor 装饰器创建并注册一个 shell 上下文处理器

添加一个 shell 上下文

@app.shell_context_processor
def make_shell_context():
    return dict(db=db, User=User, Role=Role)

这个 shell 上下文处理器函数返回一个字典,包含数据库实例和模型。除了默认导入的 app 之外,flask shell 命令将自动把这些对象导入 shell。

$ flask shell
>>> app
<Flask 'hello'>
>>> db
<SQLAlchemy engine='sqlite:////home/flask/flasky/data.sqlite'>
>>> User
<class 'hello.User'>

使用Flask-Migrate实现数据库迁移

更新表更好的方法是使用数据库迁移框架

数据库迁移框架能跟踪数据库模式的变化,然后以增量的方式把变化应用到数据库中。

SQLAlchemy 的开发人员编写了一个迁移框架,名为 Alembic。除了直接使用 Alembic 之外,Flask 应用还可使用 Flask-Migrate 扩展。这个扩展是对 Alembic 的轻量级包装,并与 flask 命令做了集成。

创建迁移仓库
安装 Flask-Migrate
(venv) $ pip install flask-migrate
初始化 Flask-Migrate
from flask_migrate import Migrate
# ...
migrate = Migrate(app, db)

为了开放数据库迁移相关的命令,Flask-Migrate 添加了 flask db 命令和几个子命令。在新项目中可以使用 init 子命令添加数据库迁移支持:

创建 migrations 目录
(venv) $ flask db init
  Creating directory /home/flask/flasky/migrations...done
  Creating directory /home/flask/flasky/migrations/versions...done
  Generating /home/flask/flasky/migrations/alembic.ini...done
  Generating /home/flask/flasky/migrations/env.py...done
  Generating /home/flask/flasky/migrations/env.pyc...done
  Generating /home/flask/flasky/migrations/README...done
  Generating /home/flask/flasky/migrations/script.py.mako...done
  Please edit configuration/connection/logging settings in
  '/home/flask/flasky/migrations/alembic.ini' before proceeding.

这个命令会创建 migrations 目录,所有迁移脚本都存放在这里

数据库迁移仓库中的文件要和应用的其他文件一起纳入版本控制

创建迁移脚本

在 Alembic 中,数据库迁移用迁移脚本表示。脚本中有两个函数,分别是 upgrade() 和 downgrade()。

upgrade() 函数把迁移中的改动应用到数据库中,

downgrade() 函数则将改动删除。

Alembic 具有添加和删除改动的能力,意味着数据库可重设到修改历史的任意一点。

可以使用 revision 命令手动创建 Alembic 迁移,

也可使用 migrate 命令自动创建。

手动创建的迁移只是一个骨架,upgrade() 和 downgrade() 函数都是空的,开发者要使用 Alembic 提供的 Operations 对象指令实现具体操作。

自动创建的迁移会根据模型定义和数据库当前状态之间的差异尝试生成 upgrade() 和 downgrade() 函数的内容。

自动创建的迁移不一定总是正确的,有可能会漏掉一些细节。比如说我们重命名了一列,自动生成的迁移可能会把这当作删除了一列,然后又新增了一列。如果原封不动地使用自动生成的迁移,这一列中的数据就会丢失!鉴于此,自动生成迁移脚本后一定要进行检查,把不准确的部分手动改过来。

Flask-Migrate

使用 Flask-Migrate 管理数据库模式变化的步骤如下。

(1) 对模型类做必要的修改。

(2) 执行 flask db migrate 命令,自动创建一个迁移脚本。

(3) 检查自动生成的脚本,根据对模型的实际改动进行调整。

(4) 把迁移脚本纳入版本控制。

(5) 执行 flask db upgrade 命令,把迁移应用到数据库中。

flask db migrate 子命令用于自动创建迁移脚本:

(venv) $ flask db migrate -m "initial migration"
INFO  [alembic.migration] Context impl SQLiteImpl.
INFO  [alembic.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate] Detected added table 'roles'
INFO  [alembic.autogenerate] Detected added table 'users'
INFO  [alembic.autogenerate.compare] Detected added index
'ix_users_username' on '['username']'
  Generating /home/flask/flasky/migrations/versions/1bc
  594146bb5_initial_migration.py...done
更新数据库

检查并修正好迁移脚本之后,执行 flask db upgrade 命令,把迁移应用到数据库中

(venv) $ flask db upgrade
INFO  [alembic.migration] Context impl SQLiteImpl.
INFO  [alembic.migration] Will assume non-transactional DDL.
INFO  [alembic.migration] Running upgrade None -> 1bc594146bb5, initial migration

对第一个迁移来说,其作用与调用 db.create_all() 方法一样。但在后续的迁移中,flask db upgrade 命令能把改动应用到数据库中,且不影响其中保存的数据。

如果你按照之前的说明操作过,那么已经使用 db.create_all() 函数创建了数据库文件。此时,flask db upgrade 命令将失败,因为它试图创建已经存在的数据库表。一种简单的处理方法是,把 data.sqlite 数据库文件删掉,然后执行 flask db upgrade 命令,通过迁移框架重新创建数据库。另一种方法是不执行 flask db upgrade 命令,而是使用 flask db stamp 命令把现有数据库标记为已更新。

添加几个迁移

在开发项目的过程中,时常要修改数据库模型。如果使用迁移框架管理数据库,必须在迁移脚本中定义所有改动,否则改动将不可复现。修改数据库的步骤与创建第一个迁移类似。

(1) 对数据库模型做必要的修改。

(2) 执行 flask db migrate 命令,生成迁移脚本。

(3) 检查自动生成的脚本,改正不准确的地方。

(4) 执行 flask db upgrade 命令,把改动应用到数据库中。

实现一个功能时,可能要多次修改数据库模型才能得到预期结果。如果前一个迁移还未提交到源码控制系统中,可以继续在那个迁移中修改,以免创建大量无意义的小迁移脚本。在前一个迁移脚本的基础上修改的步骤如下。

(1) 执行 flask db downgrade 命令,还原前一个脚本对数据库的改动(注意,这可能导致部分数据丢失)。

(2) 删除前一个迁移脚本,因为现在已经没什么用了。

(3) 执行 flask db migrate 命令生成一个新的数据库迁移脚本。这个迁移脚本除了前面删除的那个脚本中的改动之外,还包括这一次对模型的改动。

(4) 根据前面的说明,检查并应用迁移脚本。

与数据库迁移相关的其他子命令参见 Flask-Migrate 文档(https://flask-migrate.readthedocs.io/

电子邮件 Flask-Mail

虽然 Python 标准库中的 smtplib 包可用于在 Flask 应用中发送电子邮件,但包装了 smtplib 的 Flask-Mail 扩展能更好地与 Flask 集成

安装

pip install flask-mail

Flask-Mail 连接到简单邮件传输协议(SMTP,simple mail transfer protocol)服务器,把邮件交给这个服务器发送。

如果不进行配置,则 Flask-Mail 连接 localhost 上的 25 端口,无须验证身份即可发送电子邮件。

Flask-Mail SMTP服务器的配置

配置默认值说明
MAIL_SERVERlocalhost电子邮件服务器的主机名或 IP 地址
MAIL_PORT25电子邮件服务器的端口
MAIL_USE_TLSFalse启用传输层安全(TLS,transport layer security)协议
MAIL_USE_SSLFalse启用安全套接层(SSL,secure sockets layer)协议
MAIL_USERNAMENone邮件账户的用户名
MAIL_PASSWORDNone邮件账户的密码

连接到外部 SMTP 服务器

使用 Google Gmail 账户发送电子邮件

配置 Flask-Mail 使用 Gmail

import os

# ...
app.config['MAIL_SERVER'] = 'smtp.googlemail.com'
app.config['MAIL_PORT'] = 587
app.config['MAIL_USE_TLS'] = True
app.config['MAIL_USERNAME'] = os.environ.get('MAIL_USERNAME')
app.config['MAIL_PASSWORD'] = os.environ.get('MAIL_PASSWORD')

千万不要把账户凭据直接写入脚本,特别是当你计划开源自己的作品时。

为了保护账户信息,脚本应该从环境变量中导入敏感信息。

基于安全方面的考虑,Gmail 账户要求外部应用连接电子邮件服务器时使用 OAuth2 验证身份。然而,Python 的 smtplib 库不支持这种身份验证方法。为了能以标准的 SMTP 身份验证方法使用 Gmail 账户,打开 Google 账户设置页面,在左边的菜单栏里点击“Signing in to Google”。在打开的页面中找到“Allow less secure apps”设置并勾选上。如果你对这样设置自己的 Gmail 账户不放心,可以注册一个二级账户,专门用于测试电子邮件发送。

初始化 Flask-Mail

from flask_mail import Mail

mail = Mail(app)

定义环境变量

保存电子邮件服务器用户名和密码的两个环境变量要在环境中定义

Linux 或 macOS

(venv) $ export MAIL_USERNAME=<Gmail username>
(venv) $ export MAIL_PASSWORD=<Gmail password>

Windows

(venv) $ set MAIL_USERNAME=<Gmail username>
(venv) $ set MAIL_PASSWORD=<Gmail password>

在Python shell中发送电子邮件

(venv) $ flask shell
>>> from flask_mail import Message
>>> from hello import mail
>>> msg = Message('test email', sender='you@example.com',
...     recipients=['you@example.com'])
>>> msg.body = 'This is the plain text body'
>>> msg.html = 'This is the <b>HTML</b> body'
>>> with app.app_context():
...    mail.send(msg)
...

Flask-Mail 的 send() 函数使用 current_app,因此要在激活的应用上下文中执行

在应用中集成电子邮件发送功能

函数可以使用 Jinja2 模板渲染邮件正文

from flask_mail import Message

app.config['FLASKY_MAIL_SUBJECT_PREFIX'] = '[Flasky]'
app.config['FLASKY_MAIL_SENDER'] = 'Flasky Admin <flasky@example.com>'

def send_email(to, subject, template, **kwargs):
    msg = Message(app.config['FLASKY_MAIL_SUBJECT_PREFIX'] + subject,
                  sender=app.config['FLASKY_MAIL_SENDER'], recipients=[to])
    msg.body = render_template(template + '.txt', **kwargs)
    msg.html = render_template(template + '.html', **kwargs)
    mail.send(msg)

这个函数用到了两个应用层面的配置项,分别定义邮件主题的前缀和发件人的地址。send_email() 函数的参数分别为收件人地址、主题、渲染邮件正文的模板和关键字参数列表。指定模板时不能包含扩展名,这样才能使用两个模板分别渲染纯文本正文和 HTML 正文。调用者传入的关键字参数将传给 render_template() 函数,作为模板变量提供给模板使用,用于生成电子邮件正文。

每当表单接收到新的名字,应用就给管理员发送一封电子邮件

# ...
app.config['FLASKY_ADMIN'] = os.environ.get('FLASKY_ADMIN')
# ...
@app.route('/', methods=['GET', 'POST'])
def index():
    form = NameForm()
    if form.validate_on_submit():
        user = User.query.filter_by(username=form.name.data).first()
        if user is None:
            user = User(username=form.name.data)
            db.session.add(user)
            session['known'] = False
            if app.config['FLASKY_ADMIN']:
                send_email(app.config['FLASKY_ADMIN'], 'New User',
                           'mail/new_user', user=user)
        else:
            session['known'] = True
        session['name'] = form.name.data
        form.name.data = ''
        return redirect(url_for('index'))
    return render_template('index.html', form=form, name=session.get('name'),
                           known=session.get('known', False))

创建两个模板文件,分别用于渲染纯文本和 HTML 版本的邮件正文。

这两个模板文件都保存在 templates 目录下的 mail 子目录中,以便和普通模板区分开来。

电子邮件的模板中有一个模板参数是用户,因此调用 send_email() 函数时要以关键字参数的形式传入用户。

异步发送电子邮件

mail.send() 函数在发送电子邮件时停滞了几秒钟,在这个过程中浏览器就像无响应一样。

为了在处理请求过程中避免不必要的延迟,我们可以把发送电子邮件的函数移到后台线程中

from threading import Thread

def send_async_email(app, msg):
    with app.app_context():
        mail.send(msg)

def send_email(to, subject, template, **kwargs):
    msg = Message(app.config['FLASKY_MAIL_SUBJECT_PREFIX'] + subject,
                  sender=app.config['FLASKY_MAIL_SENDER'], recipients=[to])
    msg.body = render_template(template + '.txt', **kwargs)
    msg.html = render_template(template + '.html', **kwargs)
    thr = Thread(target=send_async_email, args=[app, msg])
    thr.start()
    return thr

很多 Flask 扩展都假设已经存在激活的应用上下文和(或)请求上下文。

Flask-Mail 的 send() 函数使用 current_app,因此必须激活应用上下文。

不过,上下文是与线程配套的,在不同的线程中执行 mail.send() 函数时,要使用 app.app_context() 人工创建应用上下文。

app 实例作为参数传入线程,因此可以通过它来创建上下文。

Celery 任务队列

应用要发送大量电子邮件时,使用专门发送电子邮件的作业要比给每封邮件都新建一个线程更合适。

例如,可以把执行 send_async_email() 函数的操作发给 Celery 任务队列。

大型应用的结构

在单个脚本文件中编写小型 Web 应用很方便,但这种方法的伸缩性不好。

应用变复杂后,使用单个大型源码文件会导致很多问题

使用包和模块组织大型应用

项目结构

多文件 Flask 应用的基本结构

|-flasky
  |-app/
    |-templates/
    |-static/
    |-main/
      |-__init__.py
      |-errors.py
      |-forms.py
      |-views.py
    |-__init__.py
    |-email.py
    |-models.py
  |-migrations/
  |-tests/
    |-__init__.py
    |-test*.py
  |-venv/
  |-requirements.txt
  |-config.py
  |-flasky.py

4 个顶级文件夹

  • Flask 应用一般保存在名为 app 的包中;

  • 和之前一样,数据库迁移脚本在 migrations 文件夹中;

  • 单元测试在 tests 包中编写;

  • 和之前一样,Python 虚拟环境在 venv 文件夹中。

新文件

  • requirements.txt 列出了所有依赖包,便于在其他计算机中重新生成相同的虚拟环境;

  • config.py 存储配置;

  • flasky.py 定义 Flask 应用实例,同时还有一些辅助管理应用的任务。

配置选项

应用经常需要设定多个配置。这方面最好的例子就是开发、测试和生产环境要使用不同的数据库,这样才不会彼此影响。

除了类似字典的 app.config 对象之外,还可以使用具有层次结构的配置类。

config.py:应用的配置

import os

basedir = os.path.abspath(os.path.dirname(__file__))
class Config:
    SECRET_KEY = os.environ.get('SECRET_KEY') or 'hard to guess string'
    MAIL_SERVER = os.environ.get('MAIL_SERVER', 'smtp.googlemail.com')
    MAIL_PORT = int(os.environ.get('MAIL_PORT', '587'))
    MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() in \
        ['true', 'on', '1']
    MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
    MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
    FLASKY_MAIL_SUBJECT_PREFIX = '[Flasky]'
    FLASKY_MAIL_SENDER = 'Flasky Admin <flasky@example.com>'
    FLASKY_ADMIN = os.environ.get('FLASKY_ADMIN')
    SQLALCHEMY_TRACK_MODIFICATIONS = False

    @staticmethod
    def init_app(app):
        pass

class DevelopmentConfig(Config):
    DEBUG = True
    SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or \
        'sqlite:///' + os.path.join(basedir, 'data-dev.sqlite')

class TestingConfig(Config):
    TESTING = True
    SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or \
        'sqlite://'

class ProductionConfig(Config):
    SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
        'sqlite:///' + os.path.join(basedir, 'data.sqlite')

config = {
    'development': DevelopmentConfig,
    'testing': TestingConfig,
    'production': ProductionConfig,
    'default': DevelopmentConfig
}

基类 Config 中包含通用配置,各个子类分别定义专用的配置

为了让配置方式更灵活且更安全,多数配置都可以从环境变量中导入。

例如,SECRET_KEY 的值,这是个敏感信息,可以在环境中设定,但系统也提供了一个默认值,以防环境中没有定义。

通常,在开发过程中可以使用这些设置的默认值,但是在生产服务器中应该通过环境变量设定各个值

千万不要把密码或其他机密信息写在纳入版本控制的配置文件中

在不同的环境中使用不同的数据库

在 3 个子类中,SQLALCHEMY_DATABASE_URI 变量都被指定了不同的值。这样应用就可以在不同的环境中使用不同的数据库。

把不同环境的数据库区分开是十分必要的,因为你肯定不想让单元测试修改日常开发中使用的数据库。

各配置子类尝试从环境变量中导入数据库的 URL,如果相应的环境变量没有设定,则使用基于 SQLite 的默认值。

测试环境默认使用一个内存中的数据库,因为测试运行结束后无需保留任何数据。

定制配置

开发环境和生产环境都配置了邮件服务器。

为了再给应用提供一种定制配置的方式,Config 类及其子类可以定义 init_app() 类方法,其参数为应用实例。现在,基类 Config 中的 init_app() 方法为空。

在这个配置脚本末尾,config 字典中注册了不同的配置环境,而且还注册了一个默认配置(这里注册为开发环境)。

应用包

应用包用于存放应用的所有代码、模板和静态文件。

我们可以把这个包直接称为 app(应用),如果有需求,也可使用一个应用专属的名称。

templates 和 static 目录现在是应用包的一部分,因此要把二者移到 app 包中。

数据库模型和电子邮件支持函数也要移到这个包中,分别保存为 app/models.py 和 app/email.py。

使用应用工厂函数

单文件无法动态修改配置

在单个文件中开发应用是很方便,但却有个很大的缺点:应用在全局作用域中创建,无法动态修改配置。

运行脚本时,应用实例已经创建,再修改配置为时已晚。

这一点对单元测试尤其重要,因为有时为了提高测试覆盖度,必须在不同的配置下运行应用。

延迟创建应用实例

这个问题的解决方法是延迟创建应用实例,把创建过程移到可显式调用的工厂函数中。

这种方法不仅可以给脚本留出配置应用的时间,还能够创建多个应用实例,为测试提供便利。

应用的工厂函数在 app 包的构造文件中定义

app/__init__.py:应用包的构造文件
from flask import Flask, render_template
from flask_bootstrap import Bootstrap
from flask_mail import Mail
from flask_moment import Moment
from flask_sqlalchemy import SQLAlchemy
from config import config

bootstrap = Bootstrap()
mail = Mail()
moment = Moment()
db = SQLAlchemy()

def create_app(config_name):
    app = Flask(__name__)
    app.config.from_object(config[config_name])
    config[config_name].init_app(app)
    bootstrap.init_app(app)
    mail.init_app(app)
    moment.init_app(app)
    db.init_app(app)
    # 添加路由和自定义的错误页面
    return app

构造文件导入了大多数正在使用的 Flask 扩展。

由于尚未初始化所需的应用实例,所以创建扩展类时没有向构造函数传入参数,因此扩展并未真正初始化。

create_app() 函数是应用的工厂函数,接受一个参数,是应用使用的配置名。

配置类在 config.py 文件中定义,其中保存的配置可以使用 Flask app.config 配置对象提供的 from_object() 方法直接导入应用。

至于配置对象,则可以通过名称从 config 字典中选择。

应用创建并配置好后,就能初始化扩展了。在之前创建的扩展对象上调用 init_app() 便可以完成初始化。

现在,应用在这个工厂函数中初始化,使用 Flask 配置对象的 from_object() 方法,其参数为 config.py 中定义的某个配置类。

此外,这里还调用了所选配置的 init_app() 方法,以便执行更复杂的初始化过程。

工厂函数返回创建的应用示例,不过要注意,现在工厂函数创建的应用还不完整,因为没有路由和自定义的错误页面处理程序。

在蓝本中实现应用功能

转换成应用工厂函数的操作让定义路由变复杂了。

在单脚本应用中,应用实例存在于全局作用域中,路由可以直接使用 app.route 装饰器定义。

但现在应用在运行时创建,只有调用 create_app() 之后才能使用 app.route 装饰器,这时定义路由就太晚了。自定义的错误页面处理程序也面临相同的问题,因为错误页面处理程序使用 app.errorhandler 装饰器定义。

在蓝本中定义的路由和错误处理程序

Flask 使用蓝本(blueprint)提供了更好的解决方法。

蓝本和应用类似,也可以定义路由和错误处理程序。

不同的是,在蓝本中定义的路由和错误处理程序处于休眠状态,直到蓝本注册到应用上之后,它们才真正成为应用的一部分。

使用位于全局作用域中的蓝本时,定义路由和错误处理程序的方法几乎与单脚本应用一样

蓝本可以在单个文件中定义,也可使用更结构化的方式在包中的多个模块中创建。

为了获得最大的灵活性,在应用包中创建一个子包,用于保存应用的第一个蓝本。

app/main/__init__.py:创建主蓝本

蓝本通过实例化一个 Blueprint 类对象创建。

这个构造函数有两个必须指定的参数:蓝本的名称和蓝本所在的包或模块。

与应用一样,多数情况下第二个参数使用 Python 的 name 变量即可。

from flask import Blueprint
main = Blueprint('main', __name__)
from . import views, errors

应用的路由保存在包里的 app/main/views.py 模块中,

而错误处理程序保存在 app/main/errors.py 模块中。

导入这两个模块就能把路由和错误处理程序与蓝本关联起来。

注意,这些模块在 app/main/__init__.py 脚本的末尾导入,这是为了避免循环导入依赖,因为在 app/main/views.py 和 app/main/errors.py 中还要导入 main 蓝本,所以除非循环引用出现在定义 main 之后,否则会致使导入出错。

相对导入

from . import <some-module> 句法表示相对导入。

语句中的 . 表示当前包。

from .. import <some-module>,这里的 .. 表示当前包的上一层。

app/__init__.py:注册主蓝本

蓝本在工厂函数 create_app() 中注册到应用上

def create_app(config_name):
    # ...
    from .main import main as main_blueprint
    app.register_blueprint(main_blueprint)
    return app
主蓝本中的错误处理程序 app_errorhandler 装饰器

app/main/errors.py:主蓝本中的错误处理程序

from flask import render_template
from . import main

@main.app_errorhandler(404)
def page_not_found(e):
    return render_template('404.html'), 404

@main.app_errorhandler(500)
def internal_server_error(e):
    return render_template('500.html'), 500

在蓝本中编写错误处理程序稍有不同,

如果使用 errorhandler 装饰器,那么只有蓝本中的错误才能触发处理程序。

要想注册应用全局的错误处理程序,必须使用 app_errorhandler 装饰器。

主蓝本中定义的应用路由

app/main/views.py:主蓝本中定义的应用路由

from datetime import datetime
from flask import render_template, session, redirect, url_for
from . import main
from .forms import NameForm
from .. import db
from ..models import User

@main.route('/', methods=['GET', 'POST'])
def index():
    form = NameForm()
    if form.validate_on_submit():
        # ...
        return redirect(url_for('.index'))
    return render_template('index.html',
                           form=form, name=session.get('name'),
                           known=session.get('known', False),
                           current_time=datetime.utcnow())
在蓝本中编写视图函数主要有两点不同:

第一,与前面的错误处理程序一样,路由装饰器由蓝本提供,因此使用的是 main.route,而非 app.route;

第二,url_for() 函数的用法不同。

url_for() 函数的第一个参数是路由的端点名,在应用的路由中,默认为视图函数的名称。例如,在单脚本应用中,index() 视图函数的 URL 可使用 url_for('index') 获取。

在蓝本中就不一样了,Flask 会为蓝本中的全部端点加上一个命名空间,这样就可以在不同的蓝本中使用相同的端点名定义视图函数,而不产生冲突。

命名空间是蓝本的名称(Blueprint 构造函数的第一个参数),而且它与端点名之间以一个点号分隔。因此,视图函数 index() 注册的端点名是 main.index,其 URL 使用 url_for('main.index') 获取。

url_for() 函数还支持一种简写的端点形式,在蓝本中可以省略蓝本名,例如 url_for('.index')。在这种写法中,使用当前请求的蓝本名补足端点名。这意味着,同一蓝本中的重定向可以使用简写形式,但跨蓝本的重定向必须使用带有蓝本名的完全限定端点名。

为了完成对应用包的修改,还要把表单对象移到蓝本中,保存在 app/main/forms.py 模块里。

应用脚本

应用实例在顶级目录中的 flasky.py 模块里定义

flasky.py:主脚本

import os
from app import create_app, db
from app.models import User, Role
from flask_migrate import Migrate

app = create_app(os.getenv('FLASK_CONFIG') or 'default')
migrate = Migrate(app, db)

@app.shell_context_processor
def make_shell_context():
    return dict(db=db, User=User, Role=Role)

这个脚本先创建一个应用实例。

如果已经定义了环境变量 FLASK_CONFIG,则从中读取配置名;否则使用默认配置。

然后初始化 Flask-Migrate 和为 Python shell 定义的上下文。

需求文件

应用中最好有个 requirements.txt 文件,用于记录所有依赖包及其精确的版本号。

如果要在另一台计算机上重新生成虚拟环境,这个文件的重要性就体现出来了,

例如部署应用时使用的设备。

这个文件可由 pip 自动生成

(venv) $ pip freeze >requirements.txt

安装或升级包后,最好更新这个文件

创建这个虚拟环境的完整副本

先创建一个新的虚拟环境,然后在其中运行下述命令:

(venv) $ pip install -r requirements.txt
单元测试 unittest 包

https://docs.python.org/3.6/library/unittest.html

tests/test_basics.py:单元测试

import unittest
from flask import current_app
from app import create_app, db

class BasicsTestCase(unittest.TestCase):
    def setUp(self):
        self.app = create_app('testing')
        self.app_context = self.app.app_context()
        self.app_context.push()
        db.create_all()

    def tearDown(self):
        db.session.remove()
        db.drop_all()
        self.app_context.pop()

    def test_app_exists(self):
        self.assertFalse(current_app is None)

    def test_app_is_testing(self):
        self.assertTrue(current_app.config['TESTING'])

这些测试使用 Python 标准库中的 unittest 包编写。

测试用例类的 setUp() 和 tearDown() 方法分别在各测试之前和之后运行。

名称以 test_ 开头的方法都作为测试运行。


setUp() 方法尝试创建一个测试环境,尽量与正常运行应用所需的环境一致。

首先,使用测试配置创建应用,然后激活上下文。这一步的作用是确保能在测试中使用 current_app,就像普通请求一样。

然后,使用 Flask-SQLAlchemy 的 create_all() 方法创建一个全新的数据库,供测试使用。

数据库和应用上下文在 tearDown() 方法中删除。


第一个测试确保应用实例存在。

第二个测试确保应用在测试配置中运行。

若想把 tests 目录作为包来使用,要添加 tests/init.py 模块,不过这个文件可以为空,因为 unittest 包会扫描所有模块,找出测试。


安装所有依赖包

pip install -r requirements.txt
自定义命令 app.cli.command

为了运行单元测试,可以在 flasky.py 脚本中添加一个自定义命令。

添加 test 命令,flasky.py:启动单元测试的命令

@app.cli.command()
def test():
    """Run the unit tests."""
    import unittest
    tests = unittest.TestLoader().discover('tests')
    unittest.TextTestRunner(verbosity=2).run(tests)

app.cli.command 装饰器把自定义命令变得很简单。

被装饰的函数名就是命令名,函数的文档字符串会显示在帮助消息中。

test() 函数的定义体中调用了 unittest 包提供的测试运行程序。

运行
(venv) $ flask test
test_app_exists (test_basics.BasicsTestCase) ... ok
test_app_is_testing (test_basics.BasicsTestCase) ... ok
.----------------------------------------------------------------------
Ran 2 tests in 0.001s
OK
----

创建数据库

重组后的应用和单脚本版本使用不同的数据库。

首选从环境变量中读取数据库的 URL,同时还提供了一个默认的 SQLite 数据库作为备用。3 种配置环境中的环境变量名和 SQLite 数据库文件名都不一样。例如,在开发环境中,数据库 URL 从环境变量 DEV_DATABASE_URL 中读取,如果没有定义这个环境变量,则使用名为 data-dev.sqlite 的 SQLite 数据库。

不管从哪里获取数据库 URL,都要在新数据库中创建数据表。如果使用 Flask-Migrate 跟踪迁移,可使用下述命令创建数据表或者升级到最新修订版本:

(venv) $ flask db upgrade

运行应用

(venv) $ flask run

每次启动一个新的命令提示符会话,都要设定 FLASK_APP 和 FLASK_DEBUG 环境变量

用户身份验证

Flask的身份验证扩展

优秀的 Python 身份验证包很多,但没有一个能实现所有功能。本章介绍的身份验证方案将使用多个包,而且还要编写胶水代码,让不同的包良好协作

  • Flask-Login:管理已登录用户的用户会话

  • Werkzeug:计算密码散列值并进行核对

  • itsdangerous:生成并核对加密安全令牌

密码安全性

多数用户会在不同的网站中使用相同的密码。因此,即便不保存任何敏感信息,攻击者获得存储在数据库中的密码之后,也能访问用户在其他网站中的账户。

散列值

若想保证数据库中用户密码的安全,关键在于不存储密码本身,而是存储密码的散列值。

计算密码散列值的函数接收密码作为输入,添加随机内容(盐值)之后,使用多种单向加密算法转换密码,最终得到一个和原始密码没有关系的字符序列,而且无法还原成原始密码。

核对密码时,密码散列值可代替原始密码,因为计算散列值的函数是可复现的:只要输入(密码和盐值)一样,结果就一样。

使用Werkzeug计算密码散列值

Werkzeug 中的 security 模块实现了密码散列值的计算。这一功能的实现只需要两个函数,分别用在注册和核对两个阶段。

generate_password_hash(password, method='pbkdf2:sha256', salt_length=8)

这个函数的输入为原始密码,返回密码散列值的字符串形式,供存入用户数据库。method 和 salt_length 的默认值就能满足大多数需求。

check_password_hash(hash, password)

这个函数的参数是从数据库中取回的密码散列值和用户输入的密码。返回值为 True 时表明用户输入的密码正确。

app/models.py:在 User 模型中加入密码散列

from werkzeug.security import generate_password_hash, check_password_hash

class User(db.Model):
    # ...
    password_hash = db.Column(db.String(128))

    @property
    def password(self):
        raise AttributeError('password is not a readable attribute')

    @password.setter
    def password(self, password):
        self.password_hash = generate_password_hash(password)

    def verify_password(self, password):
        return check_password_hash(self.password_hash, password)
只写属性 property

计算密码散列值的函数通过名为 password 的只写属性实现。

设定属性 setter

设定这个属性的值时,赋值方法会调用 Werkzeug 提供的 generate_password_hash() 函数,并把得到的结果写入 password_hash 字段。

如果试图读取 password 属性的值,则会返回错误,原因很明显,因为生成散列值后就无法还原成原来的密码了。

verify_password

verify_password() 方法接受一个参数(即密码),将其传给 Werkzeug 提供的 check_password_hash() 函数,与存储在 User 模型中的密码散列值进行比对。如果这个方法返回 True,表明密码是正确的。

测试
(venv) $ flask shell
>>> u = User()
>>> u.password = 'cat'
>>> u.password
Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File "/home/flask/flasky/app/models.py", line 24, in password
    raise AttributeError('password is not a readable attribute')
AttributeError: password is not a readable attribute
>>> u.password_hash
'pbkdf2:sha256:50000$moHwFH1B$ef1574909f9c549285e8547cad181c5e0213cfa44a4aba4349
fa830aa1fd227f'
>>> u.verify_password('cat')
True
>>> u.verify_password('dog')
False
>>> u2 = User()
>>> u2.password = 'cat'
>>> u2.password_hash
'pbkdf2:sha256:50000$Pfz0m0KU$27be930b7f0e0119d38e8d8a62f7f5e75c0a7db61ae16709bc
aa6cfd60c44b74'

尝试访问 password 属性会返回 AttributeError

即使用户 u 和 u2 使用了相同的密码,它们的密码散列值也完全不一样。

密码散列测试模块

在 tests 包中新建一个模块,编写 3 个新测试,测试最近对 User 模型所做的改动

tests/test_user_model.py:密码散列测试

import unittest
from app.models import User

class UserModelTestCase(unittest.TestCase):
    def test_password_setter(self):
        u = User(password = 'cat')
        self.assertTrue(u.password_hash is not None)

    def test_no_password_getter(self):
        u = User(password = 'cat')
        with self.assertRaises(AttributeError):
            u.password

    def test_password_verification(self):
        u = User(password = 'cat')
        self.assertTrue(u.verify_password('cat'))
        self.assertFalse(u.verify_password('dog'))

    def test_password_salts_are_random(self):
        u = User(password='cat')
        u2 = User(password='cat')
        self.assertTrue(u.password_hash != u2.password_hash)
运行新增的单元测试
(venv) $ flask test
test_app_exists (test_basics.BasicsTestCase) ... ok
test_app_is_testing (test_basics.BasicsTestCase) ... ok
test_no_password_getter (test_user_model.UserModelTestCase) ... ok
test_password_salts_are_random (test_user_model.UserModelTestCase) ... ok
test_password_setter (test_user_model.UserModelTestCase) ... ok
test_password_verification (test_user_model.UserModelTestCase) ... ok
.----------------------------------------------------------------------
Ran 6 tests in 0.379s
OK

通过自动化测试验证功能不费吹灰之力,因此应该经常运行,确保以后的改动不会破坏现在可用的功能。

创建身份验证蓝本

在一个新蓝本中定义与用户身份验证子系统相关的路由,这个蓝本名为 auth。

把应用的不同子系统放在不同的蓝本中,有利于保持代码整洁有序。

auth 身份验证蓝本

auth 蓝本保存在同名 Python 包中。这个蓝本的包构造函数创建蓝本对象,再从 views.py 模块中导入路由

app/auth/__init__.py:创建身份验证蓝本

from flask import Blueprint

auth = Blueprint('auth', __name__)

from . import views
定义与身份验证相关的路由

app/auth/views.py 模块导入蓝本,然后使用蓝本的 route 装饰器定义与身份验证相关的路由

添加了一个 /login 路由,渲染同名占位模板。

app/auth/views.py:身份验证蓝本中的路由和视图函数

from flask import render_template
from . import auth

@auth.route('/login')
def login():
    return render_template('auth/login.html')
模板文件

注意,为 render_template() 指定的模板文件保存在 auth 目录中。这个目录必须在 app/ templates 中创建,因为 Flask 期望模板的路径是相对于应用的模板目录而言的。

把蓝本中用到的模板放在单独的子目录中,能避免与 main 蓝本或以后添加的蓝本发生冲突。

也可以配置蓝本使用专门的目录保存模板。如果配置了多个模板目录,那么 render_template() 函数会先搜索应用的模板目录,然后再搜索蓝本的模板目录。

注册身份验证蓝本

auth 蓝本要在 create_app() 工厂函数中附加到应用上

app/__init__.py:注册身份验证蓝本

def create_app(config_name):
    # ...
    from .auth import auth as auth_blueprint
    app.register_blueprint(auth_blueprint, url_prefix='/auth')
    return app
url_prefix

注册蓝本时使用的 url_prefix 是可选参数。

如果使用了这个参数,注册后蓝本中定义的所有路由都会加上指定的前缀,即这个例子中的 /auth。

例如,/login 路由会注册成 /auth/login,在开发 Web 服务器中,完整的 URL 就变成了 http://localhost:5000/auth/login

使用Flask-Login验证用户身份

用户登录应用后,他们的验证状态要记录在用户会话中,这样浏览不同的页面时才能记住这个状态。

Flask-Login 是个非常有用的小型扩展,专门用于管理用户身份验证系统中的验证状态,且不依赖特定的身份验证机制。

安装
(venv) $ pip install flask-login
准备用于登录的用户模型

Flask-Login 的运转需要应用中有 User 对象。

要想使用 Flask-Login 扩展,应用的 User 模型必须实现几个属性和方法

Flask-Login要求实现的属性和方法
属性/方法说明
is_authenticated如果用户提供的登录凭据有效,必须返回 True,否则返回 False
is_active如果允许用户登录,必须返回 True,否则返回 False。如果想禁用账户,可以返回 False
is_anonymous对普通用户必须始终返回 False,如果是表示匿名用户的特殊用户对象,应该返回 True
get_id()必须返回用户的唯一标识符,使用 Unicode 编码字符串
Flask-Login UserMixin

这些属性和方法可以直接在模型类中实现,

不过还有一种更简单的替代方案。Flask-Login 提供了一个 UserMixin 类,其中包含默认实现,能满足多数需求

app/models.py:修改 User 模型,支持用户登录

from flask_login import UserMixin

class User(UserMixin, db.Model):
    __tablename__ = 'users'
    id = db.Column(db.Integer, primary_key = True)
    email = db.Column(db.String(64), unique=True, index=True)
    username = db.Column(db.String(64), unique=True, index=True)
    password_hash = db.Column(db.String(128))
    role_id = db.Column(db.Integer, db.ForeignKey('roles.id'))

这个示例中还添加了 email 字段。在这个应用中,用户使用电子邮件地址登录

初始化Flask-Login

Flask-Login 在应用的工厂函数中初始化

app/__init__.py:初始化Flask-Login

from flask_login import LoginManager

login_manager = LoginManager()
login_manager.login_view = 'auth.login'

def create_app(config_name):
    # ...
    login_manager.init_app(app)
    # ...
login_view

LoginManager 对象的 login_view 属性用于设置登录页面的端点。

匿名用户尝试访问受保护的页面时,Flask-Login 将重定向到登录页面。

因为登录路由在蓝本中定义,所以要在前面加上蓝本的名称。

加载用户的函数 login_manager.user_loader

最后,Flask-Login 要求应用指定一个函数,在扩展需要从数据库中获取指定标识符对应的用户时调用

app/models.py:加载用户的函数

from . import login_manager

@login_manager.user_loader
def load_user(user_id):
    return User.query.get(int(user_id))

login_manager.user_loader 装饰器把这个函数注册给 Flask-Login,在这个扩展需要获取已登录用户的信息时调用。

传入的用户标识符是个字符串,因此这个函数先把标识符转换成整数,然后传给 Flask-SQLAlchemy 查询,加载用户。

正常情况下,这个函数的返回值必须是用户对象;

如果用户标识符无效,或者出现了其他错误,则返回 None。

保护路由 login_required

为了保护路由,只让通过身份验证的用户访问,Flask-Login 提供了一个 login_required 装饰器

from flask_login import login_required

@app.route('/secret')
@login_required
def secret():
    return 'Only authenticated users are allowed!'

多个函数装饰器可以叠加使用。

函数上有多个装饰器时,各装饰器只对随后的装饰器和目标函数起作用。

在这个示例中,secret() 函数受 login_required 装饰器的保护,禁止未授权的用户访问,得到的函数又注册为一个 Flask 路由。

如果调换两个装饰器,得到的结果将是错的,因为原始函数先注册为路由,然后才从 login_required 装饰器接收到额外的属性。

得益于 login_required 装饰器,如果未通过身份验证的用户访问这个路由,Flask-Login 将拦截请求,把用户发往登录页面。

添加登录表单

呈现给用户的登录表单中包含一个用于输入电子邮件地址的文本字段、一个密码字段、一个“记住我”复选框和一个提交按钮。这个表单使用的 Flask-WTF 类

app/auth/forms.py:登录表单

from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Length, Email

class LoginForm(FlaskForm):
    email = StringField('Email', validators=[DataRequired(), Length(1, 64),
                                             Email()])
    password = PasswordField('Password', validators=[DataRequired()])
    remember_me = BooleanField('Keep me logged in')
    submit = SubmitField('Log In')

PasswordField 类表示属性为 type="password" 的 <input> 元素。BooleanField 类表示复选框。

电子邮件字段用到了 WTForms 提供的 Length()、Email() 和 DataRequired() 这 3 个验证函数,不仅确保这个字段有值,而且必须是有效的。提供验证函数列表时,WTForms 将按照指定的顺序执行各个验证函数。倘若验证失败,显示的错误消息将是首个失败的验证函数的消息。

登录页面使用的模板保存在 auth/login.html 文件中。这个模板只需使用 Flask-Bootstrap 提供的 wtf.quick_form() 宏渲染表单即可

登录表单 current_user

base.html 模板中的导航栏可以使用 Jinja2 条件语句判断当前用户的登录状态,分别显示 Log In 或 Log Out 链接

app/templates/base.html:导航栏中的 Log In 和 Log Out 链接

<ul class="nav navbar-nav navbar-right">
    {% if current_user.is_authenticated %}
    <li><a href="{{ url_for('auth.logout') }}">Log Out</a></li>
    {% else %}
    <li><a href="{{ url_for('auth.login') }}">Log In</a></li>
    {% endif %}
</ul>

判断条件中的变量 current_user 由 Flask-Login 定义,在视图函数和模板中自动可用。

这个变量的值是当前登录的用户,如果用户未登录,则是一个匿名用户代理对象。

匿名用户对象的 is_authenticated 属性值是 False,所以通过 current_user.is_authenticated 表达式就能判断当前用户是否登录。

登入用户

app/auth/views.py:登录路由

from flask import render_template, redirect, request, url_for, flash
from flask_login import login_user
from . import auth
from ..models import User
from .forms import LoginForm

@auth.route('/login', methods=['GET', 'POST'])
def login():
    form = LoginForm()
    if form.validate_on_submit():
        user = User.query.filter_by(email=form.email.data).first()
        if user is not None and user.verify_password(form.password.data):
            login_user(user, form.remember_me.data)
            next = request.args.get('next')
            if next is None or not next.startswith('/'):
                next = url_for('main.index')
            return redirect(next)
        flash('Invalid username or password.')
    return render_template('auth/login.html', form=form)

这个视图函数创建了一个 LoginForm 对象。当请求类型是 GET 时,视图函数直接渲染模板,即显示表单。当表单通过 POST 请求提交时,Flask-WTF 的 validate_on_submit() 函数会验证表单数据,然后尝试登入用户。

为了登入用户,视图函数首先使用表单中填写的电子邮件地址从数据库中加载用户。如果电子邮件地址对应的用户存在,再调用用户对象的 verify_password() 方法,其参数是表单中填写的密码。如果密码正确,调用 Flask-Login 的 login_user() 函数,在用户会话中把用户标记为已登录。login_user() 函数的参数是要登录的用户,以及可选的“记住我”布尔值,“记住我”也在表单中勾选。如果这个字段的值为 False,关闭浏览器后用户会话就过期了,所以下次用户访问时要重新登录。如果值为 True,那么会在用户浏览器中写入一个长期有效的 cookie,使用这个 cookie 可以复现用户会话。cookie 默认记住一年,可以使用可选的 REMEMBER_COOKIE_DURATION 配置选项更改这个值。

按照“Post / 重定向 /Get 模式”,提交登录凭据的 POST 请求最后也做了重定向,不过目标 URL 有两种可能。用户访问未授权的 URL 时会显示登录表单,Flask-Login 会把原 URL 保存在查询字符串的 next 参数中,这个参数可从 request.args 字典中读取。如果查询字符串中没有 next 参数,则重定向到首页。next 参数中的 URL 会经验证,确保是相对 URL,以防恶意用户利用这个参数,把不知情的用户重定向到其他网站。

如果用户输入的电子邮件地址或密码不正确,应用会设定一个闪现消息,并再次渲染表单,让用户再次尝试登录。

在生产服务器上,应用必须使用安全的 HTTP,保证始终以加密的方式传输登录凭据和用户会话。如果没使用安全的 HTTP,敏感数据在传输过程中可能会被攻击者截获。

app/templates/auth/login.html:登录表单模板
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% block title %}Flasky - Login{% endblock %}
{% block page_content %}
<div class="page-header">
    <h1>Login</h1>
</div>
<div class="col-md-4">
    {{ wtf.quick_form(form) }}
</div>
{% endblock %}
登出用户 logout_user()

app/auth/views.py:退出路由

from flask_login import logout_user, login_required

@auth.route('/logout')
@login_required
def logout():
    logout_user()
    flash('You have been logged out.')
    return redirect(url_for('main.index'))

为了登出用户,这个视图函数调用 Flask-Login 的 logout_user() 函数,删除并重设用户会话。

随后会显示一个闪现消息,确认这次操作,然后重定向到首页,这样就成功退出了。

Flask-Login的运作方式

Flask-Login 是个相当小的扩展,但是身份验证流程中有太多变动部分

用户登录过程涉及以下操作步骤。

  • (1) 用户点击 Log In 链接,访问 http://localhost:5000/auth/login。处理这个 URL 的函数返回登录表单模板。

  • (2) 用户输入用户名和密码,然后点击提交按钮。再次调用相同的处理函数,不过这一次处理的是 POST 请求,而非 GET 请求。

  • a. 处理函数验证通过表单提交的凭据,然后调用 Flask-Login 的 login_user() 函数,登入用户。

  • b. login_user() 函数把用户的 ID 以字符串的形式写入用户会话。

  • c. 视图函数重定向到首页。

  • (3) 浏览器收到重定向响应,请求首页。

  • a. 调用首页的视图函数,渲染主页的 Jinja2 模板。

  • b. 在渲染这个 Jinja2 模板的过程中,首次出现对 Flask-Login 的 current_user 的引用。

  • c. 这个请求还没有给上下文变量 current_user 赋值,因此调用 Flask-Login 内部的 _get_user() 函数,找出用户是谁。

  • d. _get_user() 函数检查用户会话中有没有用户 ID。如果没有,返回一个 Flask-Login 的 AnonymousUser 实例。如果有 ID,调用应用中使用 user_loader 装饰器注册的函数,传入用户 ID。

  • e. 应用中的 user_loader 处理函数从数据库中读取用户,将其返回。Flask-Login 把返回的用户对象赋值给当前请求的 current_user 上下文变量。

  • f. 模板收到新赋值的 current_user。

  • 使用 login_required 装饰器装饰的视图函数将使用 current_user 上下文变量判断 current_user.is_authenticated 表达式的结果是否为 True。

  • logout_user() 函数就简单了,它直接从用户会话中把用户 ID 删除。

登录测试

app/templates/index.html:为已登录的用户显示一个欢迎消息

Hello,
{% if current_user.is_authenticated %}
    {{ current_user.username }}
{% else %}
    Stranger
{% endif %}!

这个模板再次使用 current_user.is_authenticated 判断用户是否已经登录

在 shell 中注册新用户

(venv) $ flask shell
>>> u = User(email='john@example.com', username='john', password='cat')
>>> db.session.add(u)
>>> db.session.commit()

注册新用户

添加用户注册表单

注册页面中的表单要求用户输入电子邮件地址、用户名和密码

app/auth/forms.py:用户注册表单

from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Length, Email, Regexp, EqualTo
from wtforms import ValidationError
from ..models import User

class RegistrationForm(FlaskForm):
    email = StringField('Email', validators=[DataRequired(), Length(1, 64),
                                             Email()])
    username = StringField('Username', validators=[
        DataRequired(), Length(1, 64),
        Regexp('^[A-Za-z][A-Za-z0-9_.]*$', 0,
               'Usernames must have only letters, numbers, dots or '
               'underscores')])
    password = PasswordField('Password', validators=[
        DataRequired(), EqualTo('password2', message='Passwords must match.')])
    password2 = PasswordField('Confirm password', validators=[DataRequired()])
    submit = SubmitField('Register')

    def validate_email(self, field):
        if User.query.filter_by(email=field.data).first():
            raise ValidationError('Email already registered.')

    def validate_username(self, field):
        if User.query.filter_by(username=field.data).first():
            raise ValidationError('Username already in use.')

这个表单使用 WTForms 提供的 Regexp 验证函数,确保 username 字段的值以字母开头,而且只包含字母、数字、下划线和点号。这个验证函数中正则表达式后面的两个参数分别是正则表达式的标志和验证失败时显示的错误消息。

为了安全起见,密码要输入两次。此时要验证两个密码字段中的值是否一致,这种验证可使用 WTForms 提供的另一验证函数实现,即 EqualTo。这个验证函数要附属到两个密码字段中的一个上,另一个字段则作为参数传入。

这个表单还有两个自定义的验证函数,以方法的形式实现。如果表单类中定义了以 validate_ 开头且后面跟着字段名的方法,这个方法就和常规的验证函数一起调用。本例分别为 email 和 username 字段定义了验证函数,确保填写的值在数据库中没出现过。自定义的验证函数要想表示验证失败,可以抛出 ValidationError 异常,其参数就是错误消息。

注册新用户 视图函数

提交注册表单,通过验证后,系统使用用户填写的信息在数据库中添加一个新用户

app/auth/views.py:用户注册路由

@auth.route('/register', methods=['GET', 'POST'])
def register():
    form = RegistrationForm()
    if form.validate_on_submit():
        user = User(email=form.email.data,
                    username=form.username.data,
                    password=form.password.data)
        db.session.add(user)
        db.session.commit()
        flash('You can now login.')
        return redirect(url_for('auth.login'))
    return render_template('auth/register.html', form=form)

确认账户

为了确认电子邮件地址,用户注册后,应用会立即发送一封确认邮件。

新账户先被标记成待确认状态,用户按照邮件中的说明操作后,才能证明自己可以收到电子邮件。

账户确认过程中,往往会要求用户点击一个包含确认令牌的特殊 URL 链接。

使用itsdangerous生成确认令牌

使用 id

确认邮件中最简单的确认链接是 http://www.example.com/auth/confirm/<id> 这种形式的 URL,其中 <id> 是数据库分配给用户的数字 id。用户点击链接后,处理这个路由的视图函数将确认收到的用户 id,然后将用户状态更新为已确认。

但这种实现方式显然不是很安全,只要用户能判断确认链接的格式,就可以随便指定 URL 中的数字,从而确认任意账户。

解决方法是把 URL 中的 <id> 换成包含相同信息的令牌,但是只有服务器才能生成有效的确认 URL。

使用 令牌

Flask 使用加密的签名 cookie 保护用户会话,以防止被篡改。

用户会话 cookie 中有一个由 itsdangerous 包生成的加密签名。

如果用户会话的内容被篡改,签名将不再与内容匹配,这样会使 Flask 销毁会话,然后重建一个。同样的方法也可用在确认令牌上。

使用 itsdangerous 包生成包含用户 id 的签名令牌

(venv) $ flask shell
>>> from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
>>> s = Serializer(app.config['SECRET_KEY'], expires_in=3600)
>>> token = s.dumps({ 'confirm': 23 })
>>> token
'eyJhbGciOiJIUzI1NiIsImV4cCI6MTM4MTcxODU1OCwiaWF0IjoxMzgxNzE0OTU4fQ.ey ...'
>>> data = s.loads(token)
>>> data
{'confirm': 23}
TimedJSONWebSignatureSerializer

itsdangerous 提供了多种生成令牌的方法。

其中,TimedJSONWebSignatureSerializer 类生成具有过期时间的 JSON Web 签名(JWS)。

这个类的构造函数接收的参数是一个密钥,在 Flask 应用中可使用 SECRET_KEY 设置。

dumps()

dumps() 方法为指定的数据生成一个加密签名,然后再对数据和签名进行序列化,生成令牌字符串。

expires_in

expires_in 参数设置令牌的过期时间,单位为秒。

loads()

为了解码令牌,序列化对象提供了 loads() 方法,其唯一的参数是令牌字符串。

这个方法会检验签名和过期时间,如果都有效,则返回原始数据。

如果提供给 loads() 方法的令牌无效或是过期了,则抛出异常。

把这种生成和检验令牌的功能添加到 User 模型中

app/models.py:确认用户账户

from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
from flask import current_app
from . import db

class User(UserMixin, db.Model):
    # ...
    confirmed = db.Column(db.Boolean, default=False)

    def generate_confirmation_token(self, expiration=3600):
        s = Serializer(current_app.config['SECRET_KEY'], expiration)
        return s.dumps({'confirm': self.id}).decode('utf-8')

    def confirm(self, token):
        s = Serializer(current_app.config['SECRET_KEY'])
        try:
            data = s.loads(token.encode('utf-8'))
        except:
            return False
        if data.get('confirm') != self.id:
            return False
        self.confirmed = True
        db.session.add(self)
        return True

generate_confirmation_token() 方法生成一个令牌,有效期默认为一小时。

confirm() 方法检验令牌,如果检验通过,就把用户模型中新添加的 confirmed 属性设为 True。

除了检验令牌,confirm() 方法还检查令牌中的 id 是否与存储在 current_user 中的已登录用户匹配。这样能确保为一个用户生成的确认令牌无法用于确认其他用户。

由于模型中新加入了一列用来保存账户的确认状态,因此要生成并运行一个新数据库迁移

发送确认邮件

当前的 /register 路由把新用户添加到数据库中之后,会重定向到 /index。

在重定向之前,这个路由现在需要发送确认邮件

app/auth/views.py:能发送确认邮件的注册路由
from ..email import send_email

@auth.route('/register', methods=['GET', 'POST'])
def register():
    form = RegistrationForm()
    if form.validate_on_submit():
        # ...
        db.session.add(user)
        db.session.commit()
        token = user.generate_confirmation_token()
        send_email(user.email, 'Confirm Your Account',
                   'auth/email/confirm', user=user, token=token)
        flash('A confirmation email has been sent to you by email.')
        return redirect(url_for('main.index'))
    return render_template('auth/register.html', form=form)

在发送确认邮件之前要调用 db.session.commit()。之所以这么做,是因为提交之后才能赋予新用户 id 值,而确认令牌需要用到 id

app/templates/auth/email/conf irm.txt:确认邮件的纯文本正文

Dear {{ user.username }},
Welcome to Flasky!
To confirm your account please click on the following link:
{{ url_for('auth.confirm', token=token, _external=True) }}
Sincerely,
The Flasky Team

Note: replies to this email address are not monitored.
完全限定的 URL _external=True

默认情况下,url_for() 生成相对 URL,例如 url_for('auth.confirm', token='abc') 返回的字符串是 '/auth/confirm/abc'。

这显然不是能够在电子邮件中发送的正确 URL,因为只有 URL 的路径部分。

相对 URL 在网页的上下文中可以正常使用,因为浏览器会添加当前页面的主机名和端口号,将其转换成绝对 URL。

但是通过电子邮件发送的 URL 并没有这种上下文。

添加到 url_for() 函数中的 _external=True 参数要求应用生成完全限定的 URL,包括协议(http://https://)、主机名和端口。

app/auth/views.py:确认用户的账户

from flask_login import current_user

@auth.route('/confirm/<token>')
@login_required
def confirm(token):
    if current_user.confirmed:
        return redirect(url_for('main.index'))
    if current_user.confirm(token):
        db.session.commit()
        flash('You have confirmed your account. Thanks!')
    else:
        flash('The confirmation link is invalid or has expired.')
    return redirect(url_for('main.index'))

Flask-Login 提供的 login_required 装饰器会保护这个路由,因此,用户点击确认邮件中的链接后,要先登录,然后才能执行这个视图函数。

这个函数先检查已登录的用户是否已经确认过,如果确认过,则重定向到首页,因为很显然此时不用做什么操作。这样处理可以避免用户不小心多次点击确认令牌带来的额外工作。

由于令牌确认完全在 User 模型中完成,所以视图函数只需调用 confirm() 方法即可,然后再根据确认结果显示不同的闪现消息。确认成功后,User 模型中 confirmed 属性的值会被修改并添加到会话中,然后提交数据库会话。

在蓝本中使用针对应用全局请求的钩子,必须使用 before_app_request

各个应用可以自行决定用户确认账户之前可以做哪些操作。比如,允许未确认的用户登录,但只显示一个页面,要求用户在获取进一步访问权限之前先确认账户。

这一步可使用 Flask 提供的 before_request 钩子完成,

对蓝本来说,before_request 钩子只能应用到属于蓝本的请求上。

若想在蓝本中使用针对应用全局请求的钩子,必须使用 before_app_request 装饰器

app/auth/views.py:使用 before_app_request 处理程序过滤未确认的账户

@auth.before_app_request
def before_request():
    if current_user.is_authenticated \
            and not current_user.confirmed \
            and request.blueprint != 'auth' \
            and request.endpoint != 'static':
        return redirect(url_for('auth.unconfirmed'))

@auth.route('/unconfirmed')
def unconfirmed():
    if current_user.is_anonymous or current_user.confirmed:
        return redirect(url_for('main.index'))
    return render_template('auth/unconfirmed.html')

同时满足以下 3 个条件时,before_app_request 处理程序会拦截请求。

(1) 用户已登录(current_user.is_authenticated 的值为 True)。

(2) 用户的账户还未确认。

(3) 请求的 URL 不在身份验证蓝本中,而且也不是对静态文件的请求。要赋予用户访问身份验证路由的权限,因为这些路由的作用是让用户确认账户或执行其他账户管理操作。

如果请求满足以上条件,会被重定向到 /auth/unconfirmed 路由,显示一个确认账户相关信息的页面。

如果 before_request 或 before_app_request 的回调返回响应或重定向,Flask 会直接将其发送至客户端,而不会调用相应的视图函数。因此,这些回调可在必要时拦截请求

重新发送账户确认邮件

呈现给未确认用户的页面只渲染一个模板,其中有如何确认账户的说明,此外还有一个链接,用于请求发送新的确认邮件,以防之前的邮件丢失

app/auth/views.py:重新发送账户确认邮件

@auth.route('/confirm')
@login_required
def resend_confirmation():
    token = current_user.generate_confirmation_token()
    send_email(current_user.email, 'Confirm Your Account',
               'auth/email/confirm', user=current_user, token=token)
    flash('A new confirmation email has been sent to you by email.')
    return redirect(url_for('main.index'))

这个路由为 current_user(即已登录的用户,也是目标用户)重做了一遍注册路由中的操作。这个路由也用 login_required 保护,确保只有通过身份验证的用户才能再次请求发送确认邮件。

管理账户

拥有应用账户的用户有时可能需要修改账户信息

下面这些功能可使用本章介绍的技术添加到身份验证蓝本中。

修改密码

安全意识强的用户可能想定期修改密码。这是一个很容易实现的功能,只要用户处于登录状态,就可以放心显示一个表单,要求用户输入旧密码和替换的新密码。

重设密码

为避免用户忘记密码后无法登入,应用可以提供重设密码功能。为了安全起见,有必要使用令牌,类似于确认账户时用到的。用户请求重设密码后,应用向用户注册时提供的电子邮件地址发送一封包含重设令牌的邮件。用户点击邮件中的链接,令牌通过验证后,显示一个用于输入新密码的表单。

修改电子邮件地址

应用可以提供修改注册电子邮件地址的功能,不过接受新地址之前,必须使用确认邮件进行验证。使用这个功能时,用户在表单中输入新的电子邮件地址。为了验证新地址,应用发送一封包含令牌的邮件。服务器收到令牌后,再更新用户对象。服务器收到令牌之前,可以把新电子邮件地址保存在一个新数据库字段中作为待定地址,或者将其与 id 一起保存在令牌中。

用户角色

在应用中实现角色有多种方法。具体采用何种实现方法取决于所需角色的数量和细分程度。

例如,简单的应用可能只需要两个角色,一个表示普通用户,一个表示管理员。对于这种情况,在 User 模型中添加一个 is_administrator 布尔值字段可能就够了。

复杂的应用可能需要在普通用户和管理员之间再细分出多个不同等级的角色。

有些应用甚至不能使用分立的角色,赋予用户一系列独立的权限或许更合适。

各个角色都通过权限列表定义允许用户执行的操作。

角色在数据库中的表示

app/models.py:角色数据库模型

class Role(db.Model):
    __tablename__ = 'roles'
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(64), unique=True)
    default = db.Column(db.Boolean, default=False, index=True)
    permissions = db.Column(db.Integer)
    users = db.relationship('User', backref='role', lazy='dynamic')

    def __init__(self, **kwargs):
        super(Role, self).__init__(**kwargs)
        if self.permissions is None:
            self.permissions = 0
default

这个模型新增了 default 字段。只能有一个角色的这个字段可以设为 True,其他角色都应该设为 False。

默认角色是注册新用户时赋予用户的角色。因为应用将在 roles 表中搜索默认角色,所以我们为这一列设置了索引,提升搜索的速度。

permissions

这个模型的另一处改动是添加了 permissions 字段,其值是一个整数,以简洁的方式定义一组权限。

SQLAlchemy 默认把这个字段的值设为 None,因此我们添加了一个类构造函数,在未给构造函数提供参数时,把这个字段的值设为 0。

各操作所需的权限在不同的应用中是不一样的

应用中的各项权限
操作权限名权限值
关注用户FOLLOW1
在他人的文章中发表评论`COMMENT2
写文章`WRITE4
管理他人发表的评论`MODERATE8
管理员权限`ADMIN16

使用 2 的幂表示权限值有个好处:

每种不同的权限组合对应的值都是唯一的,方便存入角色的 permissions 字段。

例如,若想为一个用户角色赋予权限,使其能够关注其他用户,并在文章中发表评论,则权限值为 FOLLOW + COMMENT = 3。通过这种方式存储各个角色的权限特别高效。

app/models.py:权限常量
class Permission:
    FOLLOW = 1
    COMMENT = 2
    WRITE = 4
    MODERATE = 8
    ADMIN = 16

添加这些权限常量之后,可以在 Role 模型中定义几个新方法,用于管理权限

app/models.py:Role 模型中管理权限的方法
class Role(db.Model):
    # ...

    def add_permission(self, perm):
        if not self.has_permission(perm):
            self.permissions += perm

    def remove_permission(self, perm):
        if self.has_permission(perm):
            self.permissions -= perm

    def reset_permissions(self):
        self.permissions = 0

    def has_permission(self, perm):
        return self.permissions & perm == perm

add_permission()、remove_permission() 和 reset_permission() 这 3 个方法使用基本的算术运算符更新权限列表。

has_permission() 方法是这几个方法中最复杂的,它使用位与运算符 &(https://docs.python.org/3/reference/expressions.html#binary-bitwise-operations)检查组合权限是否包含指定的单独权限

(venv) $ flask shell
>>> r = Role(name='User')
>>> r.add_permission(Permission.FOLLOW)
>>> r.add_permission(Permission.WRITE)
>>> r.has_permission(Permission.FOLLOW)
True
>>> r.has_permission(Permission.ADMIN)
False
>>> r.reset_permissions()
>>> r.has_permission(Permission.FOLLOW)
False

这个应用会支持的用户角色,以及定义各个角色的权限组合

用户角色权限说明
匿名对应只读权限;这是未登录的未知用户
用户FOLLOW、COMMENT、WRITE具有发布文章、发表评论和关注其他用户的权限;这是新用户的默认角色
协管员FOLLOW、COMMENT、WRITE、MODERATE增加管理其他用户所发表评论的权限
管理员FOLLOW、COMMENT、WRITE、MODERATE、ADMIN具有所有权限,包括修改其他用户所属角色的权限
app/models.py:在数据库中创建角色
class Role(db.Model):
    # ...

    @staticmethod
    def insert_roles():
        roles = {
            'User': [Permission.FOLLOW, Permission.COMMENT, Permission.WRITE],
            'Moderator': [Permission.FOLLOW, Permission.COMMENT,
                          Permission.WRITE, Permission.MODERATE],
            'Administrator': [Permission.FOLLOW, Permission.COMMENT,
                              Permission.WRITE, Permission.MODERATE,
                              Permission.ADMIN],
        }
        default_role = 'User'
        for r in roles:
            role = Role.query.filter_by(name=r).first()
            if role is None:
                role = Role(name=r)
            role.reset_permissions()
            for perm in roles[r]:
                role.add_permission(perm)
            role.default = (role.name == default_role)
            db.session.add(role)
        db.session.commit()

insert_roles() 函数并不直接创建新角色对象,而是通过角色名查找现有的角色,然后再进行更新。

只有当数据库中没有某个角色名时,才会创建新角色对象。

如此一来,如果以后更新了角色列表,就可以执行更新操作了。

要想添加新角色,或者修改角色的权限,修改函数顶部的 roles 字典,再运行这个函数即可。

注意,“匿名”角色不需要在数据库中表示出来,这个角色的作用就是为了表示不在数据库中的未知用户。

insert_roles() 是静态方法。这是一种特殊的方法,无须创建对象,而是直接在类上调用,例如 Role.insert_roles()。与实例方法不同的是,静态方法的参数中没有 self。

赋予角色

用户在应用中注册账户时,应该赋予其适当的角色。

多数用户在注册时赋予的角色是“用户”,因为这是默认角色。

唯一的例外是管理员,管理员在最开始就应该赋予“管理员”角色。

管理员由保存在设置变量 FLASKY_ADMIN 中的电子邮件地址识别,只要这个电子邮件地址出现在注册请求中,就会被赋予正确的角色

app/models.py:定义默认的用户角色
class User(UserMixin, db.Model):
    # ...

    def __init__(self, **kwargs):
        super(User, self).__init__(**kwargs)
        if self.role is None:
            if self.email == current_app.config['FLASKY_ADMIN']:
                self.role = Role.query.filter_by(name='Administrator').first()
            if self.role is None:
                self.role = Role.query.filter_by(default=True).first()
    # ...

User 类的构造函数首先调用基类的构造函数,如果创建基类对象后还没定义角色,则根据电子邮件地址决定将其设为管理员还是默认角色。

检验角色

为了简化角色和权限的实现过程,可在 User 模型中添加一个辅助方法,检查赋予用户的角色是否有某项权限。

这个辅助方法的实现很简单,直接委托前面添加的权限管理方法,

app/models.py:检查用户是否有指定的权限
from flask_login import UserMixin, AnonymousUserMixin

class User(UserMixin, db.Model):
    # ...

    def can(self, perm):
        return self.role is not None and self.role.has_permission(perm)

    def is_administrator(self):
        return self.can(Permission.ADMIN)

class AnonymousUser(AnonymousUserMixin):

    def can(self, permissions):
        return False

    def is_administrator(self):
        return False

login_manager.anonymous_user = AnonymousUser

如果角色中包含请求的权限,那么 User 模型中添加的 can() 方法会返回 True,表示允许用户执行此项操作。因为经常需要检查是否具有管理员权限,所以还单独实现了 is_administrator() 方法。

为了操作方便,我们还定义了 AnonymousUser 类,并实现了 can() 方法和 is_administrator() 方法。这样,应用无须检查用户是否登录,就能放心调用 current_user.can() 和 current_user.is_administrator()。

通过 login_manager.anonymous_user 属性告诉 Flask-Login 使用应用自定义的匿名用户类。

让视图函数只对具有特定权限的用户开放

如果想让视图函数只对具有特定权限的用户开放,可以使用自定义的装饰器。示例 9-7 实现了两个装饰器,一个用于检查常规权限,另一个专门检查管理员权限。

app/decorators.py:检查用户权限的自定义装饰器
from functools import wraps
from flask import abort
from flask_login import current_user
from .models import Permission

def permission_required(permission):

    def decorator(f):

        @wraps(f)
        def decorated_function(*args, **kwargs):
            if not current_user.can(permission):
                abort(403)
            return f(*args, **kwargs)

        return decorated_function

    return decorator

def admin_required(f):
    return permission_required(Permission.ADMIN)(f)

这两个修饰器都使用了 Python 标准库中的 functools 包(https://docs.python.org/3/library/functools.html),如果用户不具有指定权限,则返回 403 响应,即 HTTP“禁止”错误。

404 和 500 错误编写了自定义的错误页面,所以现在也要以类似的方式添加

一个 403 错误页面。下面举两个例子演示如何使用这些装饰器。
from .decorators import admin_required, permission_required

@main.route('/admin')
@login_required
@admin_required
def for_admins_only():
    return "For administrators!"

@main.route('/moderate')
@login_required
@permission_required(Permission.MODERATE)
def for_moderators_only():
    return "For comment moderators!"

根据经验,在视图函数上使用多个装饰器时,应该把 Flask 的 route 装饰器放在首位。余下的装饰器应该按照调用视图函数时的执行顺序排列。以上示例中应该先检查用户的身份验证状态,因为如果发现用户未通过身份验证,要将其重定向到登录页面。

把 Permission 类加入模板上下文

app/main/__init__.py:把 Permission 类加入模板上下文

@main.app_context_processor
def inject_permissions():
    return dict(Permission=Permission)
角色和权限的单元测试

tests/test_user_model.py:角色和权限的单元测试

class UserModelTestCase(unittest.TestCase):
    # ...

    def test_user_role(self):
        u = User(email='john@example.com', password='cat')
        self.assertTrue(u.can(Permission.FOLLOW))
        self.assertTrue(u.can(Permission.COMMENT))
        self.assertTrue(u.can(Permission.WRITE))
        self.assertFalse(u.can(Permission.MODERATE))
        self.assertFalse(u.can(Permission.ADMIN))

    def test_anonymous_user(self):
        u = AnonymousUser()
        self.assertFalse(u.can(Permission.FOLLOW))
        self.assertFalse(u.can(Permission.COMMENT))
        self.assertFalse(u.can(Permission.WRITE))
        self.assertFalse(u.can(Permission.MODERATE))
        self.assertFalse(u.can(Permission.ADMIN))
在 shell 会话中把这些新角色添加到开发数据库中
(venv) $ flask shell
>>> Role.insert_roles()
>>> Role.query.all()
[<Role 'Administrator'>, <Role 'User'>, <Role 'Moderator'>]
更新用户列表,为在此之前创建的用户账户分配用户角色
(venv) $ flask shell
>>> admin_role = Role.query.filter_by(name='Administrator').first()
>>> default_role = Role.query.filter_by(default=True).first()
>>> for u in User.query.all():
...     if u.role is None:
...         if u.email == app.config['FLASKY_ADMIN']:
...             u.role = admin_role
...         else:
...             u.role = default_role
...
>>> db.session.commit()

用户资料

用户可以把资料页面的 URL 分享给别人,告诉别人自己在这个网站上。因此,这个页面的 URL 要简短易记。

资料信息

app/models.py:用户信息字段

class User(UserMixin, db.Model):

    # ...

    name = db.Column(db.String(64))
    location = db.Column(db.String(64))
    about_me = db.Column(db.Text())
    member_since = db.Column(db.DateTime(), default=datetime.utcnow)
    last_seen = db.Column(db.DateTime(), default=datetime.utcnow)

新添加的字段保存用户的真实姓名、所在地、自我介绍、注册日期和最后访问日期。

about_me 字段的类型是 db.Text()。

db.String 和 db.Text 的区别在于后者是变长字段,因此不需要指定最大长度。

两个时间戳的默认值都是当前时间。注意,datetime.utcnow 后面没有 (),因为 db.Column() 的 default 参数可以接受函数作为默认值,每次需要生成默认值时,SQLAlchemy 都会调用指定的函数。member_since 字段使用默认值即可

last_seen 字段的默认值也是创建时的当前时间,但用户每次访问网站后,这个值都要刷新。

app/models.py:刷新用户的最后访问时间
class User(UserMixin, db.Model):

    # ...

    def ping(self):
        self.last_seen = datetime.utcnow()
        db.session.add(self)
        db.session.commit()
app/auth/views.py:更新已登录用户的最后访问时间

为了确保每个用户的最后访问时间都是最新的,每次收到用户的请求时都要调用 ping() 方法。

因为 auth 蓝本中的 before_app_request 处理程序会在每次请求前运行,所以能很轻松地实现这个需求

@auth.before_app_request
def before_request():
    if current_user.is_authenticated:
        current_user.ping()
        if not current_user.confirmed \
                and request.endpoint \
                and request.blueprint != 'auth' \
                and request.endpoint != 'static':
            return redirect(url_for('auth.unconfirmed'))

用户资料页面

app/main/views.py:资料页面的路由
@main.route('/user/<username>')
def user(username):
    user = User.query.filter_by(username=username).first_or_404()
    return render_template('user.html', user=user)

这个路由添加到 main 蓝本中。对于名为 john 的用户,其资料页面的地址是 http:// localhost:5000/user/john。

这个视图函数会在数据库中搜索 URL 中指定的用户名,如果找到,则渲染模板 user.html,并把用户名作为参数传入模板。

如果传入路由的用户名不存在,则返回 404 错误。

使用 Flask-SQLAlchemy 时,搜到结果和返回错误这两种情况可以在同一个语句中表达,即在查询对象上调用 first_or_404() 方法。

user.html 模板用于呈现用户信息,因此要把用户对象作为参数传入其中。

app/templates/user.html:用户资料页面的模板

{% extends "base.html" %}
{% block title %}Flasky - {{ user.username }}{% endblock %}
{% block page_content %}
<div class="page-header">
    <h1>{{ user.username }}</h1>
    {% if user.name or user.location %}
    <p>
        {% if user.name %}{{ user.name }}{% endif %}
        {% if user.location %}
            From <a href="http://maps.google.com/?q={{ user.location }}">
                {{ user.location }}
            </a>
        {% endif %}
    </p>
    {% endif %}
    {% if current_user.is_administrator() %}
    <p><a href="mailto:{{ user.email }}">{{ user.email }}</a></p>
    {% endif %}
    {% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %}
    <p>
        Member since {{ moment(user.member_since).format('L') }}.
        Last seen {{ moment(user.last_seen).fromNow() }}.
    </p>
</div>
{% endblock %}

name 和 location 字段在同一个 <p> 元素中渲染。Jinja2 条件语句确保,仅当至少定义了这两个字段中的一个时,才会创建 <p> 元素。

用户的 location 字段被渲染成指向谷歌地图的查询链接,点击打开后将显示一个地图,以所标位置为中心。

如果登录的用户是管理员,显示各用户的电子邮件地址,且渲染成 mailto 链接。这样便于管理员查看用户资料页面并联系该用户。

两个时间戳使用 Flask-Moment 渲染

app/templates/base.html:在导航栏中添加指向资料页面的链接

{% if current_user.is_authenticated %}
<li>
    <a href="{{ url_for('main.user', username=current_user.username) }}">
        Profile
    </a>
</li>
{% endif %}

把资料页面的链接包含在条件语句中是非常必要的,因为未通过身份验证的用户也能看到导航栏,但我们不应该让他们看到资料页面的链接。

资料编辑器

用户资料的编辑分两种情况。最显而易见的情况是,用户要进入一个页面,输入自己的资料,以便显示在自己的资料页面上。还有一种不太明显但也同样重要的情况,那就是要让管理员能够编辑任意用户的资料——不仅要能编辑用户的个人信息,还要能编辑用户不能直接访问的 User 模型字段,例如用户角色。这两种编辑需求有本质上的区别,所以我们将创建两个不同的表单。

app/main/views.py:管理员的资料编辑路由
from ..decorators import admin_required

@main.route('/edit-profile/<int:id>', methods=['GET', 'POST'])
@login_required
@admin_required
def edit_profile_admin(id):
    user = User.query.get_or_404(id)
    form = EditProfileAdminForm(user=user)
    if form.validate_on_submit():
        user.email = form.email.data
        user.username = form.username.data
        user.confirmed = form.confirmed.data
        user.role = Role.query.get(form.role.data)
        user.name = form.name.data
        user.location = form.location.data
        user.about_me = form.about_me.data
        db.session.add(user)
        db.session.commit()
        flash('The profile has been updated.')
        return redirect(url_for('.user', username=user.username))
    form.email.data = user.email
    form.username.data = user.username
    form.confirmed.data = user.confirmed
    form.role.data = user.role_id
    form.name.data = user.name
    form.location.data = user.location
    form.about_me.data = user.about_me
    return render_template('edit_profile.html', form=form, user=user)

这个路由与普通用户的那个相对简单的编辑路由具有基本相同的结构,只不过多了个 admin_required 装饰器,当非管理员尝试访问这个路由时,它会自动返回 403 错误。

用户 id 由 URL 中的动态参数指定,因此可使用 Flask-SQLAlchemy 提供的 get_or_404() 函数,在提供的 id 不正确时返回 404 错误。我们还需要再探讨一下用于选择用户角色的 SelectField。设定这个字段的初始值时,role_id 被赋值给了 form.role.data,这么做的原因在于 choices 属性中设置的元组列表使用数字标识符表示各选项。表单提交后,id 从字段的 data 属性中提取,并且查询时会使用提取出来的 id 值加载角色对象。表单中声明 SelectField 时设定的 coerce=int 参数,其作用是保证这个字段的 data 属性值始终被转换成整数

app/templates/user.html:管理员使用的资料编辑页面链接

{% if current_user.is_administrator() %}
<a class="btn btn-danger"
        href="{{ url_for('.edit_profile_admin', id=user.id) }}">
    Edit Profile [Admin]
</a>
{% endif %}

用户头像

Gravatar 是一个行业领先的头像服务,能把头像和电子邮件地址关联起来。

用户要先到 https://en.gravatar.com/ 中注册账户,然后上传图像。

这个服务通过一个特殊的 URL 对外开放用户的头像,

这个 URL 中包含用户电子邮件地址的 MD5 散列值,计算方法如下:

(venv) $ python
>>> import hashlib
>>> hashlib.md5('john@example.com'.encode('utf-8')).hexdigest()
'd4c74594d841139328695756648b6bd6'

生成的头像 URL 是在 https://secure.gravatar.com/avatar/ 之后加上这个 MD5 散列值。

例如,你在浏览器的地址栏中输入 https://secure.gravatar.com/avatar/d4c74594d841139328695756648b6bd6 后,将看到电子邮件地址 john@example.com 对应的头像。

如果这个电子邮件地址没有关联头像,则会显示一个默认图像。

得到基本的头像 URL 之后,还可以添加一些查询字符串参数,配置头像的特征。

Gravatar查询字符串参数
参数名说明
s图像尺寸,单位为像素
r图像级别,可选值有 "g"、"pg"、"r" 和 "x"
d尚未注册 Gravatar 服务的用户使用的默认图像生成方式,可选值有: "404",返回 404 错误;一个 URL,指向默认图像;某种图像生成方式,包括 "mm"、"identicon"、"monsterid"、"wavatar"、"retro" 和 "blank"
fd强制使用默认头像

在 john@example.com 的头像 URL 后加上 ?d=identicon,默认头像将变成几何图形。

头像 URL 的这些参数都可以添加到 User 模型中

app/models.py:生成 Gravatar URL
import hashlib
from flask import request

class User(UserMixin, db.Model):

    # ...

    def gravatar(self, size=100, default='identicon', rating='g'):
        url = 'https://secure.gravatar.com/avatar'
        hash = hashlib.md5(self.email.lower().encode('utf-8')).hexdigest()
        return '{url}/{hash}?s={size}&d={default}&r={rating}'.format(
            url=url, hash=hash, size=size, default=default, rating=rating)

头像的 URL 由基 URL、用户电子邮件地址的 MD5 散列值和参数组成,而且各个参数都有默认值。
注意,Gravatar 要求在计算 MD5 散列值时要规范电子邮件地址,把字母全部转换成小写,因此这个方法也添加了这一步。

有了上述实现,我们就可以在 Python shell 中轻松生成头像的 URL 了:

(venv) $ flask shell
>>> u = User(email='john@example.com')
>>> u.gravatar()
'https://secure.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=100&d=
identicon&r=g'
>>> u.gravatar(size=256)
'https://secure.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=256&d=
identicon&r=g'

gravatar() 方法也可在 Jinja2 模板中调用。示例 10-14 在资料页面中添加一个大小为 256 像素的头像。

app/tempaltes/user.html:在资料页面中添加头像

...
<img class="img-rounded profile-thumbnail" src="{{ user.gravatar(size=256) }}">
<div class="profile-header">
   ...
</div>
...
使用缓存的 MD5 散列值生成 Gravatar URL

生成头像时要生成 MD5 散列值,这是一项 CPU 密集型操作。

如果要在某个页面中生成大量头像,计算量会非常大。

只要电子邮件地址不变,对应的 MD5 散列值就不会变。

鉴于此,我们可以将其缓存在 User 模型中。

若要把 MD5 散列值保存在数据库中,需要对 User 模型做些改动

app/models.py:使用缓存的 MD5 散列值生成 Gravatar URL

class User(UserMixin, db.Model):

    # ...

    avatar_hash = db.Column(db.String(32))

    def __init__(self, **kwargs):
        # ...

        if self.email is not None and self.avatar_hash is None:
            self.avatar_hash = self.gravatar_hash()

    def change_email(self, token):
        # ...

        self.email = new_email
        self.avatar_hash = self.gravatar_hash()
        db.session.add(self)
        return True

    def gravatar_hash(self):
        return hashlib.md5(self.email.lower().encode('utf-8')).hexdigest()

    def gravatar(self, size=100, default='identicon', rating='g'):
        if request.is_secure:
            url = 'https://secure.gravatar.com/avatar'
        else:
            url = 'http://www.gravatar.com/avatar'
        hash = self.avatar_hash or self.gravatar_hash()
        return '{url}/{hash}?s={size}&d={default}&r={rating}'.format(
            url=url, hash=hash, size=size, default=default, rating=rating)

为了避免重复编写计算 Gravatar 散列值的逻辑,我们专门定义了 gravatar_hash() 方法执行此项任务。模型初始化时,散列值存储在新增的 avatar_hash 属性中。如果用户更新了电子邮件地址,则重新计算散列值。如果存储了散列值,gravatar() 方法将使用存储的值,否则将按照之前的方式计算散列值。

博客文章

重用模板、分页显示长列表,以及处理富文本

提交和显示博客文章

app/models.py:Post 模型
class Post(db.Model):
    __tablename__ = 'posts'
    id = db.Column(db.Integer, primary_key=True)
    body = db.Column(db.Text)
    timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow)
    author_id = db.Column(db.Integer, db.ForeignKey('users.id'))

class User(UserMixin, db.Model):
    # ...
    posts = db.relationship('Post', backref='author', lazy='dynamic')

博客文章包含正文、时间戳以及和 User 模型之间的一对多关系。body 字段的类型是 db.Text,所以不限制长度。

app/main/forms.py:博客文章表单
class PostForm(FlaskForm):
    body = TextAreaField("What's on your mind?", validators=[DataRequired()])
    submit = SubmitField('Submit')
app/main/views.py:处理博客文章的首页路由
@main.route('/', methods=['GET', 'POST'])
def index():
    form = PostForm()
    if current_user.can(Permission.WRITE_ARTICLES) and form.validate_on_submit():
        post = Post(body=form.body.data,
                    author=current_user._get_current_object())
        db.session.add(post)
        db.session.commit()
        return redirect(url_for('.index'))
    posts = Post.query.order_by(Post.timestamp.desc()).all()
    return render_template('index.html', form=form, posts=posts)

这个视图函数把表单和完整的博客文章列表传给模板。文章列表按照时间戳进行降序排列。博客文章表单采取惯常处理方式,如果提交的数据能通过验证,就创建一个新 Post 实例。在发布新文章之前,要检查当前用户是否有写文章的权限。

注意,新文章对象的 author 属性值为表达式 current_user._get_current_object()。变量 current_user 由 Flask-Login 提供,与所有上下文变量一样,也是实现为线程内的代理对象。这个对象的表现类似用户对象,但实际上却是一个轻度包装,包含真正的用户对象。数据库需要真正的用户对象,因此要在代理对象上调用 _get_current_object() 方法。

这个表单显示在 index.html 模板中的欢迎消息下方,其后是博客文章列表。这是我们首次尝试实现博客文章时间轴,按时间顺序由新到旧列出数据库中所有的博客文章

app/templates/index.html:显示博客文章的首页模板
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
...
<div>
    {% if current_user.can(Permission.WRITE_ARTICLES) %}
    {{ wtf.quick_form(form) }}
    {% endif %}
</div>
<ul class="posts">
    {% for post in posts %}
    <li class="post">
        <div class="profile-thumbnail">
            <a href="{{ url_for('.user', username=post.author.username) }}">
                <img class="img-rounded profile-thumbnail"
                    src="{{ post.author.gravatar(size=40) }}">
            </a>
        </div>
        <div class="post-date">{{ moment(post.timestamp).fromNow() }}</div>
        <div class="post-author">
            <a href="{{ url_for('.user', username=post.author.username) }}">
                {{ post.author.username }}
            </a>
        </div>
        <div class="post-body">{{ post.body }}</div>
     </li>
    {% endfor %}
</ul>
...

如果用户所属角色没有 WRITE 权限,经 User.can() 方法检查后,不会显示博客文章表单

在资料页中显示博客文章

改进用户资料页面,在上面显示该用户发布的博客文章列表

app/main/views.py:获取博客文章的资料页面路由
@main.route('/user/<username>')
def user(username):
    user = User.query.filter_by(username=username).first()
    if user is None:
        abort(404)
    posts = user.posts.order_by(Post.timestamp.desc()).all()
    return render_template('user.html', user=user, posts=posts)

用户发布的博客文章列表通过 User.posts 关系获取。User.posts 返回的结果类似于查询对象,因此可像常规查询对象那样在其上调用过滤器,例如 order_by()。

与 index.html 模板一样,user.html 模板也要使用一个 HTML <ul> 元素渲染博客文章列表。但是维护两个完全相同的 HTML 片段副本可不是个好主意。遇到这种情况,Jinja2 提供的 include() 指令就非常有用。生成文章列表的 HTML 片段可以移到一个单独的文件中,然后在 index.html 和 user.html 中将其导入。

在 user.html 中导入该文件

app/templates/user.html:显示有博客文章的资料页面模板

...
<h3>Posts by {{ user.username }}</h3>
{% include '_posts.html' %}
...

为了完成这种新的模板组织方式,index.html 模板中的 <ul> 元素需要移到新模板 _posts.html 中,并像上面那样换成一个 include 指令。注意,_posts.html 模板名中的下划线前缀不是必须使用的,这只是一种习惯用法,以区分完整模板和局部模板。

分页显示长博客文章列表

分页显示数据并分段渲染。

创建虚拟博客文章数据 Faker

有多个 Python 包可用于生成虚拟信息,其中功能相对完善的是 Faker

安装
(venv) $ pip install faker
区分环境依赖

严格来说,Faker 包并不是这个应用的依赖,因为它只在开发过程中使用。

为了区分生产环境的依赖和开发环境的依赖,我们可以用 requirements 子目录替换 requirements.txt 文件,在该目录中分别存储不同环境中的依赖。

在这个新目录中,我们可以创建一个 dev.txt 文件,列出开发过程中所需的依赖,

再创建一个 prod.txt 文件,列出生产环境所需的依赖。

由于两个环境所需的依赖大部分是相同的,可以创建一个 common.txt 文件,在 dev.txt 和 prod.txt 中使用 -r 参数将其导入

requirements/dev.txt:开发需求文件

-r common.txt

faker==0.7.18
生成虚拟的用户和文章

在应用中创建一个新模块,在里面定义两个函数,分别生成虚拟的用户和文章

app/fake.py:生成虚拟用户和博客文章

from random import randint
from sqlalchemy.exc import IntegrityError
from faker import Faker
from . import db
from .models import User, Post

def users(count=100):
    fake = Faker()
    i = 0
    while i < count:
        u = User(email=fake.email(),
                 username=fake.user_name(),
                 password='password',
                 confirmed=True,
                 name=fake.name(),
                 location=fake.city(),
                 about_me=fake.text(),
                 member_since=fake.past_date())
        db.session.add(u)
        try:
            db.session.commit()
            i += 1
        except IntegrityError:
            db.session.rollback()

def posts(count=100):
    fake = Faker()
    user_count = User.query.count()
    for i in range(count):
        u = User.query.offset(randint(0, user_count - 1)).first()
        p = Post(body=fake.text(),
                 timestamp=fake.past_date(),
                 author=u)
        db.session.add(p)
    db.session.commit()

这些虚拟对象的属性使用 Faker 包提供的随机信息生成器生成,可以生成看起来很逼真的姓名、电子邮件地址、句子,等等

用户的电子邮件地址和用户名必须是唯一的,但 Faker 是随机生成这些信息的,因此有重复的风险。如果发生了这种情况(虽然不太可能),提交数据库会话时会抛出 IntegrityError 异常。此时,数据库会话会回滚,取消添加重复用户的尝试。函数中的循环会一直运行,直到生成指定数量的唯一用户为止。

随机生成文章时要为每篇文章随机指定一个用户。为此,我们使用 offset() 查询过滤器。这个过滤器会跳过参数指定的记录数量。为了每次都得到不同的随机用户,我们先设定一个随机的偏移,然后调用 first() 方法。

在 Python shell 中轻松生成大量虚拟用户和文章

(venv) $ flask shell
>>> from app import fake
>>> fake.users(100)
>>> fake.posts(100)
在页面中渲染数据

app/main/views.py:分页显示博客文章列表

@main.route('/', methods=['GET', 'POST'])
def index():
    # ...
    page = request.args.get('page', 1, type=int)
    pagination = Post.query.order_by(Post.timestamp.desc()).paginate(
        page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'],
        error_out=False)
    posts = pagination.items
    return render_template('index.html', form=form, posts=posts,
                           pagination=pagination)

渲染的页数从请求的查询字符串(request.args)中获取,如果没有明确指定,则默认渲染第 1 页。

参数 type=int 确保参数在无法转换成整数时返回默认值。

为了显示某页中的记录,查询对象最后不能调用 all() 方法了,现在要调用 Flask-SQLAlchemy 提供的 paginate() 方法。

paginate() 方法的第一个参数——也是唯一必需的参数——是页数。

可选参数 per_page 指定每页显示的记录数量;如果没有指定,则默认显示 20 个记录。

另一个可选参数为 error_out,如果设为 True(默认值),则请求页数超出范围时返回 404 错误;如果设为 False,则页数超出范围时返回一个空列表。

为了能够很便利地配置每页显示的记录数量,参数 per_page 的值从应用的配置变量 FLASKY_POSTS_PER_PAGE 中读取。这个配置在 config.py 中设置。

这样修改之后,首页中的文章列表会只显示有限数量的文章。若想查看第 2 页中的文章,则要在浏览器地址栏中的 URL 后加上查询字符串 ?page=2。

添加分页导航

paginate() 方法的返回值是一个 Pagination 类对象,这个类在 Flask-SQLAlchemy 中定义。这个对象包含很多属性,用于在模板中生成分页链接,因此将其作为参数传入了模板

Flask-SQLAlchemy分页对象的属性
属性说明
items当前页面中的记录
query分页的源查询
page当前页数
prev_num上一页的页数
next_num下一页的页数
has_next如果有下一页,值为 True
has_prev如果有上一页,值为 True
pages查询得到的总页数
per_page每页显示的记录数量
total查询返回的记录总数
Flask-SQLAlchemy分页对象的方法
方法说明
iter_pages(left_edge=2,left_current=2,right_current=5,right_edge=2)一个迭代器,返回一个在分页导航中显示的页数列表。这个列表的最左边显示 left_edge 页,当前页的左边显示 left_current 页,当前页的右边显示 right_current 页,最右边显示 right_edge 页。例如,在一个 100 页的列表中,当前页为第 50 页,使用默认配置,这个方法会返回以下页数:1、2、None、48、49、50、51、52、53、54、55、None、99、100。None 表示页数之间的间隔
prev()上一页的分页对象
next()下一页的分页对象
app/templates/_macros.html:分页模板宏
{% macro pagination_widget(pagination, endpoint) %}
<ul class="pagination">
    <li{% if not pagination.has_prev %} class="disabled"{% endif %}>
        <a href="{% if pagination.has_prev %}{{ url_for(endpoint,
            page = pagination.page - 1, **kwargs) }}{% else %}#{% endif %}">
            &laquo;
        </a>
    </li>
    {% for p in pagination.iter_pages() %}
        {% if p %}
            {% if p == pagination.page %}
            <li class="active">
                <a href="{{ url_for(endpoint, page = p, **kwargs) }}">{{ p }}</a>
            </li>
            {% else %}
            <li>
                <a href="{{ url_for(endpoint, page = p, **kwargs) }}">{{ p }}</a>
            </li>
            {% endif %}
        {% else %}
        <li class="disabled"><a href="#">&hellip;</a></li>
        {% endif %}
    {% endfor %}
    <li{% if not pagination.has_next %} class="disabled"{% endif %}>
        <a href="{% if pagination.has_next %}{{ url_for(endpoint,
            page = pagination.page + 1, **kwargs) }}{% else %}#{% endif %}">
            &raquo;
        </a>
    </li>
</ul>
{% endmacro %}

这个宏创建了一个 Bootstrap 分页元素,即一个有特殊样式的无序列表,其中定义了下述页面链接。

“上一页”链接。如果当前页是第一页,为这个链接加上 CSS disabled 类。

分页对象的 iter_pages() 迭代器返回的所有页面链接。这些页面被渲染成具有明确页数的链接,页数在 url_for() 的参数中指定。当前显示的页面使用 CSS active 类高亮显示。页数列表中的间隔使用省略号表示。
“下一页”链接。如果当前页是最后一页,则会禁用这个链接。

Jinja2 宏的参数列表中不用加入 kwargs 即可接收关键字参数。分页宏把接收到的所有关键字参数都传给生成分页链接的 url_for() 方法。这种方式也可在路由中使用,例如包含动态部分的资料页面。

pagination_widget 宏可放在 index.html 和 user.html 中引入的 _posts.html 模板后面

app/templates/index.html:在博客文章列表下面添加分页导航
{% extends "base.html" %}

{% import "bootstrap/wtf.html" as wtf %}

{% import "_macros.html" as macros %}

...

{% include '_posts.html' %}
<div class="pagination">
    {{ macros.pagination_widget(pagination, '.index') }}
</div>
{% endif %}

使用Markdown和Flask-PageDown支持富文本文章

实现这个功能要用到一些新包。

PageDown:使用 JavaScript 实现的客户端 Markdown 到 HTML 转换程序。
Flask-PageDown:为 Flask 包装的 PageDown,把 PageDown 集成到 Flask-WTF 表单中。
Markdown:使用 Python 实现的服务器端 Markdown 到 HTML 转换程序。
Bleach:使用 Python 实现的 HTML 清理程序。

安装

(venv) $ pip install flask-pagedown markdown bleach
使用Flask-PageDown

Flask-PageDown 扩展定义了一个 PageDownField 类,这个类和 WTForms 中的 TextAreaField 接口一致。使用 PageDownField 字段之前,先要初始化扩展

app/__init__.py:初始化Flask-PageDown

from flask_pagedown import PageDown
# ...
pagedown = PageDown()
# ...
def create_app(config_name):
    # ...
    pagedown.init_app(app)
    # ...

若想把首页中的多行文本控件转换成 Markdown 富文本编辑器,PostForm 表单中的 body 字段必须改成 PageDownField 字段

app/main/forms.py:支持 Markdown 的文章表单

from flask_pagedown.fields import PageDownField

class PostForm(FlaskForm):
    body = PageDownField("What's on your mind?", validators=[Required()])
    submit = SubmitField('Submit')

Markdown 预览使用 PageDown 库生成,因此要把相关的文件添加到模板中。Flask-Page Down 简化了这个过程,提供了一个模板宏,从 CDN 中加载所需的文件

app/templates/index.html:Flask-PageDown 模板声明

{% block scripts %}
{{ super() }}
{{ pagedown.include_pagedown() }}
{% endblock %}

在多行文本字段中输入的 Markdown 格式文本会被立即渲染成 HTML,显示在输入框下方

在服务器端处理富文本

提交表单后,POST 请求只会发送纯 Markdown 文本,页面中显示的 HTML 预览会被丢掉。随表单一起发送生成的 HTML 预览有安全隐患,因为攻击者能很轻松地修改 HTML 代码,使其和 Markdown 源不匹配,然后再提交表单。为了安全起见,应该只提交 Markdown 源文本,然后在服务器上使用 Markdown(使用 Python 编写的 Markdown 到 HTML 转换程序)将其转换成 HTM L。得到 HTML 后,再使用 Bleach 进行清理,确保其中只包含几个允许使用的 HTML 标签。

把 Markdown 格式的博客文章转换成 HTML 的过程可以在 _posts.html 模板中完成,但这么做效率不高,因为每次渲染页面都要转换一次。为了避免重复工作,我们可在创建博客文章时做一次性转换,把结果缓存在数据库中。转换后的博客文章 HTML 代码缓存在 Post 模型的一个新字段中,在模板中可以直接调用。文章的 Markdown 源文本还要保存在数据库中,万一需要编辑时使用。示例 11-15 是对 Post 模型所做的改动。

app/models.py:在 Post 模型中处理 Markdown 文本

from markdown import markdown
import bleach

class Post(db.Model):
    # ...
    body_html = db.Column(db.Text)
    # ...
    @staticmethod
    def on_changed_body(target, value, oldvalue, initiator):
        allowed_tags = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code',
                        'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul',
                        'h1', 'h2', 'h3', 'p']
        target.body_html = bleach.linkify(bleach.clean(
            markdown(value, output_format='html'),
            tags=allowed_tags, strip=True))

db.event.listen(Post.body, 'set', Post.on_changed_body)

on_changed_body() 函数注册在 body 字段上,是 SQLAlchemy“set”事件的监听程序,这意味着只要 body 字段设了新值,这个函数就会自动被调用。on_changed_body() 函数把 body 字段中的文本渲染成 HTML 格式,将结果保存在 body_html 中,自动且高效地完成 Markdown 文本到 HTML 的转换。

真正的转换过程分 3 步完成。首先,markdown() 函数初步把 Markdown 文本转换成 HTML。然后,把得到的结果和允许使用的 HTML 标签列表传给 clean() 函数。clean() 函数删除所有不在白名单中的标签。转换的最后一步由 linkify() 函数完成,这个函数由 Bleach 提供,把纯文本中的 URL 转换成合适的 链接。最后一步是很有必要的,因为 Markdown 规范没有为自动生成链接提供官方支持,但这是个十分便利的功能。在客户端,PageDown 以扩展的形式实现了这个功能,因此在服务器上要调用 linkify() 函数,确保结果一致。

最后,如果 post.body_html 字段存在,还要把模板中的 post.body 换成 post.body_html

app/templates/_posts.html:在模板中使用文章内容的 HTML 格式

<div class="post-body">
    {% if post.body_html %}
        {{ post.body_html | safe }}
    {% else %}
        {{ post.body }}
    {% endif %}
</div>

渲染 HTML 格式内容时使用| safe 后缀,其目的是告诉 Jinja2 不要转义 HTML 元素。出于安全考虑,默认情况下 Jinja2 会转义所有模板变量,但是从 Markdown 到 HTML 的转换是在我们自己的服务器上完成的,因此可以放心直接渲染。

博客文章的固定链接

每篇文章都要有一个专页,使用唯一的 URL 引用

支持固定链接功能的路由和视图函数

app/main/views.py:为文章提供固定链接

@main.route('/post/<int:id>')
def post(id):
    post = Post.query.get_or_404(id)
    return render_template('post.html', posts=[post])

博客文章的 URL 使用插入数据库时分配的唯一 id 字段构建

博客文章编辑器

博客文章编辑器显示在单独的页面中,而且也基于 Flask-PageDown 实现,因此页面中要有个文本框,显示博客文章的 Markdown 文本,并在下方显示预览

app/templates/edit_post.html:编辑博客文章的模板

{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% block title %}Flasky - Edit Post{% endblock %}
{% block page_content %}
<div class="page-header">
    <h1>Edit Post</h1>
</div>
<div>
    {{ wtf.quick_form(form) }}
</div>
{% endblock %}

{% block scripts %}
{{ super() }}
{{ pagedown.include_pagedown() }}
{% endblock %}

app/main/views.py:编辑博客文章的路由

@main.route('/edit/<int:id>', methods=['GET', 'POST'])
@login_required
def edit(id):
    post = Post.query.get_or_404(id)
    if current_user != post.author and \
            not current_user.can(Permission.ADMIN):
        abort(403)
    form = PostForm()
    if form.validate_on_submit():
        post.body = form.body.data
        db.session.add(post)
        db.session.commit()
        flash('The post has been updated.')
        return redirect(url_for('.post', id=post.id))
    form.body.data = post.body
    return render_template('edit_post.html', form=form)

这个视图函数只允许博客文章的作者编辑文章,但管理员例外,管理员能编辑所有用户的文章。

如果用户试图编辑其他用户的文章,则视图函数返回 403 错误。这里使用的 PostForm 表单类和首页中使用的是同一个。

关注者

社交 Web 应用允许用户之间相互联系。不同的应用以不同的名称称呼这样的关系,例如关注者、好友、联系人、联络人或伙伴。不管使用什么名称,其功能都是一样的,都要记录两个用户之间的定向联系,在数据库查询中也要使用这种联系。

再论数据库关系

数据库使用关系建立记录之间的联系。其中,一对多关系是最常用的关系类型,它把一个记录和一组相关的记录联系在一起。实现这种关系时,要在“多”这一侧加入一个外键,指向“一”这一侧连接的记录。

示例应用现在包含两个一对多关系:一个把用户角色和一组用户联系起来,另一个把用户和发布的博客文章联系起来。

多数其他关系类型都可以从一对多类型中衍生。

多对一关系从“多”这一侧看,就是一对多关系。

一对一关系是简化版的一对多关系,限制“多”这一侧最多只能有一个记录。

唯一不能从一对多关系中简单演化出来的类型是多对多关系,这种关系的两侧都有多个记录。

多对多关系

应用编程接口 使用 Flask 实现符合 REST 架构的 API

Web 应用有种趋势,那就是业务逻辑被越来越多地移到客户端,开创出了一种称为富互联网应用(RIA,rich Internet application)的架构。

在 RIA 中,服务器的主要功能(有时是唯一功能)是为客户端提供数据存取服务。

在这种模式中,服务器变成了 Web 服务或应用编程接口(API,application programming interface)。

RIA 可采用多种协议与 Web 服务通信。

远程过程调用(RPC,remote procedure call)协议,例如 XML-RPC,

以及由其衍生的简单对象访问协议(SOAP,simplified object access protocol),在几年前比较受欢迎。

最近,表现层状态转移(REST,representational state transfer)架构崭露头角,成为 Web 应用的新宠,因为这种架构建立在大家熟识的万维网基础之上。

Flask 是开发 REST 架构 Web 服务的理想框架,因为 Flask 天生轻量

REST简介

Roy Fielding 在其博士论文“Architectural Styles and the Design of Network-based Software Architectures”的第 5 章中描述了 Web 服务的 REST 架构方式,并列出了 6 个符合这一架构定义的特征。

客户端 – 服务器

客户端和服务器之间必须有明确的界线。

无状态

客户端发出的请求中必须包含所有必要的信息。服务器不能在两次请求之间保存客户端的任何状态。

缓存

服务器发出的响应可以标记为可缓存或不可缓存,这样出于优化目的,客户端(或客户端和服务器之间的中间服务)可以使用缓存。

接口统一

客户端访问服务器资源时使用的协议必须一致、定义良好,且已经标准化。这是 REST 架构最复杂的一方面,涉及唯一的资源标识符、资源表述、客户端和服务器之间自描述的消息,以及超媒体(hypermedia)。

系统分层

在客户端和服务器之间可以按需插入代理服务器、缓存或网关,以提高性能、稳定性和伸缩性。

按需编程

客户端可以选择从服务器中下载代码,在客户端的上下文中执行。

资源就是一切
资源是 REST 架构风格的核心概念。

在 REST 架构中,资源是应用中你要着重关注的事物。

例如,在博客应用中,用户、博客文章和评论都是资源。

每个资源都要使用唯一的 URL 表示。

对 HTTP 协议来说,资源的标识符是 URL。

还是以博客应用为例,一篇博客文章可以使用 URL /api/posts/12345 表示,其中 12345 是这篇文章的唯一标识符,使用文章在数据库中的主键表示。

URL 的格式或内容无关紧要,只要资源的 URL 只表示唯一的一个资源即可。

某一类资源的集合也要有一个 URL。

博客文章集合的 URL 可以是 /api/posts/,评论集合的 URL 可以是 /api/comments/。

API

API 还可以为某一类资源的逻辑子集定义集合 URL。

例如,编号为 12345 的博客文章,其中的所有评论可以使用 URL /api/posts/12345/comments/ 表示。

表示资源集合的 URL 习惯在末端加上一个斜线,代表一种“子目录”结构。


注意,Flask 会特殊对待末端带有斜线的路由。

如果客户端请求的 URL 的末端没有斜线,而唯一匹配的路由末端有斜线,Flask 会自动响应一个重定向,转向末端带斜线的 URL。反之则不会重定向。

请求方法

客户端应用在建立起的资源 URL 上发送请求,使用请求方法表示期望的操作。

若要从博客 API 中获取博客文章列表,客户端可以向 http://www.example.com/api/posts/ 发送 GET 请求。

若要插入一篇新博客文章,客户端可以向同一地址发送 POST 请求,而且请求主体中要包含博客文章的内容。

若要获取编号为 12345 的博客文章,客户端可以向 http://www.example.com/api/posts/12345 发送 GET 请求。

REST式API使用的HTTP请求方法
请求方法目标说明HTTP 状态码
GET单个资源的 URL获取目标资源200
GET资源集合的 URL获取资源的集合(如果服务器实现了分页,还可以是一页中的资源)200
POST资源集合的 URL创建新资源,并将其加入目标集合。服务器为新资源指派 URL,并在响应的 Location首部中返回201
PUT单个资源的 URL修改一个现有资源。如果客户端能为资源指派 URL,还可用来创建新资源200 或 204
DELETE单个资源的 URL删除一个资源200 或 204
DELETE资源集合的 URL删除目标集合中的所有资源200 或 204

REST 架构不要求必须为一个资源实现所有的请求方法。

如果资源不支持客户端使用的请求方法,响应的状态码为 405(不允许使用的方法)。Flask 会自动处理这种错误。

请求方法不止 GET、POST、PUT 和 DELETE。HTTP 协议还定义了其他方法,

例如 HEAD 和 OPTIONS,这些方法由 Flask 自动实现。

请求和响应主体

在请求和响应的主体中,资源在客户端和服务器之间来回传送,但 REST 没有指定编码资源的方式。请求和响应中的 Content-Type 首部用于指明主体中资源的编码方式。使用 HTTP 协议的内容协商机制,可以找到一种客户端和服务器都支持的编码方式。

REST 式 Web 服务常用的两种编码方式是 JavaScript 对象表示法(JSON,JavaScript object notation)和可扩展标记语言(XML,extensible markup language)。

对基于 Web 的 RIA 来说,JSON 更具吸引力,因为 JSON 比 XML 简洁,而且 JSON 与 Web 浏览器使用的客户端脚本语言 JavaScript 联系紧密

一篇博客文章对应的资源可以使用如下的 JSON 表示:

{
    "self_url": "http://www.example.com/api/posts/12345",
    "title": "Writing RESTful APIs in Python",
    "author_url": "http://www.example.com/api/users/2",
    "body": "... text of the article here ...",
    "comments_url": "http://www.example.com/api/posts/12345/comments"
}

self_url、author_url 和 comments_url 字段都是完整的资源 URL。这是很重要的表示方法,因为客户端可以通过这些 URL 发掘新资源。

在设计良好的 REST 式 API 中,客户端只需知道几个顶级资源的 URL,其他资源的 URL 则从响应中包含的链接上发掘。

这就好比浏览网络时,你在自己知道的网页中点击链接发掘新网页一样。

版本

服务器可以随时更新 Web 浏览器中的客户端,但无法强制更新智能手机中的应用,因为更新前先要获得机主的许可。即便机主想更新,也不能保证每个智能手机都更新到服务器端部署的新版本了。

Web 服务的容错能力要比一般的 Web 应用强,而且还要保证旧版客户端能继续使用。更新 Web 服务一定要格外小心,倘若破坏了向后兼容性,如果客户端没有更新到新版,现有的客户端将无法使用。这一问题的常见解决办法是使用版本区分 Web 服务所处理的 URL。例如,首次发布的博客 Web 服务可以通过 /api/v1/posts/ 提供博客文章的集合。

在 URL 中加入 Web 服务的版本号有助于组织化管理新旧功能,让服务器能为新客户端提供新功能,同时继续支持旧版客户端。博客服务可能会修改博客文章使用的 JSON 格式,通过 /api/v2/posts/ 提供修改后的博客文章,而客户端仍能通过 /api/v1/posts/ 获取旧的 JSON 格式。

提供多版本支持会增加服务器的维护负担,但在某些情况下,这是不破坏现有部署且能让应用不断发展的唯一方式。等到所有客户端都升级到新版之后,可以弃用旧版服务,待时机成熟后再把旧版完全删除。

使用Flask实现REST式Web服务

使用 Flask 创建 REST 式 Web 服务十分简单。

使用熟悉的 route() 装饰器及其 methods 可选参数可以声明服务所提供资源 URL 的路由。

处理 JSON 数据同样简单,请求中的 JSON 数据可以通过 request.get_json() 转换成字典格式,而且可以使用 Flask 提供的辅助函数 jsonify(),从 Python 字典中生成需要包含 JSON 的响应。

创建API蓝本

REST 式 API 相关的路由是应用中一个自成一体的子集。

因此,为了更好地组织代码,最好把这些路由放到独立的蓝本中

API 蓝本的结构

|-flasky
  |-app/
    |-api
      |-__init__.py
      |-users.py
      |-posts.py
      |-comments.py
      |-authentication.py
      |-errors.py
      |-decorators.py

如果以后需要创建一个向前兼容的 API 版本,可以再添加一个带版本号的包,让应用同时支持两个版本的 API。

在这个 API 蓝本中,各资源分别在不同的模块中实现。

蓝本中还包含处理身份验证、错误以及提供自定义装饰器的模块。

app/api/__init__.py:API 蓝本的构造文件
from flask import Blueprint

api = Blueprint('api', __name__)

from . import authentication, posts, users, comments, errors

这个蓝本的包构造文件与其他蓝本的类似。一定要导入蓝本中的所有模块,这样才能注册路由和错误处理程序。因为很多模块要导入 api 包,所以相关模块在底部导入,以防循环依赖导致出错。

app/__init__.py:注册API 蓝本
def create_app(config_name):
    # ...
    from .api import api as api_blueprint
    app.register_blueprint(api_blueprint, url_prefix='/api/v1')
    # ...

注册 API 蓝本时指定了一个 URL 前缀,因此蓝本中所有路由的 URL 都将以 /api/v1 开头。

注册蓝本时设置前缀是个好主意,这样就无须在蓝本的每个路由中硬编码版本号了。

错误处理

REST 式 Web 服务将请求的状态告知客户端时,会在响应中发送适当的 HTTP 状态码,并将额外信息放入响应主体。

API返回的常见HTTP状态码
HTTP状态码名称说明
200OK(成功)请求成功
201Created(已创建)请求成功,而且创建了一个新资源
202Accepted(已接收)请求已接收,但仍在处理中,将异步处理
204No Content(没有内容)请求成功处理,但是返回的响应没有数据
400Bad Request(坏请求)请求无效或不一致
401Unauthorized(未授权)请求未包含身份验证信息,或者提供的凭据无效
403Forbidden(禁止)请求中发送的身份验证凭据无权访问目标
404Not Found(未找到)URL 对应的资源不存在
405Method Not Allowed(不允许使用的方法)指定资源不支持请求使用的方法
500Internal Server Error(内部服务器错误)处理请求的过程中发生意外错误

处理 404 和 500 状态码时会遇到点小麻烦,因为这两个错误是由 Flask 自己生成的,而且一般会返回 HTML 响应。这很可能会让 API 客户端困惑,因为客户端期望所有响应都是 JSON 格式。

为所有客户端生成适当响应的一种方法是,在错误处理程序中根据客户端请求的格式改写响应,这种技术称为内容协商。

改进后的 404 错误处理程序,它向 Web 服务客户端发送 JSON 格式响应,除此之外则发送 HTML 格式响应。500 错误处理程序的写法类似。

app/api/errors.py:使用 HTTP 内容协商机制处理 404 错误
@main.app_errorhandler(404)
def page_not_found(e):
    if request.accept_mimetypes.accept_json and \
            not request.accept_mimetypes.accept_html:
        response = jsonify({'error': 'not found'})
        response.status_code = 404
        return response
    return render_template('404.html'), 404

这个新版错误处理程序检查 Accept 请求首部(解码为 request.accept_mimetypes),根据首部的值决定客户端期望接收的响应格式。

浏览器一般不限制响应的格式,但是 API 客户端通常会指定。仅当客户端接受的格式列表中包含 JSON 但不包含 HTML 时,才生成 JSON 响应。

app/api/errors.py:API 蓝本中 403 状态码的错误处理程序
def forbidden(message):
    response = jsonify({'error': 'forbidden', 'message': message})
    response.status_code = 403
    return response

API 蓝本中的视图函数在必要时可以调用这些辅助函数生成错误响应。

使用Flask-HTTPAuth验证用户身份

与普通 Web 应用一样,Web 服务也需要保护信息,确保未经授权的用户无法访问。为此,RIA 必须询问用户的登录凭据,并将其传给服务器进行验证。

REST 式 Web 服务的特征之一是无状态,即服务器在两次请求之间不能“记住”客户端的任何信息。客户端必须在发出的请求中包含所有必要信息,因此所有请求都必须包含用户凭据。

Flasky 应用当前的登录功能是在 Flask-Login 的帮助下实现的,数据存储在用户会话中。默认情况下,Flask 把会话保存在客户端 cookie 中,因此服务器没有保存任何用户相关信息,都转交给客户端保存。这种实现方式看起来遵守了 REST 架构的无状态要求,但在 REST 式 Web 服务中使用 cookie 有点不现实,因为 Web 浏览器之外的客户端很难提供对 cookie 的支持。鉴于此,在 API 中使用 cookie 并不是一个很好的设计选择。

REST 架构的无状态要求看起来似乎过于严格,但这并不是随意提出的要求——无状态的服务器伸缩起来更加简单。如果服务器保存了客户端的相关信息,那么必须保证特定客户端发送的请求由同一台服务器处理,或者使用共享存储器存储客户端数据。这两点都难以实现,但是如果服务器是无状态的,这两个问题也就不复存在。

因为 REST 架构基于 HTTP 协议,所以发送凭据的最佳方式是使用 HTTP 身份验证,基本验证和摘要验证都可以。在 HTTP 身份验证中,用户凭据包含在每个请求的 Authorization 首部中。

HTTP 身份验证协议很简单,可以直接实现,不过 Flask-HTTPAuth 扩展提供了一个便利的包装,把协议的细节隐藏在装饰器之中,类似于 Flask-Login 提供的 login_required 装饰器。

安装

(venv) $ pip install flask-httpauth

若想使用 HTTP 基本验证初始化这个扩展,要创建一个 HTTPBasicAuth 类对象。与 Flask- Login 一样,Flask-HTTPAuth 不对验证用户凭据所需的步骤做任何假设,所需的信息在回调函数中提供

初始化 Flask-HTTPAuth

app/api/authentication.py:初始化 Flask-HTTPAuth

from flask_httpauth import HTTPBasicAuth

auth = HTTPBasicAuth()

@auth.verify_password
def verify_password(email, password):
    if email == '':
        return False
    user = User.query.filter_by(email = email).first()
    if not user:
        return False
    g.current_user = user
    return user.verify_password(password)

因为这种身份验证方法只在 API 蓝本中使用,所以 Flask-HTTPAuth 扩展只在蓝本包中初始化,而不像其他扩展那样要在应用包中初始化。

电子邮件和密码使用 User 模型中现有的方法验证。如果登录凭据正确,这个验证回调函数返回 True,否则返回 False。如果请求中没有身份验证信息,Flask-HTTPAuth 也会调用回调函数,把两个参数都设为空字符串。此时,email 的值是一个空字符串,回调函数立即返回 False 以阻断请求。某些应用遇到这种情况时可以返回 True,允许匿名用户访问。这个回调函数把通过身份验证的用户保存在 Flask 的上下文变量 g 中,供视图函数稍后访问。

Flask-HTTPAuth 错误处理程序

如果身份验证凭据不正确,则服务器向客户端返回 401 状态码。默认情况下,Flask- HTTPAuth 自动生成这个状态码,但为了与 API 返回的其他错误保持一致,我们可以自定义这个错误响应

app/api/authentication.py:Flask-HTTPAuth 错误处理程序

from .errors import unauthorized

@auth.error_handler
def auth_error():
    return unauthorized('Invalid credentials')
保护路由

若想保护路由,可使用 auth.login_required 装饰器:

@api.route('/posts/')
@auth.login_required
def get_posts():
    pass

不过,这个蓝本中的所有路由都要使用相同的方式进行保护,所以我们可以在 before_request 处理程序中使用一次 login_required 装饰器,将其应用到整个蓝本

app/api/authentication.py:在 before_request 处理程序中验证身份

from .errors import forbidden

@api.before_request
@auth.login_required
def before_request():
    if not g.current_user.is_anonymous and \
            not g.current_user.confirmed:
        return forbidden('Unconfirmed account')

现在,API 蓝本中的所有路由都能自动验证身份。

此外,before_request 处理程序还会拒绝已通过身份验证但还没有确认账户的用户。

基于令牌的身份验证 itsdangerous

每次请求,客户端都要发送身份验证凭据。

为了避免总是发送敏感信息(例如密码),我们可以使用一种基于令牌的身份验证方案。

在基于令牌的身份验证方案中,客户端先发送一个包含登录凭据的请求,通过身份验证后,得到一个访问令牌。这个令牌可以代替登录凭据对请求进行身份验证。出于安全考虑,令牌有过期时间。令牌过期后,客户端必须重新发送登录凭据,获取新的令牌。令牌短暂的使用期限,可以降低令牌落入他人之手所导致的安全隐患。为了生成和核查身份验证令牌,我们要在 User 模型中定义两个新方法。这两个新方法用到了 itsdangerous 包

app/models.py:支持基于令牌的身份验证
class User(db.Model):
    # ...
    def generate_auth_token(self, expiration):
        s = Serializer(current_app.config['SECRET_KEY'],
                       expires_in=expiration)
        return s.dumps({'id': self.id}).decode('utf-8')

    @staticmethod
    def verify_auth_token(token):
        s = Serializer(current_app.config['SECRET_KEY'])
        try:
            data = s.loads(token)
        except:
            return None
        return User.query.get(data['id'])

generate_auth_token() 方法使用编码后的用户 id 字段值生成一个签名令牌,还指定了以秒为单位的过期时间。

verify_auth_token() 方法接受的参数是一个令牌,如果令牌有效就返回对应的用户。verify_auth_token() 是静态方法,因为只有解码令牌后才能知道用户是谁。

改进核查回调,支持令牌

为了能够使用令牌验证请求,我们必须修改 Flask-HTTPAuth 提供的 verify_password 回调,

除了普通的凭据之外,还要接受令牌

app/api/authentication.py:改进核查回调,支持令牌

@auth.verify_password
def verify_password(email_or_token, password):
    if email_or_token == '':
        return False
    if password == '':
        g.current_user = User.verify_auth_token(email_or_token)
        g.token_used = True
        return g.current_user is not None
    user = User.query.filter_by(email=email_or_token).first()
    if not user:
        return False
    g.current_user = user
    g.token_used = False
    return user.verify_password(password)

在这个新版本中,第一个参数可以是电子邮件地址,也可以是身份验证令牌。

如果这个参数为空,那就和之前一样,假定是匿名用户。

如果密码为空,那就假定 email_or_token 参数提供的是令牌,按照令牌的方式进行验证。

如果两个参数都不为空,那么假定使用常规的邮件地址和密码进行验证。

在这种实现方式中,基于令牌的身份验证是可选的,由客户端决定是否使用。

为了让视图函数能区分这两种身份验证方法,我们添加了 g.token_used 变量。

生成身份验证令牌

把身份验证令牌发送给客户端的路由也要添加到 API 蓝本中

app/api/authentication.py:生成身份验证令牌

@api.route('/tokens/', methods=['POST'])
def get_token():
    if g.current_user.is_anonymous or g.token_used:
        return unauthorized('Invalid credentials')
    return jsonify({'token': g.current_user.generate_auth_token(
        expiration=3600), 'expiration': 3600})

因为这个路由也在蓝本中,所以添加到 before_request 处理程序上的身份验证机制也会用在这个路由上。

为了确保这个路由使用电子邮件地址和密码验证身份,而不使用之前获取的令牌,我们检查了 g.token_used 的值,拒绝使用令牌验证身份。

这样做是为了防止用户绕过令牌过期机制,使用旧令牌请求新令牌。

这个视图函数返回 JSON 格式的响应,其中包含过期时间为 1 小时的令牌。过期时间也在 JSON 响应中。

资源和JSON的序列化转换

开发 Web 服务时,经常需要在资源的内部表示和 JSON 之间进行转换。

JSON 是 HTTP 请求和响应使用的传输格式。

把内部表示转换成传输格式的过程称为序列化

序列化

app/models.py:把文章转换成 JSON 格式的序列化字典

class Post(db.Model):
    # ...
    def to_json(self):
        json_post = {
            'url': url_for('api.get_post', id=self.id),
            'body': self.body,
            'body_html': self.body_html,
            'timestamp': self.timestamp,
            'author_url': url_for('api.get_user', id=self.author_id),
            'comments_url': url_for('api.get_post_comments', id=self.id),
            'comment_count': self.comments.count()
        }
        return json_post

url、author_url 和 comments_url 字段要分别返回相应资源的 URL,因此它们的值使用 url_for() 生成,所调用的路由即将在 API 蓝本中定义。

这段代码还说明表示资源时可以使用虚构的属性。comment_count 字段是博客文章的评论数量,并不是模型的真实属性,它之所以包含在这个资源中,是为了便于客户端使用。

User 模型的 to_json() 方法可以使用类似的方式实现

app/models.py:把用户转换成 JSON 格式的序列化字典

class User(UserMixin, db.Model):
    # ...
    def to_json(self):
        json_user = {
            'url': url_for('api.get_user', id=self.id),
            'username': self.username,
            'member_since': self.member_since,
            'last_seen': self.last_seen,
            'posts_url': url_for('api.get_user_posts', id=self.id),
            'followed_posts_url': url_for('api.get_user_followed_posts',
                                          id=self.id),
            'post_count': self.posts.count()
        }
        return json_user

为了保护隐私,这个方法没有把用户的某些属性加入响应,例如 email 和 role。这段代码再次说明,提供给客户端的资源表示没必要与数据库模型的内部定义完全一致。

反序列化

序列化的逆向操作称为反序列化。把 JSON 结构反序列化成模型时面临的问题是,客户端提供的数据可能无效、错误或者多余

从 JSON 格式数据创建 Post 模型实例

app/models.py:从 JSON 格式数据创建一篇博客文章

from app.exceptions import ValidationError

class Post(db.Model):
    # ...
    @staticmethod
    def from_json(json_post):
        body = json_post.get('body')
        if body is None or body == '':
            raise ValidationError('post does not have a body')
        return Post(body=body)

上述代码在实现过程中只选择使用 JSON 字典中的 body 属性,忽略了 body_html 属性,因为只要 body 属性的值发生变化,就会触发一个 SQLAlchemy 事件,自动在服务器端渲染 Markdown。除非允许客户端指定过去或未来的日期(这个应用并不支持该功能),否则无须使用 timestamp 属性。

因为客户端无权选择博客文章的作者,所以没有使用 author_url 字段。

author_url 字段唯一能使用的值是通过身份验证的用户。

comments_url 和 comment_count 属性使用数据库关系自动生成,因此其中没有创建文章所需的有用信息。

最后,url 字段也被忽略了,因为在这个实现中资源的 URL 由服务器指派,而不是客户端。

ValidationError 异常

注意检查错误的方式。如果没有 body 字段或者其值为空,那么抛出 ValidationError 异常。

在这种情况下,抛出异常才是处理错误的正确方式,

因为 from_json() 方法并没有掌握处理问题的足够信息,唯有把错误交给调用者,由上层代码处理这个错误。

ValidationError 类是 Python 中 ValueError 类的简单子类

app/exceptions.py:ValidationError 异常

class ValidationError(ValueError):
    pass
全局异常处理 errorhandler

应用需要处理这个异常,向客户端提供适当的响应。

为了避免在视图函数中编写捕获异常的代码,可以使用 Flask 的 errorhandler 装饰器注册一个全局异常处理程序。

app/api/errors.py:API 中 ValidationError 异常的处理程序

@api.errorhandler(ValidationError)
def validation_error(e):
    return bad_request(e.args[0])

这里使用的 errorhandler 装饰器与注册 HTTP 状态码处理程序时使用的是同一个,只不过此时接收的参数是 Exception 类,只要抛出了指定类的异常,就会调用被装饰的函数。

注意,这个装饰器从 API 蓝本中调用,所以只有处理蓝本中的路由时抛出了异常才会调用这个处理程序。

这样做,视图函数中的代码就可以写得十分简洁明了,而且无须检查错误。

@api.route('/posts/', methods=['POST'])
def new_post():
    post = Post.from_json(request.json)
    post.author = g.current_user
    db.session.add(post)
    db.session.commit()
    return jsonify(post.to_json())
实现资源的各个端点

实现处理不同资源的路由。GET 请求往往是最简单的,因为它们只返回信息,而不做任何改动

app/api/posts.py:文章资源 GET 请求的处理程序
@api.route('/posts/')
def get_posts():
    posts = Post.query.all()
    return jsonify({ 'posts': [post.to_json() for post in posts] })

@api.route('/posts/<int:id>')
def get_post(id):
    post = Post.query.get_or_404(id)
    return jsonify(post.to_json())

第一个路由处理获取文章集合的请求。这个函数使用列表推导生成所有文章的 JSON 版本。

第二个路由返回单篇博客文章,如果在数据库中没找到指定 id 对应的文章,则返回 404 错误。

文章资源 POST 请求的处理程序

博客文章资源的 POST 请求处理程序把一篇新博客文章插入数据库
app/api/posts.py:文章资源 POST 请求的处理程序

@api.route('/posts/', methods=['POST'])
@permission_required(Permission.WRITE)
def new_post():
    post = Post.from_json(request.json)
    post.author = g.current_user
    db.session.add(post)
    db.session.commit()
    return jsonify(post.to_json()), 201, \
        {'Location': url_for('api.get_post', id=post.id)}

这个视图函数包含在 permission_required 装饰器(下一个示例定义)中,确保通过身份验证的用户有写博客文章的权限。

得益于前面实现的错误处理程序,创建博客文章的过程变得很直观。

博客文章从 JSON 数据中创建,其作者就是通过身份验证的用户。

这个模型写入数据库之后,返回 201 状态码,并把 Location 首部的值设为刚创建的这个资源的 URL。

注意,为便于客户端操作,响应的主体中包含了新建的资源。如此一来,客户端就无须在创建资源后再立即发起一个 GET 请求以获取资源。

permission_required 装饰器

用来防止未授权用户创建新博客文章的 permission_required 装饰器与应用中使用的类似,但要针对 API 蓝本做些定制

app/api/decorators.py:permission_required 装饰器

def permission_required(permission):

    def decorator(f):

        @wraps(f)
        def decorated_function(*args, **kwargs):
            if not g.current_user.can(permission):
                return forbidden('Insufficient permissions')
            return f(*args, **kwargs)
        return decorated_function
    return decorator
文章资源 PUT 请求的处理程序

博客文章 PUT 请求的处理程序用于更新现有资源

app/api/posts.py:文章资源 PUT 请求的处理程序

@api.route('/posts/<int:id>', methods=['PUT'])
@permission_required(Permission.WRITE)
def edit_post(id):
    post = Post.query.get_or_404(id)
    if g.current_user != post.author and \
            not g.current_user.can(Permission.ADMIN):
        return forbidden('Insufficient permissions')
    post.body = request.json.get('body', post.body)
    db.session.add(post)
    db.session.commit()
    return jsonify(post.to_json())

本例中要进行的权限检查更为复杂。

检查用户是否有写博客文章的权限通过装饰器实现,但为了确保用户能编辑博客文章,这个函数还要保证用户是文章的作者或管理员。此项检查直接添加到视图函数中。

如果这种检查要应用于多个视图函数,为避免代码重复,最好的做法是定义装饰器。

因为应用不允许删除文章,所以没必要实现 DELETE 请求方法的处理程序。

用户资源和评论资源的处理程序实现方式类似

Flasky应用的API资源
资源URL方法说明
/users/<int:id>GET返回一个用户
/users/<int:id>/posts/GET返回一个用户发布的所有博客文章
/users/<int:id>/timeline/GET返回一个用户所关注用户发布的所有文章
/posts/GET返回所有博客文章
/posts/POST创建一篇博客文章
/posts/<int:id>GET返回一篇博客文章
/posts/<int:id>PUT修改一篇博客文章
/posts/<int:id/>comments/GET返回一篇博客文章的评论
/posts/<int:id/>comments/POST在一篇博客文章中添加一条评论
/comments/GET返回所有评论
/comments/<int:id>GET返回一条评论
分页大型资源集合

对大型资源集合来说,获取集合的 GET 请求消耗很大,而且难以管理

app/api/posts.py:分页文章资源
@api.route('/posts/')
def get_posts():
    page = request.args.get('page', 1, type=int)
    pagination = Post.query.paginate(
        page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'],
        error_out=False)
    posts = pagination.items
    prev = None
    if pagination.has_prev:
        prev = url_for('api.get_posts', page=page-1)
    next = None
    if pagination.has_next:
        next = url_for('api.get_posts', page=page+1)
    return jsonify({
        'posts': [post.to_json() for post in posts],
        'prev_url': prev,
        'next_url': next,
        'count': pagination.total
    })

JSON 格式响应中的 posts 字段依旧包含一系列文章,但现在这只是其中一页,而不是完整的集合。

prev_url 和 next_url 字段分别是前一页和后一页资源的 URL,如果某个方向没有更多分页了,则相应字段的值为 None。count 是集合中元素的总数。

这种技术可应用于所有返回集合的路由。

使用HTTPie测试Web服务

测试 Web 服务必须使用 HTTP 客户端。

在命令行中测试 Web 服务最常使用的两个客户端是 cURL 和 HTTPie。这两个工具都很强大,但后者的命令行句法更简洁,可读性也更高,而且为 API 请求做了特别优化

安装 HTTPie
(venv) $ pip install httpie
发起 GET 请求

假设开发服务器运行在默认地址 http://127.0.0.1:5000 上。在另一个终端窗口中,可按照如下的方式发起 GET 请求:

(venv) $ http --json --auth <email>:<password> GET \
> http://127.0.0.1:5000/api/v1/posts
HTTP/1.0 200 OK
Content-Length: 7018
Content-Type: application/json
Date: Sun, 22 Dec 2013 08:11:24 GMT
Server: Werkzeug/0.9.4 Python/2.7.3

{
    "posts": [
        ...
    ],
    "prev_url": null
    "next_url": "http://127.0.0.1:5000/api/v1/posts/?page=2",
    "count": 150
}

注意响应中的分页链接。因为这是第一页,所以没有上一页,不过返回了获取下一页的 URL 和总数

发送 POST 请求,添加一篇新博客文章
(venv) $ http --auth <email>:<password> --json POST \
> http://127.0.0.1:5000/api/v1/posts/ \
> "body=I'm adding a post from the *command line*."
HTTP/1.0 201 CREATED
Content-Length: 360
Content-Type: application/json
Date: Sun, 22 Dec 2013 08:30:27 GMT
Location: http://127.0.0.1:5000/api/v1/posts/111
Server: Werkzeug/0.9.4 Python/2.7.3

{
    "author": "http://127.0.0.1:5000/api/v1/users/1",
    "body": "I'm adding a post from the *command line*.",
    "body_html": "<p>I'm adding a post from the <em>command line</em>.</p>",
    "comments": "http://127.0.0.1:5000/api/v1/posts/111/comments",
    "comment_count": 0,
    "timestamp": "Sun, 22 Dec 2013 08:30:27 GMT",
    "url": "http://127.0.0.1:5000/api/v1/posts/111"
}
使用令牌

如果不想使用用户名和密码验证身份,而是使用令牌,要先向 /api/v1/tokens/ 发送 POST 请求:

(venv) $ http --auth <email>:<password> --json POST \
> http://127.0.0.1:5000/api/v1/tokens/
HTTP/1.0 200 OK
Content-Length: 162
Content-Type: application/json
Date: Sat, 04 Jan 2014 08:38:47 GMT
Server: Werkzeug/0.9.4 Python/3.3.3

{
    "expiration": 3600,
    "token": "eyJpYXQiOjEzODg4MjQ3MjcsImV4cCI6MTM4ODgyODMyNywiYWxnIjoiSFMy..."
}

在接下来的 1 小时中,可以使用这个令牌访问 API。

请求时要把用户名字段设为这个令牌,密码字段则留空:

(venv) $ http --json --auth eyJpYXQ...: GET http://127.0.0.1:5000/api/v1/posts/

令牌过期后,请求会返回 401 错误,指明需要获取新令牌。

测试

编写单元测试主要有两个目的。

实现新功能时,单元测试能够确保新添加的代码按预期方式运行。当然,这个过程也可手动完成,不过自动化测试显然能节省时间和精力,因为自动化测试能轻松地重复运行。

另外,一个更重要的目的是,每次修改应用后,运行单元测试能保证现有代码的功能没有回归,即新改动没有影响原有代码的正常运行。

模型类很容易在运行中的应用上下文之外进行测试,因此不用花费太多精力,为数据库模型中实现的全部功能编写单元测试至少能有效保证应用的这一部分在不断完善的过程中仍能按预期运行。

获取代码覆盖度报告 coverage

代码覆盖度工具用于统计单元测试检查了应用的多少功能,并提供一份详细的报告,说明应用的哪些代码没有测试到。

这个信息非常重要,因为它能指引你为最需要测试的部分编写新测试。

安装 coverage
(venv) $ pip install coverage

这个工具本身是一个命令行脚本,可在任何一个 Python 应用中检查代码覆盖度。

除此之外,它还提供了更方便的脚本访问功能,使用编程方式启动覆盖检查引擎。

为了能更好地把覆盖检测集成到 flask test 命令中,可以添加一个 --coverage 选项

覆盖度检测

flasky.py:覆盖度检测

import os
import sys
import click

COV = None

if os.environ.get('FLASK_COVERAGE'):
    import coverage

    COV = coverage.coverage(branch=True, include='app/*')
    COV.start()

# ...

@app.cli.command()
@click.option('--coverage/--no-coverage', default=False,
              help='Run tests under code coverage.')
def test(coverage):
    """Run the unit tests."""
    if coverage and not os.environ.get('FLASK_COVERAGE'):
        os.environ['FLASK_COVERAGE'] = '1'
        os.execvp(sys.executable, [sys.executable] + sys.argv)
    import unittest

    tests = unittest.TestLoader().discover('tests')
    unittest.TextTestRunner(verbosity=2).run(tests)
    if COV:
        COV.stop()
        COV.save()
        print('Coverage Summary:')
        COV.report()
        basedir = os.path.abspath(os.path.dirname(__file__))
        covdir = os.path.join(basedir, 'tmp/coverage')
        COV.html_report(directory=covdir)
        print('HTML version: file://%s/index.html' % covdir)
        COV.erase()

若想查看代码覆盖度,就把 --coverage 选项传给 flask test 命令。

为了在 test 命令中添加这个布尔值选项,我们用到了 click.option 装饰器。这个装饰器把布尔值标志的值作为参数传入函数。

不过,在 flasky.py 脚本中集成代码覆盖度检测功能有个小问题。test() 函数收到 --coverage 选项的值后再启动覆盖度检测为时已晚,那时全局作用域中的所有代码都已经执行了。为了保证检测的准确性,设定完环境变量 FLASK_COVERAGE 后,脚本会重启自身。再次运行时,脚本顶端的代码发现已经设定了环境变量,于是立即启动覆盖检测。这一步甚至发生在导入全部应用之前。

coverage.coverage() 函数启动覆盖度检测引擎。branch=True 选项开启分支覆盖度分析,除了跟踪哪行代码已经执行之外,还要检查每个条件语句的 True 分支和 False 分支是否都执行了。include 选项限制检测的文件在应用包内,因为我们只需分析这些代码。如果不指定 include 选项,那么虚拟环境中安装的全部扩展以及测试代码都会包含于覆盖度报告中,给报告添加很多杂项。

执行完所有测试后,test() 函数会在终端输出报告,同时还会生成一份 HTML 版本报告,写入磁盘。HTML 格式以不同的颜色注解全部源码,标明哪些行被测试覆盖了,而哪些没有被覆盖。

文本格式的报告示例

(venv) $ flask test --coverage

...
.----------------------------------------------------------------------
Ran 23 tests in 6.337s
OK
Coverage Summary:
Name                           Stmts   Miss Branch BrPart  Cover
----------------------------------------------------------------
app/__init__.py                   32      0      0       0  100%
app/api/__init__.py                3      0      0       0  100%
app/api/authentication.py         29     18     10       0   28%
app/api/comments.py               40     30     12       0   19%
app/api/decorators.py             11      3      2       0   62%
app/api/errors.py                 17     10      0       0   41%
app/api/posts.py                  36     24      8       0   27%
app/api/users.py                  30     24     12       0   14%
app/auth/__init__.py               3      0      0       0  100%
app/auth/forms.py                 45      8      8       0   70%
app/auth/views.py                116     91     42       0   16%
app/decorators.py                 14      3      2       0   69%
app/email.py                      15      9      0       0   40%
app/exceptions.py                  2      0      0       0  100%
app/main/__init__.py               6      1      0       0   83%
app/main/errors.py                20     15      6       0   19%
app/main/forms.py                 39      7      6       0   71%
app/main/views.py                178    140     34       0   18%
app/models.py                    236     42     42       6   79%
----------------------------------------------------------------
TOTAL                            872    425    184       6   45%
HTML version: file:///home/flask/flasky/tmp/coverage/index.html

上述报告显示,整体覆盖度为 45%。情况并不遭,但也不太好。现阶段,模型类是单元测试的关注焦点,在 236 个语句中,测试覆盖了 79%。很明显,main 和 auth 蓝本中的 views.py 文件以及 api 蓝本中的路由的覆盖度都很低,因为我们没有为这些代码编写单元测试。当然,这些覆盖度指标无法表明项目中的代码是多么健康,因为代码有没有缺陷还受其他因素的影响(例如测试的质量)。

有了这个报告,我们很容易就能看出,为了提高覆盖度,应该在测试组件中添加哪些测试。但遗憾的是,并非应用的所有组成部分都像数据库模型那样易于测试

Flask测试客户端

应用的某些代码严重依赖运行中的应用所创建的环境。例如,你不能直接调用视图函数中的代码进行测试,因为这个函数可能需要访问 Flask 上下文变量,如 request 或 session;视图函数可能还等待接收 POST 请求中的表单数据,而且某些视图函数要求用户先登录。简而言之,视图函数只能在请求上下文和运行中的应用里运行。

Flask 内建了一个测试客户端用于解决(至少部分解决)这一问题。测试客户端能复现应用运行在 Web 服务器中的环境,让测试充当客户端来发送请求。

在测试客户端中运行的视图函数和正常情况下的没有太大区别,服务器收到请求,将其分派给合适的视图函数,视图函数生成响应,将其返回给测试客户端。执行视图函数后,生成的响应会传入测试,检查是否正确。

测试Web应用

tests/test_client.py:使用 Flask 测试客户端编写的测试框架

import unittest
from app import create_app, db
from app.models import User, Role

class FlaskClientTestCase(unittest.TestCase):

    def setUp(self):
        self.app = create_app('testing')
        self.app_context = self.app.app_context()
        self.app_context.push()
        db.create_all()
        Role.insert_roles()
        self.client = self.app.test_client(use_cookies=True)

    def tearDown(self):
        db.session.remove()
        db.drop_all()
        self.app_context.pop()

    def test_home_page(self):
        response = self.client.get('/')
        self.assertEqual(response.status_code, 200)
        self.assertTrue('Stranger' in response.get_data(as_text=True))

与 tests/test_basics.py 相比,这个模块添加了 self.client 实例变量,它是 Flask 测试客户端对象。在这个对象上可调用方法向应用发起请求。如果创建测试客户端时启用了 use_cookies 选项,这个测试客户端就能像浏览器一样接收和发送 cookie,因此能使用依赖 cookie 的功能记住请求之间的上下文。值得一提的是,启用这个选项后便可使用存储在 cookie 中的用户会话。

test_home_page() 测试是一个简单的例子,演示了测试客户端的作用。这里,客户端向应用的根路由发起了一个请求。在测试客户端上调用 get() 方法得到的结果是一个 Flask 响应对象,其内容是调用视图函数得到的响应。为了检查测试是否成功,我们先检查响应的状态码,然后通过 response.get_data() 获取响应主体,在里面搜索单词“Stranger”。这个词在显示给匿名用户的欢迎消息中,即“Hello, Stranger!”。注意,默认情况下 get_data() 返回的响应主体是一个字节数组,传入参数 as_text=True 后得到的是一个更易于处理的字符串。

在测试配置中禁用 CSRF 保护机制

测试客户端还能使用 post() 方法发送包含表单数据的 POST 请求,不过提交表单时会有一个小麻烦。第 4 章说过,Flask-WTF 生成的表单中包含一个隐藏字段,其内容是 CSRF 令牌,需要和表单中的数据一起提交。为了发送 CSRF 令牌,测试必须请求表单所在的页面,然后解析响应返回的 HTML 代码,从中提取令牌,这样才能把令牌和表单中的数据一起发送。为了避免在测试中处理 CSRF 令牌这一烦琐的操作,最好在测试环境的配置中禁用 CSRF 保护机制

config.py:在测试配置中禁用 CSRF 保护机制

class TestingConfig(Config):

    #...

    WTF_CSRF_ENABLED = False
高级的单元测试

更为高级的单元测试,模拟了新用户注册账户、登录、使用确认令牌确认账户以及退出等一系列过程。

tests/test_client.py:使用 Flask 测试客户端模拟新用户注册的整个流程

class FlaskClientTestCase(unittest.TestCase):

    # ...
    def test_register_and_login(self):
        # 注册新账户
        response = self.client.post('/auth/register', data={
            'email': 'john@example.com',
            'username': 'john',
            'password': 'cat',
            'password2': 'cat'
        })
        self.assertEqual(response.status_code, 302)
        # 使用新注册的账户登录
        response = self.client.post('/auth/login', data={
            'email': 'john@example.com',
            'password': 'cat'
        }, follow_redirects=True)
        self.assertEqual(response.status_code, 200)
        self.assertTrue(re.search('Hello,\s+john!',
                                  response.get_data(as_text=True)))
        self.assertTrue(
            'You have not confirmed your account yet' in response.get_data(
                as_text=True))
        # 发送确认令牌
        user = User.query.filter_by(email='john@example.com').first()
        token = user.generate_confirmation_token()
        response = self.client.get('/auth/confirm/{}'.format(token),
                                   follow_redirects=True)
        user.confirm(token)
        self.assertEqual(response.status_code, 200)
        self.assertTrue(
            'You have confirmed your account' in response.get_data(
                as_text=True))
        # 退出
        response = self.client.get('/auth/logout', follow_redirects=True)
        self.assertEqual(response.status_code, 200)
        self.assertTrue('You have been logged out' in response.get_data(
            as_text=True))

这个测试先向注册路由提交一个表单。post() 方法的 data 参数是个字典,包含表单中的各个字段,各字段的名称必须严格匹配定义 HTML 表单时使用的名称。由于 CSRF 保护机制已经在测试配置中禁用了,因此无须和表单数据一起发送。

/auth/register 路由有两种响应方式。如果注册数据可用,则返回一个重定向,把用户转到登录页面。未成功注册时,返回的响应会再次渲染注册表单,而且还包含适当的错误消息。为了确认注册成功,测试检查响应的状态码是否为 302,这个代码表示重定向。

这个测试的第二部分使用刚才注册时的电子邮件和密码登录应用,即向 /auth/login 路由发起 POST 请求。这一次,调用 post() 方法时指定了参数 follow_redirects=True,让测试客户端像浏览器那样,自动向重定向的 URL 发起 GET 请求。指定这个参数后,返回的不是 302 状态码,而是请求重定向的 URL 返回的响应。

成功登录后的响应应该是一个页面,显示一个包含用户名的欢迎消息,并提醒用户需要确认账户才能获得权限。为此,我们使用两个断言语句检查响应是否为这个页面。值得注意的一点是,直接搜索字符串 'Hello, john!' 并没有用,因为这个字符串由动态部分和静态部分组成,而 Jinja2 模板生成最终的 HTML 时会在二者之间加上额外的空格。为了避免空格影响测试结果,我们使用正则表达式。

下一步要确认账户,这里也有一个小障碍。账户确认 URL 在注册过程中通过电子邮件发给用户,而在测试中无法轻松获取这个 URL。上述测试使用的解决方法忽略了注册时生成的令牌,直接在 User 实例上调用方法重新生成一个新令牌。在测试环境中,Flask-Mail 会保存邮件正文,所以还有一种可行的解决方法,即通过解析邮件正文来提取令牌。

得到令牌后,下一步要模拟用户点击邮件中的确认 URL。为此,我们要向这个包含令牌的 URL 发起 GET 请求。这个请求的响应是重定向并转到首页,但这里再次指定了参数 follow_redirects=True,因此测试客户端会自动向重定向的页面发起请求并返回响应。得到响应后,检查是否包含欢迎消息,以及一个向用户说明确认成功的闪现消息。

这个测试的最后一步是向退出路由发送 GET 请求。为了证实成功退出,这段测试在响应中搜索一个闪现消息。

测试Web服务

Flask 测试客户端还可用于测试 REST 式 Web 服务

tests/test_api.py:使用 Flask 测试客户端测试 REST 式 API

class APITestCase(unittest.TestCase):

    # ...
    def get_api_headers(self, username, password):
        return {
            'Authorization':
                'Basic ' + b64encode(
                    (username + ':' + password).encode('utf-8')).decode('utf-8'),
            'Accept': 'application/json',
            'Content-Type': 'application/json'
        }

    def test_no_auth(self):
        response = self.client.get(url_for('api.get_posts'),
                                   content_type='application/json')
        self.assertEqual(response.status_code, 401)

    def test_posts(self):
        # 添加一个用户
        r = Role.query.filter_by(name='User').first()
        self.assertIsNotNone(r)
        u = User(email='john@example.com', password='cat', confirmed=True,
                 role=r)
        db.session.add(u)
        db.session.commit()

        # 写一篇文章
        response = self.client.post(
            '/api/v1/posts/',
            headers=self.get_api_headers('john@example.com', 'cat'),
            data=json.dumps({'body': 'body of the *blog* post'}))
        self.assertEqual(response.status_code, 201)
        url = response.headers.get('Location')
        self.assertIsNotNone(url)

        # 获取刚发布的文章
        response = self.client.get(
            url,
            headers=self.get_api_headers('john@example.com', 'cat'))
        self.assertEqual(response.status_code, 200)
        json_response = json.loads(response.get_data(as_text=True))
        self.assertEqual('http://localhost' + json_response['url'], url)
        self.assertEqual(json_response['body'], 'body of the *blog* post')
        self.assertEqual(json_response['body_html'],
                        '<p>body of the <em>blog</em> post</p>')

测试 API 时使用的 setUp() 和 tearDown() 方法与测试普通应用所用的一样,不过 API 不使用 cookie,所以无须配置相应支持。get_api_headers() 是一个辅助方法,返回多数 API 请求要发送的通用首部,包括身份验证凭据和 MIME 类型相关的首部。

test_no_auth() 是一个简单的测试,确保 Web 服务会拒绝没有提供身份验证凭据的请求,返回 401 错误码。test_posts() 测试把一个用户插入数据库,然后使用基于 REST 的 API 创建一篇博客文章,再读取这篇文章。请求主体中发送的数据要使用 json.dumps() 方法进行编码,因为 Flask 测试客户端不会自动编码 JSON 格式数据。类似地,返回的响应主体也是 JSON 格式,处理之前必须使用 json.loads() 方法解码。

使用Selenium进行端到端测试

Flask 测试客户端不能完全模拟运行中的应用所在的环境。例如,如果应用依赖在客户端浏览器中运行的 JavaScript 代码的话,就不能使用 Flask 测试客户端,因为返回给测试的响应中的 JavaScript 代码不会执行。

如果测试需要完整的环境,除了使用真正的 Web 浏览器连接 Web 服务器中运行的应用之外,别无他选。幸运的是,多数 Web 浏览器都支持自动化操作。Selenium 是一个 Web 浏览器自动化工具,支持 3 种主要操作系统中的多数主流 Web 浏览器。

Selenium 的 Python 接口使用 pip 安装:

(venv) $ pip install selenium

除了浏览器本身,Selenium 还要求安装相应的驱动。主流浏览器都有驱动,如果你想全面测试,可以编写一个复杂的测试框架,支持多个浏览器。不过,我们只想使用 Google Chrome 浏览器测试这个应用,所以只安装相应的驱动即可。这个驱动名为 ChromeDriver,如果你使用 macOS 系统,而且计算机中有包安装程序 brew,可以使用下述命令安装 ChromeDriver:

(venv) $ brew install chromedriver

如果你使用的是 Linux 或微软 Windows 系统,抑或是没有 brew 的 macOS 系统,可以从 ChromeDriver 的网站(https://sites.google.com/a/chromium.org/chromedriver/downloads)下载常规的安装程序。

使用 Selenium 进行的测试要求应用在 Web 服务器中运行,监听真实的 HTTP 请求。本节使用的方法是,让应用运行在后台线程里的开发服务器中,而测试运行在主线程中。在测试的控制下,Selenium 启动 Web 浏览器,连接应用,执行所需的操作。

使用这种方法要解决一个问题,即所有测试都完成后,要停止 Flask 服务器,而且最好使用一种优雅的方式,以便代码覆盖度检测引擎等后台作业能够顺利完成。Werkzeug Web 服务器本身就有停止选项,但由于服务器运行在单独的线程中,关闭服务器的唯一方法是发送一个普通的 HTTP 请求

app/main/views.py:关闭服务器的路由

@main.route('/shutdown')
def server_shutdown():
    if not current_app.testing:
        abort(404)
    shutdown = request.environ.get('werkzeug.server.shutdown')
    if not shutdown:
        abort(500)
    shutdown()
    return 'Shutting down...'

仅当应用运行在测试环境中时,这个关闭服务器的路由才可用;倘若在其他配置中调用,将返回 404 响应。为了关闭服务器,我们要调用 Werkzeug 对环境开放的关闭函数。调用这个函数且处理完请求之后,开发服务器就知道自己需要优雅地退出了。

tests/test_selenium.py:使用 Selenium 运行测试的框架
from selenium import webdriver

class SeleniumTestCase(unittest.TestCase):
    client = None

    @classmethod
    def setUpClass(cls):
        # 启动Chrome
        options = webdriver.ChromeOptions()
        options.add_argument('headless')
        try:
            cls.client = webdriver.Chrome(chrome_options=options)
        except:
            pass

        # 如果无法启动浏览器,跳过这些测试
        if cls.client:
            # 创建应用
            cls.app = create_app('testing')
            cls.app_context = cls.app.app_context()
            cls.app_context.push()
            # 禁止日志,保持输出简洁
            import logging

            logger = logging.getLogger('werkzeug')
            logger.setLevel("ERROR")

            # 创建数据库,并使用一些虚拟数据填充
            db.create_all()
            Role.insert_roles()
            fake.users(10)
            fake.posts(10)

            # 添加管理员
            admin_role = Role.query.filter_by(permissions=0xff).first()
            admin = User(email='john@example.com',
                         username='john', password='cat',
                         role=admin_role, confirmed=True)
            db.session.add(admin)
            db.session.commit()

            # 在一个线程中启动Flask服务器
            cls.server_thread = threading.Thread(
                target=cls.app.run, kwargs={'debug': 'false',
                                            'use_reloader': False,
                                            'use_debugger': False})
            cls.server_thread.start()

    @classmethod
    def tearDownClass(cls):
        if cls.client:
             关闭Flask服务器和浏览器
            ls.client.get('http://localhost:5000/shutdown')
            ls.client.quit()
            ls.server_thread.join()

             销毁数据库
            b.drop_all()
            b.session.remove()

             删除应用上下文
            ls.app_context.pop()

    def setUp(self):
        if not self.client:
            self.skipTest('Web browser not available')

    def tearDown(self):
        pass

setUpClass() 和 tearDownClass() 类方法分别在这个类中的全部测试运行之前和之后执行。setUpClass() 方法使用 Selenium 提供的 webdriver API 启动一个 Chrome 实例,然后创建一个应用和数据库,在其中写入一些供测试使用的初始数据。然后调用 app.run() 方法,在一个线程中启动应用。完成所有测试后,应用会收到一个发往 /shutdown 的请求,使后台线程终止。随后,关闭浏览器,删除测试数据库。

在 Flask 引入基于 Click 的命令行界面之前,若想启动 Flask 的 Web 开发服务器,要在应用的主脚本中调用 app.run() 方法,或者使用第三方扩展,例如 Flask-Script。现在,启动服务器的 app.run() 方法被 flask run 命令代替了,不过 Flask 依然支持 app.run() 方法。这里你便能看到,这个方法在复杂的测试情景中仍然有用武之地。

除 Chrome 之外,Selenium 还支持很多 Web 浏览器。如果你想使用其他 Web 浏览器,或者想再额外测试别的浏览器,请查阅 Selenium 文档(https://docs.seleniumhq.org/docs/)。

tests/test_selenium.py:Selenium 单元测试示例

setUp() 方法在每个测试运行之前执行,如果 Selenium 无法利用 startUpClass() 方法启动 Web 浏览器就跳过测试。

class SeleniumTestCase(unittest.TestCase):

    # ...
    def test_admin_home_page(self):
        # 进入首页
        self.client.get('http://localhost:5000/')
        self.assertTrue(re.search('Hello,\s+Stranger!',
                                  self.client.page_source))

        # 进入登录页面
        self.client.find_element_by_link_text('Log In').click()
        self.assertIn('<h1>Login</h1>', self.client.page_source)

        # 登录
        self.client.find_element_by_name('email').\
            send_keys('john@example.com')
        self.client.find_element_by_name('password').send_keys('cat')
        self.client.find_element_by_name('submit').click()
        self.assertTrue(re.search('Hello,\s+john!', self.client.page_source))

        # 进入用户资料页面
        self.client.find_element_by_link_text('Profile').click()
        self.assertIn('<h1>john</h1>', self.client.page_source)

这个测试使用 setUpClass() 方法中创建的管理员账户登录应用,然后打开用户的资料页面。注意,这里使用的测试方法与使用 Flask 测试客户端时不一样。使用 Selenium 进行测试时,测试向 Web 浏览器发出指令,从不直接与应用交互。发给浏览器的指令与真实用户使用鼠标或键盘执行的操作几乎一样。

这个测试首先调用 get() 方法访问应用的首页。在浏览器中,这个操作就是在地址栏中输入 URL。为了验证这一步操作的结果,测试代码检查页面源码中是否包含“Hello, Stranger!”这个欢迎消息。

为了访问登录页面,测试使用 find_element_by_link_text() 方法查找“Log In”链接,然后在这个链接上调用 click() 方法,从而在浏览器中触发一次真正的点击。Selenium 提供了很多 find_element_by...() 简便方法,可使用不同的方式在 HTML 页面中搜索元素。

为了登录应用,测试使用 find_element_by_name() 方法通过名称找到表单中的电子邮件和密码字段,然后再使用 send_keys() 方法在各字段中填入值。填完之后,在提交按钮上调用 click() 方法,提交表单。然后检查页面中有没有针对用户的欢迎消息,确保登录成功,而且浏览器中显示的是首页。

测试的最后一部分在导航栏中查找“Profile”链接,然后点击。为证实资料页已经加载,测试在页面源码中搜索内容为用户名的标题。

此时执行 flask test 命令,你看不到单元测试的运行有什么差别。示例中的单元测试 test_admin_home_page 在无界面 Chrome 实例中运行,并执行所有操作。如果你想在 Chrome 窗口中查看执行的操作,把 setUpClass() 方法中的 options.add_argument('headless') 一行注释掉,让 Selenium 启动带窗口的常规 Chrome 实例。

值得测试吗

为了测试而如此折腾 Flask 测试客户端和 Selenium,值得吗?这是一个合理的疑问,但是不容易回答。

不管你是否喜欢,应用肯定要做测试。如果你自己不做测试,用户就要充当不情愿的测试员,用户发现问题后,你就要顶着压力修正。检查数据库模型和其他无须在应用上下文中执行的代码很简单,而且有针对性,这类测试一定要做,因为你无须投入过多精力就能保证应用逻辑的核心功能可以正常运行。

我们有时候也需要使用 Flask 测试客户端和 Selenium 进行端到端形式的测试,不过这类测试编写起来比较复杂,只适用于无法单独测试的功能。应该合理组织应用代码,尽量把业务逻辑写入独立于应用上下文的模块中,这样测试起来才更简单。视图函数中的代码应该保持简洁,仅发挥粘合剂的作用,收到请求后调用其他类中相应的操作或者封装应用逻辑的函数。

因此,测试绝对值得。重要的是我们要设计一个高效的测试策略,还要编写能合理利用这一策略的代码。

性能

没人喜欢运行缓慢的应用。页面加载时间太长会让用户失去兴趣,所以尽早发现并修正性能问题是一件很重要的工作

在日志中记录影响性能的缓慢数据库查询

如果应用的性能随着时间推移不断降低,很有可能是因为数据库查询变慢了,随着数据库规模的增长,这一情况会变得更糟。优化数据库有时很简单,只需添加更多的索引即可;有时却很复杂,需要在应用和数据库之间加入缓存。多数数据库查询语言都提供了 explain 语句,用于显示数据库执行查询时采取的步骤。从这些步骤中,我们经常能发现数据库或索引设计的不足之处。

不过,在开始优化查询之前,我们必须知道哪些查询是值得优化的。一次请求往往可能要执行多条数据库查询,所以经常很难分辨哪一条查询较慢。Flask-SQLAlchemy 提供了一个选项,可以记录一次请求中与数据库查询有关的统计数据。在示例 16-1 中可以看到如何使用这个功能把速度慢于所设阈值的查询写入日志。

app/main/views.py:报告缓慢的数据库查询
from flask_sqlalchemy import get_debug_queries

@main.after_app_request
def after_request(response):
    for query in get_debug_queries():
        if query.duration >= current_app.config['FLASKY_SLOW_DB_QUERY_TIME']:
            current_app.logger.warning(
                'Slow query: %s\nParameters: %s\nDuration: %fs\nContext: %s\n' %
                   (query.statement, query.parameters, query.duration,
                    query.context))
    return response

这个功能使用 after_app_request 处理程序实现,它和 before_app_request 处理程序的工作方式类似,只不过在视图函数处理完请求之后执行。Flask 把响应对象传给 after_app_request 处理程序,以防需要修改响应。

在本例中,after_app_request 处理程序没有修改响应,只是获取 Flask-SQLAlchemy 记录的查询时间,把缓慢的查询写入日志(应用的日志记录器通过 app.logger 设置),然后再返回响应,发送给客户端。

Flask-SQLAlchemy记录的查询统计数据

get_debug_queries() 函数返回一个列表,其元素是请求中执行的查询

名称说明
statementSQL 语句
parametersSQL 语句使用的参数
start_time执行查询时的时间
end_time返回查询结果时的时间
duration查询持续的时间,单位为秒
context表示查询在源码中所处位置的字符串

这个 after_app_request 处理程序遍历 get_debug_queries() 函数返回的列表,把持续时间比所设阈值(通过配置变量 FLASKY_SLOW_DB_QUERY_TIME 设置)长的查询写入日志。这里设置的日志等级是“警告”,不过有时更适合把缓慢的数据库查询视作错误。

默认情况下,get_debug_queries() 函数只在调试模式中可用。但是数据库性能问题很少发生在开发阶段,因为开发过程中使用的数据库较小。因此,在生产环境中使用该选项才更能发挥它的作用。若想在生产环境中监控数据库性能,我们必须修改配置

config.py:启用缓慢查询记录功能的配置
class Config:
    # ...
    SQLALCHEMY_RECORD_QUERIES = True
    FLASKY_SLOW_DB_QUERY_TIME = 0.5
    # ...

SQLALCHEMY_RECORD_QUERIES 告诉 Flask-SQLAlchemy 启用记录查询统计数据的功能。我们把缓慢查询的阈值设为 0.5 秒。这两个配置变量都在 Config 基类中设置,因此适用于所有环境。

每当发现缓慢查询,Flask 应用的日志记录器就会写入一条记录。若想保存这些日志记录,必须配置日志记录器。日志记录器的配置根据应用所在主机使用的平台而有所不同

分析源码

性能问题的另一个可能诱因是高 CPU 消耗,由执行大量运算的函数导致。源码分析器能找出应用中执行最慢的部分。分析器监视运行中的应用,记录调用的函数以及运行各函数所消耗的时间,然后生成一份详细的报告,指出运行最慢的函数。

一般只在开发环境中分析源码。源码分析器会导致应用的运行速度比常规情况下慢得多,因为分析器要实时监视并记录应用中发生的一切。不建议在生产环境中分析源码,除非使用专为生产环境设计的轻量级分析器。

在请求分析器的监视下运行应用

Flask 使用的 Web 开发服务器由 Werkzeug 提供,可根据需要为每条请求启用 Python 分析器。

flasky.py:在请求分析器的监视下运行应用

@app.cli.command()
@click.option('--length', default=25,
              help='Number of functions to include in the profiler report.')
@click.option('--profile-dir', default=None,
              help='Directory where profiler data files are saved.')
def profile(length, profile_dir):
    """Start the application under the code profiler."""
    from werkzeug.contrib.profiler import ProfilerMiddleware
    app.wsgi_app = ProfilerMiddleware(app.wsgi_app, restrictions=[length],
                                      profile_dir=profile_dir)
    app.run(debug=False)

这个命令通过应用的 wsgi_app 属性,把 Werkzeug 的 ProfilerMiddleware 中间件依附到应用上。WSGI 中间件在 Web 服务器把请求分派给应用时调用,可用于修改处理请求的方式。这里通过中间件捕获分析数据。注意,随后通过 app.run() 方法,以编程的方式启动应用。

使用 flask profile 命令启动应用后,控制台会显示每条请求的分析数据,其中包含运行最慢的 25 个函数。--length 选项可以修改报告中显示的函数数量。如果指定了 --profile-dir 选项,每条请求的分析数据会保存到指定目录下的一个文件中。分析器输出的数据文件可用于生成更详细的报告,例如调用图。Python 分析器的详细信息请参阅官方文档(https://docs.python.org/2/library/profile.html)。

部署

Flask 自带的 Web 开发服务器不够稳健、安全和高效,不适合在生产环境中使用

部署流程

不管使用哪种托管方案,应用安装到生产服务器上之后,都要执行一系列任务,其中就包括创建或更新数据库表。

如果每次安装或升级应用都手动执行这些任务,那么会容易出错,也浪费时间。因此,可以在 flasky.py 中添加一个命令,自动执行全部任务。

deploy 命令

flasky.py:deploy 命令

from flask_migrate import upgrade
from app.models import Role, User

@manager.command
def deploy():
    """Run deployment tasks."""
    # 把数据库迁移到最新修订版本
    upgrade()

    # 创建或更新用户角色
    Role.insert_roles()

    # 确保所有用户都关注了他们自己
    User.add_self_follows()

这个命令调用的函数之前都已经定义好了,现在只不过是在一个命令中集中调用,以简化部署应用的过程。

定义这些函数时考虑到了多次执行的情况,所以即使多次执行也不会产生问题。每次安装或升级应用时只需运行 deploy 命令,无须担心运行的时机不当而导致的副作用。

把生产环境中的错误写入日志

在调试模式中运行的应用发生错误时,Werkzeug 的交互式调试器会出现。网页中会显示错误的栈跟踪,而且可以查看源码,甚至还能使用 Flask 的网页版交互调试器在每个栈帧的上下文中执行表达式。

调试器是开发过程中调试问题的优秀工具,但显然不能在生产环境中使用。生产环境中发生的错误会被静默掉,取而代之的是向用户显示一个 500 错误页面。不过幸好错误的栈跟踪不会完全丢失,因为 Flask 会将其写入日志文件。

在应用启动过程中,Flask 会创建一个 Python 的 logging.Logger 类实例,并将其附属到应用实例上,通过 app.logger 访问。在调试模式中,日志记录器把日志写入控制台;但在生产模式中,默认情况下没有配置日志的处理程序,所以如果不添加处理程序,就不会保存日志

应用出错时发送电子邮件

配置一个日志处理程序,把生产模式中出现的错误通过电子邮件发送给 FLASKY_ADMIN 设置的管理员。

config.py:应用出错时发送电子邮件

class ProductionConfig(Config):

    # ...
    @classmethod
    def init_app(cls, app):
        Config.init_app(app)

        # 出错时邮件通知管理员
        import logging
        from logging.handlers import SMTPHandler
        credentials = None
        secure = None
        if getattr(cls, 'MAIL_USERNAME', None) is not None:
            credentials = (cls.MAIL_USERNAME, cls.MAIL_PASSWORD)
            if getattr(cls, 'MAIL_USE_TLS', None):
                secure = ()
        mail_handler = SMTPHandler(
            mailhost=(cls.MAIL_SERVER, cls.MAIL_PORT),
            fromaddr=cls.FLASKY_MAIL_SENDER,
            toaddrs=[cls.FLASKY_ADMIN],
            subject=cls.FLASKY_MAIL_SUBJECT_PREFIX + ' Application Error',
            credentials=credentials,
            secure=secure)
        mail_handler.setLevel(logging.ERROR)
        app.logger.addHandler(mail_handler)

你可能还记得,所有配置类都有一个 init_app() 静态方法,在 create_app() 方法中调用,但目前还没用到。现在,在 ProductionConfig 类的 init_app() 方法中,我们为应用日志记录器配置了一个处理程序,把错误通过电子邮件发给指定的收件人。

电子邮件日志记录器的日志等级被设为 logging.ERROR,所以只有发生严重错误时才会发送电子邮件。通过添加适当的日志处理程序,可以把等级较轻缓的日志消息写入文件、系统日志或支持的其他目的地。日志的处理方法很大程度上依赖于应用所在的托管平台。

云部署

如今流行把应用托管在“云端”,不过这有多层意思。最简单的情况下,云托管的意思是把应用部署到一台或多台虚拟服务器上。虚拟服务器操作起来的感受与物理设备很像,但却是由云服务公司管理的虚拟设备。AWS(Amazon Web Services)提供的 EC2 服务就是这样的服务器。把应用部署到虚拟服务器上的方法与部署到传统的专用服务器上的方法(本章后文将讨论)类似。

更高级的部署方法是基于容器。一个容器把应用隔离在一个映像(image)中,里面包含应用及其全部依赖。你可以安装容器平台,例如 Docker,在支持的任何系统中安装并运行预先生成好的容器映像。

另一种部署方式,正式的说法是平台即服务(PaaS,platform as a service),它让应用开发者从安装和维护运行应用的软硬件平台的日常工作中解脱出来。在 PaaS 模型中,服务提供商完全接管了运行应用的平台。应用开发者只需把应用代码上传到服务提供商维护的服务器中,整个过程往往只需几秒钟。多数 PaaS 提供商都支持按需添加或删除服务器,动态“缩放”应用,以满足不同量级的请求。

Heroku平台

Heroku 是最早出现的 PaaS 提供商之一,从 2007 年就开始运营。Heroku 平台的灵活性极高,且支持多种编程语言(包括 Python)。若想把应用部署到 Heroku 上,开发者要使用 Git 把应用推送到 Heroku 特殊的 Git 服务器上。这个服务器将自动触发安装、升级、配置和部署等操作。

Heroku 使用名为 dyno 的计算单元衡量用量,并以此为依据收取服务费用。最常用的 dyno 类型是 Web dyno,表示一个 Web 服务器实例。如果想增加处理请求的能力,可以部署多个 Web dyno,每个 dyno 运行一个应用实例。另一种 dyno 类型是 Worker dyno,用于执行后台作业或其他辅助任务。

Heroku 提供了大量的插件和扩展,可用于数据库、电子邮件和其他很多服务

准备工作

若想使用 Heroku,应用必须存入 Git 仓库。如果你的应用托管在像 GitHub 或 Bitbucket 这样的远程 Git 服务器上,那么克隆应用后会创建一个本地 Git 仓库,可无缝用于 Heroku。如果你的应用没有存入 Git 仓库,那么必须在开发设备上创建一个仓库。

1 注册Heroku账户

在使用 Heroku 提供的服务之前,你必须注册一个账户(https://www.heroku.com/)。Heroku 有免费套餐,允许托管几个简单的应用,非常适合做实验。

2安装Heroku CLI

为了使用 Heroku 服务,必须安装 Heroku CLI(https://devcenter.heroku.com/articles/heroku-cli)。这是一个命令行客户端,负责处理你与服务的交互。Heroku 为 3 大主流操作系统都提供了安装程序。

安装好 Heroku CLI 之后,首先要通过 heroku login 命令验证自己的 Heroku 账户:

$ heroku login
Enter your Heroku credentials.
Email: <your-email-address>
Password: <your-password>

别忘了把你的 SSH 公钥上传到 Heroku,这样才能使用 git push 命令。

正常情况下,login 命令会自动创建并上传 SSH 公钥,

但也可以使用 heroku keys:add 命令单独上传公钥或者上传额外所需的公钥。

3 创建应用

接下来要创建应用。在此之前,应用要纳入 Git 源码控制。

然后,在应用的顶级目录中执行下述命令,在 Heroku 中注册你的应用:

$ heroku create <appname>
Creating <appname>... done
https://<appname>.herokuapp.com/ | https://git.heroku.com/<appname>.git

Heroku 应用的名称在所有客户中必须是独一无二的,因此你必须想一个没被其他应用占用的名称。如 create 命令的输出所示,部署后应用可通过 https://<appname>.herokuapp.com 访问。Heroku 也支持为应用设置自定义域名。

在创建应用的过程中,Heroku 会为你的应用创建一个专用的 Git 服务器,地址为 https://git.heroku.com/<appname>.git。create 命令调用 git remote 命令把这个地址添加为本地 Git 仓库的远程服务器,名为 heroku。

必须设置 FLASK_APP 环境变量才能使用 flask 命令。为了确保在 Heroku 环境中能成功执行任何命令,最好注册这个环境变量,让 Heroku 在执行与应用有关的命令时自动设置。这一步使用 config 命令操作:

$ heroku config:set FLASK_APP=flasky.py
Setting FLASK_APP and restarting <appname>... done, v4
FLASK_APP: flasky.py
4 配置数据库

Heroku 以扩展形式支持 Postgres 数据库。Heroku 的免费套餐包含一个小型数据库,最多能存储 1 万行记录。执行下述命令,为应用绑定一个 Postgres 数据库:

$ heroku addons:create heroku-postgresql:hobby-dev
Creating heroku-postgresql:hobby-dev on <appname>... free
Database has been created and is available
! This database is empty. If upgrading, you can transfer
! data from another database with pg:copy
Created postgresql-cubic-41298 as DATABASE_URL
Use heroku addons:docs heroku-postgresql to view documentation

从输出可以看到,应用在 Heroku 平台中运行时,可以通过 DATABASE_URL 环境变量获取数据库的地址和凭据。这个变量的值是个 URL,与 SQLAlchemy 要求的格式完全一样。回想一下 config.py 脚本的内容,如果设定了 DATABASE_URL,就使用其中保存的值,所以现在应用可以自动连接到 Postgres 数据库。

5 配置日志

之前我们实现了通过电子邮件发送重大错误消息的功能,除此之外,配置其他轻缓等级的消息也尤为重要。其中一个很好的例子是数据库缓慢查询警告消息。

Heroku 把应用写入 stdout 或 stderr 的输出视为日志,因此要添加相应的日志处理程序。Heroku 会捕获输出的日志,在 Heroku CLI 中可以使用 heroku logs 命令查看。

日志的配置可添加到 ProductionConfig 类的 init_app() 静态方法中,但由于这种日志处理方式是 Heroku 专用的,最好专门为这个平台新建一个配置类,把 ProductionConfig 作为不同类型生产平台的基类。HerokuConfig 类的定义如示例 所示。

config.py:Heroku 的配置类

class HerokuConfig(ProductionConfig):

    @classmethod
    def init_app(cls, app):
        ProductionConfig.init_app(app)
        # 输出到stderr
        import logging
        from logging import StreamHandler
        file_handler = StreamHandler()
        file_handler.setLevel(logging.INFO)
        app.logger.addHandler(file_handler)

Heroku 运行应用时,要知道该使用这个新配置。flasky.py 脚本创建的应用实例通过环境变量 FLASK_CONFIG 决定使用哪个配置,所以我们要在 Heroku 的环境中正确设定这个变量。Heroku 平台中的环境变量使用 Heroku 客户端的 config:set 命令设定:

$ heroku config:set FLASK_CONFIG=heroku
Setting FLASK_CONFIG and restarting <appname>... done, v4
FLASK_CONFIG: heroku

为了提升应用的安全性,最好为应用的密钥配置一个难猜的字符串,用于签署用户会话和身份验证令牌。Config 基类中的 SECRET_KEY 属性就是这个用途,如果有同名环境变量就使用变量的值。在开发设备中可以不设定这个变量,而是在 Config 类中硬编码一个值。但是在生产平台上必须设置一个特别难猜的密钥,不能让任何人知道,因为一旦密钥泄露,攻击者便能伪造用户会话的内容或生成有效的令牌。为了确保密钥的安全性,只需把 SECRET_KEY 环境变量设为一个唯一的字符串,但不存储在任何地方

$ heroku config:set SECRET_KEY=d68653675379485599f7876a3b469a57
Setting SECRET_KEY and restarting <appname>... done, v4
SECRET_KEY: d68653675379485599f7876a3b469a57

密钥用的随机字符串有多种生成方法,使用 Python 可以这样生成:

(venv) $ python -c "import uuid; print(uuid.uuid4().hex)"
d68653675379485599f7876a3b469a57
6 配置电子邮件

Heroku 没有提供 SMTP 服务器,所以我们要配置一个外部服务器。有很多第三方扩展能把适用于生产环境的邮件发送服务集成到 Heroku 中,但对于测试和评估而言,使用继承自 Config 基类的 Gmail 配置已经足够了。

因为直接把安全密令写入脚本存在安全隐患,所以我们把访问 Gmail SMTP 服务器的用户名和密码保存在环境变量中(最好别使用你个人的电子邮件账户,可以为测试注册一个临时账户):

$ heroku config:set MAIL_USERNAME=<your-gmail-username>
$ heroku config:set MAIL_PASSWORD=<your-gmail-password>
7 添加顶层需求文件

Heroku 从应用顶级目录下的 requirements.txt 文件中加载包依赖。这个文件中的所有依赖都会在部署过程中导入 Heroku 创建的虚拟环境。

Heroku 的需求文件必须包含应用在生产环境中使用的所有通用依赖,以及让 SQLAlchemy 能访问 Postgres 数据库的 psycopg2 包。我们可以在 requirements 目录中新建一个 heroku.txt 文件,写入这些依赖,然后在顶级目录中的 requirements.txt 文件里导入

requirements.txt:Heroku 需求文件

-r requirements/heroku.txt
8 使用Flask-SSLify启用安全的HTTP

用户通过 Web 表单提交的用户名和密码,有被恶意的第三方截获的风险。在开发过程中,这不是什么问题,但是把应用部署到生产服务器上之后,我们要设法降低这种风险。为了避免用户的凭据在传输过程中泄露,有必要使用安全的 HTTP,使用公钥加密客户端和服务器之间的所有通信。

无须任何配置,Heroku 中的所有应用都能通过 http://https:// 访问为你分配的二级域名。因为这是 Heroku 的域名,所以使用的是 Heroku 的 SSL 证书。因此,为了确保应用的安全,我们只需拦截发给 http:// 的请求,将其重定向到 https://。而这正是 Flask-SSLify 扩展的功能。

Flask-SSLify 使用 pip 安装:

(venv) $ pip install flask-sslify

然后在应用的工厂函数中激活这个扩展

app/__init__.py:把所有请求重定向到安全的 HTTP 协议

def create_app(config_name):
    # ...
    if app.config['SSL_REDIRECT']:
        from flask_sslify import SSLify
        sslify = SSLify(app)
    # ...

对 SSL 的支持只需在生产模式中启用,而且仅当平台支持时才启用。为了便于启停 SSL,我们添加了一个名为 SSL_REDIRECT 的环境变量。在 Config 基类中,把它设为 False,即默认不启用 SSL 重定向。在 HerokuConfig 类中却要覆盖这个变量,启用重定向。这个环境变量的实现如示例 所示。

config.py:配置 SSL

class Config:
    # ...
    SSL_REDIRECT = False

class HerokuConfig(ProductionConfig):
    # ...
    SSL_REDIRECT = True if os.environ.get('DYNO') else False

在 HerokuConfig 类中,仅当 DYNO 环境变量存在时,才把 SSL_REDIRECT 的值设为 True。DYNO变量由 Heroku 设置,因此使用 Heroku 配置在本地测试不会启用 SSL 重定向。

这样修改之后,用户访问 Heroku 中的应用时将强制使用 SSL 连接。不过,还需要调整一个细节,才能完善此项功能。使用 Heroku 时,客户端不直接连接应用,而是通过反向代理服务器连接。反向代理服务器接收来自多个应用的请求,然后把请求转发给相应的应用。在这种架构中,只有代理服务器运行在 SSL 模式下。SSL 连接到代理服务器即告终结,代理服务器转发给应用的请求是不加密的。如此一来,应用在生成绝对 URL 时就会出现问题,因为 Flask 应用收到的请求对象针对的是转发后的请求,是不加密的,而不是客户端通过加密连接发送的原始请求。

这种状况会导致问题,例如通过电子邮件发给用户的账户确认或密码重设链接。为了生成这些链接的绝对 URL,我们要调用 url_for(),并指定 _external=True 参数,但是 Flask 将使用 http:// 协议,因为 Flask 不知道有从外部接收加密连接的反向代理存在。

代理服务器把客户端发来的原始请求发给目标 Web 服务器时,会设定一些自定义的 HTTP 首部,我们可以利用这一点判断用户是不是通过 SSL 连接应用的。Werkzeug 提供的一个 WSGI 中间件能检查代理服务器设定的这些自定义 HTTP 首部,然后据此更新请求对象。例如,request.is_secure 的值会反映客户端发给反向代理服务器的请求的加密状态,而不是代理服务器转发给应用的请求的加密状态。这个中间件是 ProxyFix,添加到应用中的方法如示例所示。

config.py:添加对代理服务器的支持

class HerokuConfig(ProductionConfig):
    # ...
    @classmethod
    def init_app(cls, app):
        # ...
        # 处理反向代理服务器设定的首部
        from werkzeug.contrib.fixers import ProxyFix
        app.wsgi_app = ProxyFix(app.wsgi_app)

这个中间件添加到 Heroku 配置的初始化方法中。WSGI 中间件,例如 ProxyFix,初始化时要传入 WSGI 应用。请求发来时,在处理请求之前,中间件将有机会审查环境。不仅 Heroku 需要 ProxyFix 中间件,使用反向代理服务器的任何部署方式都需要。

9 运行Web生产服务器

Heroku 要求应用自己启动 Web 生产服务器,并在 PORT 环境变量设定的端口号上监听请求。

Flask 自带的 Web 开发服务器不适合在这种情况下使用,因为它不是为生产环境设计的服务器。有两个 Web 服务器适合在生产环境中使用,而且支持 Flask 应用,它们是 Gunicorn 和 uWSGI。

建议在本地虚拟环境中安装其中一个 Web 服务器,以便在类似 Heroku 的环境中测试。例如,可通过如下命令安装 Gunicorn:

(venv) $ pip install gunicorn

然后执行下述命令,在本地使用 Gunicorn 运行应用:

(venv) $ gunicorn flasky:app
[2017-08-03 23:54:36 -0700] [INFO] Starting gunicorn 19.7.1
[2017-08-03 23:54:36 -0700] [INFO] Listening at: http://127.0.0.1:8000 (68982)
[2017-08-03 23:54:36 -0700] [INFO] Using worker: sync
[2017-08-03 23:54:36 -0700] [INFO] Booting worker with pid: 68985

flasky:app 告诉 Gunicorn 应用实例的位置,冒号前面的部分是实例所在的包名或模块名,冒号后面的部分是应用实例的名称。注意,Gunicor 默认使用 8000 端口,而 Flask 默认使用 5000。与 Flask 的 Web 开发服务器一样,可以按 Ctrl+C 键退出 Gunicorn。

Gunicorn Web 服务器不能在微软 Windows 中运行。前文推荐的另一个 Web 服务器,uWSGI,可以在 Windows 中运行,但是它难以安装,因为是用原生代码编写的。如果你想在 Windows 系统中测试 Heroku 部署环境,可以使用 Waitress(https://docs.pylonsproject.org/projects/waitress/en/latest/)。它也是纯 Python Web 服务器,与 Gunicorn 有很多相同点,只不过完全支持 Windows。Waitress 使用 pip 安装:

(venv) $ pip install waitress

Waitress Web 服务器使用 waitress-serve 命令启动:

(venv) $ waitress-serve --port 8000 flasky:app
10 添加Procfile文件

Heroku 需要知道使用哪个命令启动应用。这个命令在一个名为 Procfile 的特殊文件中指定。这个文件必须放在应用的顶级目录中。

Procf ile:Heroku Procf ile 文件

web: gunicorn flasky:app

Procfile 文件内容的格式很简单:一行指定一个任务,任务名后跟一个冒号,然后是运行这个任务的命令。名为 web 的任务比较特殊,Heroku 使用这个任务启动 Web 服务器。Heroku 会为这个任务提供一个 PORT 环境变量,用于设定应用监听请求的端口。如果环境中设定了 PORT 变量,Gunicorn 默认就使用那个端口,因此无须在启动命令中显式指定。

如果你使用的是微软 Windows,或者想让你的应用完全兼容 Windows 平台,可以换为 Waitress Web 服务器:

web: waitress-serve --port=$PORT flasky:app

应用可在 Procfile 中使用 web 之外的名称声明其他任务。Procfile 中的每个任务在单独的 dyno 中启动。

使用heroku local测试

Heroku CLI 有个 local 命令,其作用是在本地以非常接近 Heroku 服务器的环境测试应用。然而,在本地运行应用时,FLASK_APP 等环境变量就不再是环境变量了。heroku local 命令在应用顶层目录下的 .env 文件中寻找配置应用的环境变量。例如,.env 文件可能包含如下变量:

FLASK_APP=flasky.py
FLASK_CONFIG=heroku
MAIL_USERNAME=<your-gmail-username>
MAIL_PASSWORD=<your-gmail-password>

由于 .env 文件中包含密码和其他敏感的账户信息,千万不要将其纳入版本控制。

启动应用之前还要执行部署任务,创建数据库。一次性任务可以使用 local:run 命令运行:

(venv) $ heroku local:run flask deploy
[OKAY] Loaded ENV .env File as KEY=VALUE Format
INFO  Context impl SQLiteImpl.
INFO  Will assume non-transactional DDL.
INFO  Running upgrade  -> 38c4e85512a9, initial migration
INFO  Running upgrade 38c4e85512a9 -> 456a945560f6, login support
INFO  Running upgrade 456a945560f6 -> 190163627111, account confirmation
INFO  Running upgrade 190163627111 -> 56ed7d33de8d, user roles
INFO  Running upgrade 56ed7d33de8d -> d66f086b258, user information
INFO  Running upgrade d66f086b258 -> 198b0eebcf9, caching of avatar hashes
INFO  Running upgrade 198b0eebcf9 -> 1b966e7f4b9e, post model
INFO  Running upgrade 1b966e7f4b9e -> 288cd3dc5a8, rich text posts
INFO  Running upgrade 288cd3dc5a8 -> 2356a38169ea, followers
INFO  Running upgrade 2356a38169ea -> 51f5ccfba190, comments

heroku local 命令读取 Procfile 的内容,执行其中定义的任务:

(venv) $ heroku local
[OKAY] Loaded ENV .env File as KEY=VALUE Format
11:37:49 AM web.1 |  [INFO] Starting gunicorn 19.7.1
11:37:49 AM web.1 |  [INFO] Listening at: http://0.0.0.0:5000 (91686)
11:37:49 AM web.1 |  [INFO] Using worker: sync
11:37:49 AM web.1 |  [INFO] Booting worker with pid: 91689

这个命令把所有任务的日志输出整合为一个流,在控制台打印出来,每一行前都有时间戳和任务名。

heroku local 命令还支持使用多个 dyno 模拟应用的伸缩情况。下述命令启动 3 个 Web 职程(worker),每一个职程监听不同的端口:

(venv) $ heroku local web=3
执行git push命令部署

部署过程的最后一步是把应用上传到 Heroku 服务器。在此之前,要确保所有改动都已提交到本地 Git 仓库,然后执行 git push heroku master,把应用上传到远程仓库 heroku:

$ git push heroku master
Counting objects: 502, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (426/426), done.
Writing objects: 100% (502/502), 108.03 KiB | 0 bytes/s, done.
Total 502 (delta 303), reused 146 (delta 61)
remote: Compressing source files... done.
remote: Building source:
remote:
remote: -----> Python app detected
remote: -----> Installing python-3.6.2
remote: -----> Installing pip
remote: -----> Installing requirements with pip
...
remote: -----> Discovering process types
remote: Procfile declares types -> web
remote:
remote: -----> Compressing...
remote: Done: 49.4M
remote: -----> Launching...
remote: Released v8
remote: https://<appname>.herokuapp.com/ deployed to Heroku
remote:
remote: Verifying deploy... done.
To https://git.heroku.com/<appname>.git
 * [new branch]      master -> master

现在应用已经部署好,并正在运行,但还不能正常使用,因为还没执行 deploy 命令初始化数据库表。这个命令可通过 Heroku CLI 执行:

$ heroku run flask deploy
Running flask deploy on <appname>... up, run.3771 (Free)
INFO  [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO  [alembic.runtime.migration] Will assume transactional DDL.
...

创建并配置好数据库表之后,重启应用,使用更新后的数据库:

$ heroku restart
Restarting dynos on <appname>... done

至此,应用就完全部署好了,可通过 https://<appname>.herokuapp.com 访问。

查看应用的日志

Heroku 会捕获应用输出的日志。若想查看日志的内容,使用 logs 命令:

$ heroku logs

在测试的过程中,还可以使用下述命令跟踪日志文件的内容:

$ heroku logs -t
升级后重新部署

升级 Heroku 应用时要重复上述步骤。所有改动都提交到 Git 仓库之后,执行下述命令进行升级:

$ heroku maintenance:on
$ git push heroku master
$ heroku run flask deploy
$ heroku restart
$ heroku maintenance:off

Heroku CLI 提供的 maintenance 命令在升级过程中下线应用,并向用户显示一个静态页面,告知网站很快就能恢复。这样能避免用户在升级的过程访问应用。

Docker容器

容器没有 PaaS 自动化程度高,但是更灵活,而且不限于特定的云服务提供商。

容器是一种特殊的虚拟设备,运行在宿主操作系统的内核之上。与标准的虚拟设备不同,容器没有虚拟化的内核和硬件。因为虚拟化在内核终止,所以容器比虚拟设备更轻量、更高效,但是要求操作系统支持此项功能。Linux 内核完全支持容器。

17.5.1 安装Docker

最流行的容器平台是 Docker,它有免费社区版(Docker CE),也有订阅式企业版(Docker EE)。Docker 可在 3 大主流桌面操作系统中安装,也可以在云服务器中安装。若想开发并测试容器化应用,最简单的方法是在开发系统中安装 Docker CE。macOS 和微软 Windows 用户可从 Docker 商店中下载一键安装程序(https://store.docker.com/search?offering=community&type=edition)。这个页面还有针对 CentOS、Fedora、Debian 和 Ubuntu 等 Linux 发行版的安装说明。

在系统中安装好 Docker CE 之后,便可以在终端使用 docker 命令:

$ docker version

Windows 版 Docker 需要启动微软的 Hyper-V 功能。安装程序通常会为你启用这个功能,但是倘若安装后无法正常使用 Docker,首先应该检查 Hyper-V 虚拟机监控程序(hypervisor)。注意,如果在 Windows 设备中启用了 Hyper-V,其他虚拟机监控程序(例如 Oracle 的 VirtualBox)就无法使用了。如果你的系统不支持 Hyper-V 虚拟化,或者你想在不影响其他虚拟化技术的前提下使用 Docker,可以安装 Docker Toolbox(https://docs.docker.com/toolbox/overview/),这是旧的 Windows 版 Docker,基于 VirtualBox 实现。

构建容器映像

使用容器的第一步是为应用构建一个容器映像。

映像是容器内文件系统的快照,是创建新容器的模板。

创建 Docker 映像的指令写在 Dockerfile 文件中

Dockerfile:容器映像构建脚本

FROM python:3.6-alpine

ENV FLASK_APP flasky.py
ENV FLASK_CONFIG docker
RUN adduser -D flasky
USER flasky

WORKDIR /home/flasky

COPY requirements requirements
RUN python -m venv venv
RUN venv/bin/pip install -r requirements/docker.txt

COPY app app
COPY migrations migrations
COPY flasky.py config.py boot.sh ./

# 运行时配置
EXPOSE 5000
ENTRYPOINT ["./boot.sh"]

Dockerfile 文件中可以使用的构建命令参见文档(https://docs.docker.com/engine/reference/builder/)。其实,这些是部署命令,它们在容器的文件系统(与系统隔离)中安装并配置应用。

所有 Dockerfile 文件中都要有 FROM 命令,其作用是指定一个基容器映像,在其基础上构建当前映像。多数情况下都使用 Docker Hub(Docker 容器映像仓库)中公开可用的映像。Docker Hub 中有针对不同 Python 解释器版本的官方映像。这些映像在操作系统中安装了 Python。指定映像时要提供名称和标签。Docker Hub 中官方的 Python 映像名为 python。可在 Docker Hub 中映像的页面查看可用的标签。对 python 映像来说,标签用于指定解释器的版本和适用的平台。这里使用的是 3.6 版解释器,基于 Alpine Linux 发行版构建。容器映像经常使用 Alpine Linux,因为它体量小。

macOS 和 Windows 版 Docker 能运行基于 Linux 的容器。

ENV 命令定义运行时环境变量,其参数有两个:变量名及其值。这个命令定义的环境变量将在基于这个映像创建的容器中可用。这里定义了 flask 命令所需的 FLASK_APP 变量,以及在启动时配置应用的 FLASK_CONFIG 配置类。采用 Docker 部署时,我们将使用一个新的配置,名为 docker,对应的 DockerConfig 类如示例 17-10 所示。这个新配置类继承自 ProductionConfig,只不过把日志重定向到 stderr。Docker 将自动从中捕获日志,通过 docker logs 命令对外输出。

config.py:Docker 配置

class DockerConfig(ProductionConfig):
    @classmethod
    def init_app(cls, app):
        ProductionConfig.init_app(app)
        # 把日志输出到stderr
        import logging
        from logging import StreamHandler
        file_handler = StreamHandler()
        file_handler.setLevel(logging.INFO)
        app.logger.addHandler(file_handler)

config = {
    # ...
    'docker': DockerConfig,
    # ...
}
RUN

RUN 命令在容器映像的上下文中执行指定的命令。在示例中,第一个 RUN 命令在容器中创建一个名为 flasky 的用户。adduser 命令由 Alpine Linux 提供,在 FROM 命令指定的基映像中可用。adduser 命令的 -D 参数禁止命令提示用户输入密码。

USER

USER 命令选择以哪个用户的身份运行容器,以及 Dockerfile 文件中后续的命令。Docker 默认使用 root 用户,但是如无必要,一般建议切换为常规用户。

WORKDIR

WORKDIR 命令定义应用所在的顶层目录。这里使用的是前面创建的 flasky 用户的家目录(home directory)。Dockerfile 文件中余下的命令都将在这个目录中执行。

COPY

COPY 命令从本地文件系统中把文件复制到容器的文件系统中。这里复制了 requirements、app 和 migrations 这 3 个完整的目录,以及应用顶层目录中的 flasky.py、config.py 和新出现的 boot.sh 文件(稍后讨论)。

后面两个 RUN 命令创建虚拟环境,并在里面安装所需的包。我们为 Docker 部署方式专门准备了一个需求文件,即 requirements/docker.txt。这个文件从 requirements/common.txt 中导入全部依赖,在此基础上又添加了 Gunicorn,在 Heroku 部署方式中用作 Web 服务器。

EXPOSE

EXPOSE 命令定义服务器安装在容器的哪个端口上。启动容器后,Docker 会把这个端口映射到宿主设备的真实端口上,以便容器接收外部世界发来的请求。

ENTRYPOINT

最后一个命令 ENTRYPOINT 指定启动容器时如何运行应用。我们把前面复制到容器中的 boot.sh 当作启动脚本。


boot.sh

boot.sh:容器启动脚本

#!/bin/sh
source venv/bin/activate
flask deploy
exec gunicorn -b 0.0.0.0:5000 --access-logfile - --error-logfile - flasky:app

这个脚本先激活构建容器的过程中创建的 venv 虚拟环境,然后执行本章前面为应用定义的 deploy 命令(部署到 Heroku 中也用到了)。deploy 命令创建一个新数据库,将其更新到最新版本,然后插入默认用户角色。我们没有设定 DATABASE_URL 环境变量,因此这里使用的是 SQLite 数据库。最后,启动 Gunicorn 服务器,监听 5000 端口。Docker 会捕获应用的所有输出,将其写入日志,因此我们配置 Gunicorn,把访问日志和错误日志文件都写入标准输出。使用 exec 命令启动 Gunicorn 后,Gunicorn 的进程便取代了运行 boot.sh 文件的进程。这是因为 Docker 会特别留意启动容器的进程,希望整个生命周期内它都是主进程。如果这个进程停止运行了,容器也就停止了。

为 Flasky 构建容器映像:
$ docker build -t flasky:latest .
Sending build context to Docker daemon  51.08MB
Step 1/14 : FROM python:3.6-alpine
 ---> a6beab4fa70b
...
Successfully built 930e17a89b42
Successfully tagged flasky:latest

docker build 命令的 -t 参数指定容器映像的名称和标签,二者之间以冒号分隔。标签经常使用 latest,即使用容器映像的最新版本。build 命令最后那个点号把当前目录设为构建过程中的顶级目录。Docker 将在这个目录中寻找 Dockerfile 文件,而且容器映像可以从这个目录及其全部子目录中复制所需的文件。

docker build 命令成功运行结束后,本地映像仓库中将多出一个容器映像。本地系统中映像仓库的内容可使用 docker images 命令查看:

$ docker images
REPOSITORY   TAG          IMAGE ID      CREATED         SIZE
flasky       latest       930e17a89b42  5 minutes ago   127MB
python       3.6-alpine   a6beab4fa70b  3 weeks ago     88.7MB

这个命令列出了我们刚构建的 flasky:latest 映像,以及 Dockerfile 中使用 FROM 命令引用的 Python 3.6 解释器映像。后者在构建过程中由 Docker 下载并安装。

运行容器

构建好应用的容器映像后就可以运行了。这个任务很简单,执行 docker run 命令即可:

$ docker run --name flasky -d -p 8000:5000 \
  -e SECRET_KEY=57d40f677aff4d8d96df97223c74d217 \
  -e MAIL_USERNAME=<your-gmail-username> \
  -e MAIL_PASSWORD=<your-gmail-password> flasky:latest

--name 选项为容器指定一个名称。名称可以不指定,如果未指定,Docker 将使用随机的词生成一个。

-d 选项指定以孤立模式启动容器,即在系统的后台作业中运行容器。非孤立模式下的容器作为前台任务执行,依附在当前控制台会话上。

-p 选项把宿主系统的 8000 端口映射到容器的 5000 端口上。Docker 给了我们充分的自由,允许我们把容器端口映射到宿主系统的任何端口上。映射后,同一个容器映像将在宿主的不同端口上运行两个或多个容器映像实例,而各实例都使用自己的虚拟化 5000 端口。

-e 选项定义在容器的上下文中存在的环境变量,与 Dockerfile 文件中使用 ENV 命令定义的环境变量共存。SECRET_KEY 变量的值确保使用唯一且极难猜到的密钥签署用户会话和令牌。你要为这个变量生成唯一的密钥。MAIL_USERNAME 和 MAIL_PASSWORD 变量配置发送电子邮件的 Gmail 服务。如果你在生产环境中使用其他电子邮件服务,可能还要定义 MAIL_SERVER、MAIL_PORT 和 MAIL_USE_TLS 变量。

docker run 命令的最后一个参数是要运行的容器映像的名称和标签。这个参数的值应该与执行 docker build 命令时提供给 -t 选项的值一致。

容器在后台启动后,docker run 命令会在控制台打印容器的 ID。这是一个 256 位的唯一标识符,以十六进制表示。需要引用容器的命令都可以使用这个 ID(其实只需提供 ID 的前几个字符,这样就足以唯一标识容器了)。

为了确认容器确实在运行中,可以执行 docker ps 命令查看:

$ docker ps
CONTAINER ID  IMAGE          CREATED     STATUS     PORTS                  NAMES
71357ee776ae  flasky:latest  4 secs ago  Up 8 secs  0.0.0.0:8000->5000/tcp flasky

既然容器已经运行起来了,那么现在就可以在系统的 8000 端口上访问这个容器化应用。

本地使用的地址是 http://localhost:8000,在同一网络中的其他计算机上则使用 http://<ip-address>:8000

若想停止运行容器,执行 docker stop 命令:

$ docker stop 71357ee776ae
71357ee776ae

stop 命令只停止运行容器,但不从系统中将其删除。如果想删除容器,执行 docker rm 命令:

$ docker rm 71357ee776ae
71357ee776ae

这两个命令可以合并为 docker rm -f:

$ docker rm -f 71357ee776ae
71357ee776ae
审查运行中的容器

容器出现异常时,可能需要调试。最简单的调试方法是在应用中添加输出日志的语句,然后使用 docker logs 命令监控运行中的容器。

不过,有些情况下更适合在运行中的容器里打开一个 shell 会话,以进行更深入的分析。这个任务通过 docker exec 命令操作:

docker exec -it 71357ee776ae sh

执行这个命令后,Docker 将使用 sh(Unix shell)打开一个 shell 会话,而且不打断容器的运行。-it 选项把执行这个命令的终端会话与新启动的进程连接起来,让 shell 执行交互式操作。如果容器中有其他更高级的 shell,例如 bash 或 Python 解释器,也可以拿来用。

排查容器问题的常用策略是创建一个特殊的容器,加载一些辅助工具,例如调试器,然后在 shell 会话中调用。

把容器映像推送到外部注册处

Docker Hub 是 Docker 官方映像仓库,这是一项便利服务,你可以把自己的映像托管在这里。Docker Hub 免费账户提供无限量的公开容器映像存储,不过只能存储一个私有映像。如果想增加私有映像的数量,那么要购买收费套餐。请访问 https://hub.docker.com 创建一个 Docker Hub 账户。

注册好 Docker Hub 账户后,可以在命令行中使用 docker login 命令登录:

$ docker login
Login with your Docker ID to push and pull images from Docker Hub.
Username: <your-dockerhub-username>
Password: <your-dockerhub-password>
Login Succeeded

若想登录 Docker Hub 之外的容器映像仓库,把仓库的地址作为参数传给 docker login 命令。

本地容器映像有个简单的名称。若想把映像推送到 Docker Hub,映像名称前必须加上你的 Docker Hub 账户名,而且二者之间以一个斜线分隔。我们可以为前面构建的 flasky:latest 映像再起个名称,以便推送到 Docker Hub。这个任务使用 docker tag 命令操作:

$ docker tag flasky:latest <your-dockerhub-username>/flasky:latest

然后执行 docker push 命令,把映像推送到 Docker Hub:

$ docker push <your-dockerhub-username>/flasky:latest

现在,这个容器映像公开了,任何人都能使用 docker run 命令基于它启动一个容器:

$ docker run --name flasky -d -p 8000:5000 \
<your-dockerhub-username>/flasky:latest
使用外部数据库

使用 Docker 容器部署 Flasky 有个缺点:应用默认使用的 SQLite 数据库在容器内非常难升级,因为容器一旦停止运行,数据库就不见了。

更好的方案是在应用的容器之外托管数据库服务器。这样升级应用时只需换个新容器,数据库就能轻松地保留下来。

Docker 推荐采用模块化方式构建应用,一个容器针对一个服务。MySQL、Postgres 等很多数据库服务器都有公开可用的容器映像。使用 docker run 命令可以直接把其中任何一个部署到系统中。下述命令把 MySQL 5.7 数据库服务器部署到系统中:

$ docker run --name mysql -d -e MYSQL_RANDOM_ROOT_PASSWORD=yes \
  -e MYSQL_DATABASE=flasky -e MYSQL_USER=flasky \
  -e MYSQL_PASSWORD=<database-password> \
  mysql/mysql-server:5.7

这个命令创建一个名为 mysql 的容器,在后台运行。-e 选项设定几个环境变量,用于配置容器。这些变量及其他可用变量的作用参见 Docker Hub 中这个 MySQL 映像的页面。上述命令为数据库生成一个随机的 root 密码(启动容器后可使用 docker logs mysql 命令在日志中查看生成的密码),然后创建一个全新的数据库,名为 flasky,并赋予 flasky 用户访问权限。你要通过 MYSQL_PASSWORD 环境变量为这个用户设定一个安全的密码。

为了连接 MySQL 数据库,SQLAlchemy 要求安装一个被它支持的 MySQL 客户端包,例如 pymysql。你可以把这个包添加到 docker.txt 需求文件中。

修改 requirements/docker.txt 文件后要重新构建容器映像:

$ docker build -t flasky:latest .

如果之前的应用容器还在运行中,那么执行 docker rm -f 命令将其停止并删除,然后启动一个新容器,运行更新后的应用:

$ docker run -d -p 8000:5000 --link mysql:dbserver \
  -e DATABASE_URL=mysql+pymysql://flasky:<database-password>@dbserver/flasky \
  -e MAIL_USERNAME=<your-gmail-username> -e MAIL_PASSWORD=<your-gmail-password> \
  flasky:latest

这个 docker run 命令有两个新增项。--link 选项把这个新容器与一个现有的容器连接起来。--link 选项的值是以冒号分隔的两个名称,一个是目标容器的名称或 ID,另一个是在当前容器中访问目标容器所用的别名。这里,目标容器是 mysql,即前面启动的那个数据库容器。在这个新 Flasky 容器中,可以通过 dbserver 主机名访问那个容器。

数据库外移后,还要设定 DATABASE_URL 环境变量,值为 mysql 容器中 flasky 数据库的 URL,数据库主机名使用别名 dbserver,Docker 会将其解析为所连接的容器的 IP 地址。此外,在 mysql 容器中设置的 MYSQL_PASSWORD 环境变量也要写在这个 URL 中。DATABASE_URL 的值将覆盖默认的 SQLite 数据库,因此这样简单修改之后,容器将连接 MySQL 数据库。

使用Docker Compose编排容器

容器化应用通常由多个容器组成。前一节我们看到,主应用和数据库服务器分别运行在单独的容器中。应用变复杂后,难免要用到多个容器。有些应用可能需要使用额外的服务,例如消息队列或缓存。另一些应用可能采用微服务架构,以分布式结构部署多个小型子应用,分别运行在单独的容器中。需要处理高负载或者需要高容错能力的应用可能想进行伸缩,在负载均衡程序背后运行多个实例。

随着应用所需的容器数量不断增长,如果只使用 Docker,那么管理和协调容器的任务将变得难上加难。这种情况下,使用构建在 Docker 基础上的编排框架能助你一臂之力。

随 Docker 一起安装的 Compose 工具集提供了基本的编排功能。使用 Compose 时,构成应用的各容器在一个配置文件中描述,这个文件通常命名为 docker-compose.yml。这里定义的所有容器,可以使用 docker-compose 命令一次性全部启动。

针对容器化 Flasky 及其 MySQL 服务的 docker-compose.yml 文件如示例所示。

docker-compose.yml:Compose 配置文件

version: '3'

services:
  flasky:
    build: .
    ports:
      - "8000:5000"
    env_file: .env
    links:
      - mysql:dbserver
    restart: always

  mysql:
    image: "mysql/mysql-server:5.7"
    env_file: .env-mysql
    restart: always

这个文件的内容使用 YAML 格式编写。YAML 是一种简洁的格式,通过键-值映射和列表表示层次结构。version 键指定使用哪个版本的 Compose,services 键在子元素中定义应用的各个容器。Flasky 应用使用两个服务,分别名为 flasky 和 mysql。

flasky 服务是应用的一部分,名下的子键指定传给 docker build 和 docker run 命令的参数。build 键指定构建目录,即 Dockerfile 文件所在的目录。ports 键指定网络端口映射。env_file 键是为容器定义多个环境变量的便利方式。links 键连接 MySQL 容器,对外的主机名为 dbserver。restart 键设为 always,这样一旦容器意外退出,Docker 便会自动重启容器。此次部署的 .env 文件中要定义下述变量:

FLASK_APP=flasky.py
FLASK_CONFIG=docker
SECRET_KEY=3128b4588e7f4305b5501025c13ceca5
MAIL_USERNAME=<your-gmail-username>
MAIL_PASSWORD=<your-gmail-password>
DATABASE_URL=mysql+pymysql://flasky:<database-password>@dbserver/flasky

mysql 服务的结构较简单,因为这个服务直接使用现有的映像启动,无须构建。image 键指定这个服务所用容器映像的名称和标签。与 docker run 命令一样,Docker 会从容器映像注册处下载指定的映像。env_file 和 restart 键的作用与 flasky 容器中的那些键相仿。注意,MySQL 容器的环境变量存储在另一个文件中,名为 .env-mysql。你可能会想把所有容器的环境变量都放在 .env 文件中,但是这样做不好,最好禁止一个容器访问另一个容器的机密信息。.env-mysql 文件中要定义下述环境变量:

MYSQL_RANDOM_ROOT_PASSWORD=yes
MYSQL_DATABASE=flasky
MYSQL_USER=flasky
MYSQL_PASSWORD=<database-password>

.env 和 .env-mysql 文件中包含密码和其他敏感信息,因此千万不要将其纳入版本控制。

docker-compose.yml 文件的完整说明参见 Docker 网站(https://docs.docker.com/compose/compose-file/)。

编排系统往往有个问题,即不能以正确的顺序启动各个容器。即便启动的顺序是正确的,也无法留出足够的时间,让作为其他高层容器基础的低层容器启动和初始化。对 Flasky 来说,要先启动 mysql 容器,这样启动 flasky 容器时才有数据库可用,然后才能连接数据库,应用数据库迁移,最后再启动 Web 服务器。

Compose 能按正确的顺序启动 mysql 和 flasky 容器,因为它能从 flasky 的 links 键检测到二者之间的依赖关系。MySQL 可能要花几秒钟才能启动,但是 Compose 不会等待。设计分布式系统时,连接外部服务器时一般都会多试几次。示例 17-13 是 boot.sh 脚本的改进版本,启动 flasky 容器时会多执行几次 flask deploy 命令,直到成功更新数据库为止。

boot.sh:等数据库启动

#!/bin/sh
source venv/bin/activate

while true; do
    flask deploy
    if [[ "$?" == "0" ]]; then
        break
    fi
    echo Deploy command failed, retrying in 5 secs...
    sleep 5
done
exec gunicorn -b :5000 --access-logfile - --error-logfile - flasky:app

我们在一个循环中不断重试执行 flask deploy 命令,这样容器便有了一定的容错能力,不要求数据库服务立即就接受请求。

配置好 Compose 之后,可以使用 docker-compose up 命令启动应用:

$ docker-compose up -d --build

docker-compose up 命令的 --build 选项指明,应该在启动应用之前构建。这是为了构建 flasky 容器映像。构建好映像之后,将按顺序启动 mysql 和 flasky 容器。与使用单个容器时一样,-d 选项在孤立模式下启动多个容器。几秒钟之后,应用便能在后台运行起来,此时可通过 http://localhost:8000 访问应用。

Compose 把所有容器的日志合并为一个流,可以使用 docker-compose logs 命令查看:

$ docker-compose logs

持续监控日志流

$ docker-compose logs -f

docker-compose ps 命令输出运行中各容器的概况和状态:

$ docker-compose ps
     Name                Command          State           Ports
------------------------------------------------------------------------
flasky_flasky_1   ./boot.sh               Up      0.0.0.0:8000->5000/tcp
flasky_mysql_1    /entrypoint.sh mysqld   Up      3306/tcp, 33060/tcp

升级应用时,先做好修改,然后再次执行前面用于启动容器的 docker-compose up 命令即可。只要有变化,Compose 就会重新构建应用容器,把旧容器替换掉。

若想停止应用,使用 docker-compose down 命令。如果想把容器停止并删掉,使用 docker-compose rm --stop --force 命令。

清理旧容器和映像

使用容器的时间一长,系统中难免会堆积一些不再需要的旧容器或映像。最好定期检查并清理,以免占用系统空间。

查看系统中有哪些容器

$ docker ps -a

这个命令将列出运行中的容器,以及停止了但仍在系统中的容器。若想删除列表中的某个容器,使用 docker rm -f 命令,并指定容器的名称或 ID:

$ docker rm -f <name-or-id><name-or-id> ...

若想查看系统中存储的容器映像,使用 docker images 命令。如果想删除某个映像,使用 docker rmi 命令。

有些容器会在宿主计算机中创建虚拟卷(volume),作为容器文件系统之外的存储空间。例如,MySQL 容器映像把所有数据库文件都放在一个卷中。可以使用 docker volume ls 命令查看系统分配的全部卷。若想删除某个不再使用的卷,使用 docker volume rm。

如果你想使用更自动化的清理方式,那么使用 docker system prune --volumes 命令。这个命令会删除所有不再使用的映像或卷,以及停止后依然在系统中的容器。

在生产环境中使用Docker

有一些安全问题需要考虑

监控和提醒

如果容器化应用崩溃了怎么办?Docker 能重启意外退出的容器,但是不会监控容器,也不会在容器不稳定时发出警告。

日志

Docker 为每个容器维护一个单独的日志流。Compose 对此做了改善,把不同的流合并为一个,但是没有长期存储机制,也没有搜索或过滤功能。

机密信息管理

通过环境变量配置密码和其他凭据是不安全的,因为事先配置好的环境变量可以通过 docker inspect 命令或 API 访问。

可靠性和伸缩性

为了提高容错能力,或者增加负载处理能力,要在一个或多个负载均衡程序背后的多台主机中运行多个应用实例。

这些局限一般可以通过构建在 Docker 基础上的更精巧的编排框架或其他容器运行时来克服。Docker Swarm(现已并入 Docker)、Apache Mesos 和 Kubernetes 等框架有助于构建稳健的容器部署方案。

传统部署方式

传统托管方式。采用这种方式部署应用的话,要购买或租用服务器(物理服务器或虚拟服务器),然后在服务器上手动设置所有需要的组件。这显然是最费力的部署方式,但是如果能通过终端连接生产服务器的硬件,也还算是个不错的选择

架设服务器

在能够托管应用之前,在服务器上必须完成多项管理任务。

  • 安装数据库服务器,例如 MySQL 或 Postgres。也可使用 SQLite 数据库,但由于它在修改现有的数据库模式方面有种种限制,不建议在生产服务器中使用。

  • 安装邮件传输代理(mail transport agent,MTA),例如 Sendmail 或 Postfix,用于向用户发送邮件。不要妄图在线上应用中使用 Gmail,因为这个服务的配额少得可怜,而且服务条款明确禁止商用。

  • 安装适用于生产环境的 Web 服务器,例如 Gunicorn 或 uWSGI。

  • 安装一个进程监控工具,例如 Supervisor,在服务器崩溃或恢复电力后立即重启。

  • 为了启用安全的 HTTP,安装并配置 SSL 证书。

  • (可选,但强烈推荐)安装前端反向代理服务器,例如 nginx 或 Apache。反向代理服务器能直接服务于静态文件,并把其他请求转发给应用的 Web 服务器。Web 服务器监听 localhost 中的一个私有端口。

  • 提升服务器的安全性。这一过程包含多项任务,目标在于降低服务器被攻击的概率,例如安装防火墙、删除不用的软件和服务,等等。

不要手动执行这些任务。可以使用自动化框架(例如 Ansible、Chef 或 Puppet)编写一个部署脚本。

导入环境变量

与 Heroku 和 Docker 一样,运行在独立服务器上的应用也要通过环境变量做些设置,例如数据库连接 URL、电子邮件服务器凭据,等等。

现在,启动应用之前没有 Heroku 或 Docker 为我们配置这些变量了,因此我们要靠自己。设置环境变量的具体方法依所用的平台和工具而有所不同。为了统一不同平台中配置环境变量的方式,解放我们的双手,我们可以编写一段代码,像 heroku local 和 docker-compose 命令那样,从 .env 文件中导入环境变量,如示例 17-14 所示。这段代码用到一个 Python 包,名为 python-dotenv,我们要使用 pip 安装它。在 flasky.py 脚本中,环境变量在创建应用实例之前导入,这样配置类才能访问这些变量。

flasky.py:从 .env 文件中导入环境变量

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)

.env 文件中可以定义 FLASK_CONFIG 变量,选择使用哪个配置;可以定义 DATABASE_URL 变量,指定连接数据库的 URL;还可以定义电子邮件服务器的凭据,等等。如前所述,由于 .env 文件中包含敏感信息,不能纳入版本控制。

如果你的 .env 文件是为 Heroku 或 Docker 准备的,那么要适当调整一下,因为根据前面的代码,所有配置都将使用这个文件中的环境变量。

配置日志

在基于 Unix 的服务器中,日志可发送给守护进程 syslog。我们可以专门为 Unix 创建一个新配置,继承自 ProductionConfig

config.py:Unix 配置示例

class UnixConfig(ProductionConfig):
    @classmethod
    def init_app(cls, app):
        ProductionConfig.init_app(app)
        # 写入syslog
        import logging
        from logging.handlers import SysLogHandler
        syslog_handler = SysLogHandler()
        syslog_handler.setLevel(logging.WARNING)
        app.logger.addHandler(syslog_handler)

这样配置之后,应用的日志将写入配置的 syslog 消息文件,通常是 /var/log/messages 或 / var/log/syslog,具体要看所用的 Linux 发行版本。如果需要的话,还可以配置 syslog 服务,把应用的日志写入别的文件或者发给其他设备。

Flask扩展

  • Flask-Babel:提供国际化和本地化支持。

  • Marshmallow:序列化和反序列化 Python 对象,可在 API 中提供资源的不同表述。

  • Celery:处理后台作业的任务队列。

  • Frozen-Flask:把 Flask 应用转换成静态网站。

  • Flask-DebugToolbar:在浏览器中使用的调试工具。

  • Flask-Assets:用于合并、压缩及编译 CSS 和 JavaScript 静态资源文件。

  • Flask-Session:使用服务器端存储实现的用户会话。

  • Flask-SocketIO:实现 Socket.IO 服务器,支持 WebSocket 和长轮询。

Flask 官方扩展网站(http://flask.pocoo.org/extensions/

其他可以搜寻扩展的地方有 Python Package Index、GitHub 和 Bitbucket。