以 JavaScript / TypeScript 为参照,“重学”并“精通” Python。
@app.post:装饰器
定义
下面两段完全等价:
人话:请把下面这个函数
create_task,注册成处理POST /tasks的处理器
@app.post("/tasks")
async def create_task(payload: CreateTaskRequest):
...等价于
async def create_task(payload: CreateTaskRequest):
...
create_task = app.post("/tasks")(create_task)一句话:
装饰器是入口标签;
函数签名才是主体。
你在FastAPI里真正要读懂的,往往不是@app.post那一行,而是它下面那个函数入参、返回什么类型
装饰器可以叠多层
Python 允许多个装饰器从上到下写、从下到上包:
@router.post("/tasks")
@something_else
async def create_task(...):
...等价于:
create_task = router.post("/tasks")(something_else(create_task))它不是 TS 装饰器那种编译期东西
- 和 TypeScript
装饰器不一样 Python 装饰器发生在定义函数的那一刻,解释器执行到@app.post(...)时,路由就已经被注册了。- 所以这不是“写个注释给 IDE 看”,而是程序启动 import 模块时,就会
真实执行的注册动作。
- 所以这不是“写个注释给 IDE 看”,而是程序启动 import 模块时,就会
async def
像 JS,但不完全是同一套
事件循环故事
表面很像
JS:
async function createTask() {
const data = await db.insert(...)
return data
}Python:
async def create_task():
data = await db.insert(...)
return data相同点:
async函数返回的是可等待对象,不是立刻得到最终值- 内部用
await等待I/O - 适合高并发网络服务
关键差别:Python 还有普通 def 这条路
FastAPI 允许两种路由函数:
# 普通 def
@app.get("/health")
def health():
return {"ok": True}
# async def
@app.get("/tasks")
async def list_tasks():
return await repo.list()大意是:
async def:- 跑在异步事件循环里,适合真正的
awaitI/O
- 跑在异步事件循环里,适合真正的
- 普通
def:- 框架可以放到
线程池里跑,适合调用同步阻塞库
- 框架可以放到
最容易踩的坑也在这里:
@app.get("/users")
async def list_users():
# 看起来 async,实际在阻塞事件循环
return sync_db.query_all()
# 必须 await
# return await sync_db.query_all()await 只能等 “可等待对象”
JS 里 await 一个普通值也勉强能过;Python 更严格,await 的对象通常要是 coroutine、Task、Future 这类可等待物。
async def bad():
# 不行,是普通值
await 123async def good():
# 可以
await asyncio.sleep(0.1)另外,Python 中 你定义了
async def f(),调用f()只是得到一个coroutine 对象;
真正推进它,需要await,或交给事件循环去调度。
对照感:
- JS:
async函数一调用,通常就返回已在推进中的 Promise - Python:
async def一调用,先得到coroutine;要等事件循环 /await才执行
类型注解:看起来像 TS,运行时却更 “可选且可被框架征用”
写法对照
TS:
function createTask(payload: CreateTaskRequest): TaskResponse {
return { id: 1, title: payload.title }
}Python:
def create_task(payload: CreateTaskRequest) -> TaskResponse:
return TaskResponse(id=1, title=payload.title)表面几乎一样。差别在语义:
- TS 的类型主要在
编译期;编译成 JS 后,运行时类型信息基本消失 - Python 的注解默认也不强制检查;
payload: CreateTaskRequest不会自动校验 - 但 Python 会把注解保存在函数对象上,框架可以在运行时可读到
这正是 FastAPI / Pydantic 发力的地方:它们去读这些注解,决定参数来源、做校验、生成 OpenAPI。
所以对 FastAPI 来说:
类型注解不只是给
编辑器看的,也是框架的运行时配置。
这和 Koa 常见写法不同。
Koa 里:TS 类型只在编辑期提醒你;运行时还要自己Number(ctx.query.x)、自己if校验。
FastAPI 常让同一份Pydantic Model/ 参数注解,同时服务编辑器提示、运行时校验和文档。
->:返回类型注解,对应 TS 函数后面的 : Type
-> 读作“返回什么”。它写在参数列表后面、冒号前面:
def create_task(payload: CreateTaskRequest) -> TaskResponse:
return TaskResponse(id=1, title=payload.title)和 TypeScript 对照:
function createTask(payload: CreateTaskRequest): TaskResponse {
return { id: 1, title: payload.title }
}位置一一对应:
| 位置 | TypeScript | Python |
|---|---|---|
| 参数类型 | payload: CreateTaskRequest | payload: CreateTaskRequest |
| 返回类型 | ): TaskResponse { | ) -> TaskResponse: |
所以看到:
async def create_task(payload: CreateTaskRequest) -> TaskResponse:可以拆开读:
async def create_task:- 异步函数
(payload: CreateTaskRequest):- 参数叫 payload,期望是 CreateTaskRequest
-> TaskResponse:- 打算返回 TaskResponse
::函数体从这里开始
它不是:
- 不是 JS 的
=> - 不会自动帮你构造返回值
- 默认也不会在运行时
强制检查你是否真的返回了那个类型
它是:
给人看的返回约定- 给
mypy / 编辑器看的静态信息 - 给 FastAPI 参考的返回类型线索
在 FastAPI 里,-> 和 response_model 常一起出现
常见写法:
@app.post(
"/tasks",
response_model=TaskResponse,
status_code=201,
)
async def create_task(payload: CreateTaskRequest) -> TaskResponse:
return TaskResponse(id=1, title=payload.title)两者很像,但职责不完全相同:
-> TaskResponse:- Python 层面的返回类型注解,主要服务阅读、静态检查,FastAPI 也能读到
response_model=TaskResponse:- FastAPI 的
输出边界,负责过滤字段、序列化,并进入 OpenAPI
- FastAPI 的
可以记:
->是语言注解;response_model是框架出口闸门。
很多例子两者写同一个 Model,是为了让“函数承诺返回什么”和“对外实际暴露什么”保持一致。
真正决定对外 JSON 长什么样的,通常是 response_model。
async 函数的返回注解写最终结果,不写 Coroutine
TS 里你常会写:
async function createTask(...): Promise<TaskResponse>async 函数对外是
Promise。
Python 习惯直接写最终结果类型:
async def create_task(...) -> TaskResponse:
...无返回值时:
def save() -> None:
...对应 TS 的
: void。
str | None、list[int] 这些
现代 Python(3.10+)的写法越来越像 TS:
limit: int = 20
title: str | None = None
ids: list[int]对照:
limit: number = 20
title: string | null = null
ids: number[]Annotated:把“类型”和“参数”捆在一起
很多人第一次看 FastAPI,卡在这里:
limit: Annotated[int, Query(ge=1, le=100)] = 20它看起来既像类型,又像配置。拆开就清楚了。
先从你熟悉的 Koa 写法说起
Koa 里,取值、校验、取当前用户,通常是进了路由之后自己做:
router.get('/tasks', auth, async (ctx) => {
// 1. 自己从 ctx 取值
const raw = ctx.query.limit
// 2. 自己做转换和校验
const limit = raw === undefined ? 20 : Number(raw)
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
ctx.throw(400, 'limit invalid')
return
}
// 3. 当前用户通常由鉴权中间件提前挂到 ctx.state
const user = ctx.state.user
ctx.body = await listTasks({ limit, user })
})这里其实有三类信息:
- ① 值最后是什么:整数、用户对象
- ② 值从哪里来:
ctx.query、ctx.state - ③ 有什么约束:1 到 100,不传就用 20
FastAPI 想把这三类信息写进函数签名里,让框架在进路由前就准备好。 写法上就用到了 Annotated。
Annotated 本身是什么
它来自 Python 标准库 typing,不是 FastAPI 发明的。意思很朴素:
在类型后面,再附加一些
元数据;对人 / IDE 来说主类型不变,后面的信息留给框架读取。
格式固定:
Annotated[真正的类型, 元数据1, 元数据2, ...]所以:
limit: Annotated[int, Query(ge=1, le=100)]对 IDE 来说,基本就等于:
limit: intQuery(...) 不会改变“这是 int”。
FastAPI 才会去读后面的元数据,决定:从 Query 取、最小值 1、最大值 100。
用同一参数,从简到繁看一遍
先看最简:
async def list_tasks(limit: int = 20):
...对照 Koa:
const limit = ctx.query.limit === undefined ? 20 : Number(ctx.query.limit)FastAPI 的理解大致是:
- 类型是
int - 有默认值
20,所以可选 - 不是
Path,也不是Body Model,是Query:?limit=20
这已经能用。但你没法在类型位置上表达“只能 1 到 100”。
再看加上约束:
from typing import Annotated
from fastapi import Query
async def list_tasks(
limit: Annotated[int, Query(ge=1, le=100)] = 20,
):
...对照 Koa:
const raw = ctx.query.limit
const limit = raw === undefined ? 20 : Number(raw)
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
ctx.throw(400, 'limit invalid')
}逐段读 FastAPI 这行:
| 片段 | 含义 | Koa 里你通常怎么写 |
|---|---|---|
limit | 参数名 | 自己起的变量名 |
Annotated[...] | 类型后面还附带说明书 | 取值 + 校验写在函数体里 |
int | 最终拿到整数 | Number(...) 之后当数字用 |
Query(...) | 从 URL query 来 | ctx.query.limit |
ge=1, le=100 | 1 到 100 | limit < 1 || limit > 100 时抛错 |
= 20 | 没传时默认 20 | raw === undefined ? 20 : ... |
请求例子:
GET /tasks→limit = 20GET /tasks?limit=10→limit = 10GET /tasks?limit=0→ 校验失败,通常422
差别在于:Koa 这些逻辑写在函数体;FastAPI 写在
签名里,由框架先执行。
和 Depends 是同一套写法
对比
koa的 中间件
Koa 里当前用户常这么来:
app.use(auth()) // 中间件先跑,挂上 ctx.state.user
router.get('/me', async (ctx) => {
ctx.body = ctx.state.user
})FastAPI 更常见是写进参数:
from typing import Annotated
from fastapi import Depends
async def get_me(
user: Annotated[User, Depends(get_current_user)],
):
return user读法:
| 片段 | 含义 | Koa 里你通常怎么写 |
|---|---|---|
User | 路由最终拿到 User | ctx.state.user |
Depends(get_current_user) | 先调用这个函数,再注入结果 | auth 中间件先准备好 user |
所以:
| FastAPI 写法 | 含义 | Koa 里你通常怎么做 |
|---|---|---|
Annotated[类型, Query(...)] | 从 query 取并校验 | ctx.query + 手工转换/校验 |
Annotated[类型, Path(...)] | 从路径取并校验 | ctx.params + 转换/校验 |
Annotated[类型, Header(...)] | 从请求头取 | ctx.headers / ctx.get(...) |
Annotated[类型, Depends(...)] | 从依赖函数取 | 中间件写入 ctx.state |
Annotated 是挂钩;Query / Depends 才是挂上去的具体说明。
和 Koa 对照表
| 你想表达的事 | Koa | FastAPI |
|---|---|---|
| 参数类型 | 自己转换后心里当 number / object 用 | Annotated 里的第一个类型,如 int |
| 从 query 取 | ctx.query.limit | Annotated[int, Query()] |
| 约束范围 | if (limit < 1 || limit > 100) ctx.throw(...) | Query(ge=1, le=100) |
| 当前用户 | 鉴权中间件 → ctx.state.user | Annotated[User, Depends(get_current_user)] |
| 默认值 | raw === undefined ? 20 : Number(raw) | = 20 |
一句话:
Koa:进了函数再自己从
ctx取、自己校验。
FastAPI:在签名里声明好,框架进函数前先准备好。Annotated就是这份声明的写法。
为什么常看到有人抽成别名
依赖写多了会很长,所以常这样收:
from typing import Annotated
from fastapi import Depends
CurrentUser = Annotated[User, Depends(get_current_user)]
@router.get("/me")
async def get_me(user: CurrentUser):
return user这不是新语法。CurrentUser 只是给
Annotated[User, Depends(get_current_user)]起了个短名字,后面每个路由都能复用。
有点像你在 Koa 里抽:
const getUser = (ctx) => ctx.state.user什么时候必须用,什么时候可以不用
| 写法 | 什么时候够用 |
|---|---|
limit: int = 20 | FastAPI 能靠猜:基础类型 + 默认值 → Query |
payload: CreateTaskRequest | Pydantic Model → Body,通常不用 Annotated |
project_id: int 且路径里有 {project_id} | 同名 → Path,简单场景够用 |
Annotated[int, Query(ge=1)] | 需要约束、别名、描述、示例时 |
Annotated[User, Depends(...)] | 需要依赖注入时 |
Annotated[str | None, Header(alias="X-Request-ID")] | Header 名和参数名不一致时 |
经验规则:
- 简单参数:先写普通类型
- 一旦要加“从哪来 / 怎么校验 / 依赖谁”:上
Annotated
最小心智模型
看到:
user: Annotated[User, Depends(get_current_user)]就翻译成三句人话:
- 这个参数最终是
User - 它不是你在函数里从
ctx手取的 - FastAPI 会先跑
get_current_user,再把结果交给路由
看到:
limit: Annotated[int, Query(ge=1, le=100)] = 20就翻译成:
- 最终是
int - 从 query string 来
- 必须在 1 到 100
- 不传就用 20
Annotated 本身不做业务;它只是把“类型”和“给框架看的说明书”捆在同一处。
函数参数默认值
Python:
def list_tasks(limit: int = 20, offset: int = 0):
...和 JS 一样:
function listTasks(limit = 20, offset = 0) {}多数时候就是「没传就用这个值」。limit: int = 20 可以当普通默认值用。
真正要单独记住的只有一点:
JS:每次没传,都现做一份默认值。
Python:函数一写好,默认值就先做好一份;以后缺省都复用同一份。
数字、字符串、None 没事,改不了。[]、{} 会出事——上一次往里塞的东西还在。
# 危险:公用那只杯子
def f(items=[]):
items.append(1)
return items
f() # [1]
f() # [1, 1]应写成:
def f(items=None):
if items is None:
items = []
return items日常记法:
limit: int = 20→ 和 JS 一样用- 别写
items=[]当默认值;写items=None,里面再新建
self、import
方法第一个参数是 self
class TaskService:
def create(self, title: str):
...调用时仍然写:
service.create("学习")self 由实例自动传入,相当于 TS class method 里隐式的 this,只是 Python 要求你在定义时写出来。
导入更像显式命名空间
from fastapi import FastAPI, Depends
import httpx对比 JavaScript
import { FastAPI, Depends } from 'fastapi'
import * as httpx from 'httpx'