系列文章目录
《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
文章目录
目录
前言
在 RESTful 工程实践中,路径末尾斜杠( / )经常成为"同一个资源却出现两个 URL"的元凶: /login 与 /login/ 会被浏览器、CDN、搜索引擎视为不同端点,进而带来
重复 SEO 权重
缓存命中率下降
前端 fetch 因 307 往返增加延迟
FastAPI/Starlette 默认采用"严格匹配"策略,不会自动合并两种写法。
StripTrailingSlashMiddleware 在"零重定向"的前提下,用内部路径改写的方式,让"带斜杠的无效路径"静默落到"无斜杠的已注册路由",从而保持 URL 唯一性,避免 307 Temporary Redirect
。
设计目标
-
不返回 307,客户端零感知。
-
仅当"原路径找不到路由"且"去掉斜杠后能找到"时才改写。
-
已注册 /path/ 的路由不受任何影响(优先匹配原路径)。
-
对任意 HTTP 方法(GET、POST、PUT...)均生效。
-
代码 ≤ 30 行,零第三方依赖。
实现原理
python
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
python
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.path
if 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_path
return await call_next(request)
步骤 3:cors_config
python
# 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中
python
# 定义允许的跨域源列表
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:在主程序导入并注册
python
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:验证
python
# 原路由只注册了 /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 开头加
python
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个
python
@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}