FastAPI系列-05-参数分类

FastAPI系列-05-参数分类

_

05 参数分类 —— 同一个 URL,数据可以从三个地方来

前置阅读: 04-路由
关键词: Query, Path, Body, Annotated, async def
难度: ★★★☆☆

场景导入:一个 /books 端点,到底能接收多少种参数?

来看一个真实的 GET /books 请求:/books?author_id=1&keyword=fastapi&page=2&size=20。这里有按作者筛选、按标题关键词搜索、分页控制——全都挤在查询字符串里。与此同时,GET /books/42 把图书 ID 嵌在 URL 路径中,POST /books 把一整坨 JSON 塞进请求体。

FastAPI 怎么判断哪个参数从哪来?答案是三个工具:Path 对应路径模板里的变量,Query 对应问号后的键值对,Body 对应请求体里的 JSON 或表单。更妙的是,你可以用 Annotated 把类型和约束写在一起,让函数签名又好看又精准。本篇就在异步栈下演示这三种参数与 Depends 共存时的标准写法。

原理解析:参数来源的三条管道

FastAPI 在启动阶段扫描你的处理函数签名,识别每个参数的类型注解和默认值,然后推断它属于哪一类。规则大致是这样:

参数特征

FastAPI 推断为

数据来源

名字出现在路径模板中(如 {book_id}

Path 参数

URL 路径段

标量类型(intstrbool等),不在路径模板中

Query 参数

URL 查询字符串

Pydantic 模型类型

Body 参数

请求体 JSON

Body() 显式标记的标量

Body 参数

请求体 JSON

当然,显式声明永远优于自动推断。当你写出 Annotated[int, Query(gt=0)],FastAPI 就不需要猜——你明确告诉它:这是个查询参数,值必须大于 0。

三种参数的继承关系可以用一张类图来理解。它们都来自同一个祖先——Pydantic 的 FieldInfo

classDiagram class FieldInfo { <<abstract>> +default +alias +description } FieldInfo <|-- Param FieldInfo <|-- Body class Param { +in_ : ParamTypes } Param <|-- Query Param <|-- Path class Query { +ge +le +gt +max_length } class Path { +include_in_schema +ge +le } class Body { +embed +media_type } Body <|-- Form Body <|-- File

(图注:FieldInfo 是元数据基类,Param 负责 URL 参数(Path + Query),Body 负责请求负载(JSON / Form / File)。)

请求到达时,参数的处理是串行的——先提取,再校验,最后注入:

flowchart LR A["HTTP 请求"] --> B{"参数来源"} B -->|"路径模板"| C["Path:提取路径段"] B -->|"查询串"| D["Query:读取键值"] B -->|"请求负载"| E["Body:解析 JSON 或表单"] C --> F["类型转换与约束校验"] D --> F E --> F F -->|"失败"| G["RequestValidationError → 422"] F -->|"通过"| H["注入 handler 参数"] H --> I["执行业务逻辑"]

(图注:不管数据从哪来,最终都进入同一条校验管道。任何一环失败,框架直接返回 422,handler 根本不会被调用。)

这里有一个容易被忽视的点:DependsQuery / Path / Body 可以在同一个函数签名里和平共处。启动期 analyze_param 看到 Annotated[T, Query(...)] 就把 T 放入参数字段,看到 Annotated[T, Depends(...)] 就登记到依赖图——两个机制各走各的解析路径,最终以关键字参数的形式一起注入 handler。在咱们的图书 API 里,db: DBDep(数据库会话)和 author_id: int = Query(default=None, gt=0)(查询参数)经常并排出现,各司其职。

handler 形态的选择与参数类型无关。不管参数是 Path、Query 还是 Body,决定 async def 还是 def 的永远是 handler 函数体里有没有可 await 的 I/O:

flowchart LR A["handler 形态"] --> B{"是否含可 await 的 I/O?"} B -->|"是"| C["async def handler<br/>事件循环内执行"] B -->|"否"| D["def handler<br/>Starlette 投递线程池"]

(图注:handler 形态由 I/O 决定,与参数分类正交。数据库 handler 走 async def,纯计算走 def。)

Annotated[T, Query(...)] 和旧写法 author_id: int = Query(default=None, gt=0) 在性能上完全等价,差异只在可读性。Annotated 把"类型 + 元数据"放在冒号左边,把"默认值"放回 PEP 风格的等号右边,参数多的时候阅读负担更小。新项目建议统一用 Annotated 风格。

代码实现:两种形态对照

下面的代码展示两类 handler。第一段是涉及数据库的端点,用 async def;第二段是纯 ISBN 校验,用 def。数据库依赖符号(第 13 章正式定义)在代码中用占位符。

# app/api/books.py
from typing import Annotated, Optional

from fastapi import APIRouter, Query

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


@router.get("")
async def list_books(
    db: ...,  # 第 13 章的数据库依赖(占位)
    author_id: Optional[int] = Query(default=None, gt=0),
    keyword: Optional[str] = Query(default=None, max_length=64),
) -> dict:
    """涉及数据库,使用 async def;完整实现在第 16 章。"""
    return {"placeholder": True, "author_id": author_id, "keyword": keyword}


def _validate_isbn(isbn: str) -> bool:
    """纯计算:13 位数字校验,无 I/O。"""
    return len(isbn) == 13 and isbn.isdigit()


@router.get("/isbn/{isbn}")
def check_isbn(isbn: str) -> dict:
    """纯计算校验,handler 使用 def;Starlette 自动放入线程池。"""
    return {"isbn": isbn, "valid": _validate_isbn(isbn)}

几个行为细节值得注意:

  • 访问 /books?author_id=1&keyword=fastapi 时,author_id 被转成整数并按 gt=0 校验,keywordmax_length=64 截断校验——任一不通过直接返回 422。

  • 访问 /books/isbn/9787121362200 时,isbn 从路径段注入,_validate_isbn 在线程池里跑完 13 位数字判断,返回 200。

  • 访问 /books/isbn/abc 时,_validate_isbn 返回 False,但响应码仍然是 200——这是业务校验,不是框架校验。要让格式不合法的 ISBN 返回 422,应该在 handler 里显式 raise HTTPException

Body 参数的声明方式与 Query 类似,只是把元数据换成 Body()。多字段场景强烈推荐用 Pydantic 模型收口(第 06 章展开),只在确实需要把单个标量标记为请求体字段时才直接使用 Body()

避坑指南:参数声明中的暗礁

  • Annotated[..., Query(...)]Annotated[..., Depends(...)] 不冲突。两者在启动期分道扬镳——前者进 FieldInfo,后者进依赖图,运行时各走各的解析路径,最终以关键字参数汇入 handler。

  • 框架校验 vs 业务校验,语义不同Query(gt=0) 校验失败返回 422,响应体里带着 loc(如 query.author_id)和 msg,客户端可以据此定位出错字段。但 ISBN 格式校验是业务逻辑,框架不会自动帮你做——要统一错误格式,你需要在 handler 里显式抛出 HTTPException

  • 字符串查询参数一定要设 max_length。不加限制的 keyword 会让整段 URL 被解析进内存,不仅浪费资源,还可能被反向代理或网关截断。推荐 max_length=64128,配合数据库层 LIKE 查询的索引设计。

  • GET 请求不要带 Body。虽然 HTTP 规范没有明确禁止,但 FastAPI 对 GET 请求的 Body 参数处理不一致,而且很多中间件和缓存层会直接忽略 GET 请求体。筛选和分页参数请老老实实用 Query

面试 QA

Q1 [原理]: FastAPI 如何判断一个函数参数属于 Path、Query 还是 Body?

FastAPI 遵循"路径模板优先 + 类型推断 + 显式覆盖"三层规则。第一步,检查参数名是否出现在路径模板中——是则归类为 Path。第二步,对于不在模板中的参数,看类型:Pydantic 模型归类为 Body,标量类型(intstrbool 等)归类为 Query。第三步,如果开发者用 Query()Path()Body() 显式标记,则以标记为准。

启动阶段,fastapi/dependencies/utils.py 中的 analyze_param 完成这个分类,并把元数据写入路由的内部结构。请求到达时,request_params_to_args 处理 URL 参数,request_body_to_args 处理请求体,最终汇入同一套类型转换与校验管线。

在图书 API 中,一个实用的经验法则是:资源定位(如 book_id)用 Path,集合筛选和分页用 Query,创建和更新用 Body。这种边界划分让网关缓存、审计日志和权限规则可以按 HTTP 语义各司其职。

Q2 [项目]: 为什么推荐 Annotated[T, Query(...)] 而不是 Query(...) 作为默认值的老写法?

两种写法性能等价,差异在可读性。旧写法 author_id: int | None = Query(default=None, gt=0) 把默认值和约束元数据堆在等号右边,参数一多,哪是类型、哪是默认值、哪是校验规则,混成一团。Annotated[int | None, Query(default=None, gt=0)] = None 把类型和元数据打包在冒号左边,等号右边只放 Python 默认值,语义上更干净。

对于 IDE 和类型检查器(mypy、pyright),两种写法的推断结果完全一致。建议新代码统一用 Annotated,旧代码在重构时逐步迁移。

小结

本篇把 HTTP 请求的三条数据管道——PathQueryBody——逐个拆解了一遍。核心结论其实就一句:参数来源由"路径模板 + 类型 + 显式标记"三者共同决定,所有参数最终进入同一条校验管线,失败即 422。handler 选 async def 还是 def 与参数类型无关,只看函数体里有没有可 await 的 I/O。

下一篇《06 请求与响应》将进一步把请求体字段收口为 Pydantic 模型,并引入 response_model——它不只是格式化工具,更是对外 API 契约的最后一道防线。

FastAPI系列-06-请求与响应 2026-06-18
FastAPI系列-04-路由 2026-06-15

评论区