转载说明:文章最新在掘金上发用户名为CaffeinePro,CSDN是我最早加入的社区,但由于vip文章的原因不想然文章付费所以选择首发在掘金。
假设现在有个需求,要做一个最小可用的用户注册功能:
- 前端页面展示Logo、样式,用户填写手机号和昵称后提交;
- 前端运行在
http://127.0.0.1:5500,API在http://127.0.0.1:8000——跨域; - 注册成功后API立刻返回,但还要在后台发欢迎短信、写审计日志——不能让用户等。
这三个需求,正好对应 FastAPI 的三块能力:静态资源挂载、CORS 中间件、BackgroundTasks。
推荐项目结构
demo/ ├── main.py # FastAPI入口,CORS、静态挂载、注册接口 ├── tasks/ │ └── background.py # 后台任务,发短信、写日志 ├── static/ │ ├── css/style.css # 样式 │ ├── js/app.js # 前端fetch(含自定义请求头) │ ├── images/logo.svg # Logo图片 │ └── demo.html # 注册页(也可用 Live Server 打开) ├── logs/ # 运行后自动生成:注册日志 ├── requirements.txt └── test_main.http # HTTP 测试用例一、挂载本地静态文件夹
/static/css/style.css—页面样式/static/js/app.js—提交表单的JavaScript/static/images/logo.svg—页面图标
这些文件放在服务器本地磁盘,不需要经过Python业务逻辑,FastAPI用StaticFiles直接映射URL到目录。
需要在main中实现代码实现代码:
frompathlibimportPathfromfastapi.staticfilesimportStaticFiles STATIC_DIR=Path(__file__).resolve().parent/"static"app.mount("/static",StaticFiles(directory=str(STATIC_DIR)),name="static")定义静态目录时,代码通过Path(__file__).resolve().parent/"static"构建路径:__file__代表当前脚本文件,resolve()将其彻底解析为绝对路径并清除符号链接,parent获取该文件所在的上级目录,最后利用/运算符将static子文件夹拼接到其后,生成一个确定的Path对象。这一做法的核心目的是确保静态文件目录与项目代码处于同一级目录下,无论应用从何处启动,路径定位始终可靠,从而增强了项目的可移植性和部署时的稳定性。
挂载操作通过app.mount()方法实现,它将路由前缀/static与本地静态目录绑定,使得客户端访问http://域名/static/xxx时能够直接获取对应的本地文件资源。需要注意的是,StaticFiles的directory参数要求传入字符串形式,因此必须用str(STATIC_DIR)显式转换;同时,指定name="static"为挂载点命名,便于在应用内部通过request.url_for("static", path="...")动态反向生成静态资源的完整 URL,既保证了静态文件的正常服务,也提升了代码中资源引用的灵活性与可维护性。
访问http://127.0.0.1:8000/static/images/logo.svg时,FastAPI会从static/images/logo.svg读取文件并返回,并自动设置合适的Content-Type。
返回HTML页面
用FileResponse返回demo.html,示例代码为:
fromfastapi.responsesimportFileResponse@app.get("/demo")asyncdefdemo_page():returnFileResponse(STATIC_DIR/"demo.html")StaticFiles适合css/js/图片等纯静态资源,单个HTML页面用FileResponse更灵活可以加鉴权、动态变量等。
下面是几点注意事项
| 要点 | 说明 |
|---|---|
mount路径 | 以/static挂载后,目录内css/style.css对应URL/static/css/style.css |
| 挂载顺序 | mount的路由优先级低于普通@app.get路由;避免路径冲突 |
| 生产环境 | 大量静态资源建议走Nginx/CDN,Python进程专注API |
| 缓存 | 生产可在Nginx层加Cache-Control,开发阶段--reload即可 |
二、CORS 跨域中间件完整配置
前端在http://127.0.0.1:5500,API在http://127.0.0.1:8000——协议相同、域名相同、端口不同,浏览器判定为跨域。
若不做处理,浏览器控制台会出现:
Access to fetch at 'http://127.0.0.1:8000/api/users/register' from origin 'http://127.0.0.1:5500' has been blocked by CORS policy在本案例中的代码示例为:
fromfastapi.middleware.corsimportCORSMiddleware ALLOWED_ORIGINS=["http://127.0.0.1:5500","http://localhost:5500","http://127.0.0.1:5173",# Vite的默认端口"http://localhost:5173",]app.add_middleware(CORSMiddleware,allow_origins=ALLOWED_ORIGINS,allow_credentials=True,allow_methods=["GET","POST","PUT","DELETE","OPTIONS"],allow_headers=["*"],expose_headers=["X-Request-Id"],max_age=600,)下面是对上面代码的参数解析:
| 参数 | 作用 | 案例取值理由 |
|---|---|---|
allow_origins | 允许哪些源访问 | 开发环境列出前端地址;生产写具体域名 |
allow_credentials | 是否允许携带Cookie | 登录态场景需要True |
allow_methods | 允许的HTTP方法 | 必须包含OPTIONS(预检用)和POST |
allow_headers | 允许客户端发送的请求头 | Demo前端发了X-Client-Version |
expose_headers | 允许JS读取的响应头 | 如X-Request-Id用于链路追踪 |
max_age | 预检结果缓存秒数 | 600秒内重复请求不再发OPTIONS |
前端预检请求OPTIONS的坑
constres=awaitfetch(`${API_BASE}/api/users/register`,{method:"POST",headers:{"Content-Type":"application/json","X-Client-Version":"demo-1.0",// 非简单头触发预检},body:JSON.stringify(payload),});浏览器在真正发POST之前,会先发送OPTIONS预检请求,询问服务器:"我能不能从5500端口、用POST、带X-Client-Version头访问你。"整体实际的流程如下图所示:
常见的踩坑与修改如下表所示:
| 现象 | 原因 | 修复 |
|---|---|---|
| OPTIONS 404 | 路由只注册了POST,没处理OPTIONS | 使用CORSMiddleware,它会自动处理OPTIONS |
| 预检通过但POST失败 | allow_headers未包含自定义头 | 加入X-Client-Version或设allow_headers=["*"] |
| Cookie带不过去 | allow_origins=["*"]与allow_credentials=True冲突 | 必须写具体域名,不能* |
127.0.0.1vslocalhost | 浏览器视为不同源 | 两个都加到allow_origins |
| 改了CORS仍报错 | 浏览器缓存了失败的预检 | 清缓存或用无痕窗口使用 |
三、BackgroundTasks注册后的短信与日志
案例中的需求用户点击立即注册后,主流程进行校验手机号、写入内存数据库、返回201(用户无需等待);后置任务发送欢迎短信、写JSONL审计日志。这些后置任务失败不应影响注册结果,且不应拖慢HTTP响应。
后台任务函数示例:
defsend_sms(phone:str,message:str)->None:importtime time.sleep(0.5)# 模拟同步I/Oprint(f"[SMS]->{phone}:{message}")defwrite_registration_log(user_id:int,phone:str,nickname:str)->None:# 追加写入logs/registrations.jsonl...注册接口示例:
fromfastapiimportBackgroundTasks@app.post("/api/users/register")asyncdefregister_user(body:UserRegisterRequest,background_tasks:BackgroundTasks):# 校验、保存用户...background_tasks.add_task(send_sms,body.phone,f"欢迎{body.nickname}!")background_tasks.add_task(write_registration_log,user_id,body.phone,body.nickname)returnUserRegisterResponse(user_id=user_id,...)执行的时序图如下图所示:
四、后台任务与异步任务,别搞混!
概念对照
| async/await异步路由 | BackgroundTasks后台任务 | |
|---|---|---|
| 目的 | 在等待I/O时释放事件循环 | 响应返回后执行善后工作 |
| 用户是否等待 | 是,必须等路由函数执行完 | 否,响应已先返回 |
| 典型场景 | 查数据库、调HTTP API异步客户端 | 发短信、写日志、发邮件 |
| 函数类型 | 路由用async def+await | 任务函数通常是普通 def同步 |
| 失败影响 | 直接导致HTTP 5xx | 不影响已返回的响应 |
简单的说这次请求还需要等它做完用异步I/O,这次请求已经答完了,顺便帮我把后面的事做了用后台任务。
五、BackgroundTasks与Celery的适用边界
针对后台任务的处理,BackgroundTasks与Celery在适用边界上差异显著。前者作为FastAPI内置组件,无需额外依赖,在同一进程的线程池中执行任务,部署简单且启动成本极低,适合低 QPS、短耗时且允许任务丢失的场景如发送通知或记录日志,但它存在明显局限——任务完全驻留内存,进程重启即丢失,不支持延迟执行、定时触发、自动重试,且缺乏有效的监控手段
而Celery需引入Redis或RabbitMQ作为消息代理并独立运行Worker进程,部署和维护成本较高,却能提供消息持久化、countdown/eta定时调度、内置重试机制如autoretry_for以及Flower等可视化监控,其独立进程和水平扩展能力使其能够承载高QPS、长耗时且要求可靠交付的生产级任务。因此,开发初期或轻量级需求可优先选用BackgroundTasks以快速迭代,一旦业务对可靠性、延迟容忍度或任务规模提出更高要求,则应果断迁移至Celery,实现架构的平滑演进。
下面提供对比表:
| 维度 | BackgroundTasks | Celery(+ Redis/RabbitMQ) |
|---|---|---|
| 部署 | 零依赖,FastAPI内置 | 需Broker、Worker进程 |
| 进程模型 | 同一FastAPI进程内线程池 | 独立Worker,可水平扩展 |
| 持久化 | 任务在内存,进程重启即丢失 | 消息持久化在队列 |
| 延迟/定时 | 不支持 | 支持countdown、eta、Crontab |
| 重试 | 需自己写try/except | 内置autoretry_for、max_retries |
| 监控 | 仅 print / 日志 | Flower、Prometheus 等 |
| 适用规模 | 低QPS、短任务、可丢失 | 高QPS、长任务、必须可靠 |
七、小结
在案例实践中,静态资源通过app.mount("/static", StaticFiles(...))挂载页面所需的CSS、JS及图标;为解决前端5500端口调用后端8000端口服务的跨域问题,利用CORSMiddleware配置中间件,并通过设定allow_headers与allow_methods对X-Client-Version等自定义头部触发的OPTIONS预检请求做好应答;对于注册后发短信、写日志等非即时响应任务,则使用BackgroundTasks.add_task()快速实现异步后台执行。
在技术选型上,MVP阶段直接采用轻量的BackgroundTasks即可满足需求,随着业务对可靠性、延迟或大规模任务处理的更高要求,可逐步演进至Celery等专业任务队列,体现按业务阶段渐进式迭代的务实思路。