《Python编程实战》5.1 FastAPI 应用结构与依赖注入

用 FastAPI 0.143.0 搭一个分层后端:把 api / service / repository / schema 四层拆开,用 APIRouter 分文件装配,用 Depends 区分请求级与进程级依赖,用 lru_cache 做单例,并用 dependency_overrides 做测试隔离;每一步都给出真实运行结果。

本节目标:把「能跑的单文件 FastAPI」重构成可维护的分层应用,讲清 router / service / repository 的职责边界、APIRouter 的拆分装配、Depends 的请求级与进程级依赖,以及 lru_cache 单例的用法与陷阱。
适用版本:Python 3.12+(实测 3.14.6);FastAPI 0.143.0、Starlette 1.7.0、httpx 0.28.1

5.1 FastAPI 应用结构与依赖注入

第 4 章我们把测试、性能回归和覆盖率门禁立了起来。从本章起进入后端工程:先把应用骨架搭对,后面的数据库、缓存、认证才有地方挂。这一节不讲「FastAPI 怎么用」——那在入门篇已经打过底;我们讲真实项目里怎么组织代码,以及依赖注入到底在解决什么问题。

5.1.1 单文件 app 的尽头

几乎每个 FastAPI 项目都从这样一份 main.py 起步:所有路由、业务逻辑、数据存取挤在一个文件里。它能跑到几十个接口,然后开始失控:

  • 改一个业务规则要在几百行里翻找,冲突不断;
  • 想给「创建订单」写单元测试,必须先起 HTTP 层;
  • 数据库连接、配置对象在模块顶层 new 出来,测试之间互相污染。

根因是职责混在一起。解法是老生常谈但确实有效的分层:把「收请求」「定规则」「读写数据」三件事拆到不同层,每层只依赖它下面那层。本节用一个商品服务把它落到可运行的代码上。

5.1.2 四层:api / service / repository / schema

先定边界,再写代码。四层的职责与依赖方向如下:

层目录职责允许依赖
api(路由层)app/api/解析请求、调用 service、把异常翻译成 HTTP 状态码service、schema
service(业务层)app/services/业务规则、编排、事务边界repository
repository(数据层)app/repositories/纯数据存取,不含业务判断无(或 ORM)
schema(契约层)app/schemas.py出入参的 Pydantic 模型无

依赖方向必须单向:api → service → repository。反过来的依赖(service 里 raise HTTPException、repository 里判断权限)是分层腐化的开始。

数据层用内存字典即可,重点是接口形态;第 6 章会把它换成 SQLAlchemy 2.1.4,service 层几乎不用改:

# app/repositories/items.py
from dataclasses import dataclass


@dataclass
class Item:
    id: int
    name: str
    price: float


class ItemRepository:
    """最底层:只负责数据存取,不含业务规则。"""

    def __init__(self) -> None:
        self._rows: dict[int, Item] = {}
        self._next_id = 1

    def add(self, name: str, price: float) -> Item:
        item = Item(id=self._next_id, name=name, price=price)
        self._rows[item.id] = item
        self._next_id += 1
        return item

    def get(self, item_id: int) -> Item | None:
        return self._rows.get(item_id)

    def list(self) -> list[Item]:
        return list(self._rows.values())

service 层定义业务异常,不碰 HTTP——这是关键纪律:

# app/services/items.py
from app.repositories.items import Item, ItemRepository


class ItemNotFound(Exception):
    """业务异常,由 API 层翻译成 HTTP 状态码。"""


class ItemService:
    def __init__(self, repo: ItemRepository) -> None:
        self._repo = repo

    def create(self, name: str, price: float) -> Item:
        return self._repo.add(name=name, price=price)

    def get(self, item_id: int) -> Item:
        item = self._repo.get(item_id)
        if item is None:
            raise ItemNotFound(item_id)
        return item

    def list(self) -> list[Item]:
        return self._repo.list()

ItemService 只认 ItemRepository,不认识 FastAPI。于是「测试业务规则」可以完全脱离 HTTP:

repo = ItemRepository()
svc = ItemService(repo)
svc.create("x", 1.0)
print("service sees repo row:", repo.list()[0].name)
service sees repo row: x

5.1.3 APIRouter 拆分与装配

路由层按资源拆成多个文件,每个文件导出一个 APIRouter。注意 HTTPException 只在这一层出现——把 service 抛出的 ItemNotFound 翻译成 404:

# app/api/items.py
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, status

from app.deps import get_service
from app.schemas import ItemIn, ItemOut
from app.services.items import ItemNotFound, ItemService

router = APIRouter(prefix="/items", tags=["items"])


@router.post("", response_model=ItemOut, status_code=status.HTTP_201_CREATED)
def create_item(
    payload: ItemIn,
    svc: Annotated[ItemService, Depends(get_service)],
) -> ItemOut:
    item = svc.create(name=payload.name, price=payload.price)
    return ItemOut(id=item.id, name=item.name, price=item.price)


@router.get("/{item_id}", response_model=ItemOut)
def get_item(
    item_id: int,
    svc: Annotated[ItemService, Depends(get_service)],
) -> ItemOut:
    try:
        item = svc.get(item_id)
    except ItemNotFound:
        raise HTTPException(status_code=404, detail=f"item {item_id} not found") from None
    return ItemOut(id=item.id, name=item.name, price=item.price)

main.py 只做装配——把 router 挂到应用上,版本前缀在这一层统一:

# app/main.py
from fastapi import FastAPI

from app.api.items import router as items_router

app = FastAPI(title="Shop API", version="0.1.0")
app.include_router(items_router, prefix="/api/v1")

prefix 分层很实用:路由文件里写相对路径 /items,版本号 /api/v1 只在装配处出现一次。将来出 v2,新写一个 router 用不同前缀即可,业务代码零改动。

5.1.4 Depends 的层级:请求级 vs 进程级

Depends 常被当成「参数默认值」用,其实它的价值在于控制对象的生命周期。两种粒度要分清:

  • 进程级单例:配置、连接池、仓库实例——整个应用一份,启动时建好,请求间共享。
  • 请求级对象:service、当前用户、数据库会话——每个请求一份,用完即弃。

把它们放进 app/deps.py,靠组合拼出层级:

# app/deps.py
from functools import lru_cache

from fastapi import Depends

from app.repositories.items import ItemRepository
from app.services.items import ItemService


@lru_cache(maxsize=1)
def get_repository() -> ItemRepository:
    """进程级单例:整个应用共享一个仓库实例。"""
    return ItemRepository()


def get_service(repo: ItemRepository = Depends(get_repository)) -> ItemService:
    """请求级依赖:每次请求构造一个 service,复用单例 repo。"""
    return ItemService(repo)

依赖可以嵌套:get_service 依赖 get_repository,FastAPI 会按拓扑序依次解析,且同一请求内对同一依赖只解析一次(默认缓存)。这样路由里只声明它真正需要的 get_service,repo 自动注入进来。

5.1.5 lru_cache 单例:好用但会咬人

@lru_cache(maxsize=1) 是最省事的进程级单例写法——第一次调用建对象,之后返回同一个。实测两次解析拿到同一实例:

from app.deps import get_repository

print("repo singleton:", get_repository() is get_repository())
repo singleton: True

但它有两个坑,团队里几乎都踩过:

  1. 参数必须可哈希。lru_cache 按参数做键;如果依赖函数带不可哈希参数(如 list、未冻结的 dict),会直接 TypeError。
  2. 测试之间不隔离。单例一旦建好就常驻进程,上一个用例写进去的数据会漏给下一个。测试里要么调用 get_repository.cache_clear(),要么用下面的覆盖机制。

生产里配置对象也常用这个模式(配合 3.1 节的 pydantic-settings)。若需要更明确的「只初始化一次」语义,可用模块级模块变量 + functools.cache,或直接放进 lifespan(5.3 节)。

5.1.6 用 dependency_overrides 做测试隔离

FastAPI 提供了官方的替换开关:app.dependency_overrides[原依赖] = 替身。测试时把单例仓库换成隔离实例,用完清空:

from fastapi.testclient import TestClient

from app.deps import get_repository
from app.main import app
from app.repositories.items import ItemRepository


def make_client() -> TestClient:
    fake = ItemRepository()
    fake.add("测试商品", 10.0)
    app.dependency_overrides[get_repository] = lambda: fake
    return TestClient(app)


client = make_client()
print("overridden repo:", client.get("/api/v1/items").json())

app.dependency_overrides.clear()
print("real repo after clear:", client.get("/api/v1/items").json())
overridden repo: [{'id': 1, 'name': '测试商品', 'price': 10.0}]
real repo after clear: []

替换的是依赖解析本身,业务代码一行不动——这正是把依赖写进 Depends 而不是在函数体里 import 单例的回报。

5.1.7 跑一遍:真实请求与响应

用一个脚本把正常路径、404、校验失败都打一遍。注意 FastAPI 0.143.0 / Starlette 1.7.0 下,TestClient 会打印一条弃用提示「httpx with starlette.testclient is deprecated」——功能不受影响;异步测试建议直接用 httpx.ASGITransport(本节末尾给出)。

from fastapi.testclient import TestClient

from app.main import app

client = TestClient(app)
print("POST:", *client.post("/api/v1/items", json={"name": "键盘", "price": 199.0}).json().values())
print("GET /items:", client.get("/api/v1/items").status_code)
print("GET /items/999:", client.get("/api/v1/items/999").status_code)
print("invalid:", client.post("/api/v1/items", json={"name": "", "price": -1}).status_code)

真实输出(版本号来自本机实测):

POST /items: 1 键盘 199.0
GET /items: 200
GET /items/999: 404
POST invalid: 422

201 由 status_code=HTTP_201_CREATED 声明;404 是 service 异常被翻译而来;422 是 Pydantic 校验自动拦截——三层各司其职,互不越界。

不想被 TestClient 的弃用提示打扰,可以改用异步客户端(httpx.ASGITransport 直接驱动 ASGI 应用,无需真实端口):

import asyncio

import httpx

from app.main import app


async def main() -> None:
    transport = httpx.ASGITransport(app=app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
        r = await c.post("/api/v1/items", json={"name": "键盘", "price": 199.0})
        print("POST:", r.status_code, r.json())


asyncio.run(main())
POST: 201 {'id': 1, 'name': '键盘', 'price': 199.0}

注意 ASGITransport 不会触发 lifespan——需要启动/关闭钩子时用 asgi_lifespan 或回到 TestClient 的上下文管理器,这正是 5.3 节的主题。

5.1.8 目录树与延伸

app/
├── main.py          # 只做装配
├── deps.py          # 依赖定义(生命周期)
├── schemas.py       # Pydantic 契约
├── api/items.py     # 路由层
├── services/items.py    # 业务层
└── repositories/items.py  # 数据层

这套分层的收益会在后面几章持续兑现:6 章把 ItemRepository 换成 SQLAlchemy 会话、8 章在 service 里挂缓存、9 章在 deps.py 加认证依赖——变化都被关在单层内。

小结

  • 分层不是仪式:api / service / repository / schema 各管一段,依赖方向单向,HTTPException 只在路由层出现。
  • APIRouter 按资源拆分,main.py 只做装配,版本前缀在装配处统一。
  • Depends 的本质是控制生命周期:进程级单例(配置、连接池)与请求级对象(service、会话)分开定义、组合拼装。
  • @lru_cache(maxsize=1) 是最省事的单例,但要注意参数可哈希、以及测试间不隔离这两个坑。
  • dependency_overrides 是官方测试隔离手段,替换依赖解析而不动业务代码。
  • TestClient 在本机 Starlette 1.7.0 下会打印 httpx 弃用提示(功能正常);异步场景改用 httpx.ASGITransport,但它不跑 lifespan。

到这里应用有了骨架,但「一个请求从进门到出门」之间还缺少横切的环节——日志、请求 ID、耗时统计、鉴权预检。下一节我们把这些统一收进中间件与请求上下文。

延伸阅读:Python Web 框架全景 、Python 微服务架构 。

阅读导航:上一节:属性测试、性能回归与覆盖率门禁 · 下一节:路由、中间件与请求上下文 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时