本文目录
接口从三个涨到三十个时,某个 routers/items.py 里开始出现 eighty 行的 create_item:校验库存、算折扣、写审计日志、发邮件、顺手 commit——改邮件模板要在 HTTP 文件里翻,单测得构造 TestClient 才能测折扣公式。路由 的职责是翻译 HTTP,不是承载全部 业务逻辑;越早把规则沉到 服务 层,后面加 CLI、定时任务、消息消费者时越不用复制粘贴。
胖路由长什么样
# 反例:路由里堆满规则(节选)
@router.post("/orders")
async def create_order(body: OrderCreate, db: Session = Depends(get_db)) -> OrderRead:
if body.quantity <= 0:
raise HTTPException(400, "quantity")
item = db.get(Item, body.item_id)
if item is None:
raise HTTPException(404, "item")
if item.stock < body.quantity:
raise HTTPException(409, "out of stock")
total = item.price * body.quantity
if body.coupon:
total *= 0.9
order = Order(item_id=item.id, quantity=body.quantity, total=total)
item.stock -= body.quantity
db.add(order)
db.commit()
send_email(...) # 副作用
return OrderRead.model_validate(order)能跑,但:
- 折扣规则无法在不发 HTTP 的情况下单测。
- 邮件失败是否回滚订单?逻辑埋在 路由 里难理清 事务 边界。
- 同一「下单」若将来要接 MQ,只能再抄一遍。
分层起步:路由 / 服务 / 持久化
不必一步到 Clean Architecture;三层直觉足够:
| 层 | 职责 | 知道 HTTP 吗 |
|---|---|---|
| 路由(api) | 参数、状态码、Depends | 是 |
| 服务(service) | 业务逻辑、领域规则 | 否 |
| 持久化(models + db) | CRUD、查询 | 否 |
# shop/services/order_service.py
from sqlalchemy.orm import Session
from shop.models.item import Item
from shop.models.order import Order
from shop.schemas.order import OrderCreate
class OutOfStockError(Exception):
pass
class ItemNotFoundError(Exception):
pass
def create_order(db: Session, data: OrderCreate) -> Order:
item = db.get(Item, data.item_id)
if item is None:
raise ItemNotFoundError(data.item_id)
if item.stock < data.quantity:
raise OutOfStockError()
unit_price = item.price
total = unit_price * data.quantity
if data.coupon:
total = round(total * 0.9, 2)
order = Order(item_id=item.id, quantity=data.quantity, total=total)
item.stock -= data.quantity
db.add(order)
db.commit()
db.refresh(order)
return order# shop/api/v1/orders.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from shop.db.session import get_db
from shop.schemas.order import OrderCreate, OrderRead
from shop.services import order_service
router = APIRouter(prefix="/orders", tags=["orders"])
@router.post("", response_model=OrderRead, status_code=status.HTTP_201_CREATED)
def create_order_endpoint(
body: OrderCreate,
db: Session = Depends(get_db),
) -> OrderRead:
try:
order = order_service.create_order(db, body)
except order_service.ItemNotFoundError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except order_service.OutOfStockError:
raise HTTPException(status.HTTP_409_CONFLICT, detail="out of stock")
return OrderRead.model_validate(order)路由 做异常 → HTTP 的 映射;服务 抛领域异常或返回结果,不出现 Request、HTTPException。
服务层边界怎么划
放进 服务 的:
- 跨多 Model 的规则(库存、计价、权限判定)
- 何时 commit / rollback 的业务 事务(一个用例一次 commit)
- 可复用的用例函数(「注册用户」「作废订单」)
留在 路由 的:
- Query/Path/Header 解析(FastAPI 已做大部分)
response_model、状态码、BackgroundTasks触发- 认证装饰与
Dependswiring
留在更下层 repository(系列第 13 篇会展开)的:
- 纯 查询 拼装、分页 SQL
- 与存储细节强绑定的 bulk 操作
起步阶段 服务 直接收 Session 可接受;接口变多再抽 repository 隔离 SQL。
测试为什么变简单
# tests/test_order_service.py
from sqlalchemy.orm import Session
from shop.models.item import Item
from shop.schemas.order import OrderCreate
from shop.services.order_service import OutOfStockError, create_order
def test_create_order_reduces_stock(db_session: Session) -> None:
item = Item(name="pen", price=2.0, stock=5)
db_session.add(item)
db_session.commit()
order = create_order(db_session, OrderCreate(item_id=item.id, quantity=2, coupon=None))
db_session.refresh(item)
assert order.total == 4.0
assert item.stock == 3
def test_out_of_stock(db_session: Session) -> None:
item = Item(name="pen", price=2.0, stock=1)
db_session.add(item)
db_session.commit()
try:
create_order(db_session, OrderCreate(item_id=item.id, quantity=3, coupon=None))
except OutOfStockError:
pass
else:
raise AssertionError("expected OutOfStockError")无需启动 Uvicorn;业务逻辑 在 plain Python 函数里,用内存 SQLite fixture 即可。HTTP 层另写少量 TestClient 测试状态码 映射 即可。
副作用放哪
发邮件、调外部 API 属于副作用。起步可仍在 服务 末尾调用,但接口通过参数注入便于 mock:
def create_order(
db: Session,
data: OrderCreate,
*,
notifier: Callable[[Order], None] | None = None,
) -> Order:
...
if notifier is not None:
notifier(order)
return order成熟后可用消息队列或 BackgroundTasks(路由 注册回调,服务 只产出事件)。关键:业务逻辑 决定是否「需要通知」,路由 决定「何时异步派发」。
与依赖注入的衔接
下一篇 依赖注入 会把 get_db、当前用户、get_order_service 等统一用 Depends 装配。分层清晰后,测试里 app.dependency_overrides[get_db] = lambda: fake_session 只替换边界,服务 函数仍可直调。
胖路由如何「瘦身」:可操作的拆分清单
闻到了胖 路由 味道时,按顺序问:
- 这段代码里有没有
HTTPException以外的业务判断?→ 挪到 服务。 - 有没有超过两行 SQL 或 ORM 操作?→ 挪到 服务 或后续 repository。
- 有没有发邮件 / 调第三方支付?→ 服务 决策 + 注入副作用接口。
- 路由 剩下的是否只有参数、调用、返回、状态码?→ 合格。
一次 PR 只挪一个用例,行为不变、测试补齐,比「重构整个 router 文件」安全。
领域异常与 HTTP 状态码映射表
在 api 层集中 映射,避免每个 endpoint 手写 try/except 膨胀:
# shop/api/errors.py 概念
from collections.abc import Callable
from typing import TypeVar
from fastapi import HTTPException
T = TypeVar("T")
def map_service_error(fn: Callable[..., T]) -> Callable[..., T]:
def wrapper(*args, **kwargs) -> T:
try:
return fn(*args, **kwargs)
except ItemNotFoundError as exc:
raise HTTPException(404, detail=str(exc)) from exc
except OutOfStockError:
raise HTTPException(409, detail="out of stock") from exc
return wrapper更完整做法是用注册表或中间件异常处理器(系列第 9 篇)。起步时 路由 内显式 try/except 也可读;关键是 业务逻辑 抛的是 OutOfStockError,不是 409 字符串。
同一业务多端复用
CLI 补数据、Celery 定时对账、管理后台脚本,都可能调用「创建订单」。服务 函数 create_order(db, data) 不关心入口是 HTTP 还是 stdin,分层 的价值在这里兑现。若逻辑只在 路由 里,CLI 要么复制代码,要么荒谬地 TestClient.post 打自己。
# scripts/replay_order.py 概念
from shop.db.session import SessionLocal
from shop.schemas.order import OrderCreate
from shop.services.order_service import create_order
def main() -> None:
with SessionLocal() as db:
create_order(db, OrderCreate(item_id=1, quantity=1, coupon=None))
if __name__ == "__main__":
main()服务层要不要类
函数式 服务(模块级 def create_order)对中小项目足够。当同一聚合根有多方法共享私有 helper 或 配置 时,可 class OrderService 注入 db。不必为了「面向对象」强行上类;也不必为了「函数式」拒绝把相关用例收进一个类。一致性比范式重要。
与 Repository 层的预告
当 查询 在五个 服务 里重复拼 select(Item).where(...) 时,抽到 repositories/item_repo.py:服务 讲规则,repository 讲存取。系列第 13 篇会系统讲三层;本篇只需记住 路由 → 服务 →(可选)repository → Model,逐层变厚,不要倒过来。
读代码时的分层 smell
代码审查时可以快速闻味道:
- 路由 文件 import 了邮件 SDK → 副作用泄漏。
- 服务 文件 import
HTTPException→ Web 细节泄漏。 - Model 文件 import
APIRouter→ 分层倒置,立刻打回。
理想状态:路由 打开只见 HTTP;服务 打开只见规则与 事务;models 只见 映射。做不到百分之百,但 smell 出现时应顺手挪一层,而不是等「大重构周」。
新同事 onboarding 时给一张「改价格走哪几个文件」示例 PR,比抽象 分层 图有效:改契约动 schemas、改规则动 服务、只改状态码映射才动 路由。重复三次,业务逻辑 该放哪会形成肌肉记忆。
例外:纯 CRUD、无规则、字段与 表 一一对应的 admin 接口,路由 直调 repository 可以接受——不必为了 分层 而 分层。一旦冒出「如果库存小于零则…」,立刻挪进 服务。
参数校验与 业务逻辑 分界:Pydantic 在 路由 边界做「形状对不对」(非空、范围);服务 做「规则允不允许」(库存、优惠券叠加、黑名单)。422 与 409 的分工由此而来——不要把「库存不足」写成 422,那是 业务逻辑 拒绝,不是 JSON 格式错。
同一 服务 函数被 HTTP 与后台任务共用时候,事务 边界仍在 服务 内 commit;调用方不管 HTTP 还是 CLI,都传 Session 进来。这样 分层 竖切清晰:入口多样,规则一处。
Refactor 胖 路由 时保持行为不变:先写 服务 单测覆盖旧逻辑,再搬代码,最后 路由 改为一行调用——测试绿灯比「感觉一样」可靠。业务逻辑 抽取是机械活,别同时改规则又改 分层,一次一件事。
文档字符串写在 服务 函数上,说明前置条件、抛哪些领域异常、是否 commit——比写在 路由 的 docstring 更贴近真实契约。OpenAPI 描述 HTTP;服务 docstring 描述用例,供后端读者与 IDE 提示。
两个 路由 复用同一 服务 时(用户 API 与 admin API),规则只写一次,HTTP 差异留在各自 路由(权限 Depends、不同 response_model)。这是 分层 里最常立刻见效的去重方式,比抽象基类 路由 更简单。
常见误解
误解一:「小项目不必分层,以后再说。」 胖 路由 的复利很快;至少把用例函数抽到 services/ 一个文件。
误解二:「service 里 raise HTTPException。」 那是 Web 细节,应抛领域异常或 Result 类型,由 路由 翻译。
误解三:「一层 service 够,永远不用 repository。」 够用到 查询 重复且复杂;不是起步硬性要求。
误解四:「async 路由就要 async service。」 可 sync service + 线程池;统一风格即可,不必为 async 而 async。
误解五:「分层意味着文件越多越好。」 目标是边界清晰,不是目录深度;两个职责清晰的 模块 胜过六个空壳 包。
误解六:「HTTP 细节可以渗进 service 换方便。」 例如读取 Request.client.host 做审计,应在 路由 提取后作为参数传入 服务,保持 服务 可测。
小结
路由 保持薄:HTTP 与异常 映射;业务逻辑 沉入 服务;持久化留在 models/db。这是 分层 的第一步,后续依赖注入、Repository、测试策略都在此边界上扩展。记住:改规则找 服务,改 URL 找 路由。胖 路由 是技术债,越早还越便宜。下一篇讲 Depends 如何把这些依赖拼进 路由 而不写成全局变量。