原文:https://dev.to/alexgeorgiev17/fastapis-new-appfrontend-fixes-a-route-order-bug-in-manual-spa-serving-4mm8(作者 @alexgeorgiev17)
如果不小心把 FastAPI 的 catch-all 路由写在了 API 路由上面,那么请求 /api/ping 会返回 HTTP 200,但响应体是单页应用的 index.html,而不是你的 JSON。没有报错,没有 404,日志里也看不出任何异常。这是我在测试 app.frontend() 时发现的——它是 FastAPI 从 0.138.0 到 0.141.0(今年 6 月 20 日到 7 月 29 日之间)陆续引入的新调用。
在此之前,用 FastAPI 托管单页应用无非两种做法。要么在 / 上挂载 StaticFiles:静态资源服务没问题,但客户端路由没有兜底,在 /settings/profile 上强制刷新就会 404。要么写一个回退到 index.html 的 catch-all 路由 @app.get("/{full_path:path}"):深链接能正常访问了,但前提是你得记得把它声明在每一条 API 路由之后。app.frontend() 的定位就是取代这两种方案。我在本地搭好了 FastAPI 0.141.1,然后想办法把它弄出问题。
复现路由顺序 Bug
我写了三个小应用,托管的都是同一个 dist/ 目录(一个 index.html、一个 assets/app.js),外加一条 GET /api/ping 路由,唯一的变量是路由声明的先后顺序,以及由哪种机制来服务前端。
当 catch-all 声明在前时:
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8002/api/ping
200
$ curl -s http://127.0.0.1:8002/api/ping
<!doctype html><html><head><title>demo</title></head><body><h1>spa shell</h1></body></html>
这可是一条真实注册的 API 路由,返回了 200,响应体却完全不对劲。从这个响应本身看不出任何迹象,表明它被交给了错误的处理器。
换成把 StaticFiles 挂载在 /(而不是写 catch-all 函数)并放在前面时:
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8003/api/ping
404
$ curl -s http://127.0.0.1:8003/api/ping
{"detail":"Not Found"}
失败形式不同,根因是同一个:谁先匹配上 /,谁就赢。而 Starlette 是按声明顺序做匹配的,所以,一个在前端托管上线六个月之后才来添加 /api/ping 路由的人,根本无从知道自己必须把它加在挂载语句之前。
接下来是同样的布局,但把 router.frontend("/", directory="dist") 放在 app.include_router() 之前调用——故意放在错误的位置,看看会不会有影响:
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8004/api/ping
200
$ curl -s http://127.0.0.1:8004/api/ping
{"pong":true}
结果是没有影响。翻一下 fastapi/routing.py 的源码就能明白原因:前端路由会被单独存到 _low_priority_routes 里,只有当所有普通路径操作都匹配失败之后才会被尝试,与 .frontend() 写在文件哪个位置无关。这是一种路由行为,而不是编码风格上的约定,所以即便日后有人调整文件内代码的顺序,它也不会被破坏。
catch-all 无法区分的 404 与页面
catch-all 方案还有第二个问题,之前我完全没想过,动手一测才发现:它分不清“用户导航到了某个客户端路由”和“浏览器请求了一个不存在的资源”。这两种情况本质都是未匹配上的 GET 请求,而最朴素的写法对两者一律返回 index.html。
$ curl -s -o /dev/null -w '%{http_code}\n' -H 'Accept: application/json' \
http://127.0.0.1:8001/assets/app-typo.js
200
一个手误写错的脚本路径,返回的是 200 和一份 HTML 文档。如果这个请求来自构建流程中检查自身产物(bundle)是否存在的环节,这个检查会被误判为通过。
app.frontend() 会在决定是否回退之前先检查 Accept 请求头。同一路径、同一台服务器,改带 Accept: application/json 请求头:
$ curl -s -o /dev/null -w '%{http_code}\n' -H 'Accept: application/json' \
http://127.0.0.1:8004/assets/app-typo.js
404
而带 Accept: text/html(浏览器导航时发送的正是它)请求一条未匹配的路径,依然能拿到 SPA 外壳:
$ curl -s -o /dev/null -w '%{http_code}\n' -H 'Accept: text/html' \
http://127.0.0.1:8004/some/client/route
200
这条区分逻辑在 _is_frontend_navigation_request() 里实现:它检查 Accept 头中是否含有 text/html 或 application/xhtml+xml。而朴素的 StaticFiles(html=True),这次已经正确地挂载在 API 路由之后,默认这两件事都做不到:请求 /some/client/route 会直接 404,而这恰恰是 catch-all 当初要解决的深链接问题:
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8005/some/client/route
404
两个较小的坑
对 catch-all 通配路由发起 HEAD 请求会返回 405,因为 @app.get 只注册了 GET:
$ curl -sI http://127.0.0.1:8001/assets/app.js | head -1 HTTP/1.1 405 Method Not Allowed
app.frontend() 显式注册了 {"GET", "HEAD"},普通的 StaticFiles 挂载也是如此,所以这个问题是 catch-all 特有的,并不是老方案一贯都会出错的地方。我起初以为基于挂载的版本在 HEAD 上也会栽同样的跟头,结果并没有——像这样的结论,先验证再推广才稳妥。
在老方案里,用鉴权保护前端是个比我预想更大的坑。app.mount() 和 StaticFiles() 压根不接受 dependencies 参数:
$ python3 -c "
from fastapi import FastAPI, Depends
from fastapi.staticfiles import StaticFiles
app = FastAPI()
app.mount('/', StaticFiles(directory='dist'), dependencies=[Depends(lambda: None)])
"
TypeError: Starlette.mount() got an unexpected keyword argument 'dependencies'
框架没有内置的办法给静态挂载加上依赖门槛,你只能转而使用 ASGI 中间件,而为了这一件事专门去学另一套 API 并不划算。app.frontend() 会继承其路由上设置的全部依赖,所以只要把它包进 APIRouter(dependencies=[Depends(require_token)]),一行代码就能同时保护 SPA 外壳和它下面的所有静态资源,而同一个应用里无关的路由完全不受影响:
GET /:无 token 返回 401,带 token 返回 200GET /assets/app.js:无 token 返回 401,带 token 返回 200GET /api/ping:无 token 返回 200(不受影响),带 token 返回 200
不支持「完胜」结论的那组数字
如果这些便利要以性能为代价,那就谈不上白拿,所以我把配置正确的老方案(StaticFiles,挂在 API 路由之后)和 app.frontend() 的静态资源服务做了基准对比:各 3000 个请求、并发 20、各跑三轮:
- StaticFiles 挂载:第 1 轮 442 req/s,第 2 轮 445 req/s,第 3 轮 441 req/s
- app.frontend():第 1 轮 440 req/s,第 2 轮 430 req/s,第 3 轮 449 req/s
这只是噪声,不是趋势——六轮测试里两者的差距都在约 2% 以内,而 p50 延迟(在这台单 worker 的开发服务器上约为 26ms)在每一组对比里都完全一致。我还对比了一个完全没注册前端的应用和一个接好 app.frontend() 的应用,想看看仅仅存在这个低优先级路由组,会不会拖慢普通 API 的分发:
- 基线(无前端):第 1 轮 457 req/s,第 2 轮 443 req/s,第 3 轮 460 req/s
- 接入 app.frontend():第 1 轮 462 req/s,第 2 轮 455 req/s,第 3 轮 460 req/s
同样测不出差异。我原本预期「先逐条匹配普通路由、再兜底到低优先级路由」的设计会在 API 路径上付出一些代价,但在这样的请求量级下完全看不出来。要想确信这个结论在更大规模下依然成立,得有一个大得多的路由表才行;不过对普通规模的服务来说,两者就是打平。
过程中我搞错的地方
我第一版鉴权测试其实根本没有在调用 Mount() 时传入 dependencies 关键字——我把「老方案」的鉴权应用写成直接普通地挂载 StaticFiles,看到没有任何依赖相关的报错,就以为测试没跑起来。其实测试跑得好好的,只是我忘了传那个本来要测的参数。等我直接给 mount() 调用加上 dependencies=[Depends(...)],上面第二个发现里的那个 TypeError 立刻就冒了出来。这提醒我们:一个「通过」的测试,和一个从未真正触达你想验证之物的测试,在你回头读一遍自己写的代码之前,看起来毫无区别。
我起初还以为 HEAD 这个坑两种老方案都会踩,毕竟宽泛地说它们都算「老办法」。事实并非如此——基于挂载的版本处理 HEAD 毫无问题,因为 StaticFiles 一直都支持。只有手写的 catch-all 漏掉了它,因为裸写的 @app.get 不像 app.frontend() 和 StaticFiles 那样隐含对 HEAD 的支持。
没来得及测的部分
check_dir="auto" 的行为与文档描述完全一致:当前端目录缺失且 FASTAPI_ENV 不是 "development" 时,启动阶段会抛出 RuntimeError;如果恰好是 "development",则只发出警告。路径穿越尝试(../../../etc/passwd 及其 URL 编码变体)在新旧两种方案下都返回 404——这一点无论哪种方案都是从 StaticFiles 继承来的,算不上值得单开一节的新行为。我没有在配置多个 Uvicorn worker 的生产级 ASGI 环境下测试,只用了单 worker 的开发服务器,所以上面的吞吐数字只是两种配置之间的横向对比,并不代表任何一方性能的绝对水平。
自己动手跑一遍
这需要在虚拟环境(virtualenv)中安装 fastapi[standard]==0.141.1 和 httpx,另外还需准备一个用于对外服务的 dist/index.html 和 dist/assets/app.js:
python3 -m venv venv && ./venv/bin/pip install 'fastapi[standard]==0.141.1' httpx
mkdir -p dist/assets
echo '<!doctype html><h1>spa shell</h1>' > dist/index.html
echo 'console.log("hi")' > dist/assets/app.js
下面是顺序 Bug 的复现代码,只保留了真正要紧的两个用例:
# server.py
import sys
from fastapi import APIRouter, FastAPI
MODE = sys.argv[1]
app = FastAPI()
if MODE == "old_wrong_order":
@app.get("/{full_path:path}")
async def spa_fallback(full_path: str):
from fastapi.responses import FileResponse
return FileResponse("dist/index.html")
@app.get("/api/ping")
def ping():
return {"pong": True}
elif MODE == "new_any_order":
router = APIRouter()
router.frontend("/", directory="dist")
app.include_router(router)
@app.get("/api/ping")
def ping():
return {"pong": True}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=int(sys.argv[2]))
两个模式都跑起来,对比一下:
./venv/bin/python server.py old_wrong_order 8002 &
./venv/bin/python server.py new_any_order 8004 &
sleep 1
curl -s http://127.0.0.1:8002/api/ping # 返回的是 HTML,不是 JSON
curl -s http://127.0.0.1:8004/api/ping # {"pong":true}
前文引用的那些输出,就是我原样运行这一对命令得到的。
该怎么办
如果你已经在用 FastAPI 托管 SPA,走的是手写 catch-all 路由的方案,那就查一下它是在最新的 API 路由之前还是之后声明的——这种故障完全静默,所以别想当然地认定它还待在正确的位置,直接 grep 一把 {full_path:path},再把整个文件从头到尾读一遍。如果你要在 FastAPI 0.138 或更高版本上开新项目,app.frontend() 彻底消除了声明顺序的约束,还免费附送 HEAD 请求支持和能感知 Accept 头的回退行为,而且实测切换过去没有任何吞吐量损失。如果你需要把前端放在鉴权(auth)之后,这正是旧模式完全拿不出干净解法的唯一场景,单凭这一点就值得迁移到 app.frontend()。
原文:https://dev.to/alexgeorgiev17/fastapis-new-appfrontend-fixes-a-route-order-bug-in-manual-spa-serving-4mm8(作者 @alexgeorgiev17)



