一套 CRUD 就是三件套:表单类管校验、端点函数管流程、模板管长相。 同一个函数挂 GET 与 POST 两种方法,靠 validate_on_submit() 一次判完两件事。 POST 成功后必须重定向。直接返回页面的话,用户按刷新会再提交一次。
这一篇对应教材 2.5 到 2.5.3:CRUD 应用、表单扩充功能、新增、列表。 从这一篇起写页面,前面几篇建好的模型与查询开始派上用场。
一、表单类为什么值得单独写
class UserForm(FlaskForm):
name = StringField("姓名",
validators=[DataRequired(), Length(1, 20)])
email = StringField("邮箱",
validators=[Email(), Length(0, 60)])
submit = SubmitField("保存")
一个字段一行:类型、标签、校验器列表。 DataRequired 判「有没有值」,Length 判长短,Email 判格式。 好处是校验规则和页面分开了。改规则只改这个类,模板一个字不动。
教材 2.5.1 装的是 flask-wtf,它顺带带来了 CSRF 防护。 字段名要和模型属性对得上,下一篇的编辑页靠这个自动回填。
二、端点与模板的分工
| 地址 | 函数 | 模板 | 干什么 |
|---|---|---|---|
| /users | users() | list.html | 查全部、排序、渲染 |
| /users/new GET | user_new() | form.html | 给一张空表单 |
| /users/new POST | user_new() | form.html | 校验、写库、跳转 |
同一个函数挂两种方法是这里最常见的写法:GET 出表单,POST 收数据。
判断只用一行 if form.validate_on_submit()。它同时回答两个问题:是不是 POST,校验过没过。
两件事合成一个表达式,是因为处理方式完全一样:不是 POST 和校验没过,都返回同一张表单。
三、模板里必须有三样东西
<form method="post">
{{ form.hidden_tag() }}
<p>{{ form.name.label }} {{ form.name() }}
{% for e in form.name.errors %}<b>{{ e }}</b>{% endfor %}</p>
<p>{{ form.submit() }}</p>
</form>
form.hidden_tag() 一定要写,它负责把 CSRF 令牌埋进表单。不写的话 POST 报 400。
form.name() 一行就把完整的 input 标签渲染出来,比自己拼 HTML 省事。
那三行 for 循环是把校验失败的文字印在字段后面——没有它,用户只知道没提交成功,不知道哪里错了。
实测这一轮关掉了 CSRF,所以页面里搜不到 csrf_token。真项目不能关,关掉只是为了测试客户端少走两步。
四、空表单提交会发生什么
实测提交空表单,返回码还是 200,不是 302——说明没跳转,还在同一张页面。 页面上出现两条英文:This field is required. 与 Invalid email address.。 库里行数 0。校验没过就 return,一条也没写进去。
这两句英文是 WTForms 内置的提示。要中文得配 locale,或者自己写 messages。 教材里没提这一点,但实际项目里第一天就会碰到。
五、PRG:为什么必须重定向
实测正常提交返回 302,Location 是 /users。 直接 return 一张页面的话,用户按刷新会再发一次 POST,库里就多一条重复数据。 重定向之后刷新的是 GET /users,什么也不会重复。
这个模式叫 PRG,是 Web 表单的标准做法,不是 Flask 的规矩。换 Django、换 Rails、换 PHP 都一样。 302 之后浏览器自动跟过去,所以用户看到的是列表页,不是跳转过程。
六、表单管不了唯一性
实测同一个邮箱提交第二次,直接抛 IntegrityError。 表单校验只管格式,不管库里有没有。唯一性是数据库的约束,不是校验器能替代的。 要给友好提示,两条路:提交前先查一次,或者捕获 IntegrityError 再 flash。
前者多一次查询但提示好写,后者省查询但要记得 rollback。本篇的项目小,选前者更省事。 实测这一轮异常直接冒出来了,因为测试配置里开了 TESTING=True。 关掉它,同样的错误会变成一个 500 页面。
七、动手 Demo:CRUD 之一:新增与列表
这个 Demo 在上一篇的项目上加一套表单:/users 看列表,/users/new 收数据。 第二节先交一张空表单,看校验拦下来什么样;第三节交正常数据,看跳转与落库。
@bp.route("/users/new", methods=["GET", "POST"])
def user_new():
form = UserForm()
if form.validate_on_submit():
db.session.add(User(name=form.name.data,
email=form.email.data or None))
db.session.commit()
flash("新增成功", "success")
return redirect(url_for("main.users"))
return render_template("form.html", form=form)
实跑输出:
--------------------------------------------------------------------
CRUD 之一:新增与列表
--------------------------------------------------------------------
① 端点与模板的分工
--------------------------------------------------------------------
| 地址 | 函数 | 模板 | 干什么 |
|---|---|---|---|
| /users | users() | list.html | 查全部、排序、渲染 |
| /users/new GET | user_new() | form.html | 给一张空表单 |
| /users/new POST | user_new() | — | 校验、写库、跳转 |
! 同一个函数挂两个方法是最常见的写法:GET 出表单,POST 收数据。
靠 validate_on_submit() 一次判完「是不是 POST」和「校验过没过」。
② 空表单提交,看错误信息
--------------------------------------------------------------------
GET /users/new -> 200
页面里有 csrf 隐藏域吗 -> False
POST 空表单 -> 200
页面里显示的错误 -> ['This field is required.', 'Invalid email address.']
库里行数 -> 0
! 校验没过就直接 return 回同一张模板,一条也没写进库。
模板里那三个 for 循环负责把错误文字印在字段后面。
! 这一轮关掉了 CSRF(WTF_CSRF_ENABLED=False),否则测试客户端得先 GET 页面、
把隐藏域里的令牌抠出来再带上,多两步。
③ 正常提交,看跳转和落库
--------------------------------------------------------------------
POST 正常数据 -> 302,Location=/users
库里的行数 -> 1
<h2>用户列表(共 1 人)</h2>
<tr><th>ID</th><th>姓名</th><th>邮箱</th></tr>
<tr><td>1</td><td>张三</td><td>[email protected]</td></tr>
! POST 之后必须 redirect。
直接 return 页面的话,用户按刷新会再提交一次,库里就多一条重复数据——这叫 PRG 模式,
是 Web 表单的标准做法。
④ 邮箱重复会怎样
--------------------------------------------------------------------
再传一个相同邮箱 -> 直接抛出 IntegrityError
(sqlite3.IntegrityError) UNIQUE constraint failed: user.email
会话已回滚,后面的请求不受影响
! 表单校验管的是格式,管不了唯一性——唯一性是数据库的约束。
要给它一个友好提示,得在提交前先查一次,或者捕获 IntegrityError。
! 这里异常直接冒出来,是因为测试配置里开了 TESTING=True。
关掉它,同样的错误会变成一个 500 页面——线上看不到那么详细的报错。
第二节那两条英文提示不是我们写的,是 WTForms 自带的。 第三节的 302 与 Location 是 PRG 的证据:不是 200,说明真的跳走了。 第四节抛出来的是数据库的 IntegrityError,不是表单校验——这两件事的分界线要分清。
30 秒实验
- 把 Length(1, 20) 改成 Length(1, 2),提交三个字的名字,看提示文字。
- 在模板里加一句
{{ form.errors }},把原始错误字典打出来看一眼。 - 想一想:如果不做重定向,用户连按两次刷新,库里会有几条数据?
常见误区
- 模板里忘了 form.hidden_tag():POST 报 400,页面上一句解释都没有。
- POST 成功后直接 return 页面:刷新重复提交,列表里出现两条一样的。
- 只靠表单校验保证唯一:数据库一撞就 500,而且是抽查才发现。
- 手写 if request.method == "POST" 再一条条校验:等于把 WTForms 白装了。
自测三问(点开对答案)
1. validate_on_submit() 一次判了哪两件事? —— 是不是 POST 请求,以及校验过没过。 2. POST 成功后为什么要重定向? —— 避免用户刷新时重复提交,这叫 PRG 模式。 3. 表单校验和数据库约束的界线在哪? —— 表单管格式,唯一性、外键这类只能由数据库保证。新增和列表有了,下一篇补上编辑与删除,还要解决「关联数据怎么办」。
- ☐ (本篇完,下一篇 2.7 CRUD 之二:编辑与删除)