Chapter 01

核心概念:从外部看 FastAPI 的编程模型

这一章建立 FastAPI 的词汇表:写一个 FastAPI 程序时,每个部件分别是什么、为什么存在。先把名字认全,下一章再把它们装回机器里看请求怎么流过。

本章你将建立的 schema

  • path operation(路径操作)—— 一个 URL+方法 到一个函数的绑定
  • 类型注解即规约 —— 一份注解同时驱动校验、序列化、文档、依赖
  • Pydantic 模型 —— 用类型声明数据形状,自动获得校验与序列化
  • response_model —— 声明响应类型,框架据此过滤、校验、生成文档
  • Depends —— 把前置准备声明成函数,框架自动调用并注入结果
  • 三层封装 —— FastAPI 架在 Starlette 之上、Starlette 架在 ASGI 之上

把这一章当成认人:每个概念先给一句话定义,再说它解决了什么手工活,然后钻到官方文档停笔的下一层讲机制——怎么实现的、代价是什么、何时失效。读完这七个词,看懂别人的 FastAPI 代码就不再需要靠猜。

1.1web 框架到底替你做了什么

web 框架是夹在操作系统的 socket 和你的业务函数之间、负责把字节翻译成对象再翻译回字节的那层代码。

为什么需要它

一个 HTTP 请求到达时,操作系统交给程序的只是一串原始字节:GET /items/5 HTTP/1.1\r\nHost: localhost\r\n\r\n。没有框架,工程师要亲手做三件苦力:按 HTTP 文法切分这串字节、根据请求行里的 URL 决定调哪个函数、再把函数返回值拼回一串合法的 HTTP 响应字节。每个端点重复一遍,且任何一处写错协议就是难查的线上故障。框架把这三步收敛成一次性的基础设施。

底层机制(比文档深一层):框架做的不是"处理请求"这种笼统的事,而是一条确定的字节翻译链。第一步是解析(parse):把 GET /items/5 HTTP/1.1 这串字节拆成结构化字段——方法 GET、路径 /items/5、协议版本、各个 header、以及可选的请求体。第二步是路由(routing):拿解析出的"方法 + 路径"去一张注册表里查,找到对应的处理函数。第三步是编码(encode):把函数返回的 Python 对象序列化成 JSON 字节,再补上状态行 HTTP/1.1 200 OK 和 header,拼成完整响应送回。普通 web 框架到此为止;FastAPI 在第二步和第三步之间额外插入了"用类型注解校验输入",在第三步额外插入了"用类型注解过滤输出"——多出来的这两刀,是后面六个概念的全部主题。

类比 · 带边界声明

框架像餐厅前台:前台收单(解析)、把"靠窗那桌点了 5 号"翻译给后厨(路由)、再把做好的菜端出去(编码),后厨(你的业务函数)只管做菜。但类比有个失效处——普通前台不会核对"菜单上到底有没有这道菜",把无效订单直接塞进后厨;FastAPI 这个前台会核对,靠的是你写的类型注解。这一点正是 FastAPI 区别于"只做翻译"的框架的地方。

场景走查:浏览器发出 GET /items/5。操作系统把字节交给运行中的服务器进程 → 框架解析出 method=GET、path=/items/5 → 在注册表里查到 /items/{item_id} 这条记录绑定着名为 read_item 的函数 → 框架把 5 作为参数喂进去 → 函数返回一个 Python dict → 框架把 dict 编码成 JSON 字节加上响应头送回浏览器。整条链里你只写了 read_item 一个函数,其余都是框架的活。

与下一个概念的关系:上面那张"注册表"——把 URL+方法 映射到函数——就是下一个概念 path operation 的本体。

1.2path operation(路径操作)

path operation 是一个 (URL 路径 + HTTP 方法) 到一个函数的绑定,由装饰器在程序导入时登记。

为什么需要它

上一节那张"注册表"得有人填。手写注册表意味着到处维护一份 {("GET","/items/{id}"): read_item, ...} 字典,函数和它的 URL 分散在两处、容易写错对不上。path operation 用装饰器把"这个函数响应哪个 URL+方法"这条信息直接贴在函数头顶,登记动作交给框架自动完成。

底层机制(比文档深一层):关键时机是导入时,不是请求时。Python 执行到 @app.get("/items/{item_id}") 这一行时(也就是模块被 import 加载的那一刻),装饰器立即运行:它把下面那个函数注册进 app 内部的路由表,并当场用 inspect.signature() 读出函数的完整签名——每个参数叫什么、是什么类型、有没有默认值。等到第一个请求进来时,路由表和签名解析早已就绪,请求阶段只是查表 + 套用早就算好的规则。这解释了一个常被忽略的事实:写错类型注解导致的报错往往在服务启动时就抛出,而不是等到那个端点被访问。官方对这套东西的术语是精确的:装饰器叫 path operation decorator,被装饰的函数叫 path operation function,两者合起来叫 path operation——不是 route,不是 endpoint,不是 handler。

path_operation.py Python
from fastapi import FastAPI

app = FastAPI()

# @app.get(...) 在 import 这个模块时就执行:
#   1) 把 read_item 登记进 app 的路由表,键是 ("GET", "/items/{item_id}")
#   2) 立即 inspect.signature(read_item) 读出 item_id: int 这个签名
@app.get("/items/{item_id}")
def read_item(item_id: int):
    return {"item_id": item_id}
类比 · 带边界声明

path operation 像在公司前台登记的"分机号 → 工位"对照表:拨 /items/{item_id} 这个号就转接到 read_item 这个工位。但类比的边界在于——普通分机表只存号码到人的映射,FastAPI 登记的同时还把"这个工位接电话需要哪些信息、什么格式"(函数签名)一并记了下来,所以它能在转接前先替你核对来电内容。

场景走查:请求 GET /items/5 如何匹配到 read_item。框架解析出 method=GET、path=/items/5 → 在路由表里逐条比对路径模式,/items/{item_id} 这条能匹配上,且其中 {item_id} 是一个占位段 → 框架抓出占位段对应的子串 "5" → 因为导入时已记得 item_id 的注解是 int,框架把 "5" 转换并校验成整数 5 → 调用 read_item(item_id=5)。同一个 path /items/5 配上不同的 method(比如 DELETE)会匹配到另一个 path operation,互不干扰。

与下一个概念的关系:上面"导入时读出签名、请求时按注解把 '5' 变成 5"这件事,揭开了本教程的核心命题——类型注解不只是提示,它是框架的运行时真相来源。

1.3类型注解即规约(全章核心)

在 FastAPI 里,函数参数的类型注解同时决定这个参数从哪来、按什么规则校验、在文档里长什么样。

为什么需要它

没有这套机制,每个端点都要手写一长串:从 query string 里取值、判断在不在、转类型、转失败了返回什么错误、再在另一个文件里手动补一条 API 文档。输入声明、校验逻辑、文档三者分散且必然漂移。FastAPI 让你只写一次类型注解,这三样从同一份注解里自动派生——这就是 index 页那句"你写的类型签名就是规约,没有第二份 schema"的落地。

底层机制(比文档深一层):导入时框架用 inspect.signature() 拿到每个参数的注解后,按一套确定规则推断它的来源:

  • 参数名出现在路径的 {} 里 → 它是路径参数(path parameter),从 URL 路径段取值。
  • 注解是一个 Pydantic 模型(BaseModel 子类)→ 它是请求体(request body),从 HTTP body 的 JSON 取值。
  • 注解是普通标量(int/str/float/bool)且名字没出现在路径里 → 它是查询参数(query parameter),从 URL 问号后面取值。

这套默认推断可以用 Path() / Query() / Body() 显式覆盖。推断出来源后,同一份注解被喂给四个不同的消费者:输入校验器、类型转换器、OpenAPI schema 生成器、以及编辑器的类型补全。一处声明,四处生效——这正是图 1.1 要画的事。

类型注解 item: Item 请求校验 不合规 → 422 响应序列化 对象 → JSON OpenAPI 文档 自动生成 schema 依赖解析 注入对应对象
图 1.1一份 item: Item 注解,被框架同时喂给四个消费者。注意:这四个产物来自同一处声明——没有第二份 schema 要你手动同步。
想一想

下面这个签名里,item_id 和 q 各自从请求的哪个部分取值?

where_from.py Python
@app.get("/items/{item_id}")
def f(item_id: int, q: str | None = None):
    ...
展开答案(先停 10 秒再点)

item_id 是路径参数:它的名字出现在路径模板 /items/{item_id} 的 {} 里,所以从 URL 路径段取值,并按 int 校验转换。q 是查询参数:注解是普通标量 str、名字没出现在路径里,于是从问号后面取值;str | None = None 表示它可选,缺省为 None。访问 /items/5?q=hello 时 item_id=5, q="hello";访问 /items/5 时 item_id=5, q=None。

这道题指向的设计要点:参数来源不是你显式配置的,而是框架从"名字 + 类型"两个信号里推断出来的。推断规则一旦记牢,读任何 FastAPI 签名都能一眼看出每个参数从哪进来。

类比 · 带边界声明

类型注解像一份报关单:同一份申报既是海关放行的依据(校验),又是事后留存的记录(文档),不用填两遍。但类比的失效处很关键——报关单是人填的、可以填得和货物不符;这里的"申报"是编译器从你的类型注解里推出来的,校验依据和文档天然同源,不存在"申报与实物对不上"的空间。

场景走查:定义 def read_item(item_id: int, q: str | None = None) 绑定到 /items/{item_id}。导入时框架读签名 → 判定 item_id 为路径参数、q 为查询参数 → 同步生成一份 OpenAPI schema,记下"这个端点接受一个整型路径参数和一个可选字符串查询参数" → 运行时访问 /items/abc(路径参数给了非整数)框架直接返回 422、根本不调用你的函数;访问 /docs 则能看到这两个参数的交互式表单——全部出自那一行签名。

与下一个概念的关系:当注解不是 int 这种标量、而是一个自定义的数据形状(一个用户、一个订单)时,就需要 Pydantic 模型来声明这个形状。

1.4Pydantic 模型

Pydantic 模型是一个继承 BaseModel 的类,用字段的类型注解声明数据形状,从而自动获得校验与序列化能力。

为什么需要它

标量注解只能描述"一个整数""一个字符串"。真实请求体是结构化的:一个商品有 name、price、可选的 description,每个字段还有自己的类型和约束。没有 Pydantic,工程师要手写一大段 if "name" not in data: raise ... / if not isinstance(data["price"], (int, float)): raise ... 的校验代码,每个端点重来一遍。Pydantic 让你用一个类的字段注解把这个形状声明出来,校验代码自动生成。

底层机制(比文档深一层):编译发生在类定义时,不是每次校验时。class Item(BaseModel) 这行被执行的瞬间,BaseModel 的元类(metaclass,控制类如何被创建的机制)遍历类里所有带注解的字段,把它们编译成一套校验器并缓存在类上。之后每次校验一个请求体,只是调用这套早已编译好的校验器,而不是重新解析字段定义。这与"类型注解即规约"是同一个时机哲学——代价前置到定义/导入时,换取请求时的速度。校验器的内核由 Rust 实现(03 章会拆开看这是 v2 比 v1 快 5–50× 的根源),这里只需记住:定义时编译一次,请求时调用 N 次。

pydantic_model.py Python
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

# 类定义这一刻,元类就把三个字段编译成校验器并缓存在 Item 上
class Item(BaseModel):
    name: str
    price: float
    description: str | None = None   # 可选字段,缺省 None

# 注解是 Pydantic 模型 → item 被判定为请求体,从 JSON body 取值
@app.post("/items")
def create_item(item: Item):
    # 想把模型转回 dict 用 model_dump()(Pydantic v2),不是 v1 的 .dict()
    return {"received": item.model_dump()}
陷阱

Pydantic v2(FastAPI 0.100+ 起的标准,v1 写法已在 FastAPI 0.136.x 彻底移除)把几个常用 API 改了名:把模型转成 dict 用 model_dump(),不是 .dict();从 ORM 对象等带属性的对象构建模型,配置项叫 from_attributes=True,不是 orm_mode=True。照着旧教程写 .dict() 会直接抛 AttributeError。

类比 · 带边界声明

Pydantic 模型像一套印刷好的表格模板:模板一旦制版(类定义),后面印一万份只是套印(校验调用),不用每份重新排版。但类比的失效处——纸表格只规定"哪格填什么",不会自动拒收填错的;Pydantic 模板自带验收逻辑,price 那一格填了非数字会当场退回并精确指出是哪个字段错了。

场景走查:客户端 POST 一个缺了 price 的 JSON {"name": "book"} 到 /items。框架从 body 读出这个 dict → 交给 Item 早已编译好的校验器 → 校验器发现必填字段 price 缺失 → 框架不调用 create_item,直接返回 422,响应体里精确标出 "loc": ["body", "price"]、"msg": "Field required"。整个过程你没写一行校验代码,错误定位却精确到字段。

与下一个概念的关系:Pydantic 模型用在输入侧能校验请求体;把同一类思路用在输出侧——声明响应该长什么样——就是 response_model。

1.5response_model

response_model 声明一个端点响应的类型,框架据此对返回值做过滤、校验,并生成响应侧的文档。

为什么需要它

函数返回的对象常常比你想暴露给客户端的多。一个从数据库取出的用户对象往往带着 password_hash、internal_notes 等字段。没有 response_model,返回什么就原样序列化什么,敏感字段随响应泄漏。response_model 让你声明"对外只暴露这些字段",框架据此过滤返回值——多余字段不会出门。

底层机制(比文档深一层):返回值会被再跑一遍序列化。你的函数把对象 return 出来后,框架并不直接把它丢给 JSON 编码器,而是先用 response_model 声明的那个 Pydantic 模型的序列化器过一遍——只有模型里声明过的字段才会被读出来写进响应,模型里没有的字段被静默丢弃。这就是它能挡住数据泄漏的机制:过滤发生在"模型声明了什么"这一层,而不是"你返回了什么"。但要点清楚——这是输出过滤,不是鉴权。它能保证 password_hash 不出现在 JSON 里,但它不判断"当前这个调用者有没有资格看这条记录"。鉴权是另一件事(属于 Depends 的领域)。

response_model.py Python
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class UserOut(BaseModel):   # 对外契约:只暴露这两个字段
    id: int
    name: str

# response_model 指定返回值要过 UserOut 的序列化器
@app.get("/users/{user_id}", response_model=UserOut)
def read_user(user_id: int):
    # 故意返回一个带敏感字段的 dict
    return {
        "id": user_id,
        "name": "Ada",
        "password_hash": "$2b$12$xxxxx",   # 不在 UserOut 里 → 被静默过滤掉
    }

预期输出(访问 GET /users/7 的响应体):

response.json JSON
{"id": 7, "name": "Ada"}

运行环境:FastAPI 0.135.2 / Python 3.13

陷阱

不写 response_model、直接 return 一个 ORM 对象,会把该对象的全部字段序列化出去——password_hash、token、内部备注一并泄漏给客户端。response_model 是堵这个洞的标准做法,但再次强调它是输出过滤:它决定"哪些字段能出门",不决定"谁能调这个接口"。后者要靠下一节的 Depends。

想一想

承接上面的代码:(a) 如果返回的 dict 多了一个 UserOut 里没有的字段(如 password_hash),会怎样?(b) 如果返回的 dict 少了一个 UserOut 的必填字段(如漏了 name),又会怎样?

展开答案(先停 10 秒再点)

(a) 多出的字段被静默丢弃,响应里看不到它,不报错。序列化器只读模型声明过的字段。

(b) 缺失必填字段会触发 500(服务端内部错误),不是 422。这是个容易记反的关键区别:422 是入站校验失败(客户端发来的请求体不合规,怪客户端);而响应缺字段是出站序列化失败——是你的服务端代码没按自己声明的契约返回数据,属于服务端 bug,所以归为 500。

这道题指向的设计要点:同一个 Pydantic 模型在入站和出站两侧失败时,归责方向相反,HTTP 状态码因此不同。

类比 · 带边界声明

response_model 像寄快递前的一道分拣:只有清单上列出的物品才会被装箱寄出,没列的留在原地。但类比的失效处——分拣只管"装什么走",不管"谁有权下这个单"。把 response_model 当成访问控制是危险的误用:它过滤字段,不过滤调用者。

场景走查:函数从数据库取出完整 user 对象(含 password_hash)并返回 → 框架不直接序列化,而是先用 UserOut 的序列化器过滤 → 只有 id 和 name 被读出 → 编码成 JSON 送回。同时 /docs 里这个端点的"响应"部分会自动显示 UserOut 的结构——输出文档也来自这一处声明。

与下一个概念的关系:上面反复提到"谁有权调这个接口"该由别处管——那个"别处"就是 Depends:把鉴权、取数据库连接这类前置准备声明成可注入的依赖。

1.6Depends / 依赖注入

Depends 把"这个 path operation 执行前需要先准备好的东西"声明成一个函数,由框架自动调用并把结果注入进来。

为什么需要它

多个端点常常需要同一份前置准备:解析当前登录用户、开一个数据库连接、校验一个 API key。没有依赖注入,这段逻辑要么在每个端点函数里复制粘贴,要么手动层层传参。Depends 让你把这段逻辑写成一个函数,然后在任意端点的签名里声明"需要它",框架负责在调用端点前先调用这个函数、把返回值送进来。逻辑只写一次,处处复用。

底层机制(比文档深一层):Depends(get_current_user) 里存的是未被调用的函数本身(注意没有括号——传的是 get_current_user 不是 get_current_user())。框架拿到这个函数后,递归地用 inspect.signature() 读它的签名:如果这个依赖函数自己的参数里又有 Depends(...),那就是它的子依赖,于是这些依赖关系连成一张图(graph)。请求到来时,框架对这张图做深度优先解析(先解析最底层的子依赖,再往上逐层调用),把每一层的返回值注入到需要它的地方,最后才调用你的端点函数。03 章会展开这张图的解析顺序、缓存策略和 yield 清理;这里要记住的核心是:Depends 存的是"配方"(怎么准备)而非"成品",框架在请求时按配方现做。

depends.py Python
from typing import Annotated
from fastapi import FastAPI, Depends, Header, HTTPException

app = FastAPI()

# 一个依赖:从 header 解析当前用户,逻辑只写这一次
def get_current_user(x_token: Annotated[str, Header()]) -> str:
    if x_token != "secret":
        raise HTTPException(status_code=401, detail="bad token")
    return "Ada"

# 现代写法:Annotated[类型, Depends(依赖函数)]
# 三个端点共用 get_current_user,各自不重复鉴权逻辑
@app.get("/profile")
def profile(user: Annotated[str, Depends(get_current_user)]):
    return {"viewer": user}

@app.get("/settings")
def settings(user: Annotated[str, Depends(get_current_user)]):
    return {"owner": user}
类比 · 带边界声明

Depends 像点菜时写在订单上的"需配一副刀叉"——你声明需求,由餐厅在上菜前备齐,不用你自己跑去后厨拿。但类比的失效处——真实餐厅每次都新拿一副刀叉,而 FastAPI 的依赖默认每个请求内只解析一次并缓存结果:同一个依赖即便出现在五个子依赖里,一个请求内也只调用一次。这个缓存默认行为在某些场景(依赖每次该产出新随机值、新连接)会带来意外,03 章详述。

场景走查:三个端点 /profile、/settings、/orders 都在签名里写了 user: Annotated[str, Depends(get_current_user)]。请求 GET /profile 带着 header X-Token: secret 到达 → 框架看到 profile 依赖 get_current_user → 先调用 get_current_user(它自己又依赖一个 header 参数,框架先把 x_token 取出喂进去)→ 校验通过返回 "Ada" → 把 "Ada" 作为 user 注入 profile → 执行 profile 返回 {"viewer": "Ada"}。鉴权逻辑写一次,三个端点共享。

与下一个概念的关系:到这里六个概念里有五个——path operation、类型注解、Pydantic、response_model、Depends——全是 FastAPI 在每个端点外面加的那层封装。最后一个概念退后一步,看这层封装底下还站着谁。

1.7三层封装:FastAPI / Starlette / ASGI

FastAPI 是架在 Starlette 之上、Starlette 又架在 ASGI 协议之上的一层薄封装。

为什么需要它

读 FastAPI 源码或排查问题时会发现:路由、Request/Response 对象、中间件(middleware,请求进出时统一穿过的处理层)、WebSocket 这些东西,其实都不在 FastAPI 自己的代码里,而在它依赖的 Starlette 里。搞清三层各自的职责,才能知道一个行为该去哪一层找答案——否则会在 FastAPI 文档里徒劳地找一个本属于 Starlette 的功能。

底层机制(比文档深一层):三层各管一段。最底层 ASGI(Asynchronous Server Gateway Interface,异步服务器网关接口)是一份协议——它规定 web 服务器(如 Uvicorn)和应用之间用什么形状互相调用,本身不含实现。中间层 Starlette 是一个实现了 ASGI 协议的工具箱:路由表、Request/Response 封装、中间件栈、WebSocket、把同步函数丢进线程池的调度,全是它提供的;但它不自带服务器。最上层 FastAPI 直接继承 Starlette(class FastAPI(Starlette)),路由、请求对象、线程池调度全是继承来的——FastAPI 自己新增的唯一东西,就是在每个 path operation 外面加的那层"读类型 → 校验输入 → 序列化输出 → 生成 OpenAPI 文档"。这正好补全了 index 页「一句话本质」的后半句:"其余一切,都是架在 ASGI 协议之上的一层薄封装。"前面 1.3–1.6 讲的那层封装,就坐落在这张分层图的最上面一格。

阶段 形态 原始 body JSON bytes 解析 解析后 dict 校验 Item(校验) Item 实例 注入 你的函数 item: Item 不合规 422 错误
图 1.2请求体校验数据流:字节 → dict → Item 实例 → 你的函数。注意:校验失败时分叉到 422,你的函数根本不会被调用——拦截发生在进入业务代码之前。

场景走查(把图 1.2 走一遍):客户端 POST {"name":"book","price":9.9} 到一个签名为 def create_item(item: Item) 的端点。请求体作为 JSON bytes 到达 → Starlette 层把它解析成 dict → FastAPI 那层封装用 Item 的校验器把 dict 校验并构造成 Item 实例 → 把这个实例作为 item 注入你的函数。若 body 是 {"name":"book"}(缺 price),流程在第三步分叉:校验器报错 → 直接返回 422,create_item 不被调用。注意解析(Starlette 的活)和校验(FastAPI 那层的活)分属两层——这正是三层封装在一次请求里的体现。

类比 · 带边界声明

三层像盖楼:ASGI 是地基规范(规定承重标准,但不是楼),Starlette 是按规范浇好的承重结构(梁柱、楼梯、电梯井都在这层),FastAPI 是在结构之上做的精装修(每个房间加了门禁和验收流程)。但类比的失效处——精装修通常独立于结构,可整套拆换;而 FastAPI 是继承 Starlette 的,两者在代码上是父子关系、共享同一套结构对象,并非可热插拔的独立两层。

与下一章的关系:这一章把七个部件逐个命名了。下一章把它们装回机器,沿着 Uvicorn → ASGI → Starlette → FastAPI 这条链,看一个请求从网线上的字节一路走到 JSON 响应,每一层各自发生了什么。

§本章 self-check

先合上教程,把你能想到的答案写在纸上或编辑器里。 写完再点开答案对照——直接点开等于把这一节当再读一遍。

  1. path operation、path operation function、path operation decorator 三个官方术语分别指什么?
  2. 在 def f(item_id: int, q: str) 绑定到 /items/{item_id} 时,item_id 和 q 各被判定为哪种参数来源?框架靠哪两个信号做这个判定?
  3. response_model 为什么能防止 password_hash 泄漏?为什么说它是"输出过滤"而不是"鉴权"?
  4. (设计题)"类型注解即规约"和"Pydantic 在类定义时编译校验器"这两件事,共享同一个时机哲学。这个哲学是什么?它把什么代价挪到了什么时候、换来了什么?
答案(先做完再展开)
  1. path operation decorator 是装饰器本身(@app.get("/items/{id}"));path operation function 是被它装饰的那个函数(read_item);path operation 是两者合起来的整体——一个"URL 路径 + HTTP 方法 → 函数"的绑定。它们不叫 route / endpoint / handler,这是 FastAPI 的官方用词。
  2. item_id 是路径参数(名字出现在路径的 {} 里),q 是查询参数(普通标量 str 且名字不在路径里)。判定靠两个信号:参数名字是否出现在路径模板里、以及参数的类型注解是标量还是 Pydantic 模型。
  3. 因为返回值会被 response_model 声明的 Pydantic 模型的序列化器再过一遍,只有模型里声明过的字段会被读出来写进响应,password_hash 不在模型里就被静默丢弃。它是"输出过滤"是因为它只决定"哪些字段能出门",完全不判断"当前调用者有没有资格访问这条记录"——后者是鉴权,属于 Depends 的职责。
  4. 这个哲学是把代价前置到定义/导入时,换取请求时的速度。类型注解在导入时被 inspect.signature() 一次性读出并推断来源、生成校验规则和文档;Pydantic 模型在类定义时由元类一次性把字段编译成校验器并缓存。两者都把"解析 + 编译"这件昂贵的事挪到了启动阶段(代价是启动稍慢),换来的是每次请求只需调用早已备好的产物、而非重新解析——这是高吞吐的根基。
进阶挑战 · 刚好够不着

不写 response_model,文档里为什么还能看到响应结构?

一个端点完全不声明 response_model,/docs 里这个端点的"响应"部分却往往仍能显示出一个结构。既然没有显式声明响应类型,这份响应 schema 是从哪来的?(提示落点在本章 1.3 那张"一份注解、四个产物"的图,以及"导入时读签名"这件事。)

提示(卡住再展开)

回到"类型注解即规约":框架在导入时读的是整个函数签名——不只是参数,还包括返回值注解。若函数写了返回类型注解(如 def read_item(...) -> Item:),FastAPI 会把它当作响应类型来生成文档(新版本甚至把返回注解直接当 response_model 用)。即便两者都没有,框架仍能根据实际返回值的形态推断出一份基础 schema。换句话说,文档的响应结构来源有优先级:显式 response_model > 返回值类型注解 > 兜底推断。展开这条优先级链,就回答了"没声明却还有结构"。