🧪 第1章 建立最基础的应用程序
📄 一句话概括

请求里能取的东西有四种:查询串、表单、JSON、文件,各有各的取法。 同名参数出现两次,dict() 只留第一个,要全的得用 getlist。 WTForms 把校验规则写成清单;注意 DataRequired 会把 0 判成「没填」。

📝 本章和前面几章的关系

这一篇对应教材 1.2.7 与 1.3 节前三小节:请求内容、表单规格、Python 套件规格、 程序与模板间的数据传递。 这是全书第一次处理「用户输入」,也是第一次遇到「输入不可信」这件事。

一、四种取法的分工

来源 取法 典型场景
查询串 request.args 搜索、分页、筛选
表单 request.form 登录、新增、编辑
JSON request.get_json() 前端异步提交、API
文件 request.files 头像、图片上传

取法不能混用。表单用 args 取永远是空的,JSON 用 form 取也是空的,而且都不会报错, 只是返回空——这类 bug 最难查。 判断依据看请求头: Content-Type 是 application/x-www-form-urlencoded 或 multipart/form-data 才是表单, application/json 才是 JSON。

二、同名参数那个坑

?tag=a&tag=b 这种地址很常见——勾选框多选传上来就是这样。 用 dict(request.args) 拿到的只有 {'tag': 'a'},第二个丢了,而且不报错。 要拿全必须用 request.args.getlist('tag')。表单那边对应的是 request.form.getlist(...)。

这条对分页、筛选这类功能是致命的:用户选了五个标签,系统只认第一个,结果看起来「部分生效」, 最难报 bug。

三、JSON 与 silent

request.get_json() 在 Content-Type 不是 JSON 时会抛 415,内容不是合法 JSON 时会抛 400。 加 silent=True 之后,两种情况都返回 None,不抛异常。 要不要加 silent,取决于你想不想自己兜底。做 API 时,自己判空并返回一个带错误码的 JSON, 比让框架吐一个 HTML 错误页好。

注意它还有一种情况:force=True 会忽略 Content-Type 直接解析。 只有当调用方不受你控制、又不肯设对头时才用。

四、文件上传只做了一半

request.files 里拿到的是 FileStorage 对象,有 .filename 和 .read()。 只有 multipart/form-data 编码时它才有内容。这次实测传了 2048 字节,读出来正好 2048。 但「收到」不等于「存好」。这一节只到收到为止,存盘、改名、限制类型、防重名,都在第 7 章。

顺带一句:不校验文件类型就存盘,等于允许别人往你服务器上写任意文件。

五、WTForms:把校验写成清单

手写 if not title: 报错 这种方式,字段一多就散了。WTForms 把规则声明成列表:

class BookForm(FlaskForm):
    title = StringField(u"书名", validators=[DataRequired(),
                                          Length(1, 10)])
    price = IntegerField(u"价格", validators=[DataRequired(),
                                            NumberRange(1, 9999)])

validate() 会跑完所有规则,把错误收进 form.errors:键是字段名,值是错误信息的列表。 实测一个陷阱:价格填 0,报的是「必填」。因为 DataRequired 判真假值,0 在 Python 里等于假。 要允许 0,得把校验器换成 InputRequired——它只判「有没有提交」,不管值是不是假。 这个区别在价格、数量、折扣这类字段上经常踩。

六、动手 Demo:表单与请求内容:四种取数姿势

这一节把四种取法各跑一遍,再让 WTForms 校验三种输入:空的、超范围的、填 0 的。

@app.route("/form", methods=["GET", "POST"])
def form_():
    if request.method == "POST":
        return jsonify(dict(request.form))
    return "用 POST 提交"

# 校验
with app.test_request_context(method="POST", data={}):
    f = BookForm()
    print(f.validate(), f.errors)

实跑输出:

--------------------------------------------------------------------
表单与请求内容:四种取数姿势
--------------------------------------------------------------------

  ① 查询串:request.args
  --------------------------------------------------------------------
  GET /args?name=张三&tag=a&tag=b
  dict(request.args) -> {"name": "张三", "tag": "a"}
  request.args.getlist('tag') -> ['a', 'b']
  ! 同名参数出现两次,dict() 只留下第一个。
  要用 getlist 才拿得到全部——这就是「勾选框多选」传上来的样子。

  ② 表单:request.form
  --------------------------------------------------------------------
  POST 表单 -> {"ct": "application/x-www-form-urlencoded", "got": {"pwd": "1234", "user": "李四"}}
  GET /form -> 200 用 POST 提交
  ! GET 不带正文。表单必须用 POST 提交,用 GET 取 form 永远是空的——这是新手第一个坑。

  ③ JSON:request.get_json
  --------------------------------------------------------------------
  POST JSON -> {"parsed": {"a": 1, "b": [2, 3]}, "raw_len": 21}
  POST 一个坏 JSON,silent=True 时 -> {"parsed": null, "raw_len": 14}
  ! 不加 silent,坏 JSON 会直接抛 400。
  加了就变成 None,要不要兜底由你决定——接口里通常自己兜底并返回错误码。

  ④ 文件:request.files
  --------------------------------------------------------------------
  POST 一个 2048 字节的文件 -> {"bytes": 2048, "filename": "a.png"}
  不传文件 -> 400 {"err":"没收到文件"}

  ! request.files 只有在 multipart/form-data 时才有内容。第 7 章的上传功能就是这一节加一个存盘。

  ⑤ 校验:WTForms 接手
  --------------------------------------------------------------------
  空表单 -> validate()=False,
  错误 {'title': ['This field is required.'], 'price': ['This field is required.']}
  书名太长且价格 10000 -> validate()=False,
  错误 {'title': ['Field must be between 1 and 10 characters long.'], 'price': ['Number must be between 1 and 9999.']}
  价格填 0 -> validate()=False,错误 {'price': ['This field is required.']}
  正常输入 -> validate()=True,title=Flask,price=89
  ! 注意价格填 0 那行:它明明有值,报的却是「必填」。
  因为 DataRequired 判的是真假值,0 在 Python 里等于假。要允许 0,得把校验器换成 InputRequired。
  ! validators 不是装饰,是校验规则的清单。每个字段可以挂一串,全部通过 validate() 才返回 True。

  ⑥ 校验错了怎么回到页面
  --------------------------------------------------------------------

| 字段 | 错误信息 |
|---|---|
| title | 必填 |
| price | 要在 1 到 9999 之间 |

  f.errors 是一个字典:键是字段名,值是错误信息的列表。
  模板里把它渲染在字段下方,就是教材 1.3 节那张表单的样子。

dict(request.args) 只留第一个 tag,getlist 才拿到两个——这一条对多选筛选是致命的。 表单那一段特意同时打印 Content-Type,就是为了说明「取法要看编码」。 价格填 10000 报的是「要在 1 到 9999 之间」,填 0 报的却是「必填」,两个错误信息不一样, 原因也不一样。 文件那一段传了 2048 字节,读出来也是 2048——但存盘还没做。

30 秒实验

  1. 把 NumberRange(1, 9999) 换成 NumberRange(0, 9999),价格填 0 还会报「必填」吗?
  2. 给 title 再加一个 Regexp(r'^[^<>]+$'),试试输入 <b>x</b> 会报什么。
  3. 用 request.values 取一次,看它能不能同时兼容 args 和 form——想一想为什么正式代码里很少用它。

常见误区

  1. 用 args 取表单数据:永远是空字典,而且不报错。
  2. dict(request.args) 处理多选:只留第一个值,静默丢数据。
  3. 信任用户输入:长度、类型、范围、格式,一样都不能省。
  4. DataRequired 用在数字字段上:0 会被判成没填,价格、数量都会中招。
自测三问(点开对答案) 1. 表单数据该用哪个对象取? —— `request.form`。`request.args` 只读查询串,取表单永远是空的。 2. 多选参数怎么拿全? —— 用 `getlist`——args 和 form 都有这个方法,`dict()` 只留第一个。 3. 为什么价格填 0 会报「必填」? —— `DataRequired` 判的是真假值,0 等于假。要允许 0 就用 `InputRequired`。

数据收进来了,下一步是告诉用户「成没成」。下一篇讲 flash 消息与日志,一个给人看,一个给排查用。

相关笔记