本篇用一个 Todo List 项目讲 FastAPI 后端基础开发, 骨架搭建。
核心思路只有一句:
公共能力放全局,业务能力放局部。
也就是:
配置、数据库、异常、日志、分页、鉴权
-> 放 app/core、app/db、app/schemas、app/dependencies
Todo 自己的接口、入参、业务逻辑、数据访问、表模型
-> 放 app/modules/todos
一、目录结构
FastAPI 官方推荐用 APIRouter 拆分多文件项目。这里在此基础上按业务模块组织代码。
为什么和官网目录不完全一样:
FastAPI 官方 Bigger Applications 文档里的示例目录大致是这样:
app/
├── __init__.py
├── main.py # 应用入口
├── dependencies.py # 公共依赖
├── routers/ # 路由文件
│ ├── __init__.py
│ ├── items.py
│ └── users.py
└── internal/ # 内部接口
├── __init__.py
└── admin.py
这个结构重点是教你三件事:
main.py
-> 创建 FastAPI 应用,include_router
dependencies.py
-> 放公共 Depends
routers/
-> 用 APIRouter 把接口拆到多个文件
这种方式简单,适合接口少的小项目,也适合刚开始理解 FastAPI 多文件组织。
但业务变多后,Todo 相关代码可能会分散在多个横向目录里:
routers/todos.py
schemas/todo.py
services/todo_service.py
repositories/todo_repository.py
models/todo.py
改一个 Todo 功能,要在多个目录之间来回跳。
所以这里推荐按业务模块组织:
modules/todos/
-> Todo 自己的 router、schemas、service、repository、models 都放一起
这样做的目的不是“显得更复杂”,而是让团队开发时更好找代码:
改 Todo 业务
-> 先看 app/modules/todos/
改全局配置
-> 看 app/core/config.py
改数据库连接
-> 看 app/db/session.py
改公共分页
-> 看 app/schemas/common.py 和 app/dependencies/pagination.py
推荐结构:
todo-api/
├── app/
│ ├── __init__.py
│ ├── main.py # 应用入口:创建 FastAPI,注册全局能力,挂载总路由
│ │
│ ├── api/
│ │ ├── __init__.py
│ │ └── v1/
│ │ ├── __init__.py
│ │ └── router.py # 聚合 v1 版本下所有业务 router
│ │
│ ├── core/ # 系统级公共能力
│ │ ├── __init__.py
│ │ ├── config.py # 配置读取
│ │ ├── exceptions.py # 全局异常
│ │ ├── logging.py # 日志配置
│ │ └── security.py # 密码、Token 等安全工具
│ │
│ ├── db/ # 数据库基础设施
│ │ ├── __init__.py
│ │ ├── base.py # ORM Base
│ │ ├── models.py # 统一导入所有业务模块的 ORM 模型,给 Alembic 使用
│ │ └── session.py # engine、SessionLocal
│ │
│ ├── schemas/ # 跨模块公共 schema
│ │ ├── __init__.py
│ │ └── common.py # ApiResponse、PageResponse
│ │
│ ├── dependencies/ # 跨模块公共 Depends
│ │ ├── __init__.py
│ │ ├── database.py # 数据库 session 依赖
│ │ ├── pagination.py # 分页依赖
│ │ └── auth.py # 当前用户、权限依赖
│ │
│ ├── modules/ # 业务模块
│ │ ├── __init__.py
│ │ └── todos/
│ │ ├── __init__.py
│ │ ├── router.py # Todo HTTP 接口
│ │ ├── schemas.py # Todo 请求体、响应体
│ │ ├── service.py # Todo 业务逻辑
│ │ ├── repository.py # Todo 数据库读写
│ │ └── models.py # Todo ORM 模型
│ │
│ └── utils/ # 纯工具函数
│ ├── __init__.py
│ └── time.py
│
├── migrations/ # Alembic 迁移目录
│ ├── env.py # Alembic 配置入口,设置 target_metadata
│ └── versions/ # 具体迁移文件
│
├── tests/
│ ├── conftest.py
│ └── modules/
│ └── test_todos.py
│
├── .env # 本地环境变量
├── alembic.ini # Alembic 配置文件
└── pyproject.toml # 项目依赖和工具配置
目录规则:
模块自己的东西
-> app/modules/{module_name}/
跨模块公共能力
-> app/core、app/db、app/schemas、app/dependencies、app/utils
二、项目初始化
为什么必要:
先统一依赖管理和启动方式,团队成员才不会各跑各的。
采用技术:
uv。它是现在 Python 社区常用的依赖和虚拟环境工具,速度快,命令也简单。
可以先把 uv 理解成:
uv init
-> 创建项目
uv add
-> 安装依赖
uv add --dev
-> 安装只在开发阶段用的依赖
uv run
-> 在当前项目环境里运行命令
安装依赖:
# 创建项目
uv init todo-api
cd todo-api
# 安装 FastAPI 和常用标准依赖
uv add "fastapi[standard]"
# 安装数据库、迁移、配置管理相关依赖
uv add sqlalchemy alembic pydantic-settings
# 安装测试工具,只在开发阶段使用
uv add --dev pytest
建议的启动命令:
uv run fastapi dev app/main.py
三、应用入口
main.py 是应用装配入口,不写业务逻辑。
FastAPI() 创建应用,include_router() 挂载路由。
from fastapi import FastAPI
from app.api.v1.router import api_router
from app.core.exceptions import register_exception_handlers
from app.core.logging import setup_logging
def create_app() -> FastAPI:
# 创建 FastAPI 应用实例
app = FastAPI(title="Todo API")
# 注册日志、异常、路由等全局能力
setup_logging()
register_exception_handlers(app)
app.include_router(api_router, prefix="/api/v1")
return app
# fastapi dev app/main.py 会读取这个 app 变量
app = create_app()
四、路由分层
为什么必要:
业务接口不能全部写在 main.py,否则项目一大就很难维护。
采用技术:
FastAPI APIRouter。
APIRouter 可以先理解成“接口分组器”:
APIRouter()
-> 当前模块自己的路由集合
@router.get()
-> 定义 GET 接口
app.include_router()
-> 把模块路由挂到主应用
app/api/v1/router.py
from fastapi import APIRouter
from app.modules.todos.router import router as todos_router
# v1 版本的总路由
api_router = APIRouter()
# Todo 模块统一挂到 /api/v1/todos
api_router.include_router(todos_router, prefix="/todos", tags=["todos"])
app/modules/todos/router.py
from fastapi import APIRouter, status
from app.modules.todos.schemas import TodoCreate, TodoResponse
# 当前文件只管理 Todo 相关接口
router = APIRouter()
@router.post("", response_model=TodoResponse, status_code=status.HTTP_201_CREATED)
def create_todo(payload: TodoCreate) -> TodoResponse:
# payload 是经过 Pydantic 校验后的请求体
return TodoResponse(id=1, title=payload.title, is_completed=False)
最终接口地址:
因为存在两个 prefix,所以接口地址为:
POST /api/v1/todos
-> 创建 Todo
GET /api/v1/todos/{todo_id}
-> 查看 Todo 详情
五、请求和响应模型
为什么必要:
接口入参、出参必须有结构,不能随便收 dict、随便返回 dict。
采用技术:
Pydantic。FastAPI 会用它做参数校验、序列化和 OpenAPI 文档生成。
Pydantic 的基本语法:
BaseModel
-> 定义数据模型
Field()
-> 给字段加规则,比如最小长度、最大长度
model_dump()
-> 把模型转成 dict
app/modules/todos/schemas.py
from pydantic import BaseModel, Field
class TodoCreate(BaseModel):
# title 是必填字段,最少 1 个字符
title: str = Field(min_length=1, max_length=100)
# description 可以不传,所以默认是 None
description: str | None = Field(default=None, max_length=500)
class TodoUpdate(BaseModel):
# 更新时允许只改标题
title: str | None = Field(default=None, min_length=1, max_length=100)
# 更新时允许只改描述
description: str | None = Field(default=None, max_length=500)
# 是否完成
is_completed: bool | None = None
class TodoResponse(BaseModel):
# 返回给前端的 Todo id
id: int
# Todo 标题
title: str
# Todo 描述
description: str | None = None
# 是否完成
is_completed: bool
六、ORM 数据库映射
为什么必要:
接口数据最终要落库,需要用 Python 代码描述数据库表。
采用技术:
SQLAlchemy 2.x ORM。
ORM 全称是 Object Relational Mapping,也就是“对象关系映射”:
Python 对象
-> 映射到数据库表
Python 对象的属性
-> 映射到数据库字段
所以这里说的 models.py,意思是“用 Python 类定义数据库表结构”:
class Todo(Base)
-> 一张表
__tablename__
-> 表名
Mapped[int]
-> SQLAlchemy 2.x 的 ORM 字段类型标注
mapped_column()
-> 数据库字段配置
比如:
title: Mapped[str] = mapped_column(String(100), nullable=False)
Mapped[str]
-> 这个 Python 属性读出来是 str
mapped_column(String(100), nullable=False)
-> 数据库里是 varchar(100),并且不能为空
app/db/base.py
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
# 所有数据库表模型都继承这个 Base
pass
app/modules/todos/models.py
from datetime import datetime
from sqlalchemy import Boolean, DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class Todo(Base):
# 数据库表名
__tablename__ = "todos"
# 主键 id,自增
id: Mapped[int] = mapped_column(primary_key=True, index=True)
# Todo 标题
title: Mapped[str] = mapped_column(String(100), nullable=False)
# Todo 描述,可以为空
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
# 是否完成
# Python 里读写是 True/False
# 数据库底层可能存成 boolean,也可能存成 0/1
# 这一步不用自己手动转,SQLAlchemy 的 Boolean 类型会配合数据库方言做转换
is_completed: Mapped[bool] = mapped_column(Boolean, default=False)
# 创建时间
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# 更新时间
updated_at: Mapped[datetime] = mapped_column(
DateTime,
default=datetime.utcnow,
onupdate=datetime.utcnow,
)
注意:
models.py
-> 数据库表结构
schemas.py
-> 接口请求体和响应体
这两个不要混用。
七、配置管理
为什么必要:
数据库地址、密钥、运行环境不能写死在代码里。
采用技术:
pydantic-settings。
pydantic-settings 可以先理解成“用 Pydantic 的方式读取配置”:
BaseSettings
-> 定义配置类
SettingsConfigDict(env_file=".env")
-> 指定本地配置文件
settings = Settings()
-> 创建全局配置对象
.env
APP_ENV=development
DEBUG=true
DATABASE_URL=sqlite:///./todo.db
app/core/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
# 当前运行环境
app_env: str = "development"
# 是否开启调试模式
debug: bool = False
# 数据库连接地址
database_url: str
# SettingsConfigDict 用来配置 Settings 的读取行为
model_config = SettingsConfigDict(
# 指定本地开发时读取哪个环境变量文件
env_file=".env",
# 指定 .env 文件编码,避免中文或特殊字符乱码
env_file_encoding="utf-8",
# .env 里出现 Settings 没定义的字段时,直接忽略,不报错
extra="ignore",
)
# 全局统一使用这个 settings
settings = Settings()
业务代码只导入 settings,不要到处写 os.getenv()。
如果想像前端一样区分环境,也可以这样约定:
.env
-> 默认配置
.env.development
-> 本地开发配置
.env.production
-> 生产环境配置
pydantic-settings 支持一次读取多个 env 文件:
class Settings(BaseSettings):
app_env: str = "development"
debug: bool = False
database_url: str
model_config = SettingsConfigDict(
# 后面的文件会覆盖前面的同名配置
env_file=(".env", ".env.development"),
env_file_encoding="utf-8",
extra="ignore",
)
也可以在创建 Settings 时指定读取哪个文件:
# 读取开发环境配置
settings = Settings(_env_file=".env.development")
# 读取生产环境配置
settings = Settings(_env_file=".env.production")
实际项目里更常见的是:
本地开发
-> 读取 .env.development
测试 / 生产
-> 优先使用系统环境变量或部署平台注入的环境变量
注意:生产环境的真实密钥、数据库密码不要提交到 Git。
八、数据库连接池
为什么必要:
接口每次访问数据库,都需要连接数据库。
不能每个接口自己创建连接、关闭连接,这样代码乱,且性能也差。
所以这里要统一管理:
数据库连接池
-> 负责复用数据库连接
Session
-> 负责一次请求里的数据库操作
这里的 Session 不是登录 session,也不是浏览器 cookie。
它是 SQLAlchemy 里的“数据库操作对象”:
Session
-> 帮你执行查询
-> 帮你新增、修改、删除数据
-> 帮你提交事务 commit
-> 请求结束后释放连接
你可以把它简单理解成:
engine
-> 管连接池
Session
-> 从连接池拿一条连接,完成这次数据库操作
采用技术:
SQLAlchemy engine + Session + FastAPI Depends。
这里有三个新概念:
engine
-> 数据库引擎,内部会管理连接池
Session
-> 一次数据库操作上下文
Depends
-> FastAPI 的依赖注入,把公共对象交给接口函数
app/db/session.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
# 创建数据库 engine
# engine 内部会管理数据库连接池
engine = create_engine(settings.database_url)
# 创建 Session 工厂
# 后面每个请求都从这个工厂创建一个 Session
SessionLocal = sessionmaker(
bind=engine,
autoflush=False,
autocommit=False,
)
app/dependencies/database.py
from collections.abc import Generator
from typing import Annotated
from fastapi import Depends
from sqlalchemy.orm import Session
from app.db.session import SessionLocal
def get_db() -> Generator[Session, None, None]:
# 每个请求创建一个 Session
# Session 会通过 engine 去拿数据库连接
# 后面的 repository 里会用这个 db 查询、新增、提交数据
db = SessionLocal()
try:
yield db
finally:
# 请求结束后关闭 Session
# 这里不是销毁数据库连接,而是把连接还回连接池
db.close()
# 路由里直接使用 DbSession,少写 Depends(get_db)
DbSession = Annotated[Session, Depends(get_db)]
定义好 DbSession 后,接口里就可以这样写:
def list_todos(db: DbSession):
...
FastAPI 会自动执行 get_db(),把数据库 Session 传进来。
九、数据访问层
为什么必要:
数据库读写统一放 repository,service 不直接写查询细节。
常用数据库操作先记这几个:
db.add(obj)
-> 新增
db.commit()
-> 提交事务
db.refresh(obj)
-> 刷新对象,拿到数据库生成的 id
select(Todo)
-> 查询 Todo 表
db.get(Todo, todo_id)
-> 按主键查询
app/modules/todos/repository.py
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.modules.todos.models import Todo
from app.modules.todos.schemas import TodoCreate, TodoUpdate
class TodoRepository:
def __init__(self, db: Session) -> None:
# repository 只负责数据库操作
self.db = db
def create(self, payload: TodoCreate) -> Todo:
# 创建 ORM 对象
todo = Todo(
title=payload.title,
description=payload.description,
)
# 添加到当前事务
self.db.add(todo)
self.db.commit()
# 刷新后可以拿到数据库生成的 id
self.db.refresh(todo)
return todo
def list(self, offset: int, limit: int) -> list[Todo]:
# 查询 Todo 列表
stmt = select(Todo).offset(offset).limit(limit)
return list(self.db.scalars(stmt).all())
def get_by_id(self, todo_id: int) -> Todo | None:
# 根据主键查询
return self.db.get(Todo, todo_id)
def update(self, todo: Todo, payload: TodoUpdate) -> Todo:
# model_dump() 会把 Pydantic 模型转成 dict
# exclude_unset=True 表示只取用户真正传入的字段
# 例如只传 {"title": "新标题"},就不会把 description 改成 None
update_data = payload.model_dump(exclude_unset=True)
for key, value in update_data.items():
# setattr 是 Python 内置函数,用来“按字段名动态赋值”
# setattr(todo, "title", "新标题")
# 等价于 todo.title = "新标题"
setattr(todo, key, value)
self.db.commit()
self.db.refresh(todo)
return todo
def delete(self, todo: Todo) -> None:
# 删除一条 Todo
self.db.delete(todo)
self.db.commit()
十、业务层
为什么必要:
业务规则放 service,router 不直接处理复杂逻辑。
app/modules/todos/service.py
from sqlalchemy.orm import Session
from app.core.exceptions import NotFoundError
from app.modules.todos.models import Todo
from app.modules.todos.repository import TodoRepository
from app.modules.todos.schemas import TodoCreate, TodoUpdate
def create_todo(db: Session, payload: TodoCreate) -> Todo:
# service 编排业务流程,具体数据库操作交给 repository
repository = TodoRepository(db)
return repository.create(payload)
def list_todos(db: Session, page: int, size: int) -> list[Todo]:
repository = TodoRepository(db)
# 页码从 1 开始,数据库 offset 从 0 开始
offset = (page - 1) * size
return repository.list(offset=offset, limit=size)
def get_todo(db: Session, todo_id: int) -> Todo:
repository = TodoRepository(db)
todo = repository.get_by_id(todo_id)
# 找不到就抛业务异常
if todo is None:
raise NotFoundError("Todo 不存在")
return todo
def update_todo(db: Session, todo_id: int, payload: TodoUpdate) -> Todo:
repository = TodoRepository(db)
todo = get_todo(db, todo_id)
return repository.update(todo, payload)
def delete_todo(db: Session, todo_id: int) -> None:
repository = TodoRepository(db)
todo = get_todo(db, todo_id)
repository.delete(todo)
十一、异常处理
为什么必要:
业务错误要统一返回,不能每个接口各写各的错误格式。
采用技术:
FastAPI exception handler。
app/core/exceptions.py
from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse
class BusinessError(Exception):
# 所有业务异常的基类
status_code = status.HTTP_400_BAD_REQUEST
def __init__(self, message: str) -> None:
self.message = message
class NotFoundError(BusinessError):
# 资源不存在
status_code = status.HTTP_404_NOT_FOUND
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(BusinessError)
async def business_error_handler(
request: Request,
exc: BusinessError,
) -> JSONResponse:
# 所有业务异常统一走这里
return JSONResponse(
status_code=exc.status_code,
content={
"code": exc.status_code,
"message": exc.message,
"data": None,
},
)
业务里直接抛异常就行。
比如 Todo 不存在:
from app.core.exceptions import NotFoundError
def get_todo(db: Session, todo_id: int) -> Todo:
todo = repository.get_by_id(todo_id)
if todo is None:
# 业务里只关心“发生了什么错误”
raise NotFoundError("Todo 不存在")
return todo
执行过程:
service 抛出 NotFoundError
-> NotFoundError 继承 BusinessError
-> FastAPI 捕获 BusinessError
-> 自动执行 business_error_handler
-> 返回统一 JSON 错误响应
最终响应大概是:
{
"code": 404,
"message": "Todo 不存在",
"data": null
}
十二、公共响应和分页
为什么必要:
前端需要稳定的响应结构,分页也不应该每个模块重复定义。
采用技术:
Pydantic 泛型模型。
这里的泛型可以简单理解成“响应壳里包不同类型的数据”:
T
-> 先占个位置,表示这里以后会放某种类型
Generic[T]
-> 把这个类声明成“可以带类型参数的类”
-> 这样 PageResponse[TodoResponse] 这种写法才成立
PageResponse[TodoResponse]
-> 分页里装 TodoResponse
PageResponse[UserResponse]
-> 分页里装 UserResponse
app/schemas/common.py
from typing import Generic, TypeVar
from pydantic import BaseModel, Field
# T 是一个类型占位符
# 后面可以替换成 TodoResponse、UserResponse 等具体类型
T = TypeVar("T")
class ApiResponse(BaseModel, Generic[T]):
# Generic[T] 的作用是把 ApiResponse 声明成泛型类
# 使用时可以写 ApiResponse[TodoResponse]
# 这样 data: T 里的 T 就会被替换成 TodoResponse
# 业务状态码,0 通常表示成功
code: int = 0
# 给前端展示或调试的信息
message: str = "success"
# 真正的数据,类型由外面传进来的 T 决定
data: T
class PageQuery(BaseModel):
# 当前页码,从 1 开始
page: int = Field(default=1, ge=1)
# 每页数量,限制最大值避免一次查太多
size: int = Field(default=20, ge=1, le=100)
class PageResponse(BaseModel, Generic[T]):
items: list[T]
# 当前页码
page: int
# 每页数量
size: int
def success(data: T, message: str = "success") -> ApiResponse[T]:
# 成功响应统一从这里创建
# 这样 router 里不用每次手写 ApiResponse(data=...)
return ApiResponse(
code=0,
message=message,
data=data,
)
ApiResponse 用在需要统一响应壳的接口里。
比如创建 Todo 后,不直接返回 TodoResponse,而是通过 success() 包一层:
from app.modules.todos.schemas import TodoCreate, TodoResponse
from app.schemas.common import ApiResponse, success
@router.post("", response_model=ApiResponse[TodoResponse])
def create_todo(payload: TodoCreate) -> ApiResponse[TodoResponse]:
todo = TodoResponse(
id=1,
title=payload.title,
description=payload.description,
is_completed=False,
)
return success(todo)
最终响应就是:
{
"code": 0,
"message": "success",
"data": {
"id": 1,
"title": "学习 FastAPI",
"description": null,
"is_completed": false
}
}
app/dependencies/pagination.py
from typing import Annotated
from fastapi import Depends, Query
from app.schemas.common import PageQuery
def get_page_query(
page: int = Query(default=1, ge=1),
size: int = Query(default=20, ge=1, le=100),
) -> PageQuery:
# 把查询参数组装成统一的分页对象
return PageQuery(page=page, size=size)
PageQueryDep = Annotated[PageQuery, Depends(get_page_query)]
十三、完整 Todo 接口
为什么必要:
前面的分层最终要在接口里串起来。
采用技术:
APIRouter + Depends + Pydantic response_model。
app/modules/todos/router.py
from fastapi import APIRouter, status
from app.dependencies.database import DbSession
from app.dependencies.pagination import PageQueryDep
from app.modules.todos import service
from app.modules.todos.schemas import TodoCreate, TodoResponse, TodoUpdate
from app.schemas.common import ApiResponse, PageResponse, success
router = APIRouter()
@router.post(
"",
response_model=ApiResponse[TodoResponse],
status_code=status.HTTP_201_CREATED,
)
def create_todo(db: DbSession, payload: TodoCreate) -> ApiResponse[TodoResponse]:
# router 负责接收 HTTP 请求,业务交给 service
todo = service.create_todo(db, payload)
# success() 负责统一包装 ApiResponse
return success(todo)
@router.get("", response_model=ApiResponse[PageResponse[TodoResponse]])
def list_todos(
db: DbSession,
page_query: PageQueryDep,
) -> ApiResponse[PageResponse[TodoResponse]]:
# 分页参数来自公共 Depends
items = service.list_todos(
db,
page=page_query.page,
size=page_query.size,
)
page_data = PageResponse(
items=items,
page=page_query.page,
size=page_query.size,
)
return success(page_data)
@router.get("/{todo_id}", response_model=ApiResponse[TodoResponse])
def get_todo(db: DbSession, todo_id: int) -> ApiResponse[TodoResponse]:
# todo_id 来自路径参数
todo = service.get_todo(db, todo_id)
return success(todo)
@router.patch("/{todo_id}", response_model=ApiResponse[TodoResponse])
def update_todo(
db: DbSession,
todo_id: int,
payload: TodoUpdate,
) -> ApiResponse[TodoResponse]:
# payload 只包含用户要更新的字段
todo = service.update_todo(db, todo_id, payload)
return success(todo)
@router.delete("/{todo_id}", response_model=ApiResponse[None])
def delete_todo(db: DbSession, todo_id: int) -> ApiResponse[None]:
# 如果使用统一响应壳,删除成功也返回 JSON
# 所以这里不使用 204,因为 204 表示没有响应体
service.delete_todo(db, todo_id)
return success(None)
十四、数据库迁移
为什么必要:
表结构变化要可追踪,不能靠手动改库。
采用技术:
Alembic。它是 SQLAlchemy 生态里常用的数据库迁移工具。
Alembic 可以先理解成:
models.py 改了表结构
-> Alembic 生成迁移文件
-> Alembic 执行迁移
-> 数据库表结构被更新
初始化:
uv run alembic init migrations
这条命令会生成两类东西:
alembic.ini
-> Alembic 的主配置文件
migrations/
-> 迁移脚本目录
alembic.ini 里会有类似配置:
[alembic]
script_location = migrations
这行的意思是:
Alembic 执行命令时
-> 去 migrations/ 目录找迁移环境
-> 读取 migrations/env.py
-> 再根据 env.py 里的配置生成或执行迁移
常用命令:
# 生成迁移文件
uv run alembic revision --autogenerate -m "create todos table"
# 执行迁移
uv run alembic upgrade head
注意:Alembic 不会自动扫描 modules/ 下面所有 models.py。
它主要看 migrations/env.py 里的 target_metadata:
from app.db.base import Base
# 必须让模型先被 import,Base.metadata 里才会有这些表
from app.modules.todos import models as todo_models
target_metadata = Base.metadata
也就是说:
Todo 模型没有被 import
-> Base.metadata 里不知道有 todos 表
-> Alembic autogenerate 可能检测不到它
模块多了以后,可以建一个统一导入文件:
app/db/models.py
# 统一导入所有业务模块的数据库模型
from app.modules.todos.models import Todo
from app.modules.users.models import User
然后在 migrations/env.py 里只导入它:
from app.db.base import Base
import app.db.models
target_metadata = Base.metadata
这样新增模块时,只要记得把新模型加到 app/db/models.py,Alembic 就能拿到完整表结构。
--autogenerate 不是“所有表都重新更新一遍”。
它的流程是:
读取当前数据库真实表结构
读取 Base.metadata 里的模型结构
对比两边差异
生成迁移文件
所以如果只改了 todos 表,正常只会生成 todos 相关变更。
但自动生成不是百分百可靠,生成后一定要打开迁移文件检查。
迁移规则:
改 models.py
-> 生成 migration
-> 检查 migration
-> 执行 upgrade
不要直接在线上数据库手动改表。
十五、日志
为什么必要:
上线后排查问题不能靠 print。
采用技术:
Python 标准库 logging。
app/core/logging.py
import logging
def setup_logging() -> None:
# 配置全局日志格式
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
app/modules/todos/service.py
import logging
logger = logging.getLogger(__name__)
def log_create_todo(todo_id: int) -> None:
# 关键业务动作记录日志
logger.info("todo created: id=%s", todo_id)
日志建议:
业务关键动作
-> info
可恢复异常
-> warning
系统异常
-> exception
十六、测试
为什么必要:
工程化最终要能验证行为。没有测试,后面改代码全靠手感。
采用技术:
pytest + FastAPI TestClient。
pytest 的基本语法很简单:
test_ 开头的函数
-> pytest 会自动识别成测试
assert
-> 断言结果是否符合预期
TestClient
-> 不启动真实服务,也能请求 FastAPI 接口
tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
@pytest.fixture
def client() -> TestClient:
# 测试客户端,不需要真的启动 HTTP 服务
return TestClient(app)
tests/modules/test_todos.py
from fastapi.testclient import TestClient
def test_create_todo(client: TestClient) -> None:
response = client.post(
"/api/v1/todos",
json={
"title": "学习 FastAPI 工程化",
"description": "用 Todo List 串起项目结构",
},
)
# 创建成功应该返回 201
assert response.status_code == 201
data = response.json()
assert data["title"] == "学习 FastAPI 工程化"
assert data["is_completed"] is False
def test_get_unknown_todo(client: TestClient) -> None:
response = client.get("/api/v1/todos/999")
# 查询不存在的 Todo 应该返回 404
assert response.status_code == 404
运行测试:
uv run pytest