用 WTForms 和装饰器做表单校验
在一个 Web 应用里,不管是为了业务逻辑的正确性,还是系统安全性,做好参数(querystring, form, json)验证都是非常必要的。
WTForms 是一个非常好用而且强大的表单校验和渲染的库,提供 Form 基类用于定义表单结构(类似 ORM),内置了丰富的字段类型和校验方法,可以很方便的用来做校验。如果应用需要输出 HTML,集成到模板里也很容易。对于 JSON API 应用,用不到渲染的功能,但是结构化的表单和校验功能依然非常有用。
以一个注册的应用场景为例,用户输入用户名、邮箱、密码、确认密码,服务程序先检查参数然后处理登录逻辑。这几个字段都是必填的,此外还有一些额外的限制:
- 用户名:长度在 3-20 之间
- 邮箱:合法的邮箱格式,比如 “abc” 就不合法
- 密码:长度在 8-20 之间,必须同时包含大小写字母
- 确认密码:必须与密码一致
如果参数不合法,返回 400;登录逻辑略去不表。
最原始的做法,就是直接在注册的接口里取出每个参数,逐个手动校验。这种做法可能的代码是:
@app.route('/user/signup/', methods=['POST'])
def register():
username = request.form.get('username')
if not username or not (3 <= len(username) <= 20):
abort(400)
email = request.form.get('email')
if not email or not re.match(EMAIL_REGEX, email):
abort(400)
password = request.form.get('password')
if not password:
abort(400)
if password == password.lower() or password == password.upper():
abort(400)
confirm_password = request.form.get('confirm_password')
if not confirm_password or confirm_password != password:
abort(400)
# 处理注册的逻辑
有可能是我的写法不太对,但是这样检查参数的合法性,实在不够优雅。检查参数的代码行数甚至超出了注册的逻辑,也有些喧宾夺主的感觉。可以把这些代码移出来,使得业务逻辑代码更加清晰一点。下面先用 WTForms 来改造一下。
from wtforms import Form
from wtforms.fields import StringField, PasswordField
from wtforms.validators import DataRequired, Email, Length, EqualTo, ValidationError
class SignupForm(Form):
username = StringField(validators=[DataRequired(), Length(3, 20)])
email = StringField(validators=[DataRequired(), Email()])
password = PasswordField(validators=[DataRequired()])
confirm_password = PasswordField(validators=[DataRequired(), EqualTo('password')])
def validate_password(self, field):
password = field.data
if password == password.lower() or password == passowrd.upper():
raise ValidationError(u'必须同时包含大小写字母')
@app.route('/user/signup/', methods=['POST'])
def register():
form = SignupForm(formdata=request.form)
if not form.validate():
abort(400)
# 处理注册逻辑,参数从 form 对象获取,比如
username = form.username.data
这个版本带来的好处很明显:
- 参数更加结构化了,所有字段名和类型一目了然
- 有内置的,语义清晰的校验方法,可以组合使用
- 还能自定义额外的校验方法,方法签名是
def validate_xx(self, field)
,其中xx
是字段名,通过field.data
来获取输入的值 - 还有没体现出来的,就是丰富的错误提示信息,既有内置的,也可以自定义
再看原来的 register
方法,代码变得更加简洁和清晰,整体的编码质量也得到了提升。
那么再考虑一下更复杂的场景,在一个返回 JSON 的 API 应用里,有很多 API,有不同的参数提交方式(GET 方法通过 query string,POST 方法可能有 form 和 JSON),一样的校验错误处理方式(abort(400) 或其他)。我们依然可以像上面那样处理,但如果再借助装饰器改进一下,又能少写几行“重复”的代码。
需要注意的是,WTForms 的 formdata 支持的是类似 Werkzeug/Django/WebOb 中的 MultiDict
的数据结构。Flask 中的 request.json
是一个 dict
类型,所以需要先包装一下。
继续改造注册的例子:
import functools
from werkzeug.datastructures import MultiDict
def validate_form(form_class):
def decorator(view_func):
@functools.wraps(view_func)
def inner(*args, **kwargs):
if request.method == 'GET':
formdata = request.args
else:
if request.json:
formdata = MultiDict(request.json)
else:
formdata = request.form
form = form_class(formdata=formdata)
if not form.validate():
return jsonify(code=400, message=form.errors), 400
g.form = form
return view_func(*args, **kwargs)
return inner
return decorator
@app.route('/user/signup/', methods=['POST'])
@validate_form(form_class=SignupForm)
def register():
form = g.form # 运行到这里,说明表单是合法的
# 处理注册逻辑,参数从 form 对象获取,比如
username = form.username.data
实现了一个叫 validate_form
的装饰器,指定一个 Form 类,处理统一的参数获取、校验和错误处理,如果一切正确,再把 Form 对象保存到全局变量 g
里面,这样就可以在 view 函数里取出来用了。现在的 register
方法变得更加简洁,甚至都看不到检查参数的那些代码,只需要关心具体的和注册相关的逻辑本身就好。
这个装饰器的可重用性非常好,其他的接口只要定义一个 Form 类,然后调用一下装饰器,再从 g
获取 Form 对象。不仅省了很多心思和体力劳动,代码也变得更加清晰优雅和 Pythonic.