如果你已经会写 Python,第一次做后端时最容易卡住的地方,通常不是语法,而是脑子里还没有一条完整的“请求路径”:浏览器发来的数据去了哪里,函数为什么会被调用,参数错了谁来拦,最后那段 JSON 又是谁变出来的?
FastAPI 适合拿来建立这条路径。你用普通的 Python 函数写处理逻辑,用类型声明描述输入和输出,框架负责把 HTTP 请求接到函数上,再把函数结果变成 HTTP 响应。与此同时,它还会从同一份声明中生成接口说明、校验规则和交互式文档。
这一章不急着堆功能。我们先做一个很小的图书接口,让请求真正跑一遍,再回头看 REST 约束、类型声明和异步处理分别解决了什么问题。读完以后,你应该能看懂一段 FastAPI 路由代码,也能判断一条接口设计是否把路径、方法、状态码和数据模型放在了正确的位置。
Web API 可以理解为程序之间约定好的一扇窗口。客户端把意图写进请求,服务端处理后把结果写进响应。这里的客户端不只是一张网页,也可能是手机应用、命令行脚本、另一台后端服务,甚至是定时任务。
一个 HTTP 请求至少要回答三件事:请求发给哪里、想做什么、随身带了什么数据。以“读取编号为 1 的图书”为例,请求路径是 /books/1,请求方法是 GET,请求头可以携带认证信息、追踪编号和期望的数据格式。服务端返回时,也不只给一段 JSON,还会给状态码和响应头,告诉客户端这次处理是否成功、数据该怎样解释。
GET /books/1 HTTP/1.1
Host: 127.0.0.1:8000
Accept: application/json收到请求后,FastAPI 不会把所有内容直接塞给业务函数。它先根据请求方法和路径查找对应的路由,再从路径、查询字符串、请求头和请求体中提取参数,按照类型声明完成转换与校验。只有这些步骤通过,处理函数才会执行。函数返回对象以后,框架再按响应模型整理数据,序列化为 JSON,连同状态码和响应头一起发回去。

这条顺序非常关键。假设路由要求 book_id 是整数,客户端却请求 /books/not-a-number,框架会在调用业务函数之前结束处理,并返回 422。你的查询数据库代码根本不会运行。这就是“声明式校验”在运行时最直接的价值:无效数据被挡在业务边界外,函数内部可以专心处理已经满足基本条件的数据。
下面这段最小代码已经把请求和函数接起来了:
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}app 是应用对象,@app.get("/health") 把 GET /health 登记到路由表,health() 是匹配成功后执行的处理函数。函数返回的是 Python 字典,客户端收到的是 JSON。启动应用后请求这个路径,会看到:
$ curl -i http://127.0.0.1:8000/health
HTTP/1.1 200 OK
content-type: application/json
{"status":"ok"}这里没有手写 JSON 序列化,也没有自己拼 200 OK。但这不等于 HTTP 已经不重要了。恰恰相反,框架替你处理了格式,接口语义仍然要由你决定:什么路径代表资源,用哪种方法表达操作,成功和失败分别返回什么状态码,这些才是后端设计的主体。
路径操作函数可以写成普通的 def,也可以写成 async def。两者 FastAPI 都支持。先不要把 async 当作固定装饰;本章后面会用“是否需要等待、调用的库是否支持等待”来判断。
刚写接口时,一个常见做法是把路径写成函数名:/getBook、/createBook、/deleteBook。短期看很直白,操作一多就会出现 /getAllBooks、/updateBookPrice、/searchAvailableBooks 这样的路径集合。每条路径都自带一套命名规则,客户端只能逐个记忆。
REST 风格换了一个组织角度:先找系统中的资源,再用统一的 HTTP 语义操作资源。图书集合用 /books 表示,单本图书用 /books/{book_id} 表示。读取、新建、修改和删除不必重复写进路径,而由请求方法表达。

这张图真正想强调的不是“背住五条路由”,而是稳定的阅读方式:路径先回答“操作谁”,方法再回答“做什么”,状态码最后回答“结果怎样”。客户端只要理解这套语义,就能更快推断陌生接口的行为。
下面把图书资源写成一个小应用。数据暂存在进程内的字典里,服务重启就会消失;这样可以把注意力放在 HTTP 与 FastAPI 上,数据库留到后续章节。
from fastapi import FastAPI, HTTPException, Response, status
from pydantic import BaseModel, Field
app = FastAPI(title="Book Shelf API", version="1.0.0")
class BookCreate(BaseModel):
title: str = Field(min_length=1, max_length=80)
price: float = Field(gt
创建图书时,客户端把数据放进请求体。接口返回 201 Created,并用 Location 响应头告诉客户端新资源在哪里:
$ curl -i -X POST http://127.0.0.1:8000/books \
-H 'Content-Type: application/json' \
-d '{"title":"接口设计手册","price":79.0}'
HTTP/1.1 201 Created
content-type: application/json
location: /books/2
{"title":"接口设计手册","price":79.0,"id":2}如果随后删除 /books/2,第一次会得到 204 No Content,再次读取同一资源会得到 404 Not Found。204 的意思是操作成功但没有响应体,不应该再塞一段“删除成功”的 JSON;404 则明确表示目标资源不存在。
$ curl -i -X DELETE http://127.0.0.1:8000/books/2
HTTP/1.1 204 No Content
$ curl -i http://127.0.0.1:8000/books/2
HTTP/1.1 404 Not Found
content-type: application/json
{"detail":"图书不存在"}HTTP 方法还带有可以被客户端、代理和重试机制理解的语义。GET 用来读取,不应该因为一次读取就修改资源;PUT 表达以给定表述创建或替换目标资源;DELETE 请求删除目标资源。POST 更适合让目标资源按自身规则处理提交的数据,常用于在集合中创建一项。PATCH 用于部分修改,但它不是 PUT 的随意别名。
“幂等”也是工程上常见的判断。重复发送同一个 PUT 或 DELETE,预期的资源状态应和发送一次相同;POST 通常不具备这个保证,重复提交可能创建两条记录。这里说的是服务器最终状态,不是每次响应必须逐字相同。例如第一次删除可能返回 204,第二次返回 404,但资源都处于“不存在”的状态。
这会直接影响超时重试。如果客户端没收到创建请求的响应就盲目重发,可能重复创建数据;生产系统常通过幂等键、唯一约束或业务请求编号处理这类风险。REST 风格不是把接口变得“更像名词”就结束了,它让 HTTP 基础设施能更可靠地理解你的意图。
状态码描述的是处理结果,不是装饰。不要让所有响应都返回 200,再把失败藏在 {"code": 500} 里。那样会让浏览器、网关、监控和客户端库误以为请求成功,HTTP 已有的语义也就浪费了。
REST 的全称常译为“表述性状态转移”。它不是一种传输协议,也不是某个框架提供的开关,而是一种面向分布式超媒体系统的架构风格。我们日常说的“RESTful API”,很多只实践了资源路径、HTTP 方法和状态码;这是一种实用的工程近似,但不能反过来把 REST 定义缩成这三项。
REST 由一组相互配合的约束构成。每加一项约束,系统都会获得某些性质,也要接受相应代价。

客户端负责交互和展示,服务器负责数据与业务能力,两端通过接口协作。只要接口契约保持兼容,网页可以改成手机应用,后端也可以替换存储实现。分离不代表两边互不沟通,而是把变化限制在清楚的边界内。
每个请求都应携带理解和处理它所需的信息,服务器不能依赖“上一个请求做到哪一步”才能明白这次请求。认证令牌可以随每次请求发送,资源状态当然也可以保存在数据库里;无状态约束针对的是请求之间的会话上下文,不是要求服务器没有任何数据。
它带来的直接好处是请求更容易被分配到不同服务实例。只要实例访问同一份必要数据,负载均衡器不必把某个用户永远绑在一台机器上。代价是一些上下文要重复传输,客户端也要更完整地表达请求。
响应需要明确是否允许缓存、可以缓存多久,以及怎样判断内容是否已经变化。缓存不是“在服务器里放一个字典”这么窄,它可能发生在浏览器、网关、内容分发节点或反向代理中。设计得当时,重复读取可以不再进入业务服务;设计错误时,用户可能看到过期数据,甚至缓存到不该共享的私有信息。
统一接口是 REST 区别于许多其他网络架构风格的核心。资源有稳定标识,客户端通过资源的表述来操作它;消息本身足以说明如何解释;响应还能用链接或其他控制信息告诉客户端接下来允许去哪里。
统一的代价是接口不能为每个调用者都做一套极端定制的数据传输方式。但正是这层统一,让浏览器、缓存、代理、监控和不同语言写成的客户端可以共享同一套理解方式。
客户端不必知道自己直接连接的是业务服务,还是先经过反向代理、鉴权网关、缓存和负载均衡层。每层只需要理解相邻层的接口。这样可以在不改客户端的情况下增加安全、缓存或流量治理能力,代价是层次过多时会增加延迟,也会让排查链路变长。
服务器可以把可执行代码发送给客户端,扩展客户端能力,例如网页加载脚本。这是 REST 中的可选约束,因为它能提高扩展性,也会降低交互的可见性。后端 JSON API 很少把它当作首要设计目标,但知道它是可选项,可以避免把“六项约束”误讲成六项一律强制的检查表。
工程上不必为了贴标签硬做复杂的超媒体控制,也不要把有状态业务强行改得无法维护。更实用的做法是:先明确自己采用了哪些约束、获得了什么收益、放弃了什么性质。这样团队讨论的是具体设计,而不是争论某条接口“够不够 REST”。
FastAPI 最有辨识度的地方,是它会读取标准 Python 类型声明。普通 Python 里,类型提示主要服务于编辑器、静态检查和阅读者;放进 FastAPI 的路径操作以后,同一份信息还参与参数来源判断、运行时校验、数据转换、响应整理和接口文档生成。

回看图书接口里的这几行:
class BookCreate(BaseModel):
title: str = Field(min_length=1, max_length=80)
price: float = Field(gt=0)
@app.post("/books", response_model=Book, status_code=201)
async def create_book(payload: BookCreate) -> Book:
...payload: BookCreate 告诉 FastAPI 这份数据来自请求体,并且请求体应当是一个对象。title 必须是长度 1 到 80 的字符串,price 必须是大于 0 的数字。校验通过后,函数拿到的不是随意结构的字典,而是一个 BookCreate 对象,编辑器能提示 payload.title 和 payload.price。
如果价格传成 -1,请求不会进入 create_book(),而会在边界处得到 422 Unprocessable Content:
$ curl -i -X POST http://127.0.0.1:8000/books \
-H 'Content-Type: application/json' \
-d '{"title":"无效价格","price":-1}'
HTTP/1.1 422 Unprocessable Content
content-type: application/json
{
"detail": [
{
"type": "greater_than",
"loc": ["body", "price"],
"msg": "Input should be greater than 0",
"input": -1,
"ctx": {"gt": 0.0}
}
]
}错误结构会指出问题位于请求体的 price 字段,失败原因是它没有大于 0。开发阶段这能快速定位问题;对外提供接口时,还可以统一翻译错误消息、隐藏不希望暴露的细节,并给前端约定稳定的错误代码。
response_model=Book 则守住出口。函数返回的数据会按 Book 形状进行处理,多余字段不会因为你顺手返回了一个数据库对象就自动泄漏给客户端。输入模型和输出模型分开,是后端里很值得尽早养成的习惯:客户端可以提交什么,与客户端最后可以看到什么,本来就是两份不同权限的契约。
应用启动时,FastAPI 会收集路径、方法、参数位置、请求模型、响应模型、状态码和安全声明,生成一份 OpenAPI 描述。默认情况下:
/docs 提供可以直接发请求的交互式界面;/redoc 提供另一种文档阅读界面;/openapi.json 返回机器可读的接口描述。所以“自动文档”不是框架猜测你的函数在做什么,而是把你已经声明的契约换了一种标准格式。你给 price 加上大于 0 的约束,校验器和接口描述会一起变化;你把成功状态从 200 改成 201,文档中的响应说明也随代码更新。
这种同步有一个前提:真正的业务规则仍然要写清楚。类型系统知道价格要大于 0,却不会自己知道“绝版书不能打折”“管理员才能删除图书”。自动文档减少了机械抄写,不会替你完成领域建模。
FastAPI 构建在 ASGI 能力之上,并使用 Starlette 提供路由、请求响应、WebSocket、中间件等 Web 基础,用 Pydantic 处理数据模型和校验。你平时不必为了写接口先研究每一层内部实现,但要知道框架边界:
这也是为什么 FastAPI 看起来代码很短,却仍然能支撑大型项目:框架把 Web 边界做得紧凑,业务内部怎么组织仍由项目负责。
async 的价值发生在等待期间接口函数前的 async 很显眼,也最容易被误解成“加上就会更快”。只发一个请求时,你通常看不出 def 和 async def 的区别。差别发生在请求需要等待数据库、外部接口或网络读写,而服务器同时还要照看其他请求的时候。

假设请求甲查询数据库需要等待 80 毫秒,Python 真正整理结果只用 2 毫秒。异步数据库库在等待时可以把控制权交回事件循环,服务器先处理请求乙;数据库结果回来以后,再继续请求甲。数据库没有因此变快,单个请求也不一定明显变短,提升的是同一段时间内照看多个 I/O 请求的能力。
@app.get("/reports/{report_id}")
async def get_report(report_id: int):
report = await repository.fetch_report(report_id)
return report这里能使用 await 的前提,是 fetch_report() 本身由异步库提供,并且等待期间愿意交出控制权。若数据库驱动只提供阻塞调用,却把它直接塞进 async def,事件循环仍会被堵住,其他请求不能趁这段时间推进。
判断时可以先用这套规则:
await,路径操作就写 async def;def,FastAPI 会把这类路径操作放到线程池执行;async 自动并行,通常需要进程、任务队列或专门的计算服务。还要区分“并发”和“并行”。异步并发像一个人照看多件需要等待的事:一件停在网络等待处,就先推进另一件。并行则是多个执行单元同时做计算。Web API 大量时间花在 I/O 等待上,所以并发很合适;遇到持续占满 CPU 的工作,就要换另一套处理方式。
不要在 async def 里直接调用长时间阻塞的文件、网络或数据库函数,也不要用同步等待来假装异步。async 只是允许在明确的等待点切换任务,真正是否让出控制权取决于整条调用链。
FastAPI 常被拿来和 Flask、Django 比较,但“谁全面胜出”不是一个有用的问题。三者覆盖的边界不同,项目约束也不同。
Flask 的核心很小,扩展选择自由,适合团队已经有成熟组合或只需要轻量同步服务的场景。Django 自带对象关系映射、迁移、认证、模板和管理后台,适合希望用一套完整方案快速构建数据驱动网站的团队。FastAPI 更聚焦接口服务:标准类型声明、异步能力、数据校验与 OpenAPI 描述从一开始就连在一起。
不要只凭基准测试图决定。一个接口的整体延迟通常还包括数据库查询、外部服务、序列化、网络和业务算法;框架只占其中一部分。真正应该问的是:
如果目标是用现代 Python 构建类型清晰的 API 服务,FastAPI 往往很顺手;如果项目强依赖 Django 的管理后台与整套模型能力,硬拆出来未必更省事。框架选择是边界匹配,不是排行榜答题。
后面的学习可以一直围绕“请求进来,响应出去”这条主线展开。每加一个功能,都问它落在哪个环节:
现在先记住一个最小检查表。看到任何一条接口,都可以依次问:资源是谁?方法是否符合操作语义?输入从路径、查询、请求头还是请求体进入?失败用什么状态码?响应模型会不会泄漏多余字段?处理函数是在等待 I/O,还是持续占用 CPU?
能把这几个问题答清楚,你就不再是照着装饰器抄代码,而是在设计一份客户端和服务端都能长期遵守的契约。下一步搭建开发环境时,我们会把本章的图书接口放进规范的项目结构,亲手启动、调试并查看自动生成的文档。