← 返回学习

FastAPI 全栈模板:技术选型分析

拆解官方模板 full-stack-fastapi-template 的技术选型,逐项讲清楚用了什么、为什么这么用、好在哪、边界在哪。

一、技术栈总览

后端与数据

  • FastAPI:原生异步,自动生成 OpenAPI 文档。
  • SQLModel:结合 Pydantic 校验与 SQLAlchemy 映射。
  • Pydantic v2:用类型注解做运行时校验,并驱动文档生成。
  • PostgreSQL + Alembic:成熟的数据库能力,表结构变更可版本化、回滚。
  • JWT(HS256)+ Argon2 / Bcrypt:前后端分离认证,支持密码哈希的渐进式迁移。

前端

  • React 19 + TypeScript:组件化开发和类型安全。
  • Vite 8:快速冷启动与热更新。
  • Tailwind CSS 4 + shadcn/ui:原子化样式与可复制的组件。
  • TanStack Query / Router / Table:数据请求、类型化路由和表格。
  • React Hook Form + Zod:表单管理与校验。

工具与部署

  • uv + bun:Python 与 JS 依赖管理,都支持锁文件。
  • Docker Compose + Traefik:服务编排、路由和 HTTPS。

二、为什么用 SQLModel,而不是直接 SQLAlchemy

用了什么:ORM 层选 SQLModel,一个由 FastAPI 作者(Sebastián Ramírez)设计的库。

为什么这么用:

SQLModel 的本质是「Pydantic + SQLAlchemy」的融合。传统做法里,这两件事是割裂的:

  • Pydantic 负责请求/响应的数据校验(FastAPI 的接口层)
  • SQLAlchemy 负责数据库表映射(持久层)

同一个 User 实体,往往要写两套几乎一样的类:一套 Pydantic 模型、一套 SQLAlchemy 模型。字段重复、改一处要改两处、容易不一致。

SQLModel 用一个类同时承载两种能力,消除了这种重复。

好处:

  • 一个模型同时用于「校验请求」和「映射数据库表」,减少一半的模型代码
  • 类型提示贯通全程,IDE 补全和静态检查都能吃到
  • 和 FastAPI、Pydantic 天然同源,配合无摩擦

代价 / 边界:SQLModel 比纯 SQLAlchemy 年轻,极复杂的关系映射和高级特性覆盖不如后者全。所以它适合中小型项目;超大、超复杂的数据模型场景,仍可能回退到原生 SQLAlchemy。

三、为什么一个实体要拆成多个模型类

用了什么:models.py 里,User 和 Item 每个实体都拆成了多个类——XxxBase、XxxCreate、XxxUpdate、Xxx(表)、XxxPublic。

为什么这么用:

不同场景对「字段可见性」的要求是冲突的,用一个类无法同时满足:

场景 需要的字段 不能出现的字段
用户创建(Create) 必填字段 id、owner_id、created_at(后端生成)
数据库表(table) 全部字段 无
返回前端(Public) 可公开字段 hashed_password 等敏感字段
更新(Update) 全部可选 无(PATCH 语义)

好处:

  1. 安全:UserPublic 没有 hashed_password,密码哈希从结构上就无法泄漏到响应;UserRegister(自注册)没有 is_superuser,用户从结构上就无法给自己提权。
  2. 契约清晰:哪个字段进、哪个字段出,由类定义决定,不靠函数里的 if 判断。
  3. 变更可控:加一个字段,能精确控制它出现在「入参」「出参」还是「数据库」哪一层。

核心思想:好的模型设计不只是为了复用,更为了限制——明确每个方向允许流动哪些数据,本身就是防御。

四、为什么用依赖注入做鉴权

用了什么:FastAPI 的 Depends + Annotated 类型别名,把鉴权、数据库会话封装成 SessionDep、CurrentUser 这类可复用依赖。

SessionDep = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[User, Depends(get_current_user)]

为什么这么用:

鉴权、数据库连接这些逻辑,和具体业务无关,但几乎每个接口都要。如果写进每个接口函数里,会导致:

  • 大量重复代码(每个接口都 copy 一段验 token、开连接)
  • 修改困难(改鉴权逻辑要改几十处)
  • 业务代码被淹没在样板代码里

依赖注入把这类「横切关注点」抽出来,通过参数类型声明的方式注入。

好处:

  1. 业务函数只剩业务:接口函数里看不到任何鉴权、连接管理的样板代码。
  2. 声明式:函数参数写上 current_user: CurrentUser,就自动完成「验 token → 查用户 → 查权限」整套流程。
  3. 可组合:get_current_active_superuser 依赖 get_current_user,链式叠加出「管理员专属」这种更高权限。
  4. 改一处全局生效:改 get_current_user 的实现,所有用到它的接口同步更新。

五、为什么权限写在查询条件里,而不是 if 里

用了什么:列表接口通过 where 子句做数据隔离,而不是查全表后过滤。

# 普通用户只能查自己的
select(Item).where(Item.owner_id == current_user.id)

为什么这么用:

两种做法结果一样,但代价天差地别:

  • 数据库层过滤:数据库只返回属于当前用户的数据,网络传输和内存占用都是 O(自己的数据量)。
  • 应用层过滤:先把全表读进内存,再丢弃别人的数据,是 O(全表数据量)。

好处:

  1. 性能:数据量大时,两者的差距是数量级级别的。
  2. 安全:数据从源头就不离开数据库,不存在「读出来再漏」的风险。
  3. 可扩展:未来加索引、加分库分表,都能在 SQL 层继续优化。

原则:权限控制必须下沉到数据层,不能只在应用层做判断。

六、为什么密码哈希用两个算法

用了什么:pwdlib 库,同时配置 Argon2Hasher 和 BcryptHasher 两个哈希器。

为什么这么用:

  • Argon2id 是 2015 年密码哈希竞赛的冠军,设计上需要大内存,能有效抵抗 GPU 暴力破解,是当前推荐的首选。
  • Bcrypt 是上一代主流算法,大量历史系统的密码用它存储。

如果只用 Argon2,历史 Bcrypt 密码就无法校验;如果只用 Bcrypt,就无法享受新算法的安全性提升。所以两个都要。

好处(关键在 verify_and_update 这个设计):

校验密码的函数返回元组 (是否通过, 需要更新的新哈希)。当用户密码还是旧的 Bcrypt 哈希时,校验通过的同时会返回一个新的 Argon2 哈希,系统静默写回数据库。

效果:用户无需任何操作,只需正常登录一次,密码算法就完成了从 Bcrypt 到 Argon2 的迁移。这是生产系统升级密码算法的标准做法——渐进式、无感知,而不是停服强制改密码。

七、为什么用 JWT 做认证

用了什么:JWT(HS256 算法),登录后签发 access_token,前端存储后每次请求带上。

为什么这么用:

前后端分离架构下,后端是无状态的——不保存 session,服务器可以水平扩展,任意一台机器都能处理请求。

JWT 的特点正好契合:token 自带用户身份信息(sub 字段存用户 ID),服务端用密钥验签即可,无需查询 session 存储。

好处:

  1. 无状态:服务端不存 session,天然支持横向扩容。
  2. 自包含:token 里带身份标识,验证不依赖额外存储。
  3. 跨域友好:token 放在请求头,天然适配前后端分离。

边界(同样要清楚):

  • JWT 是签名不是加密,payload 可被 base64 解码,所以绝不能放敏感信息,只能放用户 ID 这种标识符。
  • 因为无状态,token 在过期前无法强制失效。所以 get_current_user 解完 token 后必须再查一次数据库,确认用户当前状态(是否被禁用、是否还存在),否则禁用用户后他的旧 token 还能用到过期。

八、为什么本地开发用 Docker,而不用原生装数据库

用了什么:PostgreSQL、Mailpit(本地邮件捕获)、Adminer(数据库管理界面)都跑在 Docker Compose 里。

为什么这么用:

数据库、邮件服务这类「基础设施」,是开发环境依赖但又不想污染本机的东西。如果原生安装:

  • 每台机器都要手动装一遍,版本还可能不一致
  • 卸载、升级、清理都麻烦
  • 团队成员环境难以对齐

好处:

  1. 一条命令起环境:docker compose up -d db mailpit,几秒内数据库和邮件服务就绪。
  2. 环境一致:所有人用同一个镜像和版本,消除「我机器上能跑」的问题。
  3. 隔离干净:基础设施都在容器里,不污染本机,随时可删。
  4. Mailpit 的价值:本地开发时,找回密码等邮件被 Mailpit 拦截,在网页上查看,而不会真的发出去。

九、为什么用 Traefik 而不是 Nginx 做反向代理

用了什么:Traefik v3 作为反向代理,负责 HTTPS 和路由转发。

为什么这么用:

Traefik 是「云原生」时代的反向代理,专为容器环境设计。传统 Nginx 需要手写配置文件指定「哪个域名转发到哪个端口」;Traefik 则通过读取 Docker 容器的 label 自动发现服务。

labels:
  - traefik.enable=true
  - traefik.http.routers.backend.rule=Host(`your-domain.com`)

好处:

  1. 自动服务发现:新服务加上 label 就自动接入,不用改代理配置。
  2. HTTPS 自动化:内置 Let’s Encrypt 集成,一行配置完成证书申请和续期。
  3. 安全默认值:exposedbydefault=false,没显式标记的服务默认不暴露到公网。

边界:Traefik 强在容器和微服务场景;如果是有大量定制化 rewrite 规则的复杂静态站点,Nginx 的配置文件反而更直观。选型看场景。

十、为什么前后端契约要自动生成

用了什么:前端 API 客户端不是手写的,而是用 openapi-ts 从后端的 OpenAPI 文档自动生成。

为什么这么用:

传统前后端协作,接口契约靠文档或口头约定,极易出现「后端改了字段、前端不知道」的脱节。这个项目用「代码生成」把契约固化:

Pydantic 模型 → OpenAPI Schema → TypeScript 类型 + 请求函数

好处:

  1. 类型安全:后端改一个字段,重新生成后,前端调用处会直接类型报错,编译期就暴露问题。
  2. 零手写成本:前端不用手写任何请求函数和类型定义。
  3. 单一事实来源:后端模型是唯一真相,前端类型是它的投影,不会出现两套定义打架。

这背后的设计原则:FastAPI 从 Pydantic 模型自动导出 OpenAPI,是这个链路的起点——数据模型只定义一次,文档、校验、前端类型全部派生。

十一、为什么前端要同时用 Tailwind 和 shadcn/ui

用了什么:样式用 Tailwind CSS 4(原子化 CSS),组件用 shadcn/ui。

为什么这么用:

两者解决不同层面的问题:

  • Tailwind:解决「样式怎么写」。原子化类名直接写在元素上,不用维护庞大的 CSS 文件,也不用想类名。
  • shadcn/ui:解决「组件哪来」。它不是 npm 包,而是把组件源码直接复制进你的项目,可以随意修改。

好处:

  1. Tailwind:样式内聚、无全局污染、构建时自动剔除未用样式。
  2. shadcn/ui:组件代码在你手里,可自由定制,不被第三方库锁死;基于 Radix UI,无障碍和交互质量有保证。
  3. 组合效应:Tailwind 让 shadcn/ui 的组件样式易于微调,两者天然契合。

十二、为什么用 uv 和 bun 做包管理

用了什么:Python 用 uv,JS 用 bun,替代传统的 pip 和 npm。

为什么这么用:

两者都是「下一代」包管理器,核心卖点一致——快。

  • uv 用 Rust 实现,安装依赖比 pip 快 10-100 倍
  • bun 用 Zig 实现,安装依赖比 npm 快数倍,且原生支持 monorepo workspace

好处:

  1. 速度快:依赖安装、环境搭建的耗时大幅缩短。
  2. 可复现:都有锁文件(uv.lock、bun.lock),精确锁定版本,保证团队环境一致。
  3. 原生 workspace:一个项目同时管理前端和后端依赖,monorepo 结构清晰。

总结:这套选型的底层逻辑

把上面的选型串起来,能看到一条清晰的选择主线:

选型 背后的原则
SQLModel、自动生成客户端 单一事实来源——数据模型只定义一次,其他全部派生
多模型类、权限下沉 SQL 防御优先——用结构而非逻辑来保证安全
依赖注入 关注点分离——横切逻辑从业务中剥离
Argon2+Bcrypt、JWT 渐进式与无状态——迁移无感知,服务可扩展
Docker、Traefik 环境一致 + 自动化——减少人工配置和人为错误
uv、bun、Tailwind、shadcn/ui 开发效率——把时间花在业务上,而非环境搭建和样板代码

参考链接