Flask 的 request 对象怎么取参数?args、form、json、files 有什么区别?
简化版
Flask 的 request 是 Werkzeug 的 Request 对象,它按「参数来自哪里」分成几个互不重叠的属性:request.args 是 URL 查询字符串(?page=2)、request.form 是表单 body(application/x-www-form-urlencoded 或 multipart/form-data)、request.files 是上传的文件、request.json(等价 get_json())是 JSON body、request.data 是原始字节、request.values 是 args 和 form 的合并视图(优先 args)。关键点一:args 和 form 都是 MultiDict——同一个 key 可以有多个值,d["k"] 只返回第一个,要拿全部得用 getlist("k")(多选框、?tag=a&tag=b 这类场景必须用它)。关键点二:request.json 有两个坑——① 请求的 Content-Type 不是 application/json 时它会抛 415(旧版本返回 None),要宽容处理得用 get_json(silent=True) 或 force=True;② JSON 请求的数据不在 form 里,很多人用 request.form.get("x") 取 JSON 字段拿到 None 就是这个原因。关键点三:取值一律用 .get(key, default, type=int) 而不是 d["key"]——后者 key 不存在时抛的是 BadRequestKeyError(Flask 会把它转成 400 而不是 500,这点比原生 KeyError 友好,但仍然不如显式给默认值),而 type=int 能顺带完成转换、转换失败时返回默认值。关键点四:request.form 和 request.files 是惰性解析的,读取时才会消费请求流——先读了 request.data 再读 form 会拿到空。核心记忆:args=查询串、form=表单、json=JSON body、files=文件,互不重叠;MultiDict 多值用 getlist;取值用 .get(..., type=int)。
详细版
request 的参数来源对照:
| 属性 | 来源 | 类型 | 说明 |
|---|---|---|---|
args | URL 查询串 ?a=1 | MultiDict | 任何方法都有 |
form | 表单 body | MultiDict | urlencoded / multipart |
files | 上传文件 | MultiDict | 值是 FileStorage |
json | JSON body | dict/list | 需正确 Content-Type |
values | args + form | CombinedMultiDict | args 优先 |
data | 原始字节 | bytes | 读了会影响 form 解析 |
headers | 请求头 | EnvironHeaders | 大小写不敏感 |
cookies | Cookie | dict | —— |
from flask import request, abort
# ① ★查询字符串:GET /search?q=flask&page=2&tag=a&tag=b★
request.args.get("q") # 'flask'
request.args.get("page", 1, type=int) # ★2(int),缺失或非法→1★
request.args.getlist("tag") # ★['a', 'b'] ← 多值必须用 getlist★
request.args["missing"] # ★BadRequestKeyError → 400(不是 500)★
request.args.to_dict() # ★{'q':..., 'page':..., 'tag':'a'} 只取第一个★
request.args.to_dict(flat=False) # ★{'tag': ['a','b']} 保留全部★
request.query_string # b'q=flask&page=2' ★原始字节★
# ② ★表单★
request.form.get("username")
request.form.getlist("hobbies") # ★多选框★
request.form.to_dict()
# ③ ★JSON★
data = request.get_json() # ★Content-Type 不对 → 抛 415★
data = request.get_json(silent=True) # ★出错返回 None(不抛)★
data = request.get_json(force=True) # ★★忽略 Content-Type 强行解析★★
data = request.get_json(cache=False) # 不缓存(默认缓存,可重复调用)
request.json # ★等价 get_json()★
request.is_json # ★先判断更稳妥★
# ★健壮的取法★
data = request.get_json(silent=True) or {}
name = data.get("name")
# ④ ★文件上传★
f = request.files.get("avatar") # ★FileStorage 对象★
if f and f.filename: # ★★空表单项 filename 是 ''★★
from werkzeug.utils import secure_filename
f.save(f"/uploads/{secure_filename(f.filename)}")
files = request.files.getlist("photos") # ★多文件★
f.filename, f.content_type, f.mimetype, f.stream
# ⑤ ★合并视图(谨慎使用)★
request.values.get("x") # ★args + form,args 优先★
# ✗ ★不推荐★:来源不明确,容易被"查询串覆盖表单"攻击
# ⑥ ★原始数据★
request.data # ★bytes(★读了会影响 form 解析★)★
request.get_data() # 同上
request.get_data(as_text=True) # str
request.get_data(cache=True) # ★缓存后 form 仍可用★
# ⑦ ★请求元信息★
request.method, request.path, request.full_path
request.url, request.base_url, request.url_root
request.headers.get("User-Agent") # ★大小写不敏感★
request.headers.get("X-Request-Id")
request.remote_addr # ★★反代后是代理 IP,要配 ProxyFix★★
request.content_type, request.content_length
request.endpoint, request.view_args, request.blueprint
# ⑧ ★参数校验的实用封装★
def get_int(name, default=None, min_=None, max_=None):
v = request.args.get(name, default, type=int)
if v is None:
abort(400, f"缺少参数 {name}")
if min_ is not None and v < min_: abort(400, f"{name} 太小")
if max_ is not None and v > max_: abort(400, f"{name} 太大")
return v
page = get_int("page", 1, min_=1, max_=10000)
⚠️ 三个必须记住的点:①
args/form/json/files是四个互不重叠的来源,用错属性拿到的就是None。最常见的错误是前端发了 JSON、后端用request.form.get("x")去取——form只解析application/x-www-form-urlencoded和multipart/form-data两种 body,JSON body 完全不进form。排查时先打印request.content_type和request.get_data()就能立刻定位。②args和form是MultiDict,一个 key 可以有多个值:?tag=a&tag=b时request.args["tag"]只返回第一个'a',必须用getlist("tag")才能拿到['a', 'b']。同理to_dict()默认丢弃重复值,要保留得用to_dict(flat=False)。多选框、多选筛选、批量删除的 id 列表都会踩这个坑。③get_json()在Content-Type不是application/json时会抛 415(Flask 2.1 之前是返回None,2.1+ 改成抛错,这个行为变更让不少老代码出问题)。三种处理方式:get_json(silent=True)出错返回None(推荐,配合or {})、get_json(force=True)忽略 Content-Type 强行按 JSON 解析(客户端不规范时用)、或者先判断request.is_json。
完整版教学
一、四个来源的边界
★ 一个 HTTP 请求里参数能藏在哪:
┌──────────────────────────────────────────────────────┐
│ POST /search?page=2 HTTP/1.1 ← ★args(查询串)★ │
│ Host: example.com │
│ Content-Type: application/json ← ★决定 body 怎么解析★│
│ Cookie: session=abc ← ★cookies★ │
│ X-Request-Id: xyz ← ★headers★ │
│ │
│ {"keyword": "flask"} ← ★json / data★ │
└──────────────────────────────────────────────────────┘
★ ★Content-Type 决定 body 进哪个属性(★核心规则★)★:
┌────────────────────────────────────┬──────────────────┐
│ application/x-www-form-urlencoded │ ★→ request.form★ │
│ multipart/form-data │ ★→ form + files★ │
│ application/json │ ★→ request.json★ │
│ 其他(text/plain、二进制…) │ ★→ request.data★ │
└────────────────────────────────────┴──────────────────┘
★ ★没有正确的 Content-Type,body 就进不了对应的属性★
★ ★最常见的排查场景★:
前端:fetch(url, {method:"POST", body: JSON.stringify(d)})
★← 忘了设 Content-Type!默认是 text/plain★
后端:request.get_json() → ★415 Unsupported Media Type★
request.form.get("x") → ★None★
✓ 前端补上:headers: {"Content-Type": "application/json"}
✓ 后端兜底:request.get_json(force=True)
★ ★排查三件套★:
print(request.content_type)
print(request.get_data()) # ★原始 body★
print(request.args, request.form)
★ ★GET 请求能不能带 body★:
HTTP 规范★不禁止★,但很多中间件/代理会丢弃
→ ★实践上:GET 只用查询串★
→ 需要复杂查询条件时用 POST(或把条件编码进查询串)
★ ★request.values 为什么不推荐★:
它是 args 和 form 的 ★CombinedMultiDict★,★args 优先★
✗ 安全隐患:
表单里 POST 了 amount=100
攻击者在 URL 加 ?amount=1
→ ★request.values["amount"] 拿到 1★(查询串覆盖了表单)
✓ ★明确写 request.args 或 request.form★,让来源可审计
★ ★body 只能读一次(★重要机制★)★:
WSGI 的 environ["wsgi.input"] 是一个★流★
→ 读完就没了
✗ data = request.get_data(cache=False)
form = request.form # ★空的!流已被消费★
✓ Werkzeug 默认 ★cache=True★,会把 body 缓存起来
→ request.data 和 request.form 可以都访问
★ 但 ★大文件上传时缓存会占内存★ → 流式处理见后文
理解参数解析的核心规则只有一句:Content-Type 决定 body 进哪个属性。application/x-www-form-urlencoded 和 multipart/form-data 进 form(后者同时进 files)、application/json 进 json、其他一律只能从 data 拿原始字节。最常见的排查场景是前端 fetch 忘了设 Content-Type——默认发成 text/plain,于是后端 get_json() 抛 415、form.get() 拿到 None;排查三件套是打印 request.content_type、request.get_data()、request.args/request.form。request.values 不推荐使用——它是 args 和 form 的合并且 args 优先,意味着攻击者能用查询串覆盖表单里的值(表单 POST amount=100,URL 加 ?amount=1 就变成 1),明确写 args 或 form 才能让来源可审计。另外要知道 WSGI 的 body 是一个流、只能读一次,Werkzeug 默认会缓存所以 data 和 form 能都访问,但大文件上传时缓存会占内存。
二、MultiDict:一个 key 多个值
★ 为什么需要 MultiDict:
HTML 表单天然支持重名字段:
<input name="tag" value="a">
<input name="tag" value="b">
<select name="hobbies" multiple>...</select>
URL 也一样:?tag=a&tag=b&tag=c
→ ★普通 dict 存不下★
★ ★核心 API★:
d = request.args # MultiDict
d["tag"] → ★'a'(只有第一个!)★
d.get("tag") → 'a'
★d.getlist("tag")★ → ★['a', 'b', 'c'] ← 要全部必须用它★
d.to_dict() → ★{'tag': 'a'} 丢弃了 b、c★
★d.to_dict(flat=False)★ → ★{'tag': ['a','b','c']}★
list(d.items()) → [('tag','a')] ★只有第一个★
list(d.items(multi=True)) → [('tag','a'),('tag','b'),('tag','c')]
★ ★踩坑算例★:
URL: /filter?status=draft&status=published&page=2
✗ params = request.args.to_dict()
→ ★{'status': 'draft', 'page': '2'} ← published 丢了!★
✓ statuses = request.args.getlist("status")
→ ★['draft', 'published']★
★ ★type 参数:转换 + 兜底(★很实用★)★:
request.args.get("page", 1, type=int)
┌──────────────────┬──────────────────────────────┐
│ ?page=2 │ ★→ 2(int)★ │
│ ?page=abc │ ★→ 1(转换失败用默认值)★ │
│ (没有 page) │ ★→ 1★ │
└──────────────────┴──────────────────────────────┘
★ ★注意:转换失败不会报错,静默用默认值★
→ 需要"非法参数报 400"时得自己判断:
raw = request.args.get("page")
if raw is not None and not raw.isdigit():
abort(400, "page 必须是整数")
★ type 可以是任意可调用对象:
request.args.get("ids", type=lambda s: [int(x) for x in s.split(",")])
request.args.get("flag", type=lambda s: s.lower() in ("1","true","yes"))
★ ★布尔参数的坑(★极常见★)★:
?debug=false
✗ bool(request.args.get("debug")) # ★→ True!(非空字符串)★
✗ request.args.get("debug", type=bool) # ★→ True(bool("false") 是 True)★
✓ request.args.get("debug", "").lower() in ("1", "true", "yes", "on")
★ 这是"看起来对但一直是 True"的经典 bug
★ ★d[key] 抛的是什么★:
request.args["missing"]
→ ★werkzeug.exceptions.BadRequestKeyError★(继承自 KeyError 和 BadRequest)
→ ★Flask 会自动转成 400 响应★(★不是 500★)
★ 这比原生 KeyError 友好,但:
- ★错误信息对用户不友好★(debug 模式才显示缺哪个 key)
- ★不如显式 abort(400, "缺少参数 x") 清晰★
✓ 建议:★统一用 .get() + 显式校验★
★ ★MultiDict 是不可变的★:
request.args["x"] = 1 # ★TypeError: ImmutableMultiDict★
✓ 要改先转:dict(request.args) 或 request.args.copy()
★ 设计意图:★请求数据不该被修改★(避免中间件互相干扰)
MultiDict 的存在是因为 HTML 表单和 URL 天然支持重名字段。核心是三个 API 的区别:d["tag"] 和 d.get("tag") 只返回第一个值、d.getlist("tag") 返回全部、to_dict() 会丢弃重复值而 to_dict(flat=False) 保留。一个具体算例:?status=draft&status=published 用 to_dict() 会静默丢掉 published。type= 参数很实用(转换 + 转换失败时用默认值),但要注意转换失败是静默的——需要「非法参数报 400」得自己判断。布尔参数是最经典的 bug:?debug=false 用 bool() 或 type=bool 都会得到 True(因为 bool("false") 是 True),必须显式比较字符串。还有个细节:request.args["missing"] 抛的是 BadRequestKeyError,Flask 会自动转成 400 而不是 500——比原生 KeyError 友好,但仍不如显式 .get() + abort(400, ...) 清晰。最后,request.args 是不可变的(ImmutableMultiDict),设计意图是防止中间件互相干扰。
三、JSON 解析的行为与坑
★ get_json 的四个参数:
request.get_json(force=False, silent=False, cache=True)
┌────────────┬────────────────────────────────────────────┐
│ ★force★ │ ★True = 忽略 Content-Type 强行解析★ │
│ ★silent★ │ ★True = 解析失败返回 None 而不抛异常★ │
│ cache │ True(默认) = 缓存结果,可重复调用 │
└────────────┴────────────────────────────────────────────┘
★ ★Flask 2.1 的行为变更(★老项目升级会踩★)★:
┌──────────────────┬─────────────────┬─────────────────┐
│ 场景 │ ★2.1 之前★ │ ★2.1 及以后★ │
├──────────────────┼─────────────────┼─────────────────┤
│ Content-Type 不对 │ 返回 None │ ★抛 415★ │
│ body 不是合法JSON │ 抛 400 │ 抛 400 │
│ body 为空 │ 抛 400 │ 抛 400 │
└──────────────────┴─────────────────┴─────────────────┘
★ 升级后症状:★原本静默返回 None 的地方开始返回 415★
★ ★四种健壮写法★:
# ① ★最常用:宽容 + 兜底★
data = request.get_json(silent=True) or {}
name = data.get("name")
# ② ★严格:明确报错★
if not request.is_json:
abort(400, "请使用 application/json")
data = request.get_json()
# ③ ★兼容不规范客户端★
data = request.get_json(force=True, silent=True) or {}
# ④ ★同时支持表单和 JSON★
data = request.get_json(silent=True) or request.form.to_dict()
★ ★JSON 的类型陷阱★:
{"count": "5"} → ★data["count"] 是 str 不是 int★
{"amount": 0.1} → ★float 精度问题(金额要用字符串传 Decimal)★
{"id": 12345678901234567890} → Python int 无限精度,★但 JS 会丢精度★
★ → 大整数 ID 建议★用字符串传输★
★ ★空值的三种情况要分清★:
{"name": null} → data["name"] 是 ★None★(★字段存在但为空★)
{} → data.get("name") 是 ★None★(★字段不存在★)
{"name": ""} → ★空字符串★
★ 用 "name" in data 区分"没传"和"传了 null"
★ PATCH 语义下这个区别很重要:
没传 = 不修改;传了 null = 清空
★ ★请求体大小限制★:
app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024 # 16MB
→ 超过时 ★413 Request Entity Too Large★
★ ★必须设★:否则恶意的超大 body 会耗尽内存
★ 注意:反代(Nginx client_max_body_size)也有限制,★两边都要配★
★ ★手动解析(少见但有用)★:
import json
raw = request.get_data(as_text=True)
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
abort(400, f"JSON 格式错误:{e}")
★ 好处:★错误信息可控★,能记录原始 body 便于排查
get_json() 有三个参数要记住:force(忽略 Content-Type)、silent(失败返回 None)、cache(默认缓存所以可重复调用)。Flask 2.1 的行为变更是老项目升级的坑——Content-Type 不对时从「返回 None」改成了「抛 415」,症状是原本静默处理的地方开始报 415。四种健壮写法里最常用的是 request.get_json(silent=True) or {}。JSON 本身也有类型陷阱:{"count": "5"} 里是字符串不是 int、浮点金额有精度问题(应该用字符串传 Decimal)、大整数 ID 在 JS 端会丢精度所以建议用字符串传输。还要分清空值的三种情况:{"name": null} 是「字段存在但为空」、{} 是「字段不存在」、{"name": ""} 是空字符串——PATCH 语义下这个区别很重要(没传 = 不修改,传了 null = 清空),用 "name" in data 来区分。最后 MAX_CONTENT_LENGTH 必须设(否则超大 body 会耗尽内存),且反代那边的限制也要配。
四、文件上传与流式处理
★ FileStorage 对象:
f = request.files["avatar"]
f.filename # ★用户提供的文件名(★完全不可信★)★
f.content_type # 'image/png'(★浏览器声明的,也不可信★)
f.mimetype
f.stream # ★底层文件对象(可以流式读)★
f.save(path) # 保存
f.read() # 读全部(★大文件会占内存★)
f.content_length # ★经常是 0(multipart 不一定给)★
★ ★空文件项的坑★:
<input type="file" name="avatar"> ← ★用户没选文件也会提交★
f = request.files.get("avatar")
✗ if f: # ★True!FileStorage 对象总是真值★
✓ ★if f and f.filename:★ # ★没选文件时 filename 是 ''★
★ ★安全四件套(★必考★)★:
① ★文件名:secure_filename★
from werkzeug.utils import secure_filename
name = secure_filename(f.filename)
# "../../etc/passwd" → "etc_passwd"
# "中文.png" → ★""(会被清空!)★ ← ★中文文件名要自己处理★
✓ 更稳:★自己生成 uuid 文件名★,原名存数据库
ext = os.path.splitext(f.filename)[1].lower()
name = f"{uuid4().hex}{ext}"
② ★大小限制★
app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024
→ 超过抛 ★RequestEntityTooLarge (413)★
★ 注意:Flask 是在★读取时★检查,不是提前拒绝
③ ★类型校验:查魔数而不是扩展名/Content-Type★
head = f.stream.read(512); f.stream.seek(0) # ★记得 seek 回去★
if not head.startswith(b"\x89PNG"): abort(400)
★ 扩展名和 Content-Type 都是★客户端提供的★
④ ★存储位置:不要存在能被直接执行的目录★
✗ 存到 static/ 下且允许任意扩展名 → ★上传 .py/.php 可能被执行★
✓ 存到 ★web 根目录之外★,通过视图函数受控下发
✓ 或用对象存储(S3/OSS)
★ ★多文件★:
<input type="file" name="photos" multiple>
for f in request.files.getlist("photos"):
if f and f.filename: ...
★ ★大文件的内存问题★:
Werkzeug 的策略:
小文件 → ★BytesIO(内存)★
大文件 → ★自动落到临时文件(SpooledTemporaryFile)★
阈值由 ★max_form_memory_size★ 控制
★ 但 ★f.read() 仍会把全部读进内存★
✓ ★流式转存★:
with open(dest, "wb") as out:
shutil.copyfileobj(f.stream, out, length=1024*1024) # ★分块★
★ ★真正的流式上传(不经过 form 解析)★:
@app.route("/upload-raw", methods=["POST"])
def upload_raw():
with open(dest, "wb") as out:
while chunk := request.stream.read(65536): # ★直接读原始流★
out.write(chunk)
★ 前提:★不能先访问 request.form/files★(会消费掉流)
★ 适合:客户端直接 PUT 二进制、超大文件
★ ★上传进度/超时★:
★ WSGI 同步模型下,上传期间★占用一个 worker★
→ 大文件上传会拖垮并发能力
✓ 生产方案:★Nginx 的 client_body_buffer_size / 直传对象存储★
✓ 或用 ★预签名 URL 让客户端直传 S3★(★推荐★)
文件上传的第一个坑是空文件项——用户没选文件也会提交表单项,而 FileStorage 对象总是真值,必须判断 if f and f.filename(没选时 filename 是空串)。安全四件套是必考内容:secure_filename 处理文件名(但注意它会把中文文件名清空,更稳的做法是自己生成 UUID 文件名、原名存数据库)、MAX_CONTENT_LENGTH 限制大小、查魔数而不是扩展名/Content-Type(后两者都是客户端提供的,记得 read 后 seek(0))、不要存在能被直接执行的目录。内存方面 Werkzeug 会自动把大文件落到临时文件,但 f.read() 仍会全读进内存——转存要用 shutil.copyfileobj 分块。真正的流式上传要直接读 request.stream,前提是不能先访问 request.form/files(会消费掉流)。最后一个架构提醒:WSGI 同步模型下上传期间会占用一个 worker,大文件上传会拖垮并发,生产上推荐用预签名 URL 让客户端直传对象存储。
五、请求元信息与代理
★ URL 相关属性(★容易记混★):
请求 https://example.com/api/user?id=1
┌──────────────────┬────────────────────────────────┐
│ request.url │ ★https://example.com/api/user?id=1★│
│ request.base_url │ https://example.com/api/user │
│ request.url_root │ https://example.com/ │
│ request.path │ ★/api/user★ │
│ request.full_path │ ★/api/user?id=1★ │
│ request.script_root│ ''(★子路径部署时是 /myapp★) │
│ request.host │ example.com │
│ request.scheme │ https │
└──────────────────┴────────────────────────────────┘
★ ★headers 是大小写不敏感的★:
request.headers.get("content-type") # ✓
request.headers.get("Content-Type") # ✓ ★同一个★
request.headers.get("X-Request-Id")
★ 底层是 EnvironHeaders,从 WSGI environ 的 HTTP_* 键还原
★ ★★request.remote_addr 在反代后是错的(必考)★★:
Nginx → Flask 时:
request.remote_addr → ★127.0.0.1(Nginx 的 IP)★
真实 IP 在 ★X-Forwarded-For★ 头里
✗ 危险的做法:
ip = request.headers.get("X-Forwarded-For", "").split(",")[0]
→ ★这个头可以被客户端伪造!★(用于限流/封禁时会被绕过)
✓ ★正确:用 ProxyFix 中间件★
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
# ★x_for=1 表示"信任最近的 1 层代理"★
→ 之后 request.remote_addr 就是真实 IP
★ ★关键:x_for 的数字必须等于你实际的代理层数★
多了 → ★可以被伪造★;少了 → 拿到代理 IP
★ 同时 Nginx 要配:proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
★ ★为什么 ProxyFix 还要 x_proto★:
没有它:★request.scheme 永远是 http★(Nginx 到 Flask 是明文)
→ ★url_for(_external=True) 生成 http:// 链接★
→ ★redirect 到 http 导致混合内容警告/无限重定向★
✓ x_proto=1 让 Flask 读 X-Forwarded-Proto
★ ★其他实用属性★:
request.is_secure # 是否 HTTPS
request.user_agent # UserAgent 对象(2.1+ 简化了)
request.referrer
request.if_modified_since # ★条件请求头(做缓存用)★
request.range # ★Range 头(断点续传)★
request.accept_mimetypes.best_match(["application/json", "text/html"])
# ★→ 内容协商★
★ ★请求 ID(★可观测性基建★)★:
@app.before_request
def add_request_id():
g.request_id = request.headers.get("X-Request-Id") or uuid4().hex
@app.after_request
def echo_request_id(resp):
resp.headers["X-Request-Id"] = g.request_id
return resp
★ 配合日志 filter,★把一次请求的所有日志串起来★
URL 相关属性容易记混,记住 path 是纯路径、full_path 带查询串、url 是完整绝对 URL,以及子路径部署时 script_root 才是前缀。request.remote_addr 在反代后是错的——这是必考点:它拿到的是 Nginx 的 IP,真实 IP 在 X-Forwarded-For 里,但直接读这个头很危险,因为客户端可以伪造(用于限流和封禁时会被绕过)。正确做法是用 ProxyFix 中间件,而且 x_for 的数字必须等于实际的代理层数——多了可以被伪造、少了拿到代理 IP。x_proto=1 同样重要:没有它 request.scheme 永远是 http,会导致 url_for(_external=True) 生成 http 链接、redirect 造成混合内容警告甚至无限重定向。最后推荐一个可观测性基建:在 before_request 里生成/透传 X-Request-Id 并配合日志 filter,把一次请求的所有日志串起来。
六、参数校验与实践
★ 手写校验的问题:
page = request.args.get("page", 1, type=int)
if page < 1: abort(400)
size = request.args.get("size", 20, type=int)
if not 1 <= size <= 100: abort(400)
name = request.json.get("name")
if not name or len(name) > 50: abort(400)
★ → ★重复、易漏、错误信息不统一、无法生成文档★
★ ★方案一:marshmallow(★最流行★)★
from marshmallow import Schema, fields, validate, ValidationError
class QuerySchema(Schema):
page = fields.Int(load_default=1, validate=validate.Range(min=1))
size = fields.Int(load_default=20, validate=validate.Range(1, 100))
status = fields.Str(validate=validate.OneOf(["draft", "published"]))
@app.route("/posts")
def posts():
try:
args = QuerySchema().load(request.args) # ★校验 + 转换★
except ValidationError as e:
return {"errors": e.messages}, 400
...
★ 配合 ★webargs★ 更简洁:
from webargs.flaskparser import use_args
@use_args(QuerySchema(), location="query")
def posts(args): ...
★ ★方案二:pydantic(★类型提示友好★)★
from pydantic import BaseModel, Field, ValidationError
class CreateUser(BaseModel):
name: str = Field(max_length=50)
email: str
age: int = Field(ge=0, le=150)
@app.post("/users")
def create():
try:
data = CreateUser(**(request.get_json(silent=True) or {}))
except ValidationError as e:
return {"errors": e.errors()}, 400
...
★ ★方案三:装饰器封装(轻量项目)★
def validate_json(*required):
def deco(fn):
@wraps(fn)
def wrapper(*a, **kw):
data = request.get_json(silent=True) or {}
missing = [k for k in required if k not in data]
if missing:
abort(400, f"缺少字段:{', '.join(missing)}")
return fn(*a, **kw)
return wrapper
return deco
@app.post("/users")
@validate_json("name", "email")
def create(): ...
★ ★统一错误响应格式★:
@app.errorhandler(400)
def bad_request(e):
return {"error": "bad_request", "message": str(e.description)}, 400
★ 让所有参数错误返回一致的结构,前端好处理
★ 检查清单:
□ ★分清 args / form / json / files★
□ ★多值用 getlist★
□ ★用 .get(name, default, type=...) 而不是 [key]★
□ ★布尔参数显式比较字符串★
□ ★JSON 用 get_json(silent=True) or {}★
□ ★上传判断 f and f.filename★
□ ★secure_filename 或自己生成文件名★
□ ★设 MAX_CONTENT_LENGTH(两端都设)★
□ ★反代后配 ProxyFix★
□ ★复杂参数上 marshmallow/pydantic★
★ 一句话总结:
★"args 是查询串、form 是表单、json 是 JSON body、files 是文件——
Content-Type 决定 body 进哪个;MultiDict 多值要 getlist;
取值用 .get(默认值, type=) 并对布尔单独处理;
参数一多就上 marshmallow/pydantic,别手写一堆 if。"★
手写参数校验的问题是重复、易漏、错误信息不统一、无法生成文档。三种方案:marshmallow(最流行,配合 webargs 的 @use_args 更简洁)、pydantic(类型提示友好,从 FastAPI 过来的人熟悉)、轻量项目用装饰器封装。配合统一的错误响应格式(用 errorhandler(400) 让所有参数错误返回一致结构)前端才好处理。检查清单里最容易漏的是「布尔参数显式比较字符串」和「MAX_CONTENT_LENGTH 两端都设」(Flask 和 Nginx)。
记忆钩子:「Flask 的 request 按★参数来自哪里★分成互不重叠的属性:★args=URL 查询串、form=表单 body、files=上传文件、json=JSON body、data=原始字节★,而 ★Content-Type 决定 body 进哪个属性★——urlencoded/multipart → form(后者同时进 files)、application/json → json、其他 → 只能从 data 拿。★最常见的翻车:前端 fetch 忘了设 Content-Type★(默认 text/plain)→ 后端 get_json() 抛 415、form.get() 拿到 None;★排查三件套:打印 request.content_type、request.get_data()、request.args/form★。★args 和 form 是 MultiDict★(因为 HTML 表单和 URL 天然支持重名字段):★d[‘tag’] 和 .get() 只返回第一个,要全部必须 getlist()★,★to_dict() 会静默丢弃重复值,要 to_dict(flat=False)★。取值一律用 ★.get(name, default, type=int)★——type 能转换且★转换失败静默回退到默认值★(要报 400 得自己判断);★d[key] 抛的是 BadRequestKeyError,Flask 会转成 400 而不是 500★;★request.args 是不可变的 ImmutableMultiDict★。★布尔参数是经典 bug★:?debug=false 时 bool() 和 type=bool ★都得到 True★(bool(‘false’) 是 True),必须显式比较字符串。JSON 要点:★Flask 2.1 起 Content-Type 不对从『返回 None』改成『抛 415』★(老项目升级会踩),健壮写法是 ★get_json(silent=True) or {}★,不规范客户端用 force=True;★分清 {‘name’: null}(字段存在为空)/ {}(字段不存在)/ ”(空串)★,PATCH 语义下用
'name' in data区分。★request.values 不要用★——它是 args+form 且 ★args 优先,攻击者能用查询串覆盖表单值★。文件上传:★FileStorage 总是真值,必须判断 f and f.filename★(没选文件时 filename 是空串);安全四件套=★secure_filename(注意它会把中文名清空,更稳是自己生成 UUID 名)★ + ★MAX_CONTENT_LENGTH(Flask 和 Nginx 两端都要设)★ + ★查魔数而不是扩展名/Content-Type(read 后记得 seek(0))★ + ★不存在能被执行的目录★;大文件转存用 shutil.copyfileobj 分块。★反代后 request.remote_addr 是 Nginx 的 IP★,★直接读 X-Forwarded-For 会被伪造★(限流封禁失效)→ ★必须用 ProxyFix 且 x_for 的数字等于实际代理层数★(多了可伪造、少了拿到代理 IP),★x_proto=1 同样必要★否则 scheme 永远是 http 导致 _external 链接和重定向出错。★WSGI 下上传会占满一个 worker★,生产推荐★预签名 URL 直传对象存储★。」
七、常见误区与追问
- 误区:前端发的是 JSON,用
request.form.get("name")也能取到。 取不到,只会得到None。request.form只解析两种 body:application/x-www-form-urlencoded和multipart/form-data——JSON body 完全不进form,它在request.json(或get_json())里。反过来也一样:表单提交的数据用request.get_json()会抛 415。这类问题的排查方法很固定:打印request.content_type看客户端声明了什么、打印request.get_data()看原始 body 到底是什么,两行就能定位。顺带说一个高频场景:前端用fetch时忘记设headers: {"Content-Type": "application/json"},浏览器会发成text/plain,于是后端两边都取不到——后端可以用get_json(force=True)兜底,但更好的是让前端发对。 - 误区:
request.args["tag"]能拿到所有同名参数。 只能拿到第一个。args和form都是MultiDict——为了支持 HTML 表单的重名字段(多选框、多个同名 input)和 URL 里的?tag=a&tag=b,它允许一个 key 对应多个值,但d[key]和d.get(key)的语义都是「返回第一个」。要拿全部必须用getlist("tag")。同样的陷阱还有to_dict()——它默认flat=True,会静默丢弃重复值(?status=draft&status=published只剩draft),要保留得写to_dict(flat=False)。这个坑的隐蔽之处在于:开发和测试时通常只传一个值,一切正常;等到用户勾了多个筛选条件才出问题,而且不报错、只是少了数据。凡是「可能多选」的参数,一律用getlist。 - 误区:
request.args.get("debug", type=bool)能正确解析布尔参数。 永远返回True(只要参数存在)。因为type=bool实际执行的是bool("false"),而 Python 里任何非空字符串都是真值——"false"、"0"、"no"全都变成True。同理直接写bool(request.args.get("debug"))也一样。这是个「看起来对、测试时也像对的」经典 bug(因为传?debug=true确实得到True),只有传false时才暴露。正确写法是显式比较字符串:request.args.get("debug", "").lower() in ("1", "true", "yes", "on")。可以把它封装成工具函数get_bool(name, default=False)全项目复用。类似地,列表参数(?ids=1,2,3)也要自己写type=lambda s: [int(x) for x in s.split(",") if x]。 - 误区:文件上传时
if request.files.get("avatar"):就能判断用户是否选了文件。 判断不出来——用户即使没有选择文件,浏览器也会提交这个表单项,Flask 会创建一个FileStorage对象,而它总是真值。正确的判断是if f and f.filename:——没选文件时filename是空字符串。这个坑的表现是「保存了一堆空文件」或者「secure_filename('')返回空串导致保存到了目录本身」。相关的还有几个不可信的字段:f.filename完全由客户端提供(可能含../、可能是超长字符串、可能是空),f.content_type也是客户端声明的(改一下就能让.php伪装成image/png)。所以正确姿势是:用secure_filename或干脆自己生成 UUID 文件名(把原名存数据库用于展示)、通过读取文件头的魔数来判断真实类型、存到 web 根目录之外。 - 误区:反代后想拿真实 IP,读一下
X-Forwarded-For头就行。 这个头可以被客户端任意伪造。如果你直接request.headers.get("X-Forwarded-For").split(",")[0],攻击者只要在请求里自己带上这个头,就能伪造成任意 IP——限流、封禁、地域限制、审计日志全部失效。正确做法是用ProxyFix中间件:app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)。关键在于x_for的数字必须精确等于你实际的可信代理层数——它表示「从右往左信任几个值」:设成 1 时只信任最右边一个(即紧邻的那层代理写入的),客户端伪造的部分在左边会被忽略;设大了就可以被伪造,设小了会拿到代理自己的 IP。同时 Nginx 那边要配proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;。别忘了x_proto=1——否则request.scheme永远是http,会导致url_for(_external=True)生成 http 链接、以及 HTTPS 下 redirect 引发混合内容警告或无限重定向。 - 追问:为什么
request.values不推荐使用? 它是args和form的CombinedMultiDict,查找时先找args再找form——也就是查询字符串的值会覆盖表单里的同名值。这带来一个真实的安全隐患:假设有个转账表单 POST 了amount=100,攻击者构造一个链接POST /transfer?amount=1(或诱导用户点击带查询串的表单提交地址),request.values["amount"]拿到的就是1而不是表单里的100。更普遍的问题是来源不明确——代码里看到request.values.get("user_id"),你无法知道这个值应该从哪来、审计时也说不清,一个本该只从 POST body 取的敏感参数可能被 URL 覆盖。所以规矩是明确写request.args或request.form:既表达了意图,也让参数来源可审计。唯一勉强合理的场景是「刻意要同时支持 GET 和 POST 传参」的老接口,但那种情况也建议显式写request.args.get(k) or request.form.get(k)并想清楚优先级。 - 追问:请求 body 为什么「只能读一次」?Werkzeug 是怎么处理的? WSGI 规范里,请求体是
environ["wsgi.input"]这个流对象——它是从网络套接字读出来的字节流,读过的部分就没了,不能 seek 回去。所以理论上request.data、request.form、request.files、request.stream是互斥的:谁先读谁拿到数据,后来者拿到空。Werkzeug 的处理是默认缓存(get_data(cache=True)):第一次读取时把 body 存进内存或临时文件,之后无论访问哪个属性都从缓存取,所以日常开发感觉不到限制。但有两个例外要注意:① 显式get_data(cache=False)后再访问form会拿到空;② 大文件上传时缓存会占内存——Werkzeug 对 multipart 有优化(超过max_form_memory_size的部分会落到SpooledTemporaryFile),但如果你自己request.data读一个 1GB 的请求体,那就是实打实的 1GB 内存。真正的流式处理要直接读request.stream并且绝不先碰form/files。 - 追问:参数校验用 marshmallow 还是 pydantic? 两者都能用,选择更多看团队习惯和周边生态。marshmallow 是 Flask 生态里的传统选择:Schema 定义与序列化/反序列化对称(同一个 Schema 既能校验入参也能序列化出参)、和
flask-marshmallow/marshmallow-sqlalchemy集成好(能从模型自动生成 Schema)、配合webargs的@use_args(Schema(), location="query")装饰器写法很干净、flask-smorest还能基于它自动生成 OpenAPI 文档。pydantic 的优势是基于类型注解(IDE 补全和静态检查友好)、性能更好(v2 用 Rust 实现核心)、从 FastAPI 转过来的人零学习成本,配合flask-pydantic之类的小封装也能用得很顺。实践建议:已有 Flask 项目且用了 SQLAlchemy → marshmallow 生态更顺;新项目、团队熟悉类型注解、或将来可能迁 FastAPI → pydantic。无论选哪个,关键是统一错误响应格式(用errorhandler把ValidationError转成一致的 JSON 结构),别让每个视图各自返回不同形状的错误。
八、加强记忆
Flask 的 request 按「参数来自哪里」分成互不重叠的属性:args = URL 查询串、form = 表单 body、files = 上传文件、json = JSON body、data = 原始字节,而决定 body 进哪个属性的是 Content-Type——urlencoded/multipart 进 form(后者同时进 files)、application/json 进 json、其他类型只能从 data 拿。最常见的翻车是前端 fetch 忘了设 Content-Type(默认发成 text/plain),导致后端 get_json() 抛 415、form.get() 拿到 None;排查三件套是打印 request.content_type、request.get_data()、request.args/request.form。args 和 form 是 MultiDict(因为 HTML 表单和 URL 天然支持重名字段):d["tag"] 和 .get() 只返回第一个值,要全部必须用 getlist(),to_dict() 会静默丢弃重复值(要 to_dict(flat=False))。取值一律用 .get(name, default, type=int)——type 能顺带转换且转换失败时静默回退到默认值(想报 400 得自己判断);d[key] 抛的是 BadRequestKeyError,Flask 会转成 400 而不是 500;另外 request.args 是不可变的 ImmutableMultiDict。布尔参数是经典 bug:?debug=false 时 bool() 和 type=bool 都返回 True(因为 bool("false") 是真),必须显式比较字符串。JSON 方面:Flask 2.1 起 Content-Type 不对时从「返回 None」改成了「抛 415」(老项目升级会踩),健壮写法是 get_json(silent=True) or {},客户端不规范时用 force=True;还要分清 {"name": null}(字段存在但为空)、{}(字段不存在)、""(空串) 三种情况,PATCH 语义下用 "name" in data 区分。request.values 不要用——它是 args + form 且 args 优先,攻击者能用查询串覆盖表单里的值。文件上传:FileStorage 总是真值,必须判断 f and f.filename(没选文件时 filename 是空串);安全四件套是 secure_filename(注意它会把中文文件名清空,更稳的是自己生成 UUID 名)+ MAX_CONTENT_LENGTH(Flask 和 Nginx 两端都要设)+ 查魔数而不是扩展名/Content-Type(read 后记得 seek(0))+ 不存在能被执行的目录;大文件转存要用 shutil.copyfileobj 分块。反代后 request.remote_addr 拿到的是 Nginx 的 IP,而直接读 X-Forwarded-For 会被客户端伪造(限流封禁全失效)——必须用 ProxyFix,且 x_for 的数字要精确等于实际的可信代理层数(设大了可被伪造、设小了拿到代理 IP),x_proto=1 同样必要,否则 scheme 永远是 http 会让 _external 链接和重定向出错。最后一个架构提醒:WSGI 同步模型下上传会占满一个 worker,生产上推荐用预签名 URL 让客户端直传对象存储。