本文 AI 產出,尚未審核

FastAPI 應用結構與啟動方式:多檔案拆分(routers / services / models)


簡介

在實務開發中,隨著功能需求不斷擴充,將 FastAPI 應用寫在單一檔案會很快變得難以維護。
將程式碼依照 路由 (router)業務服務 (service)資料模型 (model) 進行模組化拆分,不僅能提升可讀性,還能讓團隊協作更順暢、測試更方便。

本篇文章將說明如何以 多檔案結構 建立一個可擴充的 FastAPI 專案,並提供實作範例、常見陷阱與最佳實踐,讓 初學者到中級開發者 都能快速上手、在專案中落地。


核心概念

1. 為什麼要拆分檔案?

  • 職責分離:路由只負責 HTTP 介面、服務層負責商業邏輯、模型層負責資料結構與驗證。
  • 降低耦合:各模組之間僅透過介面(function / class)互相溝通,避免相互依賴導致的循環匯入。
  • 方便測試:可以針對 service、model 單元測試,而不必啟動整個 API 伺服器。

2. 常見的專案目錄結構

my_fastapi_app/
│
├─ app/
│   ├─ __init__.py
│   ├─ main.py               # 入口點
│   ├─ routers/
│   │   ├─ __init__.py
│   │   └─ todo.py           # Todo 相關路由
│   ├─ services/
│   │   ├─ __init__.py
│   │   └─ todo_service.py   # 商業邏輯
│   └─ models/
│       ├─ __init__.py
│       ├─ todo.py           # Pydantic + ORM Model
│       └─ base.py           # 共用 BaseModel
│
├─ tests/
│   └─ test_todo.py
│
└─ requirements.txt

重點:所有子目錄都放置 __init__.py,讓 Python 把它們視為套件,方便 from app.routers import todo 這類的匯入。

3. 路由 (router)

FastAPI 的 APIRouter 讓我們把不同資源的路由分散到不同檔案,最後在 main.py 中統一掛載。

範例:app/routers/todo.py

# app/routers/todo.py
from fastapi import APIRouter, Depends, HTTPException, status
from typing import List

from app.models.todo import TodoCreate, TodoRead, TodoUpdate
from app.services.todo_service import TodoService

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

# 依賴注入 Service,保持路由層的輕量
def get_service() -> TodoService:
    return TodoService()


@router.post("/", response_model=TodoRead, status_code=status.HTTP_201_CREATED)
async def create_todo(
    todo_in: TodoCreate,
    service: TodoService = Depends(get_service)
):
    """建立一筆 Todo"""
    return await service.create(todo_in)


@router.get("/", response_model=List[TodoRead])
async def list_todos(
    service: TodoService = Depends(get_service)
):
    """取得全部 Todo 列表"""
    return await service.list_all()


@router.put("/{todo_id}", response_model=TodoRead)
async def update_todo(
    todo_id: int,
    todo_in: TodoUpdate,
    service: TodoService = Depends(get_service)
):
    """更新指定的 Todo"""
    updated = await service.update(todo_id, todo_in)
    if not updated:
        raise HTTPException(status_code=404, detail="Todo not found")
    return updated
  • router = APIRouter(... ):設定路徑前綴與分組標籤。
  • 依賴注入 (Depends):將 Service 注入,使路由層不必自行建立實例,方便測試與未來的 DI 容器擴充。

4. 服務層 (service)

服務層負責 業務邏輯、資料庫存取、交易控制等,保持路由層的「薄」與「純粹」。

範例:app/services/todo_service.py

# app/services/todo_service.py
from typing import List, Optional

from app.models.todo import TodoCreate, TodoRead, TodoUpdate, TodoORM
from app.db import get_async_session  # 假設已有 async DB session 工具

class TodoService:
    """Todo 相關的商業邏輯"""

    async def create(self, data: TodoCreate) -> TodoRead:
        async with get_async_session() as session:
            todo = TodoORM(**data.dict())
            session.add(todo)
            await session.commit()
            await session.refresh(todo)
            return TodoRead.from_orm(todo)

    async def list_all(self) -> List[TodoRead]:
        async with get_async_session() as session:
            result = await session.execute(
                TodoORM.select()
            )
            todos = result.scalars().all()
            return [TodoRead.from_orm(t) for t in todos]

    async def update(self, todo_id: int, data: TodoUpdate) -> Optional[TodoRead]:
        async with get_async_session() as session:
            todo = await session.get(TodoORM, todo_id)
            if not todo:
                return None
            for field, value in data.dict(exclude_unset=True).items():
                setattr(todo, field, value)
            await session.commit()
            await session.refresh(todo)
            return TodoRead.from_orm(todo)
  • async with get_async_session():示範如何在服務層統一管理 DB session,避免在路由層出現 session 相關程式碼。
  • Pydantic 與 ORM 之間的轉換:使用 TodoRead.from_orm() 把 ORM 物件轉成回傳的 Pydantic 模型。

5. 資料模型 (models)

模型層主要有兩種:

  1. Pydantic Model(用於請求/回應驗證)
  2. ORM Model(與資料庫對應)

範例:app/models/todo.py

# app/models/todo.py
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime

# ---------- Pydantic Schemas ----------
class TodoBase(BaseModel):
    title: str = Field(..., max_length=100, description="Todo 標題")
    description: Optional[str] = Field(None, max_length=500)

class TodoCreate(TodoBase):
    """建立 Todo 時使用的 Schema"""
    pass

class TodoUpdate(BaseModel):
    """更新 Todo 時使用的 Schema,允許部分欄位更新"""
    title: Optional[str] = Field(None, max_length=100)
    description: Optional[str] = Field(None, max_length=500)

class TodoRead(TodoBase):
    """回傳給前端的 Schema"""
    id: int
    created_at: datetime

    class Config:
        orm_mode = True   # 讓 Pydantic 支援 ORM 物件


# ---------- SQLAlchemy ORM ----------
from sqlalchemy import Column, Integer, String, Text, DateTime, func
from sqlalchemy.orm import declarative_base

Base = declarative_base()

class TodoORM(Base):
    __tablename__ = "todos"

    id = Column(Integer, primary_key=True, index=True)
    title = Column(String(100), nullable=False)
    description = Column(Text, nullable=True)
    created_at = Column(DateTime(timezone=True), server_default=func.now())
  • orm_mode = True:讓 Pydantic 能直接接受 ORM 物件。
  • 分離 TodoBaseTodoCreateTodoUpdate:避免在 更新 時必須填滿所有欄位,提升 API 使用彈性。

6. 入口檔案 main.py

最後在 main.py 中把所有 router 匯入並啟動 FastAPI 應用。

# app/main.py
from fastapi import FastAPI
from app.routers import todo

app = FastAPI(
    title="Todo API",
    description="示範 FastAPI 多檔案拆分的範例專案",
    version="0.1.0",
)

# 將子 router 加入主應用
app.include_router(todo.router)

# 若有其他 router (e.g., users, auth) 只需要再 import 並 include
# app.include_router(user.router)

執行:

uvicorn app.main:app --reload

常見陷阱與最佳實踐

陷阱 為什麼會發生 解決方式 / 最佳實踐
循環匯入 (Circular import) Router 直接匯入 Service,Service 又匯入 Router 內的型別或 DB 工具。 使用依賴注入 (Depends) 把 Service 交給 FastAPI 管理,或把共用類別搬到 models / schemas 中。
全域 DB Session 在 router 中直接使用 SessionLocal(),導致多個請求共享同一個連線。 使用 context manager (async with get_async_session()) 或 FastAPI 的 依賴式 Depends(get_db),確保每個請求都有獨立 session。
路由太肥 把大量驗證、商業邏輯寫在路由函式內。 保持路由薄:僅負責參數驗證與回傳結果,所有實際處理交給 Service。
模型與 ORM 混用 把 ORM 類別直接當作 response_model,會失去 Pydantic 的驗證與自動文件化。 分離:使用 TodoRead (Pydantic) 作為 response_model,在 Service 中 TodoRead.from_orm() 轉換。
硬編碼路徑 include_router 時寫死字串路徑,後續改動麻煩。 使用相對匯入 (from app.routers import todo) 並在 router 中設定 prefix,統一管理。

最佳實踐總結

  1. 保持每層職責單一:Router → Service → Model。
  2. 使用 Pydantic 的 orm_mode 讓資料在 ORM ↔︎ Pydantic 之間自動轉換。
  3. 依賴注入 (Depends) 是 FastAPI 的核心機制,務必在 router 與 DB session 中使用。
  4. 測試友好:在 Service 中寫純 Python 函式,測試時只需要 mock DB session;Router 測試則使用 TestClient
  5. 加入型別提示:所有函式都加上 -> TodoRead-> List[TodoRead],IDE 會自動補全,降低錯誤率。

實際應用場景

1. 企業內部的待辦清單系統

  • 需求:多部門共用同一個待辦清單,需支援 CRUD、搜尋、權限驗證。
  • 實作
    • routers/todo.py 處理 HTTP 請求。
    • services/todo_service.py 內加入 部門過濾權限檢查(例如 if user.department not in todo.allowed_departments)。
    • models/todo.py 加入 department_id 欄位與關聯表。

2. 電子商務平台的商品管理

  • 需求:商品 CRUD、批次上架、價格計算。
  • 實作
    • routers/product.pyservices/product_service.pymodels/product.py 同樣遵循三層分離。
    • 服務層 中加入 交易 (transaction),確保批次上架失敗時回滾。
    • 模型層 使用 SQLAlchemy mixins 讓所有表都有 created_atupdated_at

3. 微服務間的共用套件

  • 需求:多個微服務共用 UserToken 的驗證模型。
  • 實作
    • 把共用的 Pydantic schema 放在 app/models/common.py,其他服務只要 from app.models.common import TokenPayload 即可。
    • 這樣即使某個微服務需要新增欄位,也只要在同一個檔案更新即可,保持一致性。

總結

FastAPI 應用拆分成 router、service、model 三層結構,是提升可維護性、可測試性與團隊協作效率的關鍵。
透過 APIRouter 讓路由模組化、在服務層集中商業邏輯、使用 Pydantic + SQLAlchemy 完成資料驗證與 ORM 映射,我們可以:

  • 快速擴充:新增功能只需新增對應的 router、service、model 檔案。
  • 降低耦合:各層只透過明確的介面溝通,避免循環匯入。
  • 提升測試效率:服務層與模型層可獨立單元測試,路由層則使用 TestClient 進行整合測試。

掌握上述概念後,你的 FastAPI 專案將能在 開發速度程式品質維護成本 三方面取得最佳平衡。祝開發順利,期待看到你用這套結構打造出更大型、更穩定的 API!