codecamp

1.1 从能用的接口到可维护的项目

从能用的接口到可维护的项目

FastAPI 很容易让我们在几分钟内得到一个能返回 JSON 的接口。真正的维护困难通常会稍后出现:文章列表、文章详情、作者信息和权限判断都放进同一个文件;某次字段修改牵连了不相关的接口;新同事不知道应该从哪里启动项目。项目一开始不需要几十个文件,但应该先把可观察的接口边界说清楚。

这一章先做一个“能运行、能检查、但故意很小”的文章管理 API。它只提供健康检查、文章列表和文章详情三个只读接口。这样我们可以先确认请求如何进入应用、路由如何返回结果,再在下一节按业务领域拆分文件。拆分不是为了让目录看起来复杂,而是为了让一次修改有可预测的影响范围。

先写清楚本节的接口契约

案例中的文章暂时只放在 Python 内存里,数据用于教学,不代表持久化方案。服务进程重启后,内存内容会回到代码中的初始值;多个 worker 进程也不会自动共享这份列表。因此,这个实现适合学习路由边界和测试方法,不能直接当作生产数据库。

本节约定如下:

请求 成功结果 失败结果
GET /health 200,返回 {"status":"ok"} 本接口没有业务失败分支
GET /posts 200,返回文章数组 本节不把空列表当异常
GET /posts/{post_id} 200,返回一篇文章 不存在时返回 404

文章对象先固定为 id、title、content 和 author_id 四个字段。author_id 只是数据关联的线索,本节不查询用户,也不声称已经实现身份认证。把未实现的能力写成“已支持”,会让后面的代码和文档产生错误预期。

准备练习环境

教程路线支持 Python 3.11 及以上版本。本批验证环境为 Python 3.12.13、FastAPI 0.141.1、Starlette 0.52.1、Pydantic 2.13.5、Uvicorn 0.52.4、HTTPX 0.28.1、AnyIO 4.15.0;其他 Python 3.11+ 环境仍应单独核对依赖和运行结果。版本号写出来是为了让验证结果可复现;不要把“安装最新版”当作稳定的依赖声明。

在教程项目外新建练习目录,然后创建虚拟环境:

mkdir fastapi-best-practices-lab
cd fastapi-best-practices-lab
python3 -m venv .venv
source .venv/bin/activate
python -m pip install 
  "fastapi==0.141.1" 
  "starlette==0.52.1" 
  "pydantic==2.13.5" 
  "uvicorn==0.52.4" 
  "httpx==0.28.1" 
  "anyio==4.15.0"

创建 ch01_start 目录,把下面的完整文件保存为 ch01_start/main.py。示例使用 def 路由,是为了先保持代码和执行方式简单;同步与异步的选择会在第二章单独解释。这里没有先创建 routers、services 或 models 空目录,暂时没有职责的文件只会增加寻找成本。

from fastapi import FastAPI, HTTPException

app = FastAPI(title="文章管理 API(第一章基线)")

## 教学用内存数据:重启进程后恢复初始值,不提供持久化能力。
posts: list[dict[str, object]] = [
    {
        "id": 1,
        "title": "FastAPI 项目从哪里开始",
        "content": "先定义可检查的接口,再决定如何拆分目录。",
        "author_id": 1,
    },
    {
        "id": 2,
        "title": "让错误结果也有约定",
        "content": "找不到文章时返回明确的 404,而不是返回空对象。",
        "author_id": 2,
    },
]


@app.get("/health")
def health() -> dict[str, str]:
    return {"status": "ok"}


@app.get("/posts")
def list_posts() -> list[dict[str, object]]:
    return posts


@app.get("/posts/{post_id}")
def get_post(post_id: int) -> dict[str, object]:
    for post in posts:
        if post["id"] == post_id:
            return post

    raise HTTPException(status_code=404, detail="文章不存在")

从练习项目根目录启动时,工作目录决定了 main:app 的含义:这里必须进入 ch01_start,因为 main.py 就在当前目录。--reload 只适合开发期间自动重载文件,不是生产部署方案。

cd ch01_start
python -m uvicorn main:app --reload

另开终端并激活同一个虚拟环境,先看健康检查,再看列表和不存在的详情:

curl http://127.0.0.1:8000/health
curl http://127.0.0.1:8000/posts
curl -i http://127.0.0.1:8000/posts/999

预期分别能看到 {"status":"ok"}、包含两篇文章的数组,以及状态行 404 Not Found 和 {"detail":"文章不存在"}。如果访问 /posts/1 时得到 404,先检查 URL 中的整数路径参数;如果命令提示无法导入 main,通常是没有在 ch01_start 目录启动,或者文件名不是 main.py。

用 TestClient 留下一条最小验收线

手动 curl 能帮助理解请求,但每次改代码都重复操作很容易漏掉 404 分支。下面的检查不需要 pytest,直接用 FastAPI 提供的 TestClient 在进程内调用 ASGI 应用。它验证四件事:健康状态、列表非空、文章有标题,以及不存在的文章确实返回 404。

from fastapi.testclient import TestClient

from main import app


def main() -> None:
    with TestClient(app) as client:
        health = client.get("/health")
        assert health.status_code == 200
        assert health.json() == {"status": "ok"}

        posts = client.get("/posts")
        assert posts.status_code == 200
        items = posts.json()
        assert items
        assert all("title" in item for item in items)

        detail = client.get("/posts/1")
        assert detail.status_code == 200
        assert detail.json()["title"] == "FastAPI 项目从哪里开始"

        missing = client.get("/posts/999")
        assert missing.status_code == 404

    print("第一章基线检查通过")


if __name__ == "__main__":
    main()

停止正在运行的 Uvicorn 后,在 ch01_start 目录执行:

python check.py

检查脚本通过,只能说明这个内存基线的四个行为符合约定,不能说明数据库、并发、认证或部署已经验证。后续章节会逐步增加这些边界,并继续保留能快速发现回归的检查。

下一节将把同样的三个接口拆到 posts 业务领域,比较按技术类型放文件和按业务领域放文件的维护差异。

来源与相邻小节

阅读相邻小节时,请在教程目录中选择对应标题。

1.2 按业务领域组织代码
温馨提示
下载编程狮App,免费阅读超1000+编程语言教程
取消
确定
目录

关闭

MIP.setData({ 'pageTheme' : getCookie('pageTheme') || {'day':true, 'night':false}, 'pageFontSize' : getCookie('pageFontSize') || 20 }); MIP.watch('pageTheme', function(newValue){ setCookie('pageTheme', JSON.stringify(newValue)) }); MIP.watch('pageFontSize', function(newValue){ setCookie('pageFontSize', newValue) }); function setCookie(name, value){ var days = 1; var exp = new Date(); exp.setTime(exp.getTime() + days*24*60*60*1000); document.cookie = name + '=' + value + ';expires=' + exp.toUTCString(); } function getCookie(name){ var reg = new RegExp('(^| )' + name + '=([^;]*)(;|$)'); return document.cookie.match(reg) ? JSON.parse(document.cookie.match(reg)[2]) : null; }