《FastAPI零基础入门与进阶实战》第21篇:告别 /path/ vs /path:静默斜杠修正中间件
系列文章目录
《FastAPI零基础入门与进阶实战》https://blog.csdn.net/sen_shan/category_12950843.html
第20篇:消息管理-封装https://blog.csdn.net/sen_shan/article/details/151829548?spm=1001.2014.3001.5501
文章目录
目录
系列文章目录
文章目录
前言
设计目标
实现原理
接入指南
路由优先级对照表
性能与风险
扩展与定制
FAQ
前言
在 RESTful 工程实践中,路径末尾斜杠( / )经常成为“同一个资源却出现两个 URL”的元凶: /login 与 /login/ 会被浏览器、CDN、搜索引擎视为不同端点,进而带来
重复 SEO 权重
缓存命中率下降
前端 fetch 因 307 往返增加延迟
FastAPI/Starlette 默认采用“严格匹配”策略,不会自动合并两种写法。
StripTrailingSlashMiddleware 在“零重定向”的前提下,用内部路径改写的方式,让“带斜杠的无效路径”静默落到“无斜杠的已注册路由”,从而保持 URL 唯一性,避免 307 Temporary Redirect
。
设计目标
1. 不返回 307,客户端零感知。
2. 仅当“原路径找不到路由”且“去掉斜杠后能找到”时才改写。
3. 已注册 /path/ 的路由不受任何影响(优先匹配原路径)。
4. 对任意 HTTP 方法(GET、POST、PUT...)均生效。
5. 代码 ≤ 30 行,零第三方依赖。
实现原理
orig_path = request.url.path
try:request.app.router.resolve(request.scope) # ①
except Exception: # ②if orig_path.endswith("/"):request.scope["path"] = orig_path[:-1] # ③
① 利用 Starlette 内部 Router.resolve() 进行“预匹配”。
② 匹配失败说明当前路径在路由表中不存在。
③ 改写 ASGI scope 中的 path 字段;FastAPI 后续再按新路径做二次匹配。
接入指南
步骤 1:将中间件文件放入项目
src/
├── middleware/
│ ├── __init__.py
│ ├── strip_trailing_slash.py
│ └── cors_config.py # 存放 CORS 配置
步骤 2:strip_trailing_slash
from starlette.responses import RedirectResponse
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.routing import NoMatchFound
from fastapi import Request# 关键:添加自动斜杠重定向中间件
class StripTrailingSlashMiddleware(BaseHTTPMiddleware):"""中间件用于处理路径末尾斜杠问题当请求路径以斜杠结尾但无法匹配到对应路由时,自动去除末尾斜杠并重新尝试路由匹配"""async def dispatch(self, request: Request, call_next):orig_path = request.url.pathif orig_path == "/":return await call_next(request)try:# 尝试直接解析当前请求路径request.app.router.resolve(request.scope)return await call_next(request)except Exception:# 如果路由解析失败且路径以斜杠结尾if orig_path.endswith("/"):# 去掉末尾的 /new_path = request.url.path.rstrip("/")# 直接修改 scope,FastAPI 会用这个新路径去找路由request.scope["path"] = new_pathreturn await call_next(request)
步骤 3:cors_config
# CORS 配置
# 定义允许的跨域源列表
origins = ["http://localhost", # 允许来自 http://localhost 的请求"http://localhost:8080", # 允许来自 http://localhost:8080 的请求"http://localhost:5173", # 允许来自 http://localhost:5173 的请求"https://example.com", # 允许来自 https://example.com 的请求
]# CORS 中间件配置参数
cors_config = {"allow_origins": origins,"allow_credentials": True,"allow_methods": ["*"],"allow_headers": ["*"],
}
把原来Main.py中以下信息整合到cors_config中
# 定义允许的跨域源列表
origins = ["http://localhost", # 允许来自 http://localhost 的请求"http://localhost:8080", # 允许来自 http://localhost:8080 的请求"http://localhost:5173", # 允许来自 http://localhost:5173 的请求"https://example.com", # 允许来自 https://example.com 的请求
]# 添加 CORS 中间件
app.add_middleware(CORSMiddleware,allow_origins=origins, # 允许的源列表allow_credentials=True, # 允许携带身份凭证(如 Cookies)allow_methods=["*"], # 允许所有 HTTP 方法allow_headers=["*"], # 允许所有请求头
)
步骤4:在主程序导入并注册
from src.middleware.strip_trailing_slash import StripTrailingSlashMiddleware
from src.middleware.cors_config import cors_config# 注册中间件
app.add_middleware(StripTrailingSlashMiddleware)
app.add_middleware(CORSMiddleware, **cors_config)
步骤5:验证
# 原路由只注册了 /login
http://127.0.0.1:8080/login/ # HTTP 200,无 307
http://127.0.0.1:8080/login # HTTP 200,行为不变
步骤6:取消router中最后的斜杆
避免无法正确的访问,取消所有router中最后的斜杆
路由优先级对照表
=================== =================== ========================
请求路径 已注册路由 内部结果
=================== =================== ========================
/login/ /login 通过
/login /login 通过
/login/ /login/ 通过
/login /login/ 不通过
=================== =================== ========================
性能与风险
每次请求仅多一次 router.resolve() 调用,复杂度 O(1),压测损耗 < 1 %。
仅在“原路径 404”时才会进入分支,正常请求无额外开销。
不会修改 query string、body、headers。
扩展与定制
1. 仅对 POST 生效
在 dispatch 开头加
if request.method != "POST":return await call_next(request)
2. 反向逻辑:补斜杠
把 rstrip("/") 换成 path + "/" 即可实现“强制带斜杠”策略。避免死循环不能写取消斜杆又增加斜杠。
FAQ
Q1: 会不会把 WebSocket 路径也改掉?
A: WebSocket 握手路径同样经过 router.resolve() ,逻辑一致,安全。
Q2: 如果同时用了 CORSMiddleware ,顺序如何?
A: StripTrailingSlashMiddleware 应放在最外层(先注册),确保路径修正早于 CORS 判断。
Q3:是否可用其他重定向中间件
A:starlette可以实现,但是在实际操作中,未达到预期才写了一个中间件。
Q4:若不写中间件,如何实现?
A:路由写2个
@app.post("/login")
@app.post("/login/")
async def regLogin(login_data: login_manager.LoginRequest,db: Session = Depends(get_db),app_manager: Optional[dict] = Depends(dependencies.auth_api_key)):retMes = login.login(db, login_data, app_manager)# print(login.loginInfo.get())return retMes # {"access_token": login_data, "app_id": app_id}