FastAPI-基本入门

这篇讲什么

  • FastAPI 是「类型注解驱动」的 ASGI 框架:路由来自 Starlette,校验和序列化来自 Pydantic,剩下的核心机制就是依赖注入。这篇先把环境、第一个应用、三种启动方式和工程骨架跑通。
  • 最重要的一条实测结论:async def 里写阻塞调用比 def 里写更糟。同一个 0.4 秒的 time.sleep,def 版本四个并发 0.42 秒、async def 版本 1.61 秒,而且期间夹一个普通请求要等 1.21 秒。
  • 环境基线是 FastAPI 0.162.0 + Starlette 1.10.0 + Pydantic 2.14.3 + uvicorn 0.54.0(conda fastapi 环境,Python 3.14.10),文中每条断言都在本机跑过。

FastAPI 是什么

  • 官方首页给的定义是三个词:high performance、easy and fast to learn、based on standard Python type hints。它不打算做全功能框架,而是把「写接口」这条路径上的三件事做到自动:参数校验、序列化、文档生成。

  • 所以 FastAPI 其实是三层拼起来的,看清这层关系,后面所有行为的来源都好解释:

    • 路由与 ASGI 协议由 Starlette 提供(实测 starlette==1.10.0),Request、Response、WebSocket、中间件这些名字都能从 fastapi 直接导入,本质是 Starlette 的。
    • 数据校验与序列化由 Pydantic v2 提供(实测 pydantic==2.14.3,底层是 Rust 写的 pydantic-core==2.50.4)。
    • 依赖注入是 FastAPI 自己写的那层,靠函数签名的类型注解工作,是它区别于其它框架最核心的机制。
  • 「ASGI」是理解 FastAPI 的关键。WSGI 是同步协议,一个 worker 一次只能处理一个请求;ASGI 是异步协议,同一个进程里可以挂着成千上万个还没完成的连接。对照关系:

    • Flask、Django 的传统写法是 WSGI,要靠多进程或多线程堆并发(见 Flask-项目部署)。
    • Tornado 自带异步 HTTP 服务器,不需要外面再套一层(见 Tornado-基本入门)。
    • FastAPI 只是框架,自己不监听端口,必须配一个 ASGI 服务器,官方默认是 uvicorn。
  • 和本仓库里另外三套的分工,可以按这张表记:

    框架 定位 自带什么 要自己选的
    Django 全功能,后台与 ORM 齐全 ORM、admin、模板、迁移、认证 基本什么都不用选
    Flask 微框架,靠扩展拼装 路由 + 模板(Jinja2) ORM、校验、认证全靠自己选
    Tornado 异步框架 + 自带 HTTP 服务器 IOLoop、AsyncHTTPClient、WebSocket ORM、模板、校验
    FastAPI 异步 API 框架 校验(Pydantic)、文档(OpenAPI)、依赖注入 服务器(uvicorn)、ORM、模板

    一句话:做管理后台选 Django,做接口服务选 FastAPI,长连接/事件驱动的老项目才考虑 Tornado,Flask 适合小工具和已有生态。

  • FastAPI 不自带 ORM 和模板引擎,但两者都能接:SQLAlchemy 的接法见 FastAPI-数据库;要页面而不是 JSON 时用 Jinja2Templates,写法与 Flask 同构,但调用形式换了,见下一节。

安装与环境

  • 官方推荐的装法是一条命令:

    1
    2
    3
    conda create -n fastapi python=3.14 -y
    conda activate fastapi
    pip install "fastapi[standard]"
    • 引号别省:fastapi[standard] 里的方括号在 zsh 下会被当成通配符去匹配文件,直接报 no matches found。
    • [standard] 是 extra(附加依赖集),不带它只会装到「框架本体」,fastapi 这个命令行工具和 uvicorn 都没有。
  • 本机实测装完之后(pip list 节选,Python 3.14.10):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    fastapi              0.162.0
    starlette 1.10.0
    pydantic 2.14.3
    pydantic-core 2.50.4
    uvicorn 0.54.0
    fastapi-cli 0.0.32
    httpx 0.28.1
    anyio 4.16.0
    websockets 17.4
    jinja2 3.1.6
    python-multipart 0.0.33
    email-validator 2.3.0
    pydantic-settings 2.16.2
    fastar 0.12.0
  • standard 这个 extra 到底带了什么,直接读元数据比背清单可靠:

    1
    python -c "from importlib.metadata import metadata; r=metadata('fastapi').get_all('Requires-Dist'); print(len([x for x in r if 'standard' in x]),'条 standard 依赖'); print([x for x in r if ';' not in x])"
    1
    2
    3
    4
    standard extra 条数: 25
    主依赖: ['starlette>=0.46.0', 'pydantic>=2.9.0', 'typing-extensions>=4.8.0',
    'typing-inspection>=0.4.2', 'annotated-doc>=0.0.2', 'opentelemetry-api>=1.44.0']
    有没有 orjson: False | 有没有 fastar: True
    • 注意最后两行:新版 standard 已经不装 orjson 了,换成了 Rust 实现的 fastar。网上大量教程让你 pip install orjson 配 ORJSONResponse,这条现在双重过时——类还能导入,但一用就:

      1
      2
      3
      4
      5
      from fastapi.responses import ORJSONResponse        # 能导入
      ORJSONResponse({"a": 1})
      -> FastAPIDeprecationWarning: ORJSONResponse is deprecated, FastAPI now serializes
      data directly to JSON bytes via Pydantic when a return type or response model is set
      -> AssertionError: orjson must be installed to use ORJSONResponse

      也就是说框架已经直接走 Pydantic 的 Rust 序列化,不需要自定义响应类;ORJSONResponse 属于「官方明确弃用」的写法,别再装 orjson 去配它。

    • 主依赖里出现了 opentelemetry-api,这是 0.162 新加的:FastAPI 现在原生支持 OpenTelemetry,构造参数里就有 telemetry=(详见 FastAPI-响应模型与OpenAPI文档那篇末尾)。

  • Python 版本要求,实测元数据:

    1
    2
    3
    fastapi            Requires-Python =>=3.10
    starlette Requires-Python =>=3.11
    pydantic-settings Requires-Python =>=3.11

    也就是说虽然 FastAPI 本体写的是 3.10,但它依赖的 Starlette 1.x 要求 3.11+,实际下限是 3.11。

  • 只装 pip install fastapi 会怎样?命令行工具直接拒绝启动,而且提示写得很明确:

    1
    2
    3
    4
    5
    To use the fastapi command, please install "fastapi[standard]":

    pip install "fastapi[standard]"

    RuntimeError
  • 装第三方包之前先看它的依赖上限,pip 降级核心库是不吭声的(本机在 Django 那边踩过)。装 FastAPI 生态包时同样适用:

    1
    pip install --dry-run "sqlalchemy>=2" alembic aiomysql asyncmy asyncpg mysqlclient
    1
    2
    Would install SQLAlchemy-2.1.8 alembic-1.20.1 aiomysql-0.3.2 asyncmy-0.2.17
    asyncpg-0.32.0 mysqlclient-2.3.0 PyMySQL-1.2.3

    确认 Would install 里没有 fastapi、pydantic、starlette 的旧版本再真装,说明它没有把核心包往下拽。

第一个应用

  • 最小可运行版本,main.py:

    main.py
    1
    2
    3
    4
    5
    6
    7
    8
    from fastapi import FastAPI

    app = FastAPI()


    @app.get("/")
    async def root():
    return {"hello": "world"}
  • 起服务有三条路,任选一条就能跑起来(区别在下一节讲):

    1
    2
    3
    4
    5
    6
    # 一、直接用 uvicorn(参数最全)
    python -m uvicorn main:app --host 127.0.0.1 --port 8000
    # 二、官方 CLI 的开发模式(自动 reload,默认只听 127.0.0.1)
    fastapi dev main.py --port 8000
    # 三、官方 CLI 的生产模式(默认听 0.0.0.0、不 reload)
    fastapi run main.py

    两种起法的实测日志:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    python -m uvicorn main:app ...
    INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
    INFO: Application startup complete.

    fastapi dev main.py --port 8000
    Starting FastAPI in development mode
    Using import string: main:app
    Server started at http://127.0.0.1:8000
    Documentation at http://127.0.0.1:8000/docs
    INFO: Started reloader process [46196] using WatchFiles
    INFO: Started server process [41504]
    INFO: Application startup complete.
  • 不想起服务器也能验,TestClient 直接在进程内发请求:

    test_first.py
    1
    2
    3
    4
    5
    6
    7
    from fastapi.testclient import TestClient

    from main import app

    client = TestClient(app)
    resp = client.get("/")
    print(resp.status_code, resp.json(), "|", resp.headers.get("content-type"))
    1
    200 {'hello': 'world'} | Content-Type: application/json
  • 三个第一眼就会疑惑的点,实测过:

    • return dict 不用手动转 JSON,FastAPI 直接序列化并把 Content-Type 设成 application/json。这点和 Flask 3 的 return {...} 行为一致(见 Flask-Restful API 开发),和 Tornado 要自己 self.write(dict) 不同。
    • operationId 是自动拼出来的,不是函数名。实测 def get_user(uid: int) 挂在 /users/{uid} 上,生成的 operationId 是 get_user_users__uid__get(函数名 + 路径 + 方法,非字母换成下划线)。要固定它就在装饰器里传 operation_id="myGoods",实测文档里就变成 myGoods;函数名仍然建议按 PEP8 起(get_user_list 而不是 getUserList)。
    • async def 和 def 都能用,但语义完全不同,这是 FastAPI 最容易写错的地方,单独开一节讲。

渲染 HTML(Jinja2Templates)

  • 挂模板与静态目录,实测:

    pages.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    import os

    from fastapi import FastAPI, Request
    from fastapi.responses import HTMLResponse
    from fastapi.staticfiles import StaticFiles
    from fastapi.templating import Jinja2Templates

    BASE = os.path.dirname(os.path.abspath(__file__))

    app = FastAPI()
    templates = Jinja2Templates(directory=os.path.join(BASE, "templates"))
    app.mount("/static", StaticFiles(directory=os.path.join(BASE, "static")), name="static")


    @app.get("/page", response_class=HTMLResponse)
    def page(request: Request):
    return templates.TemplateResponse(request, "home.html", {"title": "主页", "name": "lilei"})
    templates/home.html
    1
    <h1>{{ title }}</h1><p>{{ name }}</p>
    1
    2
    GET /page            -> 200 <h1>主页</h1><p>lilei</p>
    GET /static/site.css -> 200 text/css; charset=utf-8
  • ⚠️ 老教程里的调用形式 TemplateResponse("home.html", {"request": request, ...}) 在 Starlette 1.x 上直接 500,实测报:

    1
    TypeError: cannot use 'tuple' as a dict key (unhashable type: 'dict')

    现在只有两种写法可用(实测都 200):位置参数 TemplateResponse(request, name, context),或全关键字 TemplateResponse(request=request, name=..., context=...)。签名实测是:

    1
    2
    TemplateResponse(request, name, context=None, status_code=200,
    headers=None, media_type=None, background=None)

    也就是说 request 挪到了第一个参数,不用再塞进 context 里。

  • ⚠️ 跑这个例子前先确认自己在跑哪个 app。pages.py 里又 app = FastAPI() 建了一个新的应用对象,它和 main.py 里那个没有任何关系,fastapi dev main.py 起来的服务里就是没有 /page。实测同一个目录、同一批文件,两种起法的结果正好相反:

    1
    2
    3
    4
    5
    6
    7
    fastapi dev main.py   ->  Using import string: main:app
    GET / 200 {"message":"Hello World"}
    GET /page 404 {"detail":"Not Found"}

    fastapi dev pages.py -> Using import string: pages:app
    GET / 404 {"detail":"Not Found"}
    GET /page 200 <h1>主页</h1><p>lilei</p>
    • 起服务时终端第二行 Using import string: xxx:app 就是答案:它写哪个模块,路由就只去那个模块里找。/ 正常而 /page 404,多半就是这个原因。
    • 想两个文件一起生效就别建第二个 app:把 pages.py 改成 APIRouter,由 main.py 用 include_router 挂上去,写法见下面「工程骨架」。
  • ⚠️ static/ 目录必须在导入之前就存在。StaticFiles 在构造时就检查目录,目录没有会让整个模块导入失败,服务压根起不来:

    1
    RuntimeError: Directory '...\static' does not exist

    这行就是 fastapi dev pages.py 的最后一条输出(上面是一整段 traceback),Server started at 那行根本不会出现。

  • templates/ 的行为刚好相反:Jinja2Templates 构造时不检查,缺目录、缺模板文件都能把服务起来,只是请求走到渲染那一步才 500。三种情况实测:

    1
    2
    3
    4
    无 static/                  导入即 RuntimeError,服务起不来
    有 static、无 templates/ 服务起来了,GET /page -> 500
    有 templates、无 home.html 服务起来了,GET /page -> 500
    jinja2.exceptions.TemplateNotFound: 'home.html' not found in search path

    所以「服务起不来」查静态目录,「页面 500 但服务起来了」查模板。

三种启动方式

  • 官方其实给了三条路,差别在「谁帮你拼 uvicorn 参数」:

    方式 命令 默认监听 reload 用途
    uvicorn 直接起 python -m uvicorn main:app 127.0.0.1:8000 要自己加 --reload 通用,参数最全
    fastapi dev fastapi dev main.py 127.0.0.1 默认开 开发
    fastapi run fastapi run main.py 0.0.0.0 默认关 生产(或前面再套一层)
  • fastapi dev 的官方说明是「等价于 fastapi run,但开 reload 并只监听 127.0.0.1」,实测起来是这样:

    1
    fastapi dev main.py --port 8000
    1
    2
    3
    4
    5
    6
    7
    8
    Starting FastAPI in development mode
    Using import string: main:app
    Server started at http://127.0.0.1:8000
    Documentation at http://127.0.0.1:8000/docs
    INFO: Will watch for changes in these directories: ['E:\\Desktop\\Test\\demo']
    INFO: Started reloader process [46196] using WatchFiles
    INFO: Started server process [41504]
    INFO: Application startup complete.
    • 它会自动往 PYTHONPATH 里加包所在目录,并自动找 app 对象:先按 main.py / app.py / api.py(以及 app/main.py 这种)找文件,再在模块里找叫 app 或 api 的对象,找不到就用第一个 FastAPI 实例。所以变量名随便起的话,得用 --app 或 --entrypoint 指定。
    • reload 靠 watchfiles(Rust 实现的文件监听),改文件即重启;生产环境不要开。
  • 两个命令的选项差异,实测 --help:

    1
    2
    fastapi dev  有 --reload-dir,没有 --workers
    fastapi run 有 --workers、--root-path、--proxy-headers、--forwarded-allow-ips

    多进程部署要用 fastapi run --workers 4 或 uvicorn --workers 4,细节见 FastAPI-WebSocket、测试与部署。

  • ⚠️ Windows 中文控制台上的一个坑:fastapi dev --help 会直接崩,因为它的帮助文本里有 emoji(🧪),而 rich 在 GBK 代码页下写不出来:

    1
    UnicodeEncodeError: 'gbk' codec can't encode character '\U0001f9ea' in position 7

    解法是强制 UTF-8,而不是去改终端代码页:

    1
    2
    set PYTHONUTF8=1
    fastapi dev --help

    这条对 fastapi、uvicorn、alembic 这些带 rich 输出的工具都适用,跟本机在 Django 那边遇到的 GBK 问题是同一类。

fastapi 命令报 Fatal error in launcher

  • 用 conda 建的环境里跑 fastapi dev main.py,可能一条日志都没有就先崩:

    1
    2
    3
    Fatal error in launcher: Unable to create process using
    '"C:\Users\task_178228053511861\croot\fastapi-split_1782280704753\_h_env\python.exe"
    "D:\ProgramData\anaconda3\envs\fastapi\Scripts\fastapi.exe" dev main.py --port 8000': ???????????
    • 报错里那个 C:\Users\task_…\croot\…\_h_env\python.exe 是 Anaconda 打包机上构建这个包时用的临时解释器路径,被烧死在 Scripts\fastapi.exe 这个启动器(launcher stub)里,本机当然没有这个文件。

    • 后面那串 ??????????? 不是乱码 bug,是 Windows 的「系统找不到指定的文件」被 GBK 控制台显示不出来。

    • 触发条件很典型:pip 装过 fastapi[standard] 之后又 conda install fastapi,conda 会用自己那份 exe 覆盖 pip 生成的那份。判别方法:看文件时间是不是当天。

      1
      Get-Item $env:CONDA_PREFIX\Scripts\fastapi.exe | Select-Object Name, LastWriteTime
      1
      2
      3
      Name        LastWriteTime
      ---- -------------
      fastapi.exe 2026/6/24 14:00:00 # 打包时间,不是今天 -> 这份是 conda 塞进来的坏 stub
  • 三种解法,本机都实测过:

    • 绕过启动器(最快,不用装东西):fastapi 包自带 __main__.py,所以模块方式一定能跑。

      1
      python -m fastapi dev main.py --port 8000
      1
      2
      3
      4
      Starting FastAPI in development mode
      Using import string: blocking_app:app
      Server started at http://127.0.0.1:8994
      Documentation at http://127.0.0.1:8994/docs

      (上面这段是本机实测输出,文件名与端口按当时探针的取值打印,命令换成自己的 main.py 与端口即可。)

    • 重装,把 exe 写回正确路径:

      1
      pip install -U "fastapi[standard]"
      1
      2
      3
      Would install fastapi-0.162.0 rich-toolkit-0.20.6
      # 装完 fastapi.exe 的 LastWriteTime 变成当天,--version 正常输出
      FastAPI CLI version: 0.0.32
    • 只想修启动器、不动版本时:pip install --force-reinstall --no-deps fastapi-cli。

  • 混装的代价不止是启动器,conda 还会把版本拉回 defaults 通道的旧版。本机实测这次 conda install fastapi uvicorn 之后:

    1
    2
    3
    fastapi   0.162.0 -> 0.138.0
    uvicorn 0.54.0 -> 0.52.4
    pydantic 2.14.3 -> 2.14.1

    0.138 里连 telemetry 这个构造参数都还没有(inspect.signature(FastAPI.__init__) 实测查不到),所以「同一个环境里 conda 和 pip 混着装同一族包」要特别当心:要么全用 pip,要么全用 conda,别交叉覆盖。

async def 还是 def

  • 官方规则只有一句话:处理器里要 await 东西就用 async def,全是同步库就写 def,框架会把它丢进线程池。 听起来简单,写反的代价很大,所以本机实测了三种组合(单进程 uvicorn,每个请求耗时 0.4 秒,四个并发):

    写法 四个并发总耗时 期间夹一个 /fast 的延迟
    async def + time.sleep(0.4) 1.61s 1.21s
    def + time.sleep(0.4) 0.42s 0.00s
    async def + await asyncio.sleep(0.4) 0.41s 0.00s
    blocking_app.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    import asyncio
    import time

    from fastapi import FastAPI

    app = FastAPI()


    @app.get("/slow-async")
    async def slow_async():
    time.sleep(0.4) # 错:阻塞了整个事件循环
    return {"kind": "async+time.sleep"}


    @app.get("/slow-sync")
    def slow_sync():
    time.sleep(0.4) # 对:def 版本被丢进线程池,事件循环照常跑
    return {"kind": "def+time.sleep"}


    @app.get("/slow-await")
    async def slow_await():
    await asyncio.sleep(0.4) # 对:让出事件循环
    return {"kind": "async+await"}
  • 结论要说得比「异步更好」更准:async def 里出现阻塞调用,是四种组合里唯一会让所有请求排队的写法(1.61s ≈ 4×0.4s,连一个本来 0 毫秒的 /fast 都要等 1.21s)。反过来,写成 def 时同样的 time.sleep 完全不挡别人。

  • 那 def 是不是就安全了?不是,线程池有上限。实测这个上限:

    1
    python -c "import anyio; anyio.run(lambda: print(anyio.to_thread.current_default_thread_limiter().total_tokens))"
    1
    total_tokens = 40

    60 个各睡 0.4 秒的 def 请求并发打过去,实测总耗时 0.82 秒——正好是两批(40 + 20),说明第 41 个开始就在排队了。

  • 两种写法在哪个线程上跑,也能验:

    thread_probe.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    import threading

    from fastapi import FastAPI

    app = FastAPI()


    @app.get("/thread")
    def who():
    return {"on_main_thread": threading.get_ident() == threading.main_thread()}
    1
    GET /thread -> {'on_main_thread': False}     # def 处理器确实在线程池里

    顺带一个实用结论:def 处理器里可以放心用任何阻塞库(requests、mysqlclient、open()),因为它们本来就跑在线程里;但别在 def 处理器里访问 contextvars 之外的协程上下文,跨线程传不回去。

  • 需要在 async def 里调同步库时,别把函数改成 def 就完事,官方给的是显式挪进线程池:

    async_with_sync.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    import sqlite3

    from fastapi import FastAPI
    from fastapi.concurrency import run_in_threadpool

    app = FastAPI()


    @app.get("/rows")
    async def rows():
    data = await run_in_threadpool(_query) # 阻塞调用挪走,事件循环不卡
    return {"count": len(data)}


    def _query():
    conn = sqlite3.connect("demo.sqlite")
    try:
    return conn.execute("select id from t").fetchall()
    finally:
    conn.close()

    实测 fastapi.concurrency 导出的就是这几个工具,够用:

    1
    2
    run_in_threadpool, iterate_in_threadpool, contextmanager_in_threadpool,
    run_until_first_complete, CapacityLimiter

    流式响应里同步生成器要换成 iterate_in_threadpool(...),否则同样卡循环。

  • 一句话记忆法:「写 async def 就得对整条调用链负责」——里面只要有一个同步阻塞调用,前面所有请求都得等它。做不到就用 def,或者显式 run_in_threadpool。

工程骨架

  • 单文件写到三十个接口就会失控,FastAPI 的拆法是 APIRouter。本机跑通的骨架:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    demo/
    main.py # 建 app、挂路由
    core/
    __init__.py
    config.py # 配置
    routers/
    __init__.py
    users.py
    items.py
    routers/users.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    from fastapi import APIRouter

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


    @router.get("")
    def list_users():
    return {"users": ["lilei", "hanmeimei"]}


    @router.get("/{user_id}")
    def get_user(user_id: int):
    return {"user_id": user_id, "name": "lilei"}
    main.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    from fastapi import FastAPI

    from core.config import Settings
    from routers import items, users

    settings = Settings()

    app = FastAPI(title=settings.app_name)
    app.include_router(users.router)
    app.include_router(items.router)


    @app.get("/health")
    def health():
    return {"ok": True, "name": settings.app_name}
  • 实测请求结果:

    1
    2
    3
    4
    5
    6
    /health      -> 200 {"ok": true, "name": "shop-api"}
    /users -> 200 {"users": ["lilei", "hanmeimei"]}
    /users/3 -> 200 {"user_id": 3, "name": "lilei"}
    /items -> 200 {"items": [1, 2]}
    /users/abc -> 422 {"detail": [{"type": "int_parsing", "loc": ["path", "user_id"], ...}]}
    /nope -> 404 {"detail": "Not Found"}
    • prefix="/users" + @router.get("") 得到 /users;写成 @router.get("/") 得到 /users/。两种写法都能访问通,靠的是 redirect_slashes(默认开)在中间补 307:

      1
      2
      3
      4
      5
      GET /u1  -> 307 Location: /u1/      # 路由写成 @router.get("/") 时
      GET /u1/ -> 200
      GET /u2 -> 200 # 路由写成 @router.get("") 时
      GET /u2/ -> 307 Location: /u2
      openapi paths: ['/u1/', '/u2'] # 文档里是两个不同的路径

      所以统一用空字符串:少一次 307 往返,前端按文档里的路径请求也正好命中,不会每次都先吃一个重定向。

    • /users/abc 的 422 不用你写任何校验代码:路径参数声明了 int,转换失败自动返回结构化的校验错误。

  • ⚠️ 一个 0.162 的行为变化,写脚本时会撞上:include_router 现在是懒加载的,app.routes 里会出现没有 path 属性的 _IncludedRouter 对象。老写法直接崩:

    1
    python -c "import main; print([r.path for r in main.app.routes])"
    1
    AttributeError: '_IncludedRouter' object has no attribute 'path'

    实测的路由分布是这样:

    1
    2
    3
    app.routes 总数 7
    类型分布: Counter({'Route': 4, '_IncludedRouter': 2, 'APIRoute': 1})
    直接能看到的 path: ['/openapi.json', '/docs', '/docs/oauth2-redirect', '/redoc', '/health']

    也就是说 /users、/items 这些挂在 _IncludedRouter 里,不在 app.routes 的第一层。要拿到全部路径,用受支持的公开结果,别去翻内部结构:

    list_routes.py
    1
    2
    3
    4
    from main import app

    paths = sorted(app.openapi()["paths"])
    print(paths)
    1
    ['/health', '/items', '/users', '/users/{user_id}']
    • 单个 URL 的生成仍然正常:app.url_path_for("get_user", user_id=3) 实测返回 /users/3(url_path_for 已经会钻进被包含的路由里找)。
    • 需要遍历「有效路由」时,_IncludedRouter 暴露了 effective_candidates(),实测能拿到 ['/users', '/users/{user_id}', '/items'],但这是内部实现,别写进生产代码。
  • include_router 还能带公共依赖和前缀,这一层是「整组接口都要登录」这类需求的正确落点,写法见 FastAPI-依赖注入与异常中间件。

配置从哪来

  • 官方推荐用 pydantic-settings(fastapi[standard] 已经带了),好处是配置项也有类型校验:

    core/config.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    from pydantic_settings import BaseSettings, SettingsConfigDict


    class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8",
    extra="ignore")

    app_name: str = "shop-api"
    max_limit: int = 10
    secret: str = "change-me"


    settings = Settings()
  • 优先级实测(环境变量 > .env 文件 > 代码默认值):

    1
    2
    3
    只走环境变量:            {'app_name': 'from-env',  'max_limit': 50, 'secret': 'none'}
    有 .env 时(环境变量优先): {'app_name': 'from-env', 'max_limit': 50, 'secret': 'sk-123'}
    去掉环境变量后 .env 生效: {'app_name': 'from-dotenv','max_limit': 50, 'secret': 'sk-123'}
    • 字段名 app_name 会自动匹配大写的环境变量 APP_NAME,不需要声明。
    • .env 里有代码里没声明的键时,不加 extra="ignore" 会直接 ValidationError,实测报 Extra inputs are not permitted。
    • 环境变量里放了非法值也不会静默兜底:MAX_LIMIT=abc 实测抛 ValidationError: 1 validation error for S,服务起不来——这是好事,配置写错应该在启动时就炸。
  • ⚠️ 老教程里的 class Config 写法在 v2 已经不适用,实测既告警又报错:

    legacy_settings.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    # 老写法(Pydantic v1),v2 下会报 PydanticDeprecatedSince20,且 env_file 不生效
    from pydantic_settings import BaseSettings


    class Settings(BaseSettings):
    app_name: str = "default"

    class Config:
    env_file = ".env"
    1
    2
    PydanticDeprecatedSince20: Support for class-based `config` is deprecated, use ConfigDict instead.
    ValidationError: 2 validation errors for Settings

    正确写法只有 model_config = SettingsConfigDict(...) 这一条。

  • 密钥类配置(password、JWT 签名密钥、数据库口令)走环境变量或 .env,并且把 .env 加进 .gitignore,不要把明文写进代码仓库。

  • 配置对象怎么注入到处理器里(Depends(Settings()) 的写法与坑)见 FastAPI-依赖注入与异常中间件。

文档三件套

  • 服务起来后有三个内置路径,实测状态码:

    1
    2
    3
    4
    /docs                   -> 200   Swagger UI
    /redoc -> 200 ReDoc
    /openapi.json -> 200 OpenAPI 描述
    /docs/oauth2-redirect -> 200 OAuth2 回调页(做认证时才用得到)

    这份文档不是「示例代码生成的假文档」,它就是路由与模型的真实描述,所以接口一改文档就跟着改,这也是 FastAPI 最大的日常收益。

  • 生成的描述用的是 OpenAPI 3.1.0,默认标题和版本是这样:

    1
    2
    openapi: 3.1.0
    info: {'title': 'FastAPI', 'version': '0.1.0'}

    title 和 version 在 FastAPI(title=..., version=...) 里改,别在路由上零散改。

  • 想关掉或改路径,实测三种情况:

    docs_switch.py
    1
    2
    3
    4
    from fastapi import FastAPI

    off = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
    moved = FastAPI(docs_url="/api-docs", openapi_url="/my-openapi.json")
    1
    2
    全关:/docs 404 | /openapi.json 404
    改路径:/api-docs 200 | /my-openapi.json 200 | 原来的 /docs 404
    • 只关 /docs 不关 openapi_url 是没用的——描述文件还在全网可读,等于没藏。要关就三个一起关。
    • 生产环境是否公开文档没有标准答案:内部系统一般留着方便联调,公网接口通常关掉或加鉴权(用依赖包一层,见 FastAPI-安全与认证)。

默认错误响应

  • 不用写任何代码,FastAPI 的默认错误就是 JSON,实测:

    1
    2
    3
    4
    GET  /nope      -> 404 {"detail":"Not Found"}
    POST /only-get -> 405 {"detail":"Method Not Allowed"}
    GET /users/abc -> 422 {"detail":[{"type":"int_parsing","loc":["path","user_id"],
    "msg":"Input should be a valid integer, ..."}]}
    • 404/405 由 Starlette 的路由层给,422 由校验层给,三者结构不同:422 的 detail 是列表,每项带 type、loc、msg,前端可以直接按 loc 定位到具体输入框。
    • 校验错误的完整结构、字段名含义和自定义方式在 FastAPI-请求体与Pydantic校验里逐条讲。
  • 抛业务错误用 HTTPException,它和默认错误走同一个 detail 结构:

    raise_error.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    from fastapi import FastAPI, HTTPException, status

    app = FastAPI()


    @app.get("/goods/{goods_id}")
    def get_goods(goods_id: int):
    if goods_id != 1:
    raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
    detail="商品不存在")
    return {"goods_id": 1, "name": "键盘"}
    1
    2
    /goods/1 -> 200 {"goods_id": 1, "name": "键盘"}
    /goods/9 -> 404 {"detail":"商品不存在"}
    • 必须 raise,不能 return HTTPException(...);return 一个 Response 对象才是合法的(见 FastAPI-响应模型与OpenAPI文档)。
    • 中文 detail 能正常输出,是因为 FastAPI 序列化时不转义非 ASCII;换成 ensure_ascii=True 的自定义响应就会变成 \uXXXX。

参考