K 的一隅

Python FastAPI 后端实战

路由不该什么都干:业务逻辑往哪放

胖路由 vs 薄路由:为什么 HTTP 层只该做协议转换,业务规则应沉入 service,以及分层起步时的边界划分。

9 分钟阅读 更新于 2026-07-28
本文目录

接口从三个涨到三十个时,某个 routers/items.py 里开始出现 eighty 行的 create_item:校验库存、算折扣、写审计日志、发邮件、顺手 commit——改邮件模板要在 HTTP 文件里翻,单测得构造 TestClient 才能测折扣公式。路由 的职责是翻译 HTTP,不是承载全部 业务逻辑;越早把规则沉到 服务 层,后面加 CLI、定时任务、消息消费者时越不用复制粘贴。

胖路由长什么样

python
# 反例:路由里堆满规则(节选)
@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查询
python
# 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
python
# 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 的 映射服务 抛领域异常或返回结果,不出现 RequestHTTPException

服务层边界怎么划

放进 服务 的:

  • 跨多 Model 的规则(库存、计价、权限判定)
  • 何时 commit / rollback 的业务 事务(一个用例一次 commit)
  • 可复用的用例函数(「注册用户」「作废订单」)

留在 路由 的:

  • Query/Path/Header 解析(FastAPI 已做大部分)
  • response_model、状态码、BackgroundTasks 触发
  • 认证装饰与 Depends wiring

留在更下层 repository(系列第 13 篇会展开)的:

  • 查询 拼装、分页 SQL
  • 与存储细节强绑定的 bulk 操作

起步阶段 服务 直接收 Session 可接受;接口变多再抽 repository 隔离 SQL。

测试为什么变简单

python
# 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:

python
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 只替换边界,服务 函数仍可直调。

胖路由如何「瘦身」:可操作的拆分清单

闻到了胖 路由 味道时,按顺序问:

  1. 这段代码里有没有 HTTPException 以外的业务判断?→ 挪到 服务
  2. 有没有超过两行 SQL 或 ORM 操作?→ 挪到 服务 或后续 repository。
  3. 有没有发邮件 / 调第三方支付?→ 服务 决策 + 注入副作用接口。
  4. 路由 剩下的是否只有参数、调用、返回、状态码?→ 合格。

一次 PR 只挪一个用例,行为不变、测试补齐,比「重构整个 router 文件」安全。

领域异常与 HTTP 状态码映射表

在 api 层集中 映射,避免每个 endpoint 手写 try/except 膨胀:

python
# 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 打自己。

python
# 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 如何把这些依赖拼进 路由 而不写成全局变量。