表单处理
表单是 Web 应用与用户交互的核心机制。无论是用户注册、内容发布还是数据搜索,几乎所有需要用户输入的功能都依赖表单。然而,处理表单并非简单地接收数据那么简单,我们需要考虑数据验证、安全防护、用户体验等诸多方面。
Flask-WTF 是 Flask 生态系统中最流行的表单处理扩展,它在 WTForms 库的基础上提供了与 Flask 的紧密集成,包括 CSRF 保护、文件上传、表单渲染等功能。本章将深入探讨如何使用 Flask-WTF 构建安全、高效的表单系统。
理解表单处理的核心问题
在深入技术细节之前,让我们先理解 Web 表单处理面临的核心挑战。
数据验证的多层性
表单验证不仅是检查"字段是否为空"那么简单,它涉及多个层面:
- 类型验证:用户输入的年龄是否为有效数字?日期格式是否正确?
- 业务规则验证:用户名是否已存在?邮箱是否已注册?
- 安全验证:输入是否包含恶意脚本?文件类型是否合法?
- 一致性验证:两次输入的密码是否一致?
每一层验证都有其特定的时间和方式,良好的表单系统应该清晰地区分这些层面。
CSRF 攻击防护
CSRF(Cross-Site Request Forgery,跨站请求伪造)是一种常见的 Web 安全漏洞。攻击者诱导用户在已登录的状态下访问恶意网站,该网站自动向目标网站发送请求(如转账、修改密码),由于用户已登录,请求会被成功执行。
防护 CSRF 的核心是在表单中嵌入一个攻击者无法获取的令牌(Token),服务器验证令牌的有效性后才处理请求。Flask-WTF 自动实现了这一机制。
用户友好的错误提示
当验证失败时,如何向用户清晰地展示错误信息是一门艺术。好的错误提示应该:
- 准确描述问题所在
- 提供解决问题的建议
- 支持国际化
- 与表单字段关联显示
安装与配置
安装依赖
pip install flask-wtf email-validator
email-validator 包提供了严格的邮箱格式验证,比简单的正则表达式更加准确。
基本配置
from flask import Flask
from flask_wtf import FlaskForm
app = Flask(__name__)
# CSRF 保护需要密钥
app.config['SECRET_KEY'] = 'your-secret-key-here'
# 可选配置
app.config['WTF_CSRF_ENABLED'] = True # 启用 CSRF(默认 True)
app.config['WTF_CSRF_SECRET_KEY'] = 'another-key' # CSRF 令牌密钥
app.config['WTF_CSRF_TIME_LIMIT'] = 3600 # 令牌有效期(秒)
为什么需要 SECRET_KEY?
CSRF 令牌使用密钥签名,防止攻击者伪造有效令牌。密钥应该足够复杂且保密:
import secrets
# 生成安全的密钥
app.config['SECRET_KEY'] = secrets.token_hex(32)
表单类基础
创建表单类
WTForms 采用声明式风格定义表单,每个字段作为类属性声明:
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Length
class LoginForm(FlaskForm):
"""用户登录表单"""
username = StringField(
'用户名',
validators=[
DataRequired(message='请输入用户名'),
Length(min=3, max=20, message='用户名长度必须在 3-20 个字符之间')
],
description='请输入您的用户名'
)
password = PasswordField(
'密码',
validators=[
DataRequired(message='请输入密码'),
Length(min=6, message='密码长度不能少于 6 个字符')
]
)
remember = BooleanField('记住我')
submit = SubmitField('登录')
让我们详细分析这个表单类的各个组成部分。
字段构造参数
字段构造函数接受以下常用参数:
| 参数 | 类型 | 说明 |
|---|---|---|
label | str | 字段标签,显示在表单中 |
validators | list | 验证器列表 |
filters | tuple | 数据过滤器,在验证前处理数据 |
description | str | 字段描述,用于帮助文本 |
default | any | 默认值 |
widget | Widget | 自定义渲染控件 |
render_kw | dict | 渲染时的 HTML 属性 |
class ProfileForm(FlaskForm):
# 带完整参数的示例
nickname = StringField(
label='昵称',
validators=[Length(max=50)],
filters=[lambda x: x.strip() if x else x], # 去除首尾空白
description='显示在您的个人主页',
default='匿名用户',
render_kw={
'placeholder': '请输入昵称',
'class': 'form-control',
'maxlength': 50
}
)
在视图中使用表单
from flask import render_template, redirect, url_for, flash
@app.route('/login', methods=['GET', 'POST'])
def login():
form = LoginForm()
if form.validate_on_submit():
# 验证通过,处理登录逻辑
username = form.username.data
password = form.password.data
remember = form.remember.data
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
login_user(user, remember=remember)
flash('登录成功!', 'success')
# 安全重定向
next_page = request.args.get('next')
if next_page and urlparse(next_page).netloc == '':
return redirect(next_page)
return redirect(url_for('main.index'))
flash('用户名或密码错误', 'danger')
# GET 请求或验证失败
return render_template('auth/login.html', form=form)
validate_on_submit() 方法做了两件事:
- 检查请求方法是否为 POST、PUT、PATCH 或 DELETE
- 调用
form.validate()执行所有验证器
理解表单数据流
表单数据从提交到处理经历以下流程:
用户输入 → 表单提交 → process() → filters → validators → form.data
- process():将表单数据转换为 Python 对象
- filters:对数据进行预处理(如去除空白)
- validators:验证数据有效性
- form.data:存储最终处理后的数据
# 手动处理表单(不推荐,了解原理)
form = LoginForm(request.form)
if form.validate():
# 验证通过
pass
# 手动处理 JSON 数据
if request.is_json:
form = LoginForm(data=request.get_json())
if form.validate():
pass
字段类型详解
WTForms 提供了丰富的字段类型,涵盖几乎所有常见的表单输入需求。
文本字段
from wtforms import StringField, PasswordField, TextAreaField, HiddenField
class TextForm(FlaskForm):
# 普通文本输入
title = StringField(
'标题',
validators=[DataRequired(), Length(max=100)],
render_kw={'placeholder': '请输入标题'}
)
# 密码输入(显示为星号)
password = PasswordField(
'密码',
validators=[DataRequired(), Length(min=6)]
)
# 多行文本
content = TextAreaField(
'内容',
validators=[DataRequired(), Length(max=10000)],
render_kw={'rows': 10, 'cols': 50}
)
# 隐藏字段(用于传递数据,不显示给用户)
user_id = HiddenField()
数值字段
from wtforms import IntegerField, FloatField, DecimalField
class NumberForm(FlaskForm):
# 整数输入
age = IntegerField(
'年龄',
validators=[NumberRange(min=0, max=150, message='请输入有效年龄')]
)
# 浮点数
price = FloatField(
'价格',
validators=[NumberRange(min=0)]
)
# 高精度十进制(适合货币)
amount = DecimalField(
'金额',
places=2, # 小数位数
rounding='ROUND_HALF_UP',
validators=[NumberRange(min=0)]
)
IntegerField vs FloatField vs DecimalField:
IntegerField:整数,适合计数、年龄等FloatField:浮点数,适合科学计算DecimalField:高精度十进制,适合货币金额,避免浮点误差
选择字段
from wtforms import SelectField, SelectMultipleField, RadioField
class SelectionForm(FlaskForm):
# 下拉选择
country = SelectField(
'国家',
choices=[
('', '-- 请选择 --'), # 空值选项
('cn', '中国'),
('us', '美国'),
('jp', '日本'),
('uk', '英国')
],
default='',
coerce=str # 将选择值转换为指定类型
)
# 动态选项(在视图中设置)
category = SelectField('分类', coerce=int)
# 多选下拉
tags = SelectMultipleField(
'标签',
choices=[
('python', 'Python'),
('javascript', 'JavaScript'),
('go', 'Go'),
('rust', 'Rust')
],
coerce=str
)
# 单选按钮组
gender = RadioField(
'性别',
choices=[
('male', '男'),
('female', '女'),
('other', '其他')
],
default='male'
)
动态选择选项:
当选项来自数据库时,需要在视图中动态设置:
@app.route('/post/create', methods=['GET', 'POST'])
def create_post():
form = PostForm()
# 动态设置分类选项
form.category.choices = [
(c.id, c.name) for c in Category.query.order_by(Category.name)
]
if form.validate_on_submit():
post = Post(
title=form.title.data,
category_id=form.category.data
)
db.session.add(post)
db.session.commit()
return redirect(url_for('post.detail', id=post.id))
return render_template('post/create.html', form=form)
日期时间字段
from wtforms import DateField, DateTimeField, TimeField
class ScheduleForm(FlaskForm):
# 日期选择
birth_date = DateField(
'出生日期',
format='%Y-%m-%d',
validators=[DataRequired()]
)
# 日期时间
event_time = DateTimeField(
'活动时间',
format='%Y-%m-%d %H:%M',
validators=[DataRequired()]
)
# 时间
start_time = TimeField(
'开始时间',
format='%H:%M',
validators=[DataRequired()]
)
日期格式字符串使用 Python 的 strftime 格式:
| 格式 | 说明 | 示例 |
|---|---|---|
%Y | 四位年份 | 2024 |
%m | 两位月份 | 01-12 |
%d | 两位日期 | 01-31 |
%H | 24小时制小时 | 00-23 |
%M | 分钟 | 00-59 |
%S | 秒 | 00-59 |
文件上传字段
from flask_wtf.file import FileField, FileRequired, FileAllowed
class UploadForm(FlaskForm):
# 单文件上传
avatar = FileField(
'头像',
validators=[
FileRequired(message='请选择文件'),
FileAllowed(['jpg', 'jpeg', 'png', 'gif'], message='只允许图片文件')
]
)
# 多文件上传
photos = FileField(
'相片',
validators=[
FileAllowed(['jpg', 'png'], '只允许 JPG 和 PNG 格式')
],
render_kw={'multiple': True}
)
处理文件上传:
from werkzeug.utils import secure_filename
import os
@app.route('/upload', methods=['GET', 'POST'])
def upload():
form = UploadForm()
if form.validate_on_submit():
file = form.avatar.data
# 安全处理文件名
filename = secure_filename(file.filename)
# 生成唯一文件名
unique_name = f"{uuid.uuid4().hex}_{filename}"
# 保存文件
upload_path = os.path.join(app.config['UPLOAD_FOLDER'], unique_name)
file.save(upload_path)
flash('上传成功!', 'success')
return redirect(url_for('upload'))
return render_template('upload.html', form=form)
布尔字段
from wtforms import BooleanField
class SettingsForm(FlaskForm):
# 复选框
subscribe = BooleanField(
'订阅邮件通知',
default=True
)
agree = BooleanField(
'我已阅读并同意用户协议',
validators=[DataRequired(message='必须同意协议才能继续')]
)
注意:未勾选的复选框不会出现在表单数据中,WTForms 会将其视为 False。
HTML5 字段
WTForms 提供了对 HTML5 新输入类型的支持:
from wtforms.fields.html5 import (
SearchField, TelField, URLField, EmailField,
DateField as HTML5DateField, DateTimeLocalField,
IntegerField as HTML5IntegerField, DecimalField as HTML5DecimalField
)
class HTML5Form(FlaskForm):
# 搜索框(浏览器可能显示清除按钮)
search = SearchField('搜索')
# 电话号码(移动端显示数字键盘)
phone = TelField('电话')
# URL(自动验证 URL 格式)
website = URLField('网站', validators=[DataRequired()])
# 邮箱(自动验证邮箱格式)
email = EmailField('邮箱', validators=[DataRequired(), Email()])
# 本地日期时间
event_time = DateTimeLocalField(
'活动时间',
format='%Y-%m-%dT%H:%M'
)
# 数字输入(显示上下箭头)
quantity = HTML5IntegerField('数量', validators=[NumberRange(min=1)])
HTML5 字段在浏览器端提供更好的用户体验,但仍需服务端验证。
验证器详解
验证器是表单系统的核心,负责确保数据的完整性和有效性。
内置验证器一览
| 验证器 | 用途 | 示例 |
|---|---|---|
DataRequired | 确保字段有值(非空且非纯空白) | DataRequired() |
InputRequired | 确保输入数据存在 | InputRequired() |
Optional | 允许字段为空 | Optional() |
Length | 验证字符串长度 | Length(min=3, max=20) |
NumberRange | 验证数值范围 | NumberRange(min=0, max=100) |
Email | 验证邮箱格式 | Email() |
URL | 验证 URL 格式 | URL() |
EqualTo | 验证两个字段的值相等 | EqualTo('password') |
Regexp | 正则表达式验证 | Regexp(r'^[a-z]+$') |
IPAddress | 验证 IP 地址 | IPAddress() |
UUID | 验证 UUID 格式 | UUID() |
MacAddress | 验证 MAC 地址 | MacAddress() |
AnyOf | 验证值在允许列表中 | AnyOf(['a', 'b']) |
NoneOf | 验证值不在禁止列表中 | NoneOf(['admin', 'root']) |
DataRequired vs InputRequired
这两个验证器经常混淆,它们有重要区别:
from wtforms.validators import DataRequired, InputRequired
class ExampleForm(FlaskForm):
# DataRequired:验证处理后的数据
# 对于字符串,空白字符串会被视为"空"
# 对于数字,0 会被视为"有值"
field1 = IntegerField('Field 1', validators=[DataRequired()])
# InputRequired:验证原始输入是否存在
# 即使输入的是 "0" 或 "",只要有输入就算通过
field2 = IntegerField('Field 2', validators=[InputRequired()])
使用场景:
DataRequired:大多数情况,确保字段有意义的数据InputRequired:当 0 或空字符串是有效值时
class ScoreForm(FlaskForm):
# 评分 0 也是有效分数
score = IntegerField('评分', validators=[InputRequired()])
# 年龄 0 没有意义
age = IntegerField('年龄', validators=[DataRequired()])
长度验证
from wtforms.validators import Length
class ContentForm(FlaskForm):
# 只验证最大长度
title = StringField('标题', validators=[Length(max=100)])
# 只验证最小长度
password = PasswordField('密码', validators=[Length(min=8)])
# 验证范围
username = StringField(
'用户名',
validators=[
Length(
min=3,
max=20,
message='用户名长度必须在 %(min)d 到 %(max)d 个字符之间'
)
]
)
消息字符串支持占位符:
%(min)d:最小长度%(max)d:最大长度
数值范围验证
from wtforms.validators import NumberRange
class NumberForm(FlaskForm):
# 整数范围
age = IntegerField(
'年龄',
validators=[
NumberRange(
min=0,
max=150,
message='年龄必须在 %(min)d 到 %(max)d 之间'
)
]
)
# 只验证最小值
price = FloatField(
'价格',
validators=[
NumberRange(min=0, message='价格不能为负数')
]
)
# 支持浮点数
discount = FloatField(
'折扣',
validators=[
NumberRange(min=0, max=1, message='折扣必须在 0 到 1 之间')
]
)
邮箱验证
from wtforms.validators import Email
class UserForm(FlaskForm):
email = StringField(
'邮箱',
validators=[
DataRequired(),
Email(message='请输入有效的邮箱地址')
]
)
# 高级选项
advanced_email = StringField(
'邮箱',
validators=[
Email(
message='请输入有效的邮箱地址',
check_deliverability=True, # 检查域名是否存在
granular_message=True # 显示详细错误信息
)
]
)
注意:Email 验证器需要安装 email-validator 包。
相等性验证
from wtforms.validators import EqualTo
class PasswordForm(FlaskForm):
password = PasswordField(
'新密码',
validators=[
DataRequired(),
Length(min=8, message='密码至少 8 个字符')
]
)
confirm = PasswordField(
'确认密码',
validators=[
DataRequired(),
EqualTo('password', message='两次输入的密码不一致')
]
)
消息占位符:
%(other_label)s:另一个字段的标签%(other_name)s:另一个字段的名称
正则表达式验证
from wtforms.validators import Regexp
class CustomForm(FlaskForm):
# 只允许字母、数字和下划线
username = StringField(
'用户名',
validators=[
DataRequired(),
Regexp(
r'^[a-zA-Z][a-zA-Z0-9_]*$',
message='用户名必须以字母开头,只能包含字母、数字和下划线'
)
]
)
# 手机号验证(中国大陆)
phone = StringField(
'手机号',
validators=[
Regexp(
r'^1[3-9]\d{9}$',
message='请输入有效的手机号'
)
]
)
# 使用正则标志
case_insensitive = StringField(
'值',
validators=[
Regexp(r'^test', flags=re.IGNORECASE, message='必须以 test 开头(不区分大小写)')
]
)
枚举值验证
from wtforms.validators import AnyOf, NoneOf
class EnumForm(FlaskForm):
# 值必须在允许列表中
status = StringField(
'状态',
validators=[
AnyOf(
['active', 'inactive', 'pending'],
message='状态必须是 active、inactive 或 pending'
)
]
)
# 值不能在禁止列表中
username = StringField(
'用户名',
validators=[
NoneOf(
['admin', 'root', 'system', 'administrator'],
message='此用户名不可使用'
)
]
)
可选字段验证
from wtforms.validators import Optional
class OptionalForm(FlaskForm):
# 可选字段,如果为空则跳过后续验证
nickname = StringField(
'昵称',
validators=[
Optional(), # 允许为空
Length(max=50) # 如果有值,则验证长度
]
)
# 去除空白后判断
bio = StringField(
'简介',
validators=[
Optional(strip_whitespace=True), # 去除首尾空白后判断
Length(max=200)
]
)
组合验证器
多个验证器按顺序执行,任一失败则停止:
class ComplexForm(FlaskForm):
password = PasswordField(
'密码',
validators=[
DataRequired(message='请输入密码'),
Length(min=8, message='密码至少 8 个字符'),
Regexp(r'.*[A-Z].*', message='密码必须包含大写字母'),
Regexp(r'.*[a-z].*', message='密码必须包含小写字母'),
Regexp(r'.*\d.*', message='密码必须包含数字'),
Regexp(r'.*[!@#$%^&*].*', message='密码必须包含特殊字符')
]
)
自定义验证
当内置验证器无法满足需求时,可以创建自定义验证器。
内联验证
最简单的验证方式,直接在表单类中定义验证方法:
class RegistrationForm(FlaskForm):
username = StringField('用户名', validators=[DataRequired(), Length(3, 20)])
email = StringField('邮箱', validators=[DataRequired(), Email()])
def validate_username(self, field):
"""验证用户名是否已存在"""
if User.query.filter_by(username=field.data).first():
raise ValidationError('该用户名已被注册')
def validate_email(self, field):
"""验证邮箱是否已存在"""
if User.query.filter_by(email=field.data).first():
raise ValidationError('该邮箱已被注册')
验证方法命名规则:validate_<field_name>,接受 field 参数,验证失败时抛出 ValidationError。
函数验证器
可复用的验证器函数:
from wtforms.validators import ValidationError
def unique_username(form, field):
"""验证用户名唯一性"""
if User.query.filter_by(username=field.data).first():
raise ValidationError('该用户名已被注册')
def unique_email(form, field):
"""验证邮箱唯一性"""
if User.query.filter_by(email=field.data).first():
raise ValidationError('该邮箱已被注册')
class RegistrationForm(FlaskForm):
username = StringField('用户名', validators=[
DataRequired(),
Length(3, 20),
unique_username # 使用自定义验证器
])
email = StringField('邮箱', validators=[
DataRequired(),
Email(),
unique_email
])
类验证器
更强大的验证器,支持参数配置:
from wtforms.validators import ValidationError
class Unique(object):
"""验证字段值在数据库中唯一"""
def __init__(self, model, field, message=None):
self.model = model
self.field = field
self.message = message or f'该{field}已被使用'
def __call__(self, form, field):
# 排除当前记录(用于更新场景)
exclude_id = getattr(form, 'obj_id', None)
query = self.model.query.filter(
getattr(self.model, self.field) == field.data
)
if exclude_id:
query = query.filter(self.model.id != exclude_id)
if query.first():
raise ValidationError(self.message)
# 使用
class UserForm(FlaskForm):
username = StringField('用户名', validators=[
DataRequired(),
Unique(User, 'username', message='用户名已存在')
])
email = StringField('邮箱', validators=[
DataRequired(),
Email(),
Unique(User, 'email', message='邮箱已注册')
])
验证器工厂
返回验证器函数的工厂函数:
def forbidden_password(message=None):
"""禁止使用常见密码"""
forbidden = ['password', '123456', 'qwerty', 'admin']
def _forbidden_password(form, field):
if field.data.lower() in forbidden:
raise ValidationError(message or '密码过于简单,请使用更复杂的密码')
return _forbidden_password
class SecureForm(FlaskForm):
password = PasswordField('密码', validators=[
DataRequired(),
Length(min=8),
forbidden_password()
])
带标志的验证器
验证器可以设置标志,用于模板中判断:
class StrongPassword(object):
"""强密码验证器"""
field_flags = ('strong_password',) # 设置标志
def __init__(self, message=None):
self.message = message or '密码强度不足'
def __call__(self, form, field):
# 密码强度检查逻辑
password = field.data
has_upper = any(c.isupper() for c in password)
has_lower = any(c.islower() for c in password)
has_digit = any(c.isdigit() for c in password)
has_special = any(c in '!@#$%^&*' for c in password)
if not all([has_upper, has_lower, has_digit, has_special]):
raise ValidationError(self.message)
# 在模板中使用标志
{% if field.flags.strong_password %}
<small class="help-text">密码需包含大小写字母、数字和特殊字符</small>
{% endif %}
模板渲染
基本渲染
<form method="POST" action="{{ url_for('register') }}">
{{ form.hidden_tag() }}
<!-- 渲染标签 -->
<div class="form-group">
{{ form.username.label }}
{{ form.username(class="form-control", placeholder="请输入用户名") }}
{% if form.username.errors %}
<div class="invalid-feedback">
{% for error in form.username.errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% endif %}
</div>
<div class="form-group">
{{ form.email.label }}
{{ form.email(class="form-control", placeholder="请输入邮箱") }}
</div>
{{ form.submit(class="btn btn-primary") }}
</form>
使用宏简化渲染
<!-- templates/macros/forms.html -->
{% macro render_field(field, label=true, help_text='') %}
<div class="form-group {% if field.errors %}has-error{% endif %}">
{% if label %}
{{ field.label(class="form-label") }}
{% endif %}
{{ field(
class="form-control" + (" is-invalid" if field.errors else ""),
**kwargs
) }}
{% if help_text %}
<small class="form-text text-muted">{{ help_text }}</small>
{% endif %}
{% if field.errors %}
<div class="invalid-feedback">
{% for error in field.errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endmacro %}
{% macro render_form(form, action='', method='POST') %}
<form method="{{ method }}" action="{{ action }}" novalidate>
{{ form.hidden_tag() }}
{% for field in form %}
{% if field.type not in ['CSRFTokenField', 'SubmitField'] %}
{{ render_field(field) }}
{% endif %}
{% endfor %}
<div class="form-group">
{{ form.submit(class="btn btn-primary") }}
</div>
</form>
{% endmacro %}
使用宏:
{% from "macros/forms.html" import render_field, render_form %}
<!-- 使用 render_field -->
{{ render_field(form.username, placeholder="请输入用户名") }}
{{ render_field(form.email, help_text="我们不会泄露您的邮箱") }}
<!-- 使用 render_form -->
{{ render_form(form, action=url_for('register')) }}
自定义字段样式
<!-- 添加 CSS 类 -->
{{ form.username(class="form-control is-invalid", rows=5) }}
<!-- 添加数据属性 -->
{{ form.email(
class="form-control",
**{"data-toggle": "tooltip", "data-placement": "top"}
) }}
<!-- 复选框和单选按钮 -->
<div class="form-check">
{{ form.remember(class="form-check-input") }}
{{ form.remember.label(class="form-check-label") }}
</div>
<!-- 下拉选择 -->
{{ form.category(class="form-select") }}
<!-- 遍历单选按钮 -->
{% for subfield in form.gender %}
<div class="form-check form-check-inline">
{{ subfield(class="form-check-input") }}
{{ subfield.label(class="form-check-label") }}
</div>
{% endfor %}
Bootstrap 5 风格表单
<!-- templates/macros/bootstrap_form.html -->
{% macro render_bootstrap_field(field) %}
<div class="mb-3 {% if field.errors %}has-validation{% endif %}">
{{ field.label(class="form-label") }}
{% if field.type == 'BooleanField' %}
<div class="form-check">
{{ field(class="form-check-input") }}
{{ field.label(class="form-check-label") }}
</div>
{% elif field.type == 'SelectField' %}
{{ field(class="form-select" + (" is-invalid" if field.errors else "")) }}
{% elif field.type == 'TextAreaField' %}
{{ field(
class="form-control" + (" is-invalid" if field.errors else ""),
rows=kwargs.get('rows', 4)
) }}
{% else %}
{{ field(
class="form-control" + (" is-invalid" if field.errors else ""),
**kwargs
) }}
{% endif %}
{% if field.description %}
<div class="form-text">{{ field.description }}</div>
{% endif %}
{% if field.errors %}
<div class="invalid-feedback">
{% for error in field.errors %}
{{ error }}
{% if not loop.last %}<br>{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
{% endmacro %}
表单安全
CSRF 保护
CSRF(跨站请求伪造)保护是 Web 安全的基础:
# 配置 CSRF
app.config['WTF_CSRF_ENABLED'] = True
app.config['WTF_CSRF_SECRET_KEY'] = 'your-csrf-secret'
app.config['WTF_CSRF_TIME_LIMIT'] = 3600 # 令牌有效期(秒)
<!-- 表单中包含 CSRF 令牌 -->
<form method="POST">
{{ form.hidden_tag() }}
<!-- 或手动添加 -->
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</form>
AJAX 请求中的 CSRF
// 从 meta 标签获取 CSRF 令牌
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
// 发送请求时包含令牌
fetch('/api/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify(data)
});
<!-- 在基础模板中添加 meta 标签 -->
<head>
<meta name="csrf-token" content="{{ csrf_token() }}">
</head>
AJAX 表单验证
from flask import jsonify
@app.route('/api/validate', methods=['POST'])
def validate_form():
form = MyForm(data=request.get_json())
if form.validate():
return jsonify({'valid': True})
else:
return jsonify({
'valid': False,
'errors': form.errors
})
XSS 防护
永远不要信任用户输入:
from markupsafe import escape
class CommentForm(FlaskForm):
content = TextAreaField('评论', validators=[DataRequired()])
@app.route('/comment', methods=['POST'])
def add_comment():
form = CommentForm()
if form.validate_on_submit():
# WTForms 会自动转义输出,但存储时应保留原始内容
comment = Comment(content=form.content.data)
db.session.add(comment)
db.session.commit()
return redirect(url_for('index'))
<!-- 模板中自动转义 -->
<p>{{ comment.content }}</p> <!-- 自动转义 -->
<p>{{ comment.content|safe }}</p> <!-- 不转义,危险!仅用于可信内容 -->
高级主题
表单继承
通过继承复用表单定义:
class BaseUserForm(FlaskForm):
"""用户表单基类"""
username = StringField('用户名', validators=[DataRequired(), Length(3, 20)])
email = StringField('邮箱', validators=[DataRequired(), Email()])
class RegistrationForm(BaseUserForm):
"""注册表单,继承基类并添加字段"""
password = PasswordField('密码', validators=[DataRequired(), Length(min=8)])
confirm = PasswordField('确认密码', validators=[
DataRequired(),
EqualTo('password', message='密码不一致')
])
submit = SubmitField('注册')
class ProfileForm(BaseUserForm):
"""个人资料表单,继承基类并修改验证"""
# 重写字段以添加/修改验证器
username = StringField('用户名', validators=[
DataRequired(),
Length(3, 20),
Unique(User, 'username')
])
bio = TextAreaField('个人简介', validators=[Length(max=500)])
submit = SubmitField('保存')
表单组合
使用 FormField 组合多个表单:
class AddressForm(FlaskForm):
"""地址表单"""
province = StringField('省份', validators=[DataRequired()])
city = StringField('城市', validators=[DataRequired()])
address = StringField('详细地址', validators=[DataRequired()])
postal_code = StringField('邮编')
class OrderForm(FlaskForm):
"""订单表单,包含多个地址"""
recipient = StringField('收件人', validators=[DataRequired()])
phone = StringField('电话', validators=[DataRequired()])
# 嵌入地址表单
shipping_address = FormField(AddressForm, label='收货地址')
submit = SubmitField('提交订单')
模板中访问嵌套表单:
<!-- 访问嵌套字段 -->
{{ form.shipping_address.province }}
{{ form.shipping_address.city }}
动态表单字段
运行时添加字段:
def create_dynamic_form(fields_config):
"""动态创建表单类"""
class DynamicForm(FlaskForm):
pass
for field_config in fields_config:
field_name = field_config['name']
field_type = field_config['type']
field_label = field_config.get('label', field_name)
field_validators = field_config.get('validators', [])
# 创建字段
field = field_type(field_label, validators=field_validators)
setattr(DynamicForm, field_name, field)
return DynamicForm
# 使用
config = [
{'name': 'name', 'type': StringField, 'label': '姓名', 'validators': [DataRequired()]},
{'name': 'age', 'type': IntegerField, 'label': '年龄', 'validators': [NumberRange(min=0)]},
]
DynamicForm = create_dynamic_form(config)
form = DynamicForm()
表单与模型
使用表单填充模型:
# 方式一:手动赋值
@app.route('/user/edit/<int:id>', methods=['GET', 'POST'])
def edit_user(id):
user = User.query.get_or_404(id)
form = UserForm(obj=user) # 使用 obj 参数填充表单
if form.validate_on_submit():
# 手动赋值
user.username = form.username.data
user.email = form.email.data
user.bio = form.bio.data
db.session.commit()
flash('保存成功', 'success')
return redirect(url_for('user.profile', id=user.id))
return render_template('user/edit.html', form=form)
# 方式二:自动填充(推荐)
@app.route('/user/edit/<int:id>', methods=['GET', 'POST'])
def edit_user(id):
user = User.query.get_or_404(id)
form = UserForm(obj=user)
if form.validate_on_submit():
# 自动将表单数据填充到模型
form.populate_obj(user)
db.session.commit()
flash('保存成功', 'success')
return redirect(url_for('user.profile', id=user.id))
return render_template('user/edit.html', form=form)
最佳实践
1. 表单与业务逻辑分离
# forms/auth.py - 表单定义
class LoginForm(FlaskForm):
username = StringField('用户名', validators=[DataRequired()])
password = PasswordField('密码', validators=[DataRequired()])
# services/auth.py - 业务逻辑
class AuthService:
@staticmethod
def authenticate(username, password):
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
return user
return None
# views/auth.py - 视图
@app.route('/login', methods=['GET', 'POST'])
def login():
form = LoginForm()
if form.validate_on_submit():
user = AuthService.authenticate(
form.username.data,
form.password.data
)
if user:
login_user(user)
return redirect(url_for('index'))
flash('用户名或密码错误', 'danger')
return render_template('auth/login.html', form=form)
2. 统一的错误响应格式
def form_errors_to_dict(form):
"""将表单错误转换为字典"""
errors = {}
for field_name, field_errors in form.errors.items():
errors[field_name] = field_errors
return errors
@app.route('/api/users', methods=['POST'])
def create_user():
form = UserForm(data=request.get_json())
if form.validate():
user = User(**form.data)
db.session.add(user)
db.session.commit()
return jsonify(user.to_dict()), 201
return jsonify({
'success': False,
'errors': form_errors_to_dict(form)
}), 400
3. 表单数据清洗
class CleanForm(FlaskForm):
# 使用 filters 清洗数据
username = StringField(
'用户名',
validators=[DataRequired(), Length(3, 20)],
filters=[lambda x: x.strip().lower() if x else x] # 去除空白并转小写
)
email = StringField(
'邮箱',
validators=[DataRequired(), Email()],
filters=[lambda x: x.strip().lower() if x else x]
)
4. 适当的默认值
class SearchForm(FlaskForm):
query = StringField('搜索')
category = SelectField('分类', choices=[...], default='all')
sort = SelectField('排序', choices=[
('relevance', '相关度'),
('date', '日期'),
('price', '价格')
], default='relevance')
page = IntegerField('页码', default=1)
per_page = IntegerField('每页数量', default=20)
5. 密码处理最佳实践
from werkzeug.security import generate_password_hash, check_password_hash
class PasswordMixin:
"""密码处理混入类"""
password = PasswordField('密码', validators=[
DataRequired(),
Length(min=8, message='密码至少 8 个字符')
])
def get_password_hash(self):
"""获取密码哈希"""
return generate_password_hash(self.password.data)
class UserForm(FlaskForm, PasswordMixin):
username = StringField('用户名', validators=[DataRequired()])
email = StringField('邮箱', validators=[DataRequired(), Email()])
submit = SubmitField('注册')
小结
本章深入探讨了 Flask 表单处理的各个方面:
- 表单基础:理解表单处理的核心问题,掌握 Flask-WTF 的基本用法
- 字段类型:了解各种字段类型的特点和适用场景
- 验证器:掌握内置验证器的用法,学会创建自定义验证器
- 模板渲染:学习表单渲染技巧,使用宏简化代码
- 安全实践:理解 CSRF、XSS 等 Web 安全威胁及防护方法
- 高级主题:表单继承、组合、动态字段等高级技巧
表单是 Web 应用与用户交互的核心,掌握表单处理技术对于构建安全、用户友好的应用至关重要。