路由只做四件事:取文件、验类型、跑链路、返回 JSON。 错误也返回 JSON,这是 API 和网页最容易分叉的地方。 413 被拦在进入视图之前,得靠 app.errorhandler 补上。
这一篇对应教材 14.5、14.6 与 14.6.1:编写已学习模型的执行流程式码、实作路由建置、 确认运作情况。 上一篇把前后处理写好了,这一篇把它们接成一个端点。
一、路由只做四件事
f = request.files.get("file")
if f is None or not f.filename:
return _err(400, "no file", "字段名必须是 file")
ext = f.filename.rsplit(".", 1)[-1].lower()
if ext not in current_app.config["ALLOWED"]:
return _err(415, "unsupported media type", ext)
取文件、验类型、跑链路、返回 JSON。四件事,一行不多。
检测那五步全在 detector 里。路由不该知道图片怎么缩放、框怎么还原——它只负责「收进来、
交出去」。
这一条分工是第 14.1 那个目录结构的意义所在:views/detect.py 换个模型不用改,
detector/ 换套接口不用改。
二、成功长什么样
实测 POST 一张 640x480 的图:
| 字段 | 值 |
|---|---|
| 状态码 | 200 |
count |
2 |
size |
[640, 480] |
tags[0] |
box [300,120,220,280]、label spot、score 0.647 |
tags[1] |
box [60,40,140,141]、label spot、score 0.411 |
跟第 13 章那张规格表对得上:200 带 count 与 tags,每条 label、score、box 四项。
size 这一项是给调用方算比例的——他知道原图多大,才能把框画在自己界面上。
这个字段看着多余,实际很省事。
响应体里 tags 按分数从高到低排。
顺序也是接口的一部分,调用方可能只取第一个,那就是「最可能的那个目标」。
三、三种错法,三种回答
| 情况 | 状态码 | 响应体 |
|---|---|---|
| 字段名写成 image | 400 | {"detail":"字段名必须是 file","error":"no file"} |
| 传了 .txt | 415 | {"detail":"txt","error":"unsupported media type"} |
| 传 800 KB(上限 512 KB) | 413 | HTML(还没改) |
前两个是 JSON,第三个不是——这是这一篇最后要补的洞。
detail 那一项是刻意加的。字段名写错是最高频的接入问题,一句话省掉一轮来回邮件。
415 那个 detail 回的是实际扩展名。
调用方看到 txt 就知道自己传错了,而不是去看文档猜「到底支持什么格式」。
注意 415 是按扩展名判的,没读文件内容。
把假图改名成 .png 会进到读图那一步,imdecode 返回 None,
再返回「not a readable image」——还是 415。两道叠着才挡得住。
四、413 为什么漏了
上传上限由 MAX_CONTENT_LENGTH 控制,Flask 在处理请求体的时候就拦了,
根本走不到视图函数里那句「校验扩展名」。
所以视图里的 _err() 一点用都没有,得靠 app.errorhandler(413)。
实测传一个 800 KB 的文件(上限 512 KB),拿到的是第 10 章那个 HTML 错误页,
第一行是 <!doctype html>。
对一个 API 来说这是 bug:调用方按 JSON 解析,会报「解析失败」,把「文件太大, 上限 0.5 MB」这个真正的原因盖掉。 顺带一个反常识:同样是 4000x3000 的纯色 PNG,压缩之后只有几 KB,碰不到上限。 测「文件太大」要造真的字节,不是造大尺寸的图。
五、补上 413
@app.errorhandler(413)
def too_large(e):
if request.path.startswith("/api"):
return jsonify({"error": "payload too large"}), 413
return "<h2>413 文件太大</h2>", 413
实测加上之后,同一个请求返回 {"error":"payload too large","limit_mb":0.5},状态码还是 413。
从一坨 HTML 变成一行 JSON,调用方那边从「解析失败」变成「文件超了,上限 0.5 MB」。
同一次失败,两种信息量。
按路径分流(startswith("/api"))比按 Accept 头分流简单,本课用前者。
同类要处理的还有 404、405、500。
API 的约定是「任何情况下响应体都是 JSON」,包括框架自己抛出来的那些错误。 这一条跟第 10 章那个 HTML 错误页不冲突——两套并存,按路径分。
六、对表验收
| 规格表上的行 | 实测 |
|---|---|
POST /detect 传图 |
200 + count 与 tags |
| 没带文件 | 400 + JSON |
| 不是图片 | 415 + JSON |
| 太大 | 413(补了处理器之后是 JSON) |
| 标签结构 | label、score、box 四项 |
接口先定表、后写代码,验收就变成对表。 反过来先写代码的后果很具体:写着写着发现「这里少个字段」「那个错误码没定」, 于是每个人调的时候都要问一遍。 第 13 章那张表就是这么用的。它不是文档,是验收清单。
七、这一篇之后
第 3 篇的实作部分完了:一个能收图、能检测、能返回 JSON、出错也是 JSON 的 API。
但它还跑在自己电脑上,http://127.0.0.1:5000 只有自己能访问。
下一篇把它弄到公网上:打包成镜像、推到云端、变成一个别人能调的地址。
那一步会带出一个新问题:线上的环境和你本机不一样。Python 版本、装的包、系统库、时区, 任何一样不同都可能让它跑不起来。第 15 章开头那个 Docker 就是为这件事准备的。
八、动手 Demo:模型执行与路由:把它接成一个 API
这个 Demo 起一个真的 HTTP 服务,用真的 multipart 请求打它——不是 test_client。 第四、五两节是同一个请求的前后对比:先是 HTML,补了处理器之后是 JSON。
@bp.route("/detect", methods=["POST"])
def detect():
f = request.files.get("file")
if f is None or not f.filename:
return _err(400, "no file", "字段名必须是 file")
ext = f.filename.rsplit(".", 1)[-1].lower()
if ext not in current_app.config["ALLOWED"]:
return _err(415, "unsupported media type", ext)
name = secure_filename(f.filename) or ("in." + ext)
tmp = os.path.join(tempfile.mkdtemp(), name)
f.save(tmp)
img = detector.load_image(tmp)
chw, src_wh = detector.preprocess(
img, current_app.config["INPUT_SIZE"])
tags = pp.postprocess(detector.infer(chw, img), src_wh,
current_app.config["INPUT_SIZE"],
current_app.config["CONF_THRESHOLD"],
current_app.config["NMS_IOU"])
return jsonify({"count": len(tags), "size": list(src_wh),
"tags": tags})
实跑输出:
--------------------------------------------------------------------
模型执行与路由:把它接成一个 API
--------------------------------------------------------------------
① 正常一张
--------------------------------------------------------------------
POST /api/detect -> 200,71.0 ms
响应体 -> {"count":2,"size":[640,480],"tags":[{"box":[300,120,220,280],"label":"spot","score":0.647},{"box":[60,40,140,141],"label":"spot","score":0.411}]}
! 这就是第 13 章那张规格表的样子。
count 是几个目标,size 是原图尺寸,tags 里每条有 label、score、box 四项。
② 没带文件
--------------------------------------------------------------------
字段名写成 image -> 400
响应体 -> {"detail":"字段名必须是 file","error":"no file"}
! 400,而且是 JSON。调用方拿到 error 就知道是「没给文件」,detail 还告诉你字段名该叫 file。
! 这个 detail 是刻意加的:字段名写错是最高频的接入问题,一句话省掉一轮来回。
③ 传了个不是图片的文件
--------------------------------------------------------------------
扩展名 .txt -> 415
响应体 -> {"detail":"txt","error":"unsupported media type"}
! 415 Unsupported Media Type。注意它是按扩展名判的,没读文件内容。
! 把 broken.png 改名成 ok.png 再传,会进到读图那一步,imdecode 返回 None,
然后返回「not a readable image」——还是 415。两道检查叠着,才能挡住改名的假图片。
④ 太大了
--------------------------------------------------------------------
传一个 800 KB 的文件(上限 512 KB)-> 413
响应体第一行 -> <!doctype html>
! 这里返回的是 HTML,不是 JSON。因为 413 在进入视图之前就被拦了,走的是第 10 章那个全局错误页。
! 顺带一个反常识:同样是 4000x3000 的纯色 PNG,压缩之后只有几 KB,根本碰不到上限。
所以测「文件太大」要造真的字节,不是造大尺寸的图。
! 这就是这一篇最后一个要补的洞:调用方拿到一坨 HTML,会报「JSON 解析失败」,
把「文件太大」这个真正的原因盖掉。
⑤ 让 413 也返回 JSON
--------------------------------------------------------------------
[代码原样]
| @app.errorhandler(413)
| def too_large(e):
| if request.path.startswith("/api"):
| return jsonify({"error": "payload too large"}), 413
| return "<h2>413 文件太大</h2>", 413
按路径分流:API 请求给 JSON,页面请求给 HTML。
加了处理器之后,同一个请求 -> 413
响应体 -> {"error":"payload too large","limit_mb":0.5}
正常那张还是 -> 200(没被影响)
! 从一坨 HTML 变成一行 JSON,调用方那边从「解析失败」变成「文件超了,上限 0.5 MB」。
同一次失败,两种信息量。
! 判断依据也可以换成 request.accept_mimetypes:客户端声明「我要 JSON」就给 JSON。
路径前缀更简单可控,本课用这个。
! 同类要处理的还有 404、405、500。
API 的约定是「任何情况下响应体都是 JSON」,包括框架自己抛出来的那些错误。
! 到这里,第 13 章那张规格表上每一行都对上了:200 带 count 与 tags、400 缺文件、415 类型不对、
413 太大。接口定完再写代码,验收就变成对表。
第一节那条响应就是第 13 章规格表的成品。 第二、三节的 detail 是刻意设计的,减少接入时的来回。 第五节 413 从 HTML 变 JSON,状态码没变,信息量变了。
30 秒实验
- 把
_err的 detail 去掉,再传一次 .txt,看调用方还能不能自己看出问题。 - 给
/api/detect加一个?conf=0.8查询参数,让调用方临时改阈值。 - 想一想:为什么 413 在视图里拦不住?
常见误区
- 在路由里写前处理:换模型要改路由。
- 错误返回 HTML:调用方解析不了,真正的原因被盖掉。
- 只在视图里校验上传大小:413 在进入视图之前就发生了。
- 413 不做处理:拿到一坨 HTML,以为是服务挂了。
自测三问(点开对答案)
1. 路由只做哪四件事? —— 取文件、验类型、跑链路、返回 JSON。 2. 为什么 413 在视图里拦不住? —— MAX_CONTENT_LENGTH 在解析请求体时就被检查,还没进到视图函数。 3. 为什么错误也要返回 JSON? —— 调用方按 JSON 解析,HTML 会让它报解析错误,把真正的原因盖掉。第 14 章到此结束。下一篇把它打包成镜像,推到云端,变成别人能调的地址。
- ☐ (本篇完,下一篇 15.1 Docker:把环境一起打包)