本篇用一个 Todo List 项目讲 FastAPI 后端基础开发, 骨架搭建。
核心思路只有一句:
text
公共能力放全局,业务能力放局部。
也就是:
text
配置、数据库、异常、日志、分页、鉴权
-> 放 app/core、app/db、app/schemas、app/dependencies
Todo 自己的接口、入参、业务逻辑、数据访问、表模型
-> 放 app/modules/todos
一、目录结构
FastAPI 官方推荐用 APIRouter 拆分多文件项目。这里在此基础上按业务模块组织代码。
为什么和官网目录不完全一样:
FastAPI 官方 Bigger Applications 文档里的示例目录大致是这样:
text
app/
├── __init__.py
├── main.py # 应用入口
├── dependencies.py # 公共依赖
├── routers/ # 路由文件
│ ├── __init__.py
│ ├── items.py
│ └── users.py
└── internal/ # 内部接口
├── __init__.py
└── admin.py
这个结构重点是教你三件事:
text
main.py
-> 创建 FastAPI 应用,include_router
dependencies.py
-> 放公共 Depends
routers/
-> 用 APIRouter 把接口拆到多个文件
这种方式简单,适合接口少的小项目,也适合刚开始理解 FastAPI 多文件组织。
但业务变多后,Todo 相关代码可能会分散在多个横向目录里:
text
routers/todos.py
schemas/todo.py
services/todo_service.py
repositories/todo_repository.py
models/todo.py
改一个 Todo 功能,要在多个目录之间来回跳。
所以这里推荐按业务模块组织:
text
modules/todos/
-> Todo 自己的 router、schemas、service、repository、models 都放一起
这样做的目的不是"显得更复杂",而是让团队开发时更好找代码:
text
改 Todo 业务
-> 先看 app/modules/todos/
改全局配置
-> 看 app/core/config.py
改数据库连接
-> 看 app/db/session.py
改公共分页
-> 看 app/schemas/common.py 和 app/dependencies/pagination.py
推荐结构:
text
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 # 项目依赖和工具配置
目录规则:
text
模块自己的东西
-> app/modules/{module_name}/
跨模块公共能力
-> app/core、app/db、app/schemas、app/dependencies、app/utils
二、项目初始化
为什么必要:
先统一依赖管理和启动方式,团队成员才不会各跑各的。
采用技术:
uv。它是现在 Python 社区常用的依赖和虚拟环境工具,速度快,命令也简单。
可以先把 uv 理解成:
text
uv init
-> 创建项目
uv add
-> 安装依赖
uv add --dev
-> 安装只在开发阶段用的依赖
uv run
-> 在当前项目环境里运行命令
安装依赖:
bash
# 创建项目
uv init todo-api
cd todo-api
# 安装 FastAPI 和常用标准依赖
uv add "fastapi[standard]"
# 安装数据库、迁移、配置管理相关依赖
uv add sqlalchemy alembic pydantic-settings
# 安装测试工具,只在开发阶段使用
uv add --dev pytest
建议的启动命令:
bash
uv run fastapi dev app/main.py
三、应用入口
main.py 是应用装配入口,不写业务逻辑。
FastAPI() 创建应用,include_router() 挂载路由。
python
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 可以先理解成"接口分组器":
text
APIRouter()
-> 当前模块自己的路由集合
@router.get()
-> 定义 GET 接口
app.include_router()
-> 把模块路由挂到主应用
app/api/v1/router.py
python
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
python
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)
最终接口地址:
text
因为存在两个 prefix,所以接口地址为:
POST /api/v1/todos
-> 创建 Todo
GET /api/v1/todos/{todo_id}
-> 查看 Todo 详情
五、请求和响应模型
为什么必要:
接口入参、出参必须有结构,不能随便收 dict、随便返回 dict。
采用技术:
Pydantic。FastAPI 会用它做参数校验、序列化和 OpenAPI 文档生成。
Pydantic 的基本语法:
text
BaseModel
-> 定义数据模型
Field()
-> 给字段加规则,比如最小长度、最大长度
model_dump()
-> 把模型转成 dict
app/modules/todos/schemas.py
python
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,也就是"对象关系映射":
text
Python 对象
-> 映射到数据库表
Python 对象的属性
-> 映射到数据库字段
所以这里说的 models.py,意思是"用 Python 类定义数据库表结构":
text
class Todo(Base)
-> 一张表
__tablename__
-> 表名
Mapped[int]
-> SQLAlchemy 2.x 的 ORM 字段类型标注
mapped_column()
-> 数据库字段配置
比如:
python
title: Mapped[str] = mapped_column(String(100), nullable=False)
text
Mapped[str]
-> 这个 Python 属性读出来是 str
mapped_column(String(100), nullable=False)
-> 数据库里是 varchar(100),并且不能为空
app/db/base.py
python
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
# 所有数据库表模型都继承这个 Base
pass
app/modules/todos/models.py
python
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,
)
注意:
text
models.py
-> 数据库表结构
schemas.py
-> 接口请求体和响应体
这两个不要混用。
七、配置管理
为什么必要:
数据库地址、密钥、运行环境不能写死在代码里。
采用技术:
pydantic-settings。
pydantic-settings 可以先理解成"用 Pydantic 的方式读取配置":
text
BaseSettings
-> 定义配置类
SettingsConfigDict(env_file=".env")
-> 指定本地配置文件
settings = Settings()
-> 创建全局配置对象
.env
text
APP_ENV=development
DEBUG=true
DATABASE_URL=sqlite:///./todo.db
app/core/config.py
python
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()。
如果想像前端一样区分环境,也可以这样约定:
text
.env
-> 默认配置
.env.development
-> 本地开发配置
.env.production
-> 生产环境配置
pydantic-settings 支持一次读取多个 env 文件:
python
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 时指定读取哪个文件:
python
# 读取开发环境配置
settings = Settings(_env_file=".env.development")
# 读取生产环境配置
settings = Settings(_env_file=".env.production")
实际项目里更常见的是:
text
本地开发
-> 读取 .env.development
测试 / 生产
-> 优先使用系统环境变量或部署平台注入的环境变量
注意:生产环境的真实密钥、数据库密码不要提交到 Git。
八、数据库连接池
为什么必要:
接口每次访问数据库,都需要连接数据库。
不能每个接口自己创建连接、关闭连接,这样代码乱,且性能也差。
所以这里要统一管理:
text
数据库连接池
-> 负责复用数据库连接
Session
-> 负责一次请求里的数据库操作
这里的 Session 不是登录 session,也不是浏览器 cookie。
它是 SQLAlchemy 里的"数据库操作对象":
text
Session
-> 帮你执行查询
-> 帮你新增、修改、删除数据
-> 帮你提交事务 commit
-> 请求结束后释放连接
你可以把它简单理解成:
text
engine
-> 管连接池
Session
-> 从连接池拿一条连接,完成这次数据库操作
采用技术:
SQLAlchemy engine + Session + FastAPI Depends。
这里有三个新概念:
text
engine
-> 数据库引擎,内部会管理连接池
Session
-> 一次数据库操作上下文
Depends
-> FastAPI 的依赖注入,把公共对象交给接口函数
app/db/session.py
python
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
python
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 后,接口里就可以这样写:
python
def list_todos(db: DbSession):
...
FastAPI 会自动执行 get_db(),把数据库 Session 传进来。
九、数据访问层
为什么必要:
数据库读写统一放 repository,service 不直接写查询细节。
常用数据库操作先记这几个:
text
db.add(obj)
-> 新增
db.commit()
-> 提交事务
db.refresh(obj)
-> 刷新对象,拿到数据库生成的 id
select(Todo)
-> 查询 Todo 表
db.get(Todo, todo_id)
-> 按主键查询
app/modules/todos/repository.py
python
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
python
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
python
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 不存在:
python
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
执行过程:
text
service 抛出 NotFoundError
-> NotFoundError 继承 BusinessError
-> FastAPI 捕获 BusinessError
-> 自动执行 business_error_handler
-> 返回统一 JSON 错误响应
最终响应大概是:
json
{
"code": 404,
"message": "Todo 不存在",
"data": null
}
十二、公共响应和分页
为什么必要:
前端需要稳定的响应结构,分页也不应该每个模块重复定义。
采用技术:
Pydantic 泛型模型。
这里的泛型可以简单理解成"响应壳里包不同类型的数据":
text
T
-> 先占个位置,表示这里以后会放某种类型
Generic[T]
-> 把这个类声明成"可以带类型参数的类"
-> 这样 PageResponse[TodoResponse] 这种写法才成立
PageResponse[TodoResponse]
-> 分页里装 TodoResponse
PageResponse[UserResponse]
-> 分页里装 UserResponse
app/schemas/common.py
python
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() 包一层:
python
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)
最终响应就是:
json
{
"code": 0,
"message": "success",
"data": {
"id": 1,
"title": "学习 FastAPI",
"description": null,
"is_completed": false
}
}
app/dependencies/pagination.py
python
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
python
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 可以先理解成:
text
models.py 改了表结构
-> Alembic 生成迁移文件
-> Alembic 执行迁移
-> 数据库表结构被更新
初始化:
bash
uv run alembic init migrations
这条命令会生成两类东西:
text
alembic.ini
-> Alembic 的主配置文件
migrations/
-> 迁移脚本目录
alembic.ini 里会有类似配置:
ini
[alembic]
script_location = migrations
这行的意思是:
text
Alembic 执行命令时
-> 去 migrations/ 目录找迁移环境
-> 读取 migrations/env.py
-> 再根据 env.py 里的配置生成或执行迁移
常用命令:
bash
# 生成迁移文件
uv run alembic revision --autogenerate -m "create todos table"
# 执行迁移
uv run alembic upgrade head
注意:Alembic 不会自动扫描 modules/ 下面所有 models.py。
它主要看 migrations/env.py 里的 target_metadata:
python
from app.db.base import Base
# 必须让模型先被 import,Base.metadata 里才会有这些表
from app.modules.todos import models as todo_models
target_metadata = Base.metadata
也就是说:
text
Todo 模型没有被 import
-> Base.metadata 里不知道有 todos 表
-> Alembic autogenerate 可能检测不到它
模块多了以后,可以建一个统一导入文件:
text
app/db/models.py
python
# 统一导入所有业务模块的数据库模型
from app.modules.todos.models import Todo
from app.modules.users.models import User
然后在 migrations/env.py 里只导入它:
python
from app.db.base import Base
import app.db.models
target_metadata = Base.metadata
这样新增模块时,只要记得把新模型加到 app/db/models.py,Alembic 就能拿到完整表结构。
--autogenerate 不是"所有表都重新更新一遍"。
它的流程是:
text
读取当前数据库真实表结构
读取 Base.metadata 里的模型结构
对比两边差异
生成迁移文件
所以如果只改了 todos 表,正常只会生成 todos 相关变更。
但自动生成不是百分百可靠,生成后一定要打开迁移文件检查。
迁移规则:
text
改 models.py
-> 生成 migration
-> 检查 migration
-> 执行 upgrade
不要直接在线上数据库手动改表。
十五、日志
为什么必要:
上线后排查问题不能靠 print。
采用技术:
Python 标准库 logging。
app/core/logging.py
python
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
python
import logging
logger = logging.getLogger(__name__)
def log_create_todo(todo_id: int) -> None:
# 关键业务动作记录日志
logger.info("todo created: id=%s", todo_id)
日志建议:
text
业务关键动作
-> info
可恢复异常
-> warning
系统异常
-> exception
十六、测试
为什么必要:
工程化最终要能验证行为。没有测试,后面改代码全靠手感。
采用技术:
pytest + FastAPI TestClient。
pytest 的基本语法很简单:
text
test_ 开头的函数
-> pytest 会自动识别成测试
assert
-> 断言结果是否符合预期
TestClient
-> 不启动真实服务,也能请求 FastAPI 接口
tests/conftest.py
python
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
python
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
运行测试:
bash
uv run pytest