354. Python 篇一:装饰器、async 与类型、函数参数默认值

2026.08.03

·openapipython

以 JavaScript / TypeScript 为参照,“重学”并“精通” Python。

@app.post:装饰器

定义

下面两段完全等价

人话:请把下面这个函数 create_task,注册成处理 POST /tasks 的处理器

python
@app.post("/tasks")
async def create_task(payload: CreateTaskRequest):
    ...

等价于

python
async def create_task(payload: CreateTaskRequest):
    ...

create_task = app.post("/tasks")(create_task)

一句话:

装饰器是入口标签;函数签名才是主体。
你在 FastAPI 里真正要读懂的,往往不是 @app.post 那一行,而是它下面那个函数入参返回什么类型

装饰器可以叠多层

Python 允许多个装饰器从上到下写、从下到上包:

python
@router.post("/tasks")
@something_else
async def create_task(...):
    ...

等价于:

python
create_task = router.post("/tasks")(something_else(create_task))

它不是 TS 装饰器那种编译期东西

  • 和 TypeScript 装饰器不一样
  • Python 装饰器发生在定义函数的那一刻解释器执行到 @app.post(...) 时,路由就已经被注册了。
    • 所以这不是“写个注释给 IDE 看”,而是程序启动 import 模块时,就会真实执行的注册动作。

async def

像 JS,但不完全是同一套事件循环故事

表面很像

JS:

ts
async function createTask() {
  const data = await db.insert(...)
  return data
}

Python:

python
async def create_task():
    data = await db.insert(...)
    return data

相同点:

  • async 函数返回的是可等待对象,不是立刻得到最终值
  • 内部用 await 等待 I/O
  • 适合高并发网络服务

关键差别:Python 还有普通 def 这条路

FastAPI 允许两种路由函数:

python
# 普通 def 
@app.get("/health")
def health():
    return {"ok": True}

# async def 
@app.get("/tasks")
async def list_tasks():
    return await repo.list()

大意是:

  • async def
    • 跑在异步事件循环里,适合真正的 await I/O
  • 普通 def
    • 框架可以放到线程池里跑,适合调用同步阻塞库

最容易踩的坑也在这里:

python
@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 这类可等待物

python
async def bad():
	# 不行,是普通值
    await 123
python
async def good():
	# 可以
    await asyncio.sleep(0.1)

另外,Python 中 你定义了 async def f(),调用 f() 只是得到一个 coroutine 对象
真正推进它,需要 await,或交给事件循环去调度。

对照感:

  • JS:async 函数一调用,通常就返回已在推进中的 Promise
  • Python:async def 一调用,先得到 coroutine;要等事件循环 / await 才执行

类型注解:看起来像 TS,运行时却更 “可选且可被框架征用

写法对照

TS:

ts
function createTask(payload: CreateTaskRequest): TaskResponse {
  return { id: 1, title: payload.title }
}

Python:

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

-> 读作“返回什么”。它写在参数列表后面、冒号前面:

python
def create_task(payload: CreateTaskRequest) -> TaskResponse:
    return TaskResponse(id=1, title=payload.title)

和 TypeScript 对照:

ts
function createTask(payload: CreateTaskRequest): TaskResponse {
  return { id: 1, title: payload.title }
}

位置一一对应:

位置TypeScriptPython
参数类型payload: CreateTaskRequestpayload: CreateTaskRequest
返回类型): TaskResponse {) -> TaskResponse:

所以看到:

python
async def create_task(payload: CreateTaskRequest) -> TaskResponse:

可以拆开读:

  • async def create_task
    • 异步函数
  • (payload: CreateTaskRequest)
    • 参数叫 payload,期望是 CreateTaskRequest
  • -> TaskResponse
    • 打算返回 TaskResponse
  • :函数体从这里开始

不是

  • 不是 JS 的 =>
  • 不会自动帮你构造返回值
  • 默认也不会在运行时强制检查你是否真的返回了那个类型

  • 给人看的返回约定
  • mypy / 编辑器看的静态信息
  • 给 FastAPI 参考的返回类型线索

在 FastAPI 里,->response_model 常一起出现

常见写法:

python
@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

可以记:

-> 是语言注解;response_model 是框架出口闸门。

很多例子两者写同一个 Model,是为了让“函数承诺返回什么”和“对外实际暴露什么”保持一致。

真正决定对外 JSON 长什么样的,通常是 response_model

async 函数的返回注解写最终结果,不写 Coroutine

TS 里你常会写:

ts
async function createTask(...): Promise<TaskResponse>

async 函数对外是 Promise

Python 习惯直接写最终结果类型

python
async def create_task(...) -> TaskResponse:
    ...

无返回值时:

python
def save() -> None:
    ...

对应 TS 的 : void

str | Nonelist[int] 这些

现代 Python(3.10+)的写法越来越像 TS:

python
limit: int = 20
title: str | None = None
ids: list[int]

对照:

ts
limit: number = 20
title: string | null = null
ids: number[]

Annotated:把“类型”和“参数”捆在一起

很多人第一次看 FastAPI,卡在这里:

python
limit: Annotated[int, Query(ge=1, le=100)] = 20

它看起来既像类型,又像配置。拆开就清楚了。

先从你熟悉的 Koa 写法说起

Koa 里,取值、校验、取当前用户,通常是进了路由之后自己做:

js
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.queryctx.state
  • ③ 有什么约束:1 到 100,不传就用 20

FastAPI 想把这三类信息写进函数签名里,让框架在进路由前就准备好。 写法上就用到了 Annotated

Annotated 本身是什么

它来自 Python 标准库 typing,不是 FastAPI 发明的。意思很朴素:

在类型后面,再附加一些元数据;对人 / IDE 来说主类型不变,后面的信息留给框架读取。

格式固定:

python
Annotated[真正的类型, 元数据1, 元数据2, ...]

所以:

python
limit: Annotated[int, Query(ge=1, le=100)]

对 IDE 来说,基本就等于:

python
limit: int

Query(...) 不会改变“这是 int”。

FastAPI 才会去读后面的元数据,决定:从 Query 取、最小值 1、最大值 100。

用同一参数,从简到繁看一遍

先看最简:

python
async def list_tasks(limit: int = 20):
    ...

对照 Koa:

js
const limit = ctx.query.limit === undefined ? 20 : Number(ctx.query.limit)

FastAPI 的理解大致是:

  • 类型是 int
  • 有默认值 20,所以可选
  • 不是 Path,也不是 Body Model,是 Query?limit=20

这已经能用。但你没法在类型位置上表达“只能 1 到 100”。

再看加上约束:

python
from typing import Annotated
from fastapi import Query

async def list_tasks(
    limit: Annotated[int, Query(ge=1, le=100)] = 20,
):
    ...

对照 Koa:

js
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 queryctx.query.limit
ge=1, le=1001 到 100limit < 1 || limit > 100 时抛错
= 20没传时默认 20raw === undefined ? 20 : ...

请求例子:

  • GET /taskslimit = 20
  • GET /tasks?limit=10limit = 10
  • GET /tasks?limit=0 → 校验失败,通常 422

差别在于:Koa 这些逻辑写在函数体;FastAPI 写在签名里,由框架先执行。

和 Depends 是同一套写法

对比koa的 中间件

Koa 里当前用户常这么来:

js
app.use(auth()) // 中间件先跑,挂上 ctx.state.user

router.get('/me', async (ctx) => {
  ctx.body = ctx.state.user
})

FastAPI 更常见是写进参数:

python
from typing import Annotated
from fastapi import Depends

async def get_me(
    user: Annotated[User, Depends(get_current_user)],
):
    return user

读法:

片段含义Koa 里你通常怎么写
User路由最终拿到 Userctx.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 对照表

你想表达的事KoaFastAPI
参数类型自己转换后心里当 number / object 用Annotated 里的第一个类型,如 int
从 query 取ctx.query.limitAnnotated[int, Query()]
约束范围if (limit < 1 || limit > 100) ctx.throw(...)Query(ge=1, le=100)
当前用户鉴权中间件 → ctx.state.userAnnotated[User, Depends(get_current_user)]
默认值raw === undefined ? 20 : Number(raw)= 20

一句话:

Koa:进了函数再自己从 ctx 取、自己校验。
FastAPI:在签名里声明好,框架进函数前先准备好。
Annotated 就是这份声明的写法。

为什么常看到有人抽成别名

依赖写多了会很长,所以常这样收:

python
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 只是给

python
Annotated[User, Depends(get_current_user)]

起了个短名字,后面每个路由都能复用。

有点像你在 Koa 里抽:

js
const getUser = (ctx) => ctx.state.user

什么时候必须用,什么时候可以不用

写法什么时候够用
limit: int = 20FastAPI 能靠猜:基础类型 + 默认值 → Query
payload: CreateTaskRequestPydantic 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

最小心智模型

看到:

python
user: Annotated[User, Depends(get_current_user)]

就翻译成三句人话:

  1. 这个参数最终是 User
  2. 它不是你在函数里从 ctx 手取的
  3. FastAPI 会先跑 get_current_user,再把结果交给路由

看到:

python
limit: Annotated[int, Query(ge=1, le=100)] = 20

就翻译成:

  1. 最终是 int
  2. 从 query string 来
  3. 必须在 1 到 100
  4. 不传就用 20

Annotated 本身不做业务;它只是把“类型”和“给框架看的说明书”捆在同一处。

函数参数默认值

Python:

python
def list_tasks(limit: int = 20, offset: int = 0):
    ...

和 JS 一样:

ts
function listTasks(limit = 20, offset = 0) {}

多数时候就是「没传就用这个值」。limit: int = 20 可以当普通默认值用。

真正要单独记住的只有一点:

JS:每次没传,都现做一份默认值。
Python:函数一写好,默认值就先做好一份;以后缺省都复用同一份

数字、字符串、None 没事,改不了。
[]{} 会出事——上一次往里塞的东西还在。

python
# 危险:公用那只杯子
def f(items=[]):
    items.append(1)
    return items

f()  # [1]
f()  # [1, 1]

应写成:

python
def f(items=None):
    if items is None:
        items = []
    return items

日常记法:

  • limit: int = 20 → 和 JS 一样用
  • 别写 items=[] 当默认值;写 items=None,里面再新建

selfimport

方法第一个参数是 self

python
class TaskService:
    def create(self, title: str):
        ...

调用时仍然写:

python
service.create("学习")

self 由实例自动传入,相当于 TS class method 里隐式的 this,只是 Python 要求你在定义时写出来。

导入更像显式命名空间

python
from fastapi import FastAPI, Depends
import httpx

对比 JavaScript

ts
import { FastAPI, Depends } from 'fastapi'
import * as httpx from 'httpx'