Flask-表单使用

这篇讲什么

  • 表单有两条路:直接用 request.form 接数据,或者用 Flask-WTF 的 FlaskForm 一次拿到字段定义、校验器和 CSRF 保护,这篇两条都写。
  • 覆盖 url_for 与静态文件、字段类型与验证器对照表、自定义校验、POST-Redirect-GET 模式,以及 cookie / session 两种会话保持。
  • 实测环境 Flask-WTF 1.3.0 + WTForms 3.3.2;Email 验证器必须另装 email_validator 这条坑写在正文里。

url_for

  • 根据视图函数名(准确说是 endpoint)反向构造路由地址,写模板时离不开它。
  • 本机 Flask 3.1.3 实测的三条行为:
    1
    2
    3
    url_for('login')                        -> '/login'
    url_for('static', filename='css/common.css') -> '/static/css/common.css'
    url_for('welcome', name='x', uid=250) -> '/welcome/x?uid=250'
  • 不在路由规则里的多余参数会自动变成查询串,这一条老笔记写对了。
  • 要带协议和主机名的完整地址就加 _external=True(老笔记那句「制定参数」是错别字)。
  • 完整的 url_for 行为表(含 BuildError、静态文件、外链)在 Flask-基本入门 的「重定向(redirect)」一节,这里不重复。

加载静态文件

目录结构

1
2
3
4
5
6
7
8
project/
app.py
static/ # 静态文件目录,名字是默认值
favicon.ico # 收藏夹图标
image/ # 图片
css/ # 样式
js/ # 脚本
templates/ # 模板目录
  • 老笔记把启动文件写成 manage.py,那是 Django 的叫法;Flask 侧没有这个约定,通常就叫 app.py。
  • 目录名和访问前缀都由构造参数决定,实测 Flask(__name__, static_url_path='/s') 之后地址前缀就变成 /s。

引用方式

  • 一律用 url_for('static', filename=...),别手写 /static/,这样改前缀不会漏。

  • 实测 url_for 不检查文件存不存在:

    1
    2
    3
    url_for('static', filename='不存在.css') -> '/static/%E4%B8%8D%E5%AD%98%E5%9C%A8.css'
    GET /static/css/common.css -> 200,6 字节
    GET /static/nope.css -> 404

    也就是说地址生成永远成功,404 是浏览器请求时才暴露的,排查要去 Network 面板看。

  • 收藏夹图标(不再需要 {% block head %},那是 flask-bootstrap 的写法):

    templates/base.html
    1
    <link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
  • 图片、CSS、JS 三种引用:

    1
    2
    3
    <img src="{{ url_for('static', filename='image/meinv.jpg') }}" alt="美女">
    <link rel="stylesheet" href="{{ url_for('static', filename='css/common.css') }}">
    <script src="{{ url_for('static', filename='js/common.js') }}"></script>

    Bootstrap 5 之后 <script> 不再需要 type="text/javascript"。

原生表单

  • 不用任何扩展也能收表单,模板就是一个 method="post" 的 form:

    templates/login.html
    1
    2
    3
    4
    5
    <form method="post">
    用户名:<input type="text" name="username"><br>
    密码:<input type="password" name="password"><br>
    <input type="submit" value="立即登录">
    </form>
  • 视图函数要显式声明收 POST,否则 POST 打过来是 405:

    app.py
    1
    2
    3
    4
    5
    @app.route('/login/', methods=['GET', 'POST'])
    def login():
    if request.method == 'GET':
    return render_template('login.html')
    return request.form['username']
  • 实测三种情况:

    1
    2
    3
    GET  /login                -> 'GET 分支'
    POST /login 带 username -> "POST 拿到 'tom'"
    POST /login 不带 username -> request.form['username'] 抛 BadRequestKeyError(HTTP 400)
  • form['字段'] 和 form.get('字段') 的区别就是 400 和 None,老笔记那种直接下标取值的写法,前端少传一个字段整条请求就挂。稳妥写法是 request.form.get('username', '')。

  • 原生表单没有 CSRF 保护,要么自己塞一个 token,要么用下一节的 Flask-WTF。

Flask-WTF

  • Flask-WTF 做三件事:把表单写成类、自带 CSRF 保护、把校验和渲染合并。本机版本 Flask-WTF 1.3.0 + WTForms 3.3.2。

  • 安装:

    1
    pip install flask-wtf
  • 用到 Email 验证器必须再装一个包,实测没装时校验邮箱会抛 Exception: Install 'email_validator' for email validation support.:

    1
    pip install email_validator
  • 最小可用的一套:

    app.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    from flask import Flask, render_template
    from flask_wtf import FlaskForm
    from wtforms import StringField, PasswordField, SubmitField
    from wtforms.validators import DataRequired, Length

    app = Flask(__name__)
    # CSRF 依赖签名密钥,没有它 session/表单 token 都写不进去
    app.config['SECRET_KEY'] = '换成自己的密钥'

    class NameForm(FlaskForm):
    name = StringField('用户名:', validators=[Length(6, 20, '必须在 6-20 个字符之间')])
    password = PasswordField('密码:', validators=[DataRequired('密码不能为空')])
    submit = SubmitField('提交')

    @app.route('/', methods=['GET', 'POST'])
    def index():
    form = NameForm()
    name = None
    if form.validate_on_submit():
    name = form.name.data
    return render_template('form.html', form=form, name=name)
  • 模板里逐字段渲染(这套写法不依赖任何 CSS 框架):

    templates/form.html
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    <form method="post" action="">
    {{ form.hidden_tag() }}

    {{ form.name.label() }}{{ form.name(id='xxx', class='yyy') }}<br>
    {% for e in form.name.errors %}<em>{{ e }}</em>{% endfor %}

    {{ form.password.label() }}{{ form.password() }}<br>
    {% for e in form.password.errors %}<em>{{ e }}</em>{% endfor %}

    {{ form.submit() }}
    </form>

validate_on_submit 的判定

  • 它是「请求是 POST 且 校验全部通过」的合写,实测:

    1
    2
    3
    GET 提交        -> ok=false, errors={}
    POST 全空 -> ok=false, errors 里 name/pw/age/csrf_token 全中
    POST 数据合法 -> ok=true
  • 校验失败时数据仍然读得到(form.name.data 是 'abc'),错误信息在 form.errors 这个字典里,键是字段名、值是消息列表,实测:

    1
    2
    3
    4
    {"name": ["必须在 6-20 个字符之间"],
    "password": ["密码不能为空"],
    "age": ["Not a valid integer value.", "Number must be between 1 and 120."],
    "mail": ["Invalid email address."]}

CSRF

  • form.hidden_tag() 会渲染出 csrf 隐藏域,实测长这样:

    1
    <input id="csrf_token" name="csrf_token" type="hidden" value="IjJhMmIyNDA1OTAwMjBmNDA2MDc1MjZmODk1ZmZiNGIzN2Q2MmRkYWIi...">
  • 少了这个域,validate_on_submit() 只会返回 False 并在 errors['csrf_token'] 里给一句 The CSRF token is missing.,不会报 400——很容易让人以为校验逻辑坏了。

  • 想给整个应用加硬保护,用 CSRFProtect,实测差别很明显:

    app.py
    1
    2
    from flask_wtf import CSRFProtect
    CSRFProtect(app)
    1
    2
    POST 不带 token -> HTTP 400 Bad Request
    GET -> 200,不受影响
  • 需要在模板里单独取 token(比如 AJAX 请求头)时,用 generate_csrf(),它必须在请求上下文里调用,实测上下文外调用报 RuntimeError: Working outside of application context.。

用 Bootstrap 5 渲染

  • 老笔记这里用的是 {% import 'bootstrap/wtf.html' as wtf %} + wtf.quick_form(form),那是 flask-bootstrap 提供的,包已经停更,见 Flask-模板引擎。

  • 现在直接在字段上传 Bootstrap 的 class 就够了,实测 kwargs 会原样落到标签上:

    1
    2
    {{ form.name(class='form-control', placeholder='请输入') }}
    -> <input class="form-control" id="name" maxlength="20" minlength="6" name="name" type="text" value="">
    1
    2
    {{ form.name.label(class='form-label') }}
    -> <label class="form-label" for="name">用户名</label>
  • 套上 Bootstrap 5 的表单骨架:

    templates/form_bs5.html
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    <form method="post" action="">
    {{ form.hidden_tag() }}
    <div class="mb-3">
    {{ form.name.label(class='form-label') }}
    {{ form.name(class='form-control') }}
    {% for e in form.name.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
    </div>
    <div class="mb-3">
    {{ form.password.label(class='form-label') }}
    {{ form.password(class='form-control') }}
    </div>
    <div class="mb-3 form-check">
    {{ form.agree(class='form-check-input') }}
    {{ form.agree.label(class='form-check-label') }}
    </div>
    {{ form.submit(class='btn btn-primary') }}
    </form>

字段类型

  • 老笔记的字段表基本对,只有一处要改:没有 FloatField 这个常用叫法,浮点用 DecimalField(FloatField 在 WTForms 3.3.2 里确实还存在,hasattr 为 True,但官方文档推荐 DecimalField)。

  • 本机 hasattr(wtforms, ...) 实测存活情况:

    字段 存在 渲染出来的样子(实测)
    StringField 是 <input type="text">
    PasswordField 是 <input type="password" required>
    SubmitField 是 <input type="submit">
    HiddenField 是 <input type="hidden">
    TextAreaField 是 <textarea>
    IntegerField 是 <input type="number" min max>
    DecimalField 是 <input type="number" step>
    FloatField 是 同 DecimalField,官方不推荐
    BooleanField 是 <input type="checkbox" value="y">
    RadioField 是 <ul><li><input type="radio">…
    SelectField 是 <select><option>…
    SelectMultipleField 是 <select multiple>
    FileField 是 <input type="file">
    DateField / DateTimeField 是 <input type="date"> / datetime-local
  • 一个容易忽略的点:FileField 要求表单是 multipart,实测普通 urlencoded 提交时 request.files 是空的,带文件的 multipart 提交才拿得到。所以有文件字段的 form 必须写 enctype="multipart/form-data"。

  • 校验器会自动往 HTML 上加属性,实测:Length(6, 20) 加 minlength/maxlength、DataRequired 加 required、NumberRange(1, 120) 给 IntegerField 加 min/max 并把 type 变成 number。这些是给浏览器的即时提示,服务端仍然会再校验一遍。

验证器

  • 老笔记的验证器表里 DateRequired 是笔误,实测 hasattr(wtforms.validators, 'DateRequired') 为 False,正确名字是 DataRequired。

  • 本机 WTForms 3.3.2 的存活与签名实测:

    验证器 存在 签名(实测)
    DataRequired 是 (message=None)
    Optional 是 (message=None)
    Length 是 (min=-1, max=-1, message=None)
    Email 是 (message=None, check_deliverability=False, ...),需装 email_validator
    URL 是 (message=None, require_tld=True, ...)
    NumberRange 是 (min=None, max=None, message=None)
    IntegerRange 否 用 NumberRange 代替
    Regexp 是 (regex, flags=0, message=None, matcher=match)
    EqualTo 是 (fieldname, message=None)
    IPAddress 是 (ipv4=True, ipv6=False, message=None)
  • 用法就是把实例放进字段的 validators 列表:

    app.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    from wtforms.validators import DataRequired, Email, EqualTo, Length, NumberRange, Regexp

    class F(FlaskForm):
    name = StringField('用户名', validators=[Length(6, 20, '必须 6-20')])
    pw = PasswordField('密码', validators=[DataRequired('必填'), EqualTo('pw2', '两次不一致')])
    pw2 = PasswordField('确认密码')
    age = IntegerField('年龄', validators=[NumberRange(1, 120)])
    ip = StringField('IP', validators=[Regexp(r'^\d+\.\d+\.\d+\.\d+$', message='IP 格式不对')])
    agree = BooleanField('同意', validators=[DataRequired('请勾选')])
  • 实测各条失败时给出的消息,直接照抄就行:

    1
    2
    3
    4
    5
    两次密码不一致 -> pw: ['两次不一致']
    不勾同意 -> agree: ['请勾选']
    IP 写成 999 -> ip: ['IP 格式不对']
    年龄写成 x -> age: ['Number must be between 1 and 120.']
    邮箱写成 bad -> mail: ['Invalid email address.']
  • DataRequired 和 Optional 是一对:前者要求必须有值(复选框勾了才算有值),后者允许留空并跳过后续校验。

自定义验证

  • 方法名固定是 validate_字段名,参数是 self, field,失败时 raise ValidationError(...)。

  • 「验证通过要 return True」这句老笔记可以删掉——实测什么都不返回也算通过,只有抛 ValidationError 才算失败。

    app.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    from wtforms.validators import ValidationError

    class NameForm(FlaskForm):
    name = StringField('用户名:')
    submit = SubmitField('提交')

    def validate_name(self, field):
    if 'admin' in field.data:
    raise ValidationError('用户名不能含 admin')
  • 实测提交 admin1 得到:

    1
    {"ok": false, "errors": {"name": ["用户名不能含 admin"]}}
  • 内置校验器和自定义方法会一起跑,错误消息合并在同一个字段的列表里。

  • 需要查库的重名校验、以及「确认密码」这种跨字段比较,也写在同一个地方——self.password.data 就能取到别的字段:

    app.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class RegForm(FlaskForm):
    password = PasswordField('密码', validators=[DataRequired()])
    confirm = PasswordField('确认密码')
    nickname = StringField('昵称')

    def validate_confirm(self, field):
    if field.data != self.password.data:
    raise ValidationError('两次输入的密码不一致')

    def validate_nickname(self, field):
    if Nickname.query.filter_by(name=field.data).first():
    raise ValidationError('昵称已被占用')
    1
    2
    3
    confirm 与 password 不一致 -> 400 {'confirm': ['两次输入的密码不一致']}
    nickname 撞上库里已有的 -> 400 {'nickname': ['昵称已被占用']}
    都通过 -> 200 {'ok': True}

POST重定向GET

  • 浏览器会记住最后一次请求,POST 之后按刷新就会「重新提交表单」,多数时候没必要。解决办法是 POST 处理完立刻重定向回 GET 地址,这就是 PRG(Post/Redirect/Get)。

  • 实测:

    app.py
    1
    2
    3
    4
    5
    6
    7
    @app.route('/prg', methods=['GET', 'POST'])
    def prg():
    if request.method == 'POST':
    session['last'] = request.form.get('name', '?')
    flash('提交成功')
    return redirect(url_for('prg'))
    return 'GET:%s;flash=%s' % (session.get('last'), get_flashed_messages())
    1
    2
    3
    POST /prg        -> 302 Location='/prg'
    随后 GET /prg -> "GET:tom;flash=['提交成功']"
    再 GET 一次 -> "GET:tom;flash=[]"
  • flash 的消息只弹一次,取过就没了,这是 get_flashed_messages() 的行为。

  • 和 Flask-WTF 配合时,PRG 只能在校验通过后走;校验失败要留在原页面重渲染表单,否则用户填的东西全丢:

    app.py
    1
    2
    3
    4
    if form.validate_on_submit():
    ...保存...
    return redirect(url_for('index'))
    return render_template('form.html', form=form)

会话技术

  • 设置、获取、删除三个动作都在 response 对象上:

    app.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    import datetime
    from flask import make_response, request

    @app.route('/set_cookie/')
    def set_cookie():
    resp = make_response('cookie已设置')
    resp.set_cookie('by_max_age', 'v1', max_age=10)
    resp.set_cookie('by_expires', 'v2',
    expires=datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=100))
    resp.set_cookie('plain', 'v4')
    return resp

    @app.route('/get_cookie/')
    def get_cookie():
    return request.cookies.get('plain', '未设置')

    @app.route('/del_cookie/')
    def del_cookie():
    resp = make_response('cookie已删除')
    resp.delete_cookie('by_max_age')
    return resp
  • 实测下发的 Set-Cookie:

    1
    2
    3
    4
    by_max_age=v1; Expires=Fri, 06 Nov 2026 07:43:09 GMT; Max-Age=10; Path=/
    by_expires=v2; Expires=Fri, 06 Nov 2026 07:44:39 GMT; Path=/
    plain=v4; Path=/
    delete_cookie 之后 -> by_max_age=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/
  • 老笔记「expires 是给 IE8 用的、优先级比 max_age 低」这句已经没意义了:现在 expires 接受 datetime 或整数时间戳,两者都会翻成 Expires;max_age 额外带一个 Max-Age,浏览器以 Max-Age 为准。

  • 顺带一个 Python 侧的坑:老笔记用的 datetime.utcnow() 在本机 Python 3.14 上已经报 DeprecationWarning,改写 datetime.now(datetime.UTC)。

  • 删除 cookie 的本质就是下发一个过期时间为 1970 的同名空值。

session

  • 用法就是字典:

    app.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    from flask import session

    @app.route('/set_session/')
    def set_session():
    session['name'] = 'ergouzi'
    return 'session已设置'

    @app.route('/get_session/')
    def get_session():
    return session.get('name', '未设置')

    @app.route('/del_session/')
    def del_session():
    session.pop('name', None) # 删一个
    session.clear() # 清空
    return 'session已删除'
  • 实测读回与删除:

    1
    2
    设置后读回 -> session.get='ergouzi'
    删除后读回 -> session.get='未设置'
  • Flask 的 session 是签名 cookie,不存在服务端,实测 Set-Cookie 里就是 base64 的 JSON 加一段签名:

    1
    session=eyJfcGVybWFuZW50Ijp0cnVlLCJuYW1lIjoiZXJnb3V6aSJ9.au2FOw.pJLBicahrQIOsdg3J3TrUhKguAM

    所以它只能放小东西,而且任何人都能解码看内容(只是改不了,改了签名对不上)。

  • 没有 SECRET_KEY 时写入会 500、只读能拿到空 session,这条在 Flask-基本入门 里也提过。

  • 老笔记的 app.config['SESSION_PERMANENT'] 在 Flask 3.1 里已经彻底没人读了:全仓 grep 无此键,实测设成 True 之后 session.permanent 仍是 False。要长期有效只能在代码里写:

    app.py
    1
    2
    session.permanent = True
    session['name'] = 'ergouzi'

    实测这样才会带上过期时间:

    1
    Set-Cookie=session=...; Expires=Mon, 07 Dec 2026 ...(默认 31 天后)
  • PERMANENT_SESSION_LIFETIME 仍然有效,默认值是 datetime.timedelta(days=31),可以传整数秒或 timedelta。

  • 相关配置项的默认值,实测一台新 app 上是:

    配置 默认值 说明
    SESSION_COOKIE_NAME 'session' cookie 名
    SESSION_COOKIE_DOMAIN None 跨子域时才设
    SESSION_COOKIE_PATH None 默认全站
    SESSION_COOKIE_HTTPONLY True JS 读不到
    SESSION_COOKIE_SECURE False 上 HTTPS 后改 True
    SESSION_COOKIE_SAMESITE None 防 CSRF 建议 'Lax'
    SESSION_REFRESH_EACH_REQUEST True 每次请求都重发 cookie,等于滑动过期
    PERMANENT_SESSION_LIFETIME timedelta(days=31) 只对 session.permanent=True 生效
  • 不设 permanent 时 Set-Cookie 里没有 Expires/Max-Age,就是浏览器关闭即失效的会话 cookie。

参考

本文结束 感谢您的阅读
正在加载今日诗词....