这是 Flask Mega-Tutorial 系列的第三部分,本节我们将学习如何使用Web表单。
在第二章节 ,我们为程序的首页创建了一个简单的模板,并使用虚假数据为目前还不存在的部分如用户帖子等占位。在本章,我们将修补目前程序里的诸多漏洞之一,尤其是如何通过表单接受用户输入。
任何web应用当中Web 表单都是基本构件之一,我们将使用表单来允许用户发帖以及登录进入应用程序。
在开始之前,请确认你已经完成前几章的 microblog 程序,并且能够无错运行起来。
Flask-WTF简介
为了处理Web表单,我将使用 Flask-WTF 扩展,这是对 WTForms 轻量化封装,很好的跟Flask集成在了一起。这是我要给你推荐的第一个扩展,但不会是最后一个。 扩展(Extensions)是Flask生态系统非常重要的一部分,他们提供了Flask有意忽略掉的问题的解决方案。
Flask 扩展是常规的Python包,使用pip
安装。你可以先去你的虚拟环境里去安装Flask-WTF:
(venv) $ pip install flask-wtf
配置
到目前为止程序还是非常简单的,因此我不需要操心它的配置(configuration)。但,你会发现,除了这些最简单的情况之外,Flask(也包括其扩展)为自身运作提供了相当的自由度,你需要做一些决定,通过配置变量列表传递给框架。
关于特定的配置选项,有几种格式。最基本的方法就是在 app.config
文件中定义变量为键值,用字典样式处理变量。举例来说,你可以这样做:
app = Flask(__name__)
app.config['SECRET_KEY'] = 'you-will-never-guess'
# ... 在此添加更多的变量键值
虽然上述语法足以为Flask创建配置项,但我更倾向于强制执行 关注点分离 原则, 我喜欢使用一个稍微复杂一点的结构来把配置项分离放到一个独立文件当中,而不是把配置项塞到程序中的同一个位置上。
我喜欢的一个可扩展格式就是,使用一个类来存储配置变量。为了保证良好的组织性,我要在一个独立的Python模块中创建这个类。下面你要看到的就是本程序的新配置类,它被存储在顶层文件夹的 config.py 模块中。
config.py: 密钥配置
import os
class Config(object):
SECRET_KEY = os.environ.get('SECRET_KEY') or 'you-will-never-guess'
非常简洁,对吧? 配置设定在Config
类中被定义为类的变量。一旦程序需要更多的配置项,那就可以继续想这个类当中添加即可。而将来,如果我发现我还需要更多的配置,我还可以创建这个配置类的子类进行扩展。当然,现在我们还不需要操心这个。
目前我们添加的SECRET_KEY
配置变量在大多数Flask应用程序中是非常重要的一部分。Flask和它的一些扩展要使用这个密钥值来保护Web表单避免跨站攻击的威胁 Cross-Site Request Forgery (缩写CSRF,发音为 "seasurf")。 顾名思义,密钥应该是保密的,因为用它生成的令牌和签名的强度仅取决于受信任维护者,除此之外,任何人都不应该知晓。
密钥的值被设置为由or
操作符链接的两项组成的表达式。第一项查找环境变量的值,也被称为SECRET_KEY
。第二项,只是硬编码(意思就是直接内嵌在程序当中的,固定常量
)的字符串。 对于配置变量,你会发现我经常重复使用这种模式。其思想是,如果在环境变量中定义了配置变量就直接使用,如果没有那么就使用硬编码字符串作为替代。当你在开发程序时,安全要求是比较低的,所以你可以忽略这个设置直接使用硬编码。但如果程序已经配置运行在生产服务器上了,我就会设置一个唯一的,很难猜的环境变量值,这样服务器就有一个别人无从得知的安全密钥(而且你可以随时更换之,无需修改程序)。
现在,我们有一个配置文件,我需要告诉Flask读取并应用其中的配置。这应该在Flask应用实例创建之后,调入 app.config.from_object()
方法:
app/init.py: Flask 配置
from flask import Flask
from config import Config
app = Flask(__name__)
app.config.from_object(Config)
from app import routes
一开始你可能不好理解这种导入Config
类的方式,但如果你看看Flask
类(大写的F)是如何被从flask
包(小写f)中导入的,你会注意到我也是同样导入配置的。 小写的 "config" 是Python模块config.py的名字,很明显,大写的“C”的才是实际的类。
如上所述,配置项目可以通过app.config
以字典类型被访问。下面你可以看到我通过Python交互器检查密钥值的一个会话:
>>> from microblog import app
>>> app.config['SECRET_KEY']
'you-will-never-guess'
用户登录
Flask-WTF 扩展使用Python类形式来描述Web表单,这个表单类简单的把表单的字段定义成类的变量。
再一次,基于关注点分离原则,我要使用一个新的模块 app/forms.py 来存储我的web表单类。首先,我们定一个用户登录表单,要求用户输入用户名和密码,当然还包括“记住我”(remember me)和“提交”(submit)按钮:
app/forms.py: 登录表单
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired
class LoginForm(FlaskForm):
username = StringField('Username', validators=[DataRequired()])
password = PasswordField('Password', validators=[DataRequired()])
remember_me = BooleanField('Remember Me')
submit = SubmitField('Sign In')
大多数Flask可扩展使用 flask_<name>
的方式作为顶级符号命名约定。本例中,Flask-WTF 的所有符号都在flask_wtf
下。同时也在这里从app/forms.py顶部导入“FlaskForm”基类。
由于Flask-WTF不支持自定义版本,因此表单中我所用的四个描述字段类型的类直接从WTForms包中导入。每一个字段,都会在LoginForm
类中创建一个变量对象。每个字段都会有个描述(descriptoin)或标签(label)作为第一个参数。
你看到在某些字段中会有参数选项 validators
,这将为字段添加有效性验证行为。 DataRequired
验证器只是简单的检查一下字段是否为空。还有很多可用的验证器,其中一些可以用在其他表单中
表单模板
以下步骤是给HTML模板添加表单,这样就可以渲染到一个web页面。好消息是在LoginForm
类中定义的字段知道如何把自己渲染成HTML,因此任务非常简单。下面就是登录模板,保存于app/templates/login.html文件中:
app/templates/login.html: 登录表单模板
{% extends "base.html" %}
{% block content %}
<h1>Sign In</h1>
<form action="" method="post" novalidate>
{{ form.hidden_tag() }}
<p>
{{ form.username.label }}<br>
{{ form.username(size=32) }}
</p>
<p>
{{ form.password.label }}<br>
{{ form.password(size=32) }}
</p>
<p>{{ form.remember_me() }} {{ form.remember_me.label }}</p>
<p>{{ form.submit() }}</p>
</form>
{% endblock %}
与第二章做的一样,我通过模板继承声明extends
再一次复用 了base.html
模板。将来所有模板都会如此继承,这样一来确保整个应用程序的页面布局统一具备一个顶部导航栏。
这个模板将用到一个名为form
的变量参数,它是LoginForm
类的实例化对象。这个参数将在login视图函数中被发送,当然现在还没编写:-D
HTML <form>
元素用作web表单的容器,表单的action
属性用来告诉浏览器:当提交用户输入的信息时应该使用的URL(统一资源定位符
)。如果该动作设置为空字符,表单就会被提交给当前地址栏显示的这个URL——也就是渲染出当前页面的这个URL。 method
属性指定当表单提交给服务器时要使用的HTTP请求方法
(HTTP请求方法深入请参考HTTP协议,常用的如:
get
获取资源,post
传送主体,head
获取头部,delete
删除,put
传输文件)。
默认发送采用 GET
请求,但大部分情况下,采用 POST
请求用户体验会更好一些,因为它把表单数据包含到了请求体内部。而GET
请求则是把表单数据统统附加到URL中去,这就会导致浏览器地址乱糟糟一大串。 novalidate
属性被用来告诉浏览器 不执行 表单中该字段的 有效性验证, 其作用就是把该工作留给运行在服务器上的Flask程序去做。使用 novalidate
完全是可选的,但对于这个表单来说这么设置是非常必要的,因为只有设置该项稍后你才能去测试服务器端的有效性验证。
form.hidden_tag()
模板参数生成了一个隐藏字段,包含防止表单遭受CSFR攻击的令牌。要保护表单你要做的就是包含上这个隐藏字段并在Flask配置中设置好SECRET_KEY
变量。如果你做好这两件事,剩下的Flask-WTF会统统帮你搞定。
如果你过去编写过HTML表单,你会发现奇怪的是模板中并没有HTML字段。这是因为来自与表单对象的字段们知道如何把自己转换成HTML。在需要字段标签的地方,我只需要添加 {{ form.<field_name>.label }}
标记,在需要字段的地方包含上 {{ form.<field_name>() }}
。 如果字段需要附加其他HTML属性,可以以参数形式传递过来。模板中的 username 和 password 字段就获取了size
参数,作为属性把这一参数添加给 <input>
HTML 元素。同样,你也可以给表单字段添加CSS类或IDs。
表单视图
现在只差最后一步,就能在浏览器里看到这个表单了——那就是为应用程序编写新的视图代码来渲染前一节的模板。
因此,我们编写一个新视图函数映射到 /login URL,我们将差ungjian表单并传递给模板来渲染之。这个视图函数和以前的那些存在一个模块里 app/routes.py:
app/routes.py: 登录视图函数
from flask import render_template
from app import app
from app.forms import LoginForm
# ...
@app.route('/login')
def login():
form = LoginForm()
return render_template('login.html', title='Sign In', form=form)
在这里,我从 forms.py 导入 LoginForm
类,由其实例化一个对象,然后将该对象发送给模板。 form=form
这个语法看着怪怪的,其实它只是简单的把上面创建的form
对象(“=” 右边这个)用变量名字form
(“=”左边这个)传递给模板。它包含了要渲染的所有字段。
(这样在模板中,使用变量名‘form’来调用渲染各字段,如果你把‘=‘左边改成拼音’biaodan‘,那在模板中就得使用‘biaodan.username'这样的形式调用了。)
为了易于访问登录表单,我们在base模板中的导航栏上添加一个指向它的链接:
app/templates/base.html: 导航栏里的登录连接
<div>
Microblog:
<a href="/index">Home</a>
<a href="/login">Login</a>
</div>
现在,你就可以运行程序并实际看到浏览器里的表单了。程序运行起来以后,在地址栏中输入 http://localhost:5000/
,然后在顶部的导航栏点击"Login", 就能看到崭新的登录界面了。相当酷,对吧?
接收表单数据
如果你试图点击提交按钮,浏览器将显示 "Method Not Allowed" (方法未允许)错误。这是因为上节的视图函数的工作只做了半截。它只在页面上显示了表单,但并没有处理用户提交数据的逻辑处理代码。这是Flask-WTF简化工作的另一个表现了。下面是更新版本的视图函数,它将接收并验证用户提交的数据:
app/routes.py: 接收登录验证
from flask import render_template, flash, redirect
@app.route('/login', methods=['GET', 'POST'])
def login():
form = LoginForm()
if form.validate_on_submit():
flash('Login requested for user {}, remember_me={}'.format(
form.username.data, form.remember_me.data))
return redirect('/index')
return render_template('login.html', title='Sign In', form=form)
这一版的第一个新看点是路由装饰器参数中的 methods
参数。它告诉Flask 这个视图函数接受 GET
和 POST
请求,覆盖掉了默认的只接受 GET
请求。 HTTP 协议的GET
请求状态是返回信息给客户端(本例中是浏览器)。 到目前为止,程序中所有请求都是这种类型。 POST
请求通常在浏览器传送表单数据给服务器时使用。(实际上,
GET请求也能实现这一功能,但不是一个很好的推荐
)。 前面浏览器返回给你的错误 "Method Not Allowed" ,表明浏览器试图用 POST
方法发送数据,但在程序中并没有配置接受该方法——那么,通过添加 methods
参数,Flask就知道应该接受哪一种方法了。
form.validate_on_submit()
方法会完成所有表单处理的工作。当浏览器发送 GET
请求来接收页面的表单数据时,这个方法(validate_on_submit()
)将返回FALSE
。因此这种情况下,验证表单数据函数跳过了if语句下面的内容,在视图函数的最后一行直接进行渲染模板。
当用户点击提交按钮,浏览器发送 POST
请求时,form.validate_on_submit()
就会收集所有数据,运行所有字段上附加的有效性验证器,如果所有验证无误,它就会返回True
,表明所有数据是有效的,可以被程序处理。但如果任何一个字段值验证失败,这个函数就会返回 False
,这将重新渲染此表单并回发给用户, 类似与GET
请求那样。稍后,我们将添加一个验证失败的错误信息。
当 form.validate_on_submit()
返回 True
,登录(login)视图函数将调用从Flask导入的两个新函数。 flash()
函数是给用户显示信息很有用的方法。相当多的程序都使用这个技术告知用户其操作成功或者失败。这里,我将使用这个机制作为临时解决方案,因为我们目前还没有供真实用户登录的基础结构。目前我最多也就是显示程序确认接受到了认证的信息。
登录视图函数所用到的第二个新函数是 redirect()
。 这个函数通过给定一个不同页面的参数,指示客户端自动导航过去。 此处,这个视图函数用它来把用户重新定向到程序的首页。
当你调用 flash()
函数, Flask会存储该信息,但要闪现的信息并不会自动出现在页面上。 程序的模板需要在模板布局上渲染那些闪现信息才行。我将把这个结构添加到基础模板,这样所有模板页面都会继承得到这一功能。这是更新后的基础模板:
app/templates/base.html: 在基础模板中闪现信息
<html>
<head>
{% if title %}
<title>{{ title }} - microblog</title>
{% else %}
<title>microblog</title>
{% endif %}
</head>
<body>
<div>
Microblog:
<a href="/index">Home</a>
<a href="/login">Login</a>
</div>
<hr>
{% with messages = get_flashed_messages() %}
{% if messages %}
<ul>
{% for message in messages %}
<li>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</body>
</html>
此处,我使用了一个 with
结构把调用get_flashed_messages()
得到的所有结果关联给 messages
变量, 所有这些都在模板的上下文里。 来自于 Flask的函数get_flashed_messages()
会返回一个前面由flash()
注册的信息列表。 接下来的条件语句检查 messages
中是否有内容,在这, 会把每一条信息作为一个<li>
列表项,然后组合成元素 <ul>
列表。这种呈现风格并不很好,稍后我们将介绍如何风格化整个应用程序(使用CSS)。
有趣的是,这些被闪现的信息,一旦通过 get_flashed_messages
函数请求调用后,就会被从信息列表中清除,所以他们只会在调用flash()
函数后出现一次。
这是一个伟大的时刻:不断尝试程序,测试表单是如何工作的。确保你提交的表单中使用了空白的用户名和|或密码提交,这样你就会看到DataRequired
验证器是如何 挂起 提交进程的。
改进字段验证
附加于表单字段的验证器目的就是阻止接受到的无效数据进入程序。 应用程序处理表单输入的方式是重新显示表单,以供用户进行必要的修改。
如果你尝试过提交无效数据,你肯定已经注意到如果验证机制工作良好,并没有给用户“表单有错误”的提示,用户只会单纯的看到表单重新出现。接下来的任务是改善用户体验,给每个验证失败的字段添加上有意义的错误提示信息。
事实上,表单验证器已经生成了相关的错误描述信息,所以我们缺少的就是添加一些逻辑在模板中显示就可以了。
下面是带有用户名和密码验证信息的登录模板:
app/templates/login.html: 验证错误信息的login模板
{% extends "base.html" %}
{% block content %}
<h1>Sign In</h1>
<form action="" method="post" novalidate>
{{ form.hidden_tag() }}
<p>
{{ form.username.label }}<br>
{{ form.username(size=32) }}<br>
{% for error in form.username.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
</p>
<p>
{{ form.password.label }}<br>
{{ form.password(size=32) }}<br>
{% for error in form.password.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
</p>
<p>{{ form.remember_me() }} {{ form.remember_me.label }}</p>
<p>{{ form.submit() }}</p>
</form>
{% endblock %}
我所做的更改就是在用户名和字段之后添加for循环来显示错误信息——红色字体。作为通用规则,任何带有验证器的字段的错误信息都会添加到 form.<field_name>.errors
下面。这是一个 list列表(可能包含多条而非一条信息
),因为字段可能有多条验证器,也就可能会有不知一条的信息要显示给用户。
如果你试图提交空用户名或密码的表单,你将看到红色的错误信息。
生成链接
现在,登录表单已经完成The login form is fairly complete now, but before closing this chapter I wanted to discuss the proper way to include links in templates and redirects. So far you have seen a few instances in which links are defined. For example, this is the current navigation bar in the base template:
<div>
Microblog:
<a href="/index">Home</a>
<a href="/login">Login</a>
</div>
The login view function also defines a link that is passed to the redirect()
function:
@app.route('/login', methods=['GET', 'POST'])
def login():
form = LoginForm()
if form.validate_on_submit():
# ...
return redirect('/index')
# ...
One problem with writing links directly in templates and source files is that if one day you decide to reorganize your links, then you are going to have to search and replace these links in your entire application.
To have better control over these links, Flask provides a function called url_for()
, which generates URLs using its internal mapping of URLs to view functions. For example, url_for('login')
returns /login
, and url_for('index')
return '/index
. The argument to url_for()
is the endpoint name, which is the name of the view function.
You may ask why is it better to use the function names instead of URLs. The fact is that URLs are much more likely to change than view function names, which are completely internal. A secondary reason is that as you will learn later, some URLs have dynamic components in them, so generating those URLs by hand would require concatenating multiple elements, which is tedious and error prone. The url_for()
is also able to generate these complex URLs.
So from now on, I'm going to use url_for()
every time I need to generate an application URL. The navigation bar in the base template then becomes:
app/templates/base.html: Use url_for() function for links
<div>
Microblog:
<a href="{{ url_for('index') }}">Home</a>
<a href="{{ url_for('login') }}">Login</a>
</div>
And here is the updated login()
view function:
app/routes.py: Use url_for() function for links
from flask import render_template, flash, redirect, url_for
# ...
@app.route('/login', methods=['GET', 'POST'])
def login():
form = LoginForm()
if form.validate_on_submit():
# ...
return redirect(url_for('index'))
# ...