多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

FastAPI类型注解从入门到实战:驱动校验、文档与序列化的核心引擎

FastAPI类型注解从入门到实战:驱动校验、文档与序列化的核心引擎 第一次把项目从 Flask 迁到 FastAPI 的时候我根本没把“类型注解”当回事。脚本语言嘛写了这么多年 Python哪次不是自己控制类型。结果迁移第一周就吃了大亏一个接口的查询参数忘记写类型注解Swagger 文档里参数列表直接消失前端同事拿着文档问我“这个接口到底要传什么”我翻代码翻了二十分钟。也就是从那次开始我才真正意识到 FastAPI 不是另一个 Web 框架它是一套以类型注解为核心引擎的工具链。这篇是这个 FastAPI 系列的第一篇专门讲 Python 类型。别觉得它基础你不把它搞透后面的路径参数、请求体、响应模型、依赖注入都会学得稀里糊涂。这篇适合刚接触 FastAPI、或者写了几年 Python 但从不写类型注解的朋友。1. 先别急着装框架把类型注解这件事的意义想透1.1 FastAPI 为什么靠类型“吃饭”FastAPI 官网自我介绍的几个关键词现代、快速、高效。很多人把这当成营销话术直到真正用起来才会发现它的“高效”并不是指运行性能比谁都猛而是指开发效率。传统 Python Web 框架比如 Flask写一个接口是这样的思路路由和函数一对一绑定参数从 request 里手动取类型靠自己转换出错自己 try except。FastAPI 换了一套玩法你在函数签名里写类型注解框架在运行时读取这些注解替你完成参数提取、类型转换、数据校验、序列化、文档生成。整套流程下来你少写的不只是几行代码而是一整套以前必须手写的胶水层。这套玩法背后是三个组件咬合在一起Starlette 提供 ASGI 异步能力Pydantic 提供数据校验而 Python 的类型注解就是连接它们的“通用语言”。把话说直白一点不会写类型注解FastAPI 就等于废了一半。这也是为什么 Flast 和 FastAPI 对比的文章里几乎都会提到“类型系统”这个分水岭。1.2 类型注解的三个受众人、静态检查、运行时框架在开始写任何 FastAPI 代码之前得先把“类型注解是给谁看的”这个问题想清楚。我总结了三个受众给同事和未来的自己看。函数签名里username: str比任何注释都直接不用翻函数体就知道该传什么。给静态检查工具看。mypy、Pyright、Pylance 这类工具能在代码运行之前就抓到你传错类型的问题。给运行时框架看。这一点是 FastAPI 和传统 Python 框架最大的区别。普通 Python 里注解默认只是存在__annotations__里的元数据不影响程序执行而在 FastAPI 里注解会被运行时读取变成真正的约束规则。这第三点很多人一开始反应不过来。我见过有同事把 FastAPI 的路由函数写成完全不写注解的样式结果接口全部变成“什么参数都不认识”的裸接口Swagger 文档上参数列表一片空白还以为是框架坏了。不是是你把类型信息这个“电源”拔了。1.3 准备环境一个最小可运行的 FastAPI 项目既然是系列第一篇还是把环境说清楚。建议用虚拟环境Python 版本选 3.9 以上最好 3.10 或 3.11方便用上一些比较新的类型语法。安装就两条命令pip install fastapi pip install uvicorn[standard]这里补充一句很多人搜过的“python安装”问题如果你用的是系统自带的 Python尤其 Linux 和 macOS 自带的那份建议不要直接把包装进系统环境容易把环境搞乱。用python -m venv venv建一个隔离环境再 activate 进去。Windows 上装 Python 记得勾选 Add Python to PATH不然命令行敲 python 会提示找不到命令。装完后可以快速验证环境。跑一个最小项目能启动、能访问说明后面的类型玩法都能跟上。from fastapi import FastAPI app FastAPI() app.get(/hello/{name}) def hello(name: str) - dict: return {message: fhello {name}}启动命令uvicorn main:app --reload。这个文件里已经有类型注解了name: str决定了路径参数/hello/abc的行为。你可以试试访问/hello/123返回的还是字符串123而不是数字 123因为注解是 str不会帮你转 int。这就引出了下一节的内容。2. 内置类型注解最基础也最容易翻车的那几个2.1 函数签名里的 int、str、float、bool先把最朴素的写法过一遍def add(x: int, y: int) - int: return x y冒号后面跟的是参数类型箭头后面是返回值类型。这三个位置写清楚之后调用方在编辑器里能直接看到提示mypy 也能做静态检查。不过要注意Python 在运行时不会因为你传了字符串进去就报错add(1, 2)照样会执行结果变成字符串拼接。类型注解在这里只是“声明”不是“强制”。这和热词里的“python类型转换”是两码事。类型转换是指int(42)、float(3.14)这些主动行为是把一个值变成另一个类型而类型注解只是给值贴了一个标签说明它“应该”是什么类型。很多人刚学 FastAPI 时把这两件事混在一起总觉得“我写了 int 注解框架就该帮我强转”。其实框架确实会转换但那是 FastAPI 的运行时逻辑帮你做的不是类型注解本身的功劳。这个区别会在第五节展开讲。2.2 bool 是 int 的“私生子”内置类型里最阴险的是 bool。Python 里bool是int的子类True本质上就是1False就是0所以isinstance(True, int)返回的是True。这带来一个非常隐蔽的问题def judge(flag: bool) - str: return yes if flag else no judge(1) # 运行时不报错返回 yes judge(ok) # 运行时也不报错返回 yes如果你用 mypy 检查judge(1)会在严格模式下被标红但如果你的代码没有接静态检查这个错误就只有等线上出 bug 了。更麻烦的是 FastAPI 处理查询参数的时候如果你定义一个flag: bool的参数字符串false到底会被正确解析成 False还是被当成 True取决于框架的解析规则。Pydantic 对 bool 的解析有一套明确清单true、1、yes、on都算 Truefalse、0、no、off都算 False大小写不敏感。所以在 FastAPI 里?flagfalse得到的是 False不会像原生 Python 里那样因为“非空字符串是真值”而解析成 True。这一点建议实测一下很多人第一次接触都在这儿懵过。2.3 注解不会阻止你犯错它只是把错误提前暴露既然运行时不强制那类型注解到底有什么用我的理解是它把错误从“运行时”尽量提前到“写代码时”。举个很常见的场景。接口 A 返回的数据里有个字段叫created_time你写的时候没注意拼成了create_time。原生 Python 不报错返回照样 200前端拿不到值排查半天才发现是字段名错了。如果你用了 FastAPI 的响应模型并且声明了字段类型返回结构会被比对多余的字段会被过滤掉编辑器里也会提示字段名不存在。错误在写代码那一刻就被暴露出来而不是等到联调。所以类型注解的价值不是“让 Python 变成静态语言”而是“让工具能帮你兜底”。这个心态摆正了后面学 Pydantic 模型、学响应模型都不会觉得是在学额外的东西。3. 容器类型与 typing 模块list、dict、Optional、Union 的正确打开方式3.1 Python 3.9 前后的写法差异早期 Python 想在注解里表达“一个整数列表”必须从typing模块导入List写成List[int]。从 Python 3.9 开始内置类型本身支持泛型可以直接写list[int]。我整理了一张对照表语义Python 3.8 及之前Python 3.9整数列表typing.List[int]list[int]字符串到整数的映射typing.Dict[str, int]dict[str, int]字符串集合typing.Set[str]set[str]定长元组typing.Tuple[int, str]tuple[int, str]变长元组typing.Tuple[int, ...]tuple[int, ...]老写法在新版本里还能用typing.List这类仍然存在但从 3.9 开始 PEP 585 已经允许内建泛型。新项目我建议直接写小写版本少一次 import代码也清爽。如果项目里还有大量旧写法也不用焦虑二者共存没问题只是注意别在同一份代码里混着用——风格统一比风格先进更重要。3.2 Optional 和 Union也许你根本用不到 OptionalOptional[int]的定义是Union[int, None]也就是说它只是Union的一个更具体的别名表示“这个值可能是整数也可能是 None”。这里最容易出现的误解有两个。第一个误解是很多人以为Optional[int]表示“参数可以不用传”。其实它和参数有没有默认值完全没关系。你写def f(x: Optional[int])x 仍然是一个必传参数只不过它既能接受 int也能接受 None。想让参数可省略需要给它一个默认值比如def f(x: Optional[int] None)。第二个误解是 Union 的写法。Python 3.10 开始允许用|操作符int | None和Union[int, None]等价list[int] | None也合法。在 FastAPI 的查询参数场景里最标准的写法是app.get(/search) def search(q: str | None None): if q is None: return {result: no keyword} return {result: q}这里q: str | None None表达两层意思类型上可能为 None默认值也是 None。FastAPI 看到默认值是 None就知道这是一个可选查询参数文档里会把它标记为非必填。如果你写成q: str Nonemypy 会立刻报错因为默认值 None 和类型 str 不匹配FastAPI 运行时不一定会崩但这是明显的类型错误别保留这种写法。3.3 其他常用类型Callable、Any、NoReturn、Literal除了容器类型日常开发里还有几个高频类型值得掌握Any逃逸阀门。x: Any表示“我不关心类型”静态检查会直接放弃对这个值的追踪。能用但别滥用一处 Any 就是一个类型盲区。Callable[[int, str], bool]描述“接受一个 int 和一个 str、返回 bool”的函数类型。写装饰器、回调参数时会用到FastAPI 的依赖注入机制内部也大量依赖 Callable不过日常写接口很少直接面对它。NoReturn表示函数一定抛异常或永远不返回比如def raise_error() - NoReturn。Literal[a, b]把取值范围锁死在几个字面量上。用来做接口里的状态字段非常好用例如status: Literal[active, inactive, pending]。它和 FastAPI 里的枚举可以互相替代具体取舍我在第五节再展开。还有一个容易被忽略的点dict和list的注解不要只写到类型名。只写list等价于list[Any]等于没写同理dict至少写成dict[str, str]或dict[str, Any]别图省事。4. 自定义数据结构从 class 到 dataclass 再到 Pydantic4.1 类本身就能当类型给数据一个名字前面讲的都是内置类型实际接口里更多是复合结构。比如一个用户对象有名字、年龄、邮箱如果你没有自定义类型就得写成dict[str, object]或者干脆dict这种注解约等于没有。最简单的做法是定义一个类class User: def __init__(self, name: str, age: int): self.name name self.age age def greet(user: User) - str: return f{user.name} is {user.age} years old在这个例子里User就是一个类型。greet的形参被限定为 User 实例传入字典会过不了静态检查。但这个写法有两个痛点一是要手写__init__重复劳动二是没有任何运行时校验你可以在代码里硬塞一个User(张三, 二十五)年龄字段变成了字符串没人拦你。4.2 dataclass自动补全样板代码但校验仍然缺失Python 3.7 引入 dataclass 之后普通数据类的写法简化了很多from dataclasses import dataclass dataclass class User: name: str age: int__init__、__repr__全自动生成代码量一下子少了。而且 dataclass 支持字段默认值例如age: int 0。如果你想加校验得自己在__post_init__里写逻辑比如判断 age 是不是 int不是就抛异常。这属于“自己造轮子”。每个字段都要手动校验字段一多代码立刻变得啰嗦。FastAPI 其实也支持用 dataclass 作为请求体模型但校验、序列化、文档生成的完整度远不如 Pydantic。所以我个人在 FastAPI 项目里几乎不用 dataclass 作为接口模型它更适合写内部工具代码。4.3 Pydantic BaseModelFastAPI 选中它的原因Pydantic 是 FastAPI 的核心依赖它的BaseModel看起来和 dataclass 差不多但内功完全不同from pydantic import BaseModel class UserIn(BaseModel): name: str age: int 0 tags: list[str] []区别体现在三件事上。第一运行时校验。你传UserIn(name张三, ageabc)会直接抛 ValidationError告诉你 age 不是合法整数。更贴心的是 Pydantic 会在合理范围内做类型转换比如age25会被转成整数 25这是它显式设计的宽容行为。如果你希望严格模式可以在 Pydantic v2 里配置model_config ConfigDict(strictTrue)。第二JSON 序列化。Pydantic v2 里用model_dump()和model_dump_json()v1 是dict()和json()。嵌套模型也能自动序列化这在 FastAPI 返回响应时几乎是万能钥匙。第三OpenAPI 文档生成。FastAPI 会扫描 Pydantic 模型的字段、类型、默认值、描述自动生成 Swagger UI 的 schema。这个能力是 dataclass 和普通 class 给不了的。提示Pydantic v2 和 v1 在 API 上有不少差异。新项目直接用 v2安装 fastapi 时会自动带上兼容版本。网上搜到的教程如果是class Config: orm True、.dict()这种老写法大概率是 v1注意甄别。4.4 三种数据模型怎么选看一张表就够了能力普通 classdataclassPydantic BaseModel自动__init__需要手写自动生成自动生成运行时数据校验无需要自己写内置丰富校验类型转换无无有JSON 序列化需要手动实现需配合dataclasses.asdict原生支持FastAPI 文档联动无有限支持完整支持适合场景内部逻辑对象内部工具、配置数据接口入参、出参我的选型经验是FastAPI 接口的请求体和响应体一律用 BaseModel内部算法、函数之间的中间数据结构用 dataclass只有很轻量的对象才用普通 class。别硬把一种模型套在两个场景上否则要么校验缺失要么序列化别扭。5. FastAPI 是如何“消费”这些类型信息的5.1 路径参数同一个注解接口行为完全不一样先看一个最小但关键的例子from fastapi import FastAPI app FastAPI() app.get(/users/{user_id}) def get_user(user_id: int): return {user_id: user_id, type: type(user_id).__name__}请求/users/42返回{user_id: 42, type: int}。注意这个 42 是从 URL 字符串里解析出来的FastAPI 根据user_id: int把它转成了整数。如果你访问/users/abc不会像原生 Python 那样抛 ValueError 然后 500而是返回一个标准的 422 验证错误响应格式统一客户端能直接解析。把注解从 int 换成 str 试试/users/abc又变成合法的了。这里可以很直观地感受到类型注解在 FastAPI 里就是规则本身你改一个注解接口的约束就变了。第一次意识到这一点的时候我半开玩笑地想过这哪里是“类型注解”分明是搞了一个迷你 DSL。5.2 查询参数默认值就是参数“身份标志”FastAPI 判断一个参数是路径参数、查询参数还是请求体规则非常简单粗暴参数名和大括号里的路径变量一致就是路径参数参数是 Pydantic 模型或者用Body等特殊依赖显式声明就是请求体剩下的是查询参数。查询参数里还有一个隐藏规则看默认值。有默认值的参数是可选的没有默认值的参数是必填的。比如app.get(/search) def search(q: str, page: int 1, size: int 10): return {q: q, page: page, size: size}q没有默认值所以访问/search会 422必须带?q...。page和size有默认值不传也能跑默认分别是 1 和 10。再配合 Optional 的含义来看q: str | None None是“允许缺省、缺省时是 None”q: str None虽然运行时 FastAPI 也能推断出可选但类型上自相矛盾。我在前面也强调过该用str | None None就别偷懒。很多老教程里写q: str None那是历史遗留写法现在你把它当成反例记住就好。5.3 请求体与响应模型类型就是接口契约接口最难维护的其实是请求体和响应体的结构。FastAPI 用类型把这个结构固定了下来from pydantic import BaseModel class ItemIn(BaseModel): name: str price: float tax: float | None None class ItemOut(BaseModel): name: str price: float app.post(/items, response_modelItemOut) def create_item(item: ItemIn): return {name: item.name, price: item.price, internal: 秘密字段}路径上 POST 请求的 JSON body 会被 Pydantic 解析成ItemIn实例字段多余、类型不对、缺了必填字段都会返回标准 422 错误。响应那边更有意思虽然函数返回了一个带internal字段的字典但因为response_modelItemOut只声明了 name 和 priceFastAPI 会在返回前把多余字段过滤掉。客户端拿到的永远是契约内定义的字段不会意外泄露内部数据。这就是我理解的“类型即契约”后端定义模型前端看 OpenAPI 文档生成代码双方以同一套类型结构对话。字段改名、类型变化都会在联调前被工具暴露出来而不是靠开会靠猜。5.4 枚举与字面量输出热词里的“枚举类型转换为字符串”是怎么回事热词榜里有一个非常具体的问题“枚举类型转换为字符串”。FastAPI 返回 Pydantic 模型时枚举字段会自动序列化成它的值这正是很多人到处搜的原因。看这个例子from enum import Enum class UserStatus(str, Enum): ACTIVE active DISABLED disabled class UserOut(BaseModel): name: str status: UserStatus当 FastAPI 把UserOut序列化成 JSON 时status会变成字符串active或disabled而不是类似UserStatus.ACTIVE这种对象。如果你定义的是普通Enum不继承 str序列化时同样会输出.value也就是枚举成员的值但普通 Enum 成员在代码里不能直接和字符串比较所以接口模型里我习惯用class UserStatus(str, Enum)一举两得既能直接和active比较序列化又自然。如果你不想为这种小场景单独建一个枚举类也可以用Literalfrom typing import Literal class UserOut(BaseModel): status: Literal[active, disabled]Literal 和枚举在 FastAPI 里的 OpenAPI 文档都会展示允许的取值列表功能上很接近差别主要在表达力枚举是“给一组取值命名”Literal 是“直接列出允许值”。字段多、取值语义复杂的用枚举简单两三态用 Literal看个人习惯。5.5 自动文档类型注解带回来的免费福利最后说一说 FastAPI 让我最舒服的一点/docs 页面。你把类型注解和 Pydantic 模型写好后打开http://127.0.0.1:8000/docsSwagger UI 自动生成每个接口的参数、类型、默认值、是否必填、响应结构、示例全部摆在那里。不需要你额外写一行文档注释全靠类型信息推导出来。而且这个文档不是静态的。前端调试、给第三方对接方发接口文档时直接丢一个 OpenAPI JSON 链接出去即可。参数类型变了文档立刻变。相比之下Flask 项目里用注释手写的接口文档经常和实际代码脱节写完就过期这是我当初迁移到 FastAPI 的最大推动力之一。6. 实操中的类型注解避坑与习惯养成6.1 可变默认参数list[] 这种写法要不得这个坑我在普通 Python 代码里踩过在 FastAPI 项目里也见人踩过def add_item(item: str, storage: list[str] []) - list[str]: storage.append(item) return storagePython 的默认参数在函数定义时只求值一次所以每次调用如果没传storage用的都是同一个列表对象。第一次调用存进去的元素第二次调用还在。正确写法是def add_item(item: str, storage: list[str] | None None) - list[str]: if storage is None: storage [] storage.append(item) return storage延伸到 Pydantic 模型里同样的问题有专门的解法。不要写tags: list[str] []而是写from pydantic import Field class Item(BaseModel): name: str tags: list[str] Field(default_factorylist)这样每个 Item 实例都会用自己的新列表互不污染。这个细节属于“报错不会提示、线上才会炸”的典型值得在习惯里提前规避。6.2 用 TypeAlias 给复杂类型起名字当你的类型注解变得一层套一层函数签名会快速变得没法看def load_users() - dict[str, list[dict[str, str | int]]]: ...这种签名第一眼看过去没人精神不恍惚。可以用 TypeAlias 给复杂类型一个名字from typing import TypeAlias UserMap: TypeAlias dict[str, list[dict[str, str | int]]] def load_users() - UserMap: ...Python 3.12 又提供了更简洁的语法type UserMap dict[str, list[dict[str, str | int]]]。这对 FastAPI 项目特别有用尤其是响应模型层层嵌套的场景给一个别名之后函数签名读起来就像是在读业务文档。6.3 fromfutureimport annotations有用但 FastAPI 场景里要留个心眼from __future__ import annotations会把所有注解变成字符串延迟求值。它的好处是可以解决类内部自引用、避免导入顺序问题。但在 FastAPI 里有个特殊点框架必须在运行时读取注解来做校验如果注解全是字符串Pydantic 和 FastAPI 得靠get_type_hints()把字符串解析回真正的类型。简单场景没问题但遇到复杂的泛型、嵌套模型解析失败会报一些很难看懂的错。我个人的建议是FastAPI 项目里我一般不加这个 future import除非确有必要比如和某些第三方库的兼容问题。加之前先跑一遍接口测试。Pydantic v2 对延迟注解的支持比 v1 好很多但没必要为了省一个 import 去赌边界情况。6.4 和 mypy、编辑器的配合让类型注解真正跑起来写类型注解如果不用静态检查工具效果起码打五折。我的建议是代码编辑器配 Pylance 或 Pyright 插件日常写代码实时看到类型问题项目里配 mypy 做 CI 检查配置放在pyproject.toml[tool.mypy] python_version 3.11 strict true ignore_missing_imports true遇到类型推断不出来的地方用reveal_type(x)调试mypy 会打印出它推断到的类型比肉眼猜强太多。我见过不少项目类型注解写了但从不跑检查等于写了一堆“装饰性代码”。静态检查工具才是让这些注解发挥价值的另一半。6.5 常见卡点速查表最后把我遇到比较多的几种类型问题整理成一张表方便随时翻写法问题建议list、dict不带泛型参数等同于list[Any]约等于没写写list[int]、dict[str, int]q: str None类型声明和默认值矛盾mypy 报错写 q: strOptional[int] 10类型允许 None默认值却是 10语义混乱明确默认值到底是什么def f(x: list [])可变默认参数共享同一对象用None兜底或Field(default_factorylist)不加response_model响应字段不可控可能泄露内部字段接口一律声明response_model把Any当万能药类型盲区蔓延框架白打工尽量用精确类型Any只放在边界做这个系列之前我把之前的 FastAPI 项目翻出来重读了一遍发现当时最耗时的不是在写路由而是在反复核对数据类型、手写校验、维护文档。类型注解这套东西初看是给框架用的用久了会发现它最大的受益者其实是自己——代码可读性、改动的信心、联调的速度全都提上来了。下一篇我会接着讲 FastAPI 的请求参数与校验细节把路径参数、查询参数、请求体放到真实接口里一个个过。如果你也是那种“写 Python 不写注解”的老手建议先把一个小接口加上类型注解跑一遍那种感受会非常直观。
返回列表