函数对象 · 闭包 · cell · 装饰器 · functools.wraps · 模块命名空间 · monkeypatch

函数是一等公民:闭包和装饰器

Python 的函数是普通对象,能放进字典、当参数传、当返回值。闭包是函数带着它用到的外层变量一起走;装饰器就是 f = deco(f):import 时执行,把名字 f 换成 deco 返回的东西。FastAPI 的 @app.get 靠它在 import 时登记路由,pytest 的 monkeypatch 靠“名字可以重新绑定”换掉函数。不懂这一点的典型错误是把装饰器当成 Java 注解:顺序写反、忘了 functools.wraps,接口就悄悄失效,而且不报错。

全景:一个 FastAPI 请求的一生 · 第 3 站(还有第 10 站)地图:站 01 · 函数是一等公民:闭包和装饰器以 CPython 3.13、FastAPI 0.162.2 为例

①为什么需要:请求来之前,路由表就已经建好了

在全景第 3 站,这一步要解决的是:curl 打来 /gc/summary,FastAPI 怎么知道该调哪个函数?答案在请求之前:uvicorn import main.py 的那一刻,@app.get(...) 已经作为一行普通代码执行过,把函数登记进了路由表。第 10 站是同一件事的反面:测试里要把真的 fetch 换成假的,靠的是“函数就是一个值,名字可以改绑到别的值上”。

import main.py 的时候(只执行一次) @app.get("/gc/summary") async def gc_summary(...) deco = app.get(路径) deco(gc_summary) 路由表 app.routes "/gc/summary" → 函数 原样还回函数 返回值 is 原函数:True 请求来的时候(每次) GET /gc/summary 第 3 站:查表 按路径和方法逐条匹配 调用函数对象 第 4、5 站接着跑 第 10 站:main 模块的命名空间 "fetch" ──✕──▶ 真的 fetch "fetch" ───────▶ 假的 fake_fetch monkeypatch.setattr(main, "fetch", fake_fetch) 只是把名字改指向另一个函数对象 两件事背后是同一条:函数是一个对象,名字只是贴在它上面的标签。
需求工作要用,而且是懂机制级:后面每一站都会遇到装饰器,FastAPI 的 @app.get、pytest 的 @pytest.fixture、站 04 给 agent 登记工具的 @tool。只会照抄,换个写法就会踩 ⑥ 里那几个不报错的坑。

②没有它会怎样:Java 要靠类、注解和容器

同一件事,“把一个处理函数登记到路径上”,三种写法并排。

Java 7 函数不是值,只能包成对象
router.register("/gc/summary",
  new Handler() {
    @Override
    public Object handle(Req r) {
      return gcSummary(r);
    }
  });
为了传一段逻辑,得先有接口、再 new 一个匿名类。
Spring 注解只是标签,靠容器扫描
@GetMapping("/gc/summary")
public GcSummary gcSummary(
    @RequestParam String service) {
  ...
}
// 启动时 Spring 用反射扫描所有 Bean,
// 读到注解,才把方法登记进 HandlerMapping
注解本身什么都不做,离开容器就是一行注释。
Python 装饰器是会执行的代码
@app.get("/gc/summary")
async def gc_summary(service: str):
    ...
# 等价于:
# gc_summary = app.get("/gc/summary")(gc_summary)
# import 到这一行就执行,没有容器、没有扫描
函数本身是值,app.get(...) 拿到它就能登记。

把装饰器当成注解来理解,是最常见的误用:以为写在哪一行都一样、以为它只“标记”不“替换”。⑥ 里的坑 1、2、3 全是这么来的。

③相邻方案:给函数加一层行为,有四种做法

比如给第 8 站的解析加上计时,或者给第 7 站的 fetch 加上重试。

手动包一层

gc_summary = timed(gc_summary)

和 @timed 完全等价,只是没用语法糖。

要按条件决定包不包时用,比如只在调试时计时。

装饰器

@timed
async def gc_summary(...): ...

写在定义处,一眼看出这个函数被加了什么。

默认选它:只作用于少数几个函数。

中间件

@app.middleware("http")
async def timing(req, call_next): ...

≈ Servlet Filter,每个请求都经过。

所有接口都要的事:日志、跟踪 ID、统一计时。

可调用的类

class Retry:
    def __call__(self, fn): ...

实现了 __call__ 的对象也能当装饰器。

要带很多配置或状态时用,比如限流器。

本页学:函数对象、闭包,以及装饰器的两种用法:登记型(@app.get,原样返回函数)和包装型(@timed,返回一个新函数)。Java 那边对照 Spring AOP 代理原理。

④原理:名字指向对象,闭包带着 cell 走,@ 就是重新绑定名字

四张图,从下往上搭。⑥ 里的 8 个坑都能从这四张图推出来。

先知道这些
  • 名字和对象是两回事。def parse(...) 做了两件事:造一个函数对象,再把名字 parse 贴到它上面。a = b 只是多贴一个名字,和上一页讲的列表一样。见 列表存引用。
  • 一个模块就是一个字典。main.py 里所有顶层的名字,都存在 main.__dict__ 这个字典里,main.fetch 就是查这个字典。
  • 查名字的顺序叫 LEGB:先查函数自己的局部变量(Local),再查外层函数(Enclosing),再查模块(Global),最后查内置(Builtins)。每次执行到这个名字时才去查。

1函数是对象:能存、能传、能返回,身上还带着属性 ≈ 不需要接口的 lambda

main.__dict__(模块命名空间) "parse" "handlers" "over" {"parse": ●} 函数对象 <function parse> __name__ "parse" __code__ 编译好的字节码 __defaults__ 默认参数(def 时算一次) __closure__ 闭包的 cell,没有就是 None __wrapped__ 被 wraps 包过才有 两个名字指向同一个对象:handlers["parse"] is parse → True 实测
Java 的 lambda 必须是某个函数式接口(Function、Runnable……)的实例;Python 的函数不需要接口,任何地方都能直接放。__defaults__ 存在函数对象上、只算一次,这是 ⑥ 坑 5 的根源。

2闭包:外层函数返回了,它的变量装进 cell 跟着内层函数走 ≈ lambda 捕获局部变量

slow = over(200) over 的栈帧(已退出) limit = 200 搬进 cell cell 200 check(被返回的函数) co_freevars = ('limit',) LOAD_DEREF limit ← 读 cell __closure__[0] slow(244.05) → True,slow.__closure__[0].cell_contents → 200 实测 fs = [lambda: h for h in HOSTS] lambda 1 lambda 2 lambda 3 同一个 cell h "order-03" cell 装的是“变量”,不是“当时的值”。调用时才去读, 循环早跑完了:['order-03', 'order-03', 'order-03'] 实测

Python:捕获变量本身选了它

def counter():
    n = 0
    def inc():
        nonlocal n      # 声明要改外层的 n
        n += 1
        return n
    return inc
  • 闭包里能改外层变量,写计数器、缓存很顺手。
  • 代价:迟绑定(右上图),以及忘了 nonlocal 就报 UnboundLocalError(⑥ 坑 6)。

Java:只许捕获不变的值没选

int limit = 200;
Predicate<Double> slow = ms -> ms > limit;
limit = 300;   // 编译错误:lambda 用到的局部
               // 变量必须是 final 或等效 final
  • 捕获时复制一份值,没有迟绑定,也没有并发修改的问题。
  • 想在 lambda 里计数,只能绕路用 AtomicInteger 或长度为 1 的数组。

3装饰器:@deco 就是 f = deco(f),从下往上执行,在 import 时 ≈ Spring AOP,但不用代理

# 你写的 @app.get("/gc/summary") # ② 再登记 @timed # ① 先包 async def gc_summary(...): ... # Python 实际执行的 gc_summary = app.get("/gc/summary")( timed(gc_summary))
  • 1timed(gc_summary) 返回一个新函数 wrapper,它的闭包里存着原函数。这是包装型装饰器。
  • 2app.get(...) 先执行,返回一个装饰器;再拿 wrapper 去调用它,记进路由表,原样还回来。这是登记型装饰器。
  • 3最后名字 gc_summary 指向 wrapper。路由表里存的也是 wrapper,所以每次请求都会计时。
  • 4这些都发生在 import 时。⑤ 里会看到,打印“登记进路由表”比打印任何请求结果都早。
wrapper (*args, **kwargs) __name__ = "gc_summary" __wrapped__ = ● ← wraps 抄来的 原函数 gc_summary (service: str, minutes: int = 10) 在 wrapper 的闭包里 FastAPI 读参数 inspect.signature(wrapper) 有 wraps:顺着 __wrapped__ 读到 service、minutes → 200 没有:读到 args、kwargs → 422
FastAPI 不看你写的文档,它看函数签名来决定从查询串里取哪些参数。所以包装型装饰器必须用 functools.wraps,把签名“透”出去。

装饰器实验台:三个选择,八种结果,全部是本机 FastAPI 实测

顺序
有没有 @functools.wraps(fn)
wrapper 怎么写

先猜:GET /x?service=order(端点里 await asyncio.sleep(0.1))会怎样?

4monkeypatch:改的是某个模块字典里的一个名字 ≈ 换掉 Spring 容器里的 Bean

main.__dict__ "fetch" ● "summary" ● report.__dict__ "fetch" ● ← from main import 假的 fake_fetch 真的 fetch ✕ 原来指这里 main.summary() 每次执行到 fetch, 都去 main 的字典里查 → "假的" report.report() 去 report 自己的字典查, 那个名字没人改过 → "真的去 order-01 拉日志" 两行输出都是本机实测 实测
from main import fetch 不是“链接到 main.fetch”,而是“把此刻 main 里 fetch 指向的对象,在我这里再贴一个同名标签”。所以 monkeypatch 要改“调用方查名字的那个模块”,这就是 ⑥ 坑 7。

5为什么这样设计:Python 改名字,Spring 造代理

Python 装饰器:定义时把名字换掉选了它

  • 没有容器、没有反射、没有代理类,一行函数调用就够。
  • 名字被换掉了,所以模块内部的调用也走包装:summary() 里调 parse(),两个都被记了下来,['summary', 'parse'] 实测。
  • 代价:顺序有讲究,import 有副作用,签名要自己用 wraps 透出去。

Spring AOP:容器启动时生成代理没选

  • 原来的类一个字都不用改,切面可以按规则批量套。
  • 只有“经过代理”的调用才生效:类里 this.parse() 自调用绕过代理,@Transactional 失效是老坑。
  • 离不开容器,new 出来的对象没有切面。

决定胜负的约束:Python 没有一个统一管理对象的容器,但所有名字都存在字典里,而函数又只是字典里的值。在这个前提下,最简单的办法就是“把字典里那个名字换成包装过的函数”。Java 的方法长在类上、不能单独替换,只好在外面再造一个代理对象。详见 Spring AOP 代理原理。

最少的语法:Java 怎么写,Python 怎么写 带参数的装饰器、版本差异放到 ⑥

JavaPython说明
Predicate<Double> p = ms -> ms > 200;p = lambda ms: ms > 200lambda 只能写一个表达式,复杂的用 def
list.sort(comparing(this::key))sorted(xs, key=key)直接把函数传进去
Map<String, Handler>{"gc": gc_summary}函数当字典的值
返回一个 lambdadef over(limit): def check(ms): ...; return check闭包
AtomicInteger 在 lambda 里计数nonlocal n改外层变量要声明
Object... args*args / **kwargs收任意位置参数 / 任意关键字参数
@Around 切面def deco(fn): ... return wrapper包装型装饰器
@GetMapping + 容器扫描def deco(fn): registry[k] = fn; return fn登记型装饰器
—@functools.wraps(fn)包装型装饰器必加
Mockito 替换 Beanmonkeypatch.setattr(main, "fetch", fake)pytest 提供,测完自动改回去
Function.andThen / 绑定参数functools.partial(fetch, client)先填好一部分参数

⑤亲眼看到:自己写一个迷你 @app.get,再拿真的 FastAPI 验证

还在 gc-helper 项目里做。第一个文件只用标准库,把 ④ 的四张图逐一打印出来;第二个文件装上 FastAPI,验证 wraps 和顺序的后果。

1deco_demo.py 函数是值、闭包、登记型和包装型装饰器
import asyncio, functools, time

print("== 1. 函数是值 ==")
def parse(text: str) -> int:
    return text.count("Pause")
handlers = {"parse": parse}                  # 放进字典
f = handlers["parse"]                        # 拿出来
print(f is parse, f.__name__, f("Pause Pause"))

print("== 2. 闭包 ==")
def over(limit):
    def check(ms):
        return ms > limit                    # 用到外层的 limit
    return check
slow = over(200)                             # over 已经返回,limit 还活着
print(slow(244.05), slow(31.6), slow.__closure__[0].cell_contents)

print("== 3. 登记型装饰器:import 时就执行 ==")
routes = {}
def get(path):
    print(f"  get({path!r}) 执行")
    def deco(fn):
        print(f"  deco({fn.__name__}) 执行,登记进路由表")
        routes[path] = fn
        return fn                            # 原样返回
    return deco

print("== 4. 包装型装饰器 ==")
def timed(fn):
    @functools.wraps(fn)                     # 抄名字、文档、签名
    async def wrapper(*args, **kwargs):
        t = time.perf_counter()
        try:
            return await fn(*args, **kwargs)
        finally:
            print(f"  {fn.__name__} 用了 {(time.perf_counter() - t) * 1000:.0f} ms")
    return wrapper

@get("/gc/summary")                          # ② 再登记:登记的是 wrapper
@timed                                       # ① 先包
async def gc_summary(service: str, minutes: int = 10):
    await asyncio.sleep(0.1)
    return {"service": service, "minutes": minutes}

print("routes:", list(routes), "登记的是:", routes["/gc/summary"].__name__,
      "原函数:", routes["/gc/summary"].__wrapped__.__name__)
print("== 5. 按路由表调用 ==")
print(asyncio.run(routes["/gc/summary"]("order")))
$ uv run deco_demo.py
== 1. 函数是值 ==
True parse 2
== 2. 闭包 ==
True False 200
== 3. 登记型装饰器:import 时就执行 ==
== 4. 包装型装饰器 ==
  get('/gc/summary') 执行
  deco(gc_summary) 执行,登记进路由表
routes: ['/gc/summary'] 登记的是: gc_summary 原函数: gc_summary
== 5. 按路由表调用 ==
  gc_summary 用了 100 ms
{'service': 'order', 'minutes': 10}
2fa_pits.py 换成真的 FastAPI:先 uv add fastapi httpx
import asyncio, functools, time
from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI()
calls = []

def timed_nowraps(fn):                       # 忘了 wraps
    async def wrapper(*args, **kwargs):
        t = time.perf_counter(); r = await fn(*args, **kwargs)
        calls.append(("nowraps", round((time.perf_counter() - t) * 1000))); return r
    return wrapper

def timed(fn):
    @functools.wraps(fn)
    async def wrapper(*args, **kwargs):
        t = time.perf_counter(); r = await fn(*args, **kwargs)
        calls.append((fn.__name__, round((time.perf_counter() - t) * 1000))); return r
    return wrapper

@app.get("/a")
@timed_nowraps
async def a(service: str, minutes: int = 10):
    await asyncio.sleep(0.1); return {"service": service, "minutes": minutes}

@app.get("/b")
@timed
async def b(service: str, minutes: int = 10):
    await asyncio.sleep(0.1); return {"service": service, "minutes": minutes}

@timed                                       # 写在 app.get 上面:登记的是没计时的原函数
@app.get("/c")
async def c(service: str):
    await asyncio.sleep(0.1); return {"service": service}

async def d(): return 1
print("app.get 返回原函数:", app.get("/d")(d) is d)

cl = TestClient(app)
for p in ("/a?service=order", "/b?service=order", "/c?service=order"):
    r = cl.get(p); print(p, r.status_code, r.text[:160])
print("calls", calls)
$ uv run fa_pits.py
app.get 返回原函数: True
/a?service=order 422 {"detail":[{"type":"missing","loc":["query","args"],"msg":"Field required","input":null},{"type":"missing","loc":["query","kwargs"],"msg":"Field required","inpu
/b?service=order 200 {"service":"order","minutes":10}
/c?service=order 200 {"service":"order"}
calls [('b', 100)]

实测 本机 uv 0.13.7、CPython 3.13.18、FastAPI 0.162.2、httpx 0.28.1,输出逐字复制(省略了 TestClient 提示 httpx 用法将来要换的一行警告)。

到这里已经能交差了:看得懂 @app.get 在 import 时登记、@timed 把名字换成 wrapper,也会写一个带 wraps 的计时装饰器。
已知还没处理的细节
  • /a 返回 422,/c 静悄悄地没计时 → ⑥ 坑 1、2
  • 想写 @retry(times=3) 这种带参数的装饰器 → ⑥ 第三圈
  • 测试里换掉 fetch 时换错了地方 → ⑥ 坑 7

⑥细节·规范·优化

第一圈的 8 个坑全部在本机复现过 实测。前三个最危险:接口不报 500,只是行为悄悄变了。

第一圈

注意事项:不知道就会出事

1包装型装饰器忘了 functools.wraps,接口变 422← ④-3 签名透出去
现象
GET /a?service=order 返回 422:"loc":["query","args"]、["query","kwargs"] 缺失。
根源
FastAPI 读的是 wrapper(*args, **kwargs) 的签名,把 args、kwargs 当成了必填的查询参数。
做法
包装型装饰器一律加 @functools.wraps(fn)。
2顺序写反,计时静悄悄地没了← ④-3 从下往上
现象
@timed 写在 @app.get 上面,接口 200,但 calls 里没有 c。
根源
下面的 @app.get 先执行,登记的是原函数;上面的 @timed 只改了模块里的名字,路由表里那个没变。
做法
登记型(@app.get、@pytest.fixture、@tool)写在最上面。
3用普通 def wrapper 包 async def,量到 0.001 ms← ④-3 包装型
现象
接口照样 200,计时却只有 0.001 ms,实际耗时 101 ms。
根源
调用 async def 函数只是造出一个协程对象,立刻返回;真正的等待发生在外面的 await 里,wrapper 早就量完了。
做法
包 async 函数的 wrapper 也写成 async def,里面 await fn(...)。
4循环里造 lambda,全都用了最后一个值← ④-2 cell 装变量
现象
[lambda: h for h in HOSTS] 调一遍,得到 3 个 'order-03'。
根源
闭包捕获的是变量 h,调用时才读,那时循环已经结束。
做法
用默认参数在定义时取值:lambda h=h: h;或 functools.partial(fn, h)。
5默认参数写 [],所有调用共用一份← ④-1 __defaults__
现象
def add(host, seen=[]),调两次,第二次返回 ['order-01', 'order-02']。
根源
默认值在 def 那一刻算一次,存在函数对象的 __defaults__ 上,以后每次调用都拿同一个列表。
做法
seen=None,函数里 if seen is None: seen = []。
6闭包里 n += 1,报 UnboundLocalError← ④-2 LEGB
现象
cannot access local variable 'n' where it is not associated with a value。
根源
函数里只要给 n 赋值,n 就被认定为局部变量,不再去外层找;而 n += 1 要先读它。
做法
在内层写 nonlocal n;改模块级变量用 global,但尽量别这么做。
7monkeypatch 了 main.fetch,别的模块还在调真的← ④-4 各查各的字典
现象
main.summary() 得到“假的”,report.report() 还是“真的去 order-01 拉日志”。
根源
from main import fetch 在 report 里另贴了一个名字,改 main 的那个管不到它。
做法
在“调用方查名字的地方”打补丁:monkeypatch.setattr(report, "fetch", fake);或者 report 里写 import main、用 main.fetch(...)。
8装饰器里做了 IO,import 就出事← ④-3 import 时执行
现象
在装饰器外层连数据库、读远程配置,结果 pytest 收集用例、甚至 IDE 分析代码时都在连库,连不上直接 import 失败。
根源
装饰器外层代码在定义函数时就执行,import main 等于把它们全跑一遍。
做法
外层只做登记,IO 放进 wrapper 或 FastAPI 的启动事件里。
第二圈

使用规范:团队里怎么写

包装型装饰器必加 @functools.wraps(fn)

不管是不是给 FastAPI 用,日志、调试、文档工具都靠名字和签名。

接口 422、日志里全是 wrapper。

包 async 就写 async wrapper

想同一个装饰器两边都能用,先用 inspect.iscoroutinefunction(fn) 分支。

计时、重试、限流统统量错。

登记型放最上面

从上往下读就是“登记的是包好之后的函数”。

中间那层悄悄不生效。

装饰器外层不做 IO

外层只登记、只读参数;真正的活放进 wrapper。

import 就连库,测试变慢变脆。

测试里能注入就别 patch

FastAPI 的 Depends、把 client 当参数传进去,比 monkeypatch 稳;非 patch 不可时,patch 调用方的模块。

换错地方,测试测的是真网络。

默认参数只用不可变的值

None、数字、字符串、tuple。

调用之间悄悄共享状态。

第三圈

优化和深处

带参数的装饰器为什么是三层

def retry(times=3):                 # 第 1 层:收配置
    def deco(fn):                   # 第 2 层:收函数
        @functools.wraps(fn)
        async def wrapper(*a, **k): # 第 3 层:每次调用
            for i in range(times):
                try: return await fn(*a, **k)
                except httpx.TransportError:
                    if i == times - 1: raise
        return wrapper
    return deco

@retry(times=3)      # = fetch = retry(times=3)(fetch)
async def fetch(...): ...

@ 后面是一个表达式,先求值。retry(times=3) 返回 deco,才轮到 deco 收函数。@app.get("/x") 也是这个结构。

FastAPI 是怎么读到参数的

inspect.signature(fn) 默认 follow_wrapped=True:遇到 __wrapped__ 就顺着往下找,所以 wraps 一加,FastAPI 看到的就是原函数的 service: str, minutes: int = 10。

坑 3 那种“普通 wrapper 包 async”在 FastAPI 里照样 200:实测下来它仍然拿到了协程并等完了结果。正因为不报错,计时错了才更难发现。

包一层要多花多少时间

本机一个空函数直接调 62 ns,套一层 wraps 加 *args, **kwargs 的 wrapper 后 154 ns,每层约多 90 ns 实测。

对一次要等 1 秒网络的接口毫无影响;在一个跑几百万次的解析循环里层层套装饰器,才需要算这笔账。

版本差异

版本变化
3.0nonlocal(PEP 3104)
3.2functools.wraps 开始设置 __wrapped__
3.9@ 后面可以写任意表达式(PEP 614),比如 @routes["gc"]
3.10ParamSpec(PEP 612),给装饰器写准确的类型标注

下一圈

  • 方法和 self:函数放进类里为什么就变成了“绑定方法”(描述符协议)。
  • @functools.lru_cache、@contextmanager、@dataclass:标准库里现成的装饰器。
  • FastAPI 的 Depends:Python 版的依赖注入,和 Spring DI 对照。
  • 给装饰器写类型:ParamSpec 和 TypeVar,让 IDE 认得被包过的函数。
  • 站 04 的 @tool:用登记型装饰器把函数变成 agent 能调用的工具,签名就是工具说明。

↺回到⑤:拿⑥改进值班助手

1 · 给 fetch 加重试

照第三圈写 @retry(times=3),套在全景代码的 fetch 上。想一想它应该放在 asyncio.timeout 的里面还是外面,各会怎样。

用到:带参数的装饰器、坑 3

2 · 把耗时写进返回值

写一个 @timed,把耗时放进 request.state,第 9 站返回的 elapsed_ms 从它取。故意把顺序写反一次,看会怎样。

用到:坑 1、2,规范“登记型放最上面”

3 · 写一个 monkeypatch 测试

新建 report.py,用 from main import fetch。写 pytest 用例把 fetch 换成假的,先让它失败,再按坑 7 的两种改法各修一次。

用到:④-4、坑 7

1. 用一句话说出这一页的精华。

函数是对象,名字只是标签;闭包带着外层变量的 cell 走;@deco 就是 f = deco(f),在 import 时从下往上执行,把名字换成 deco 返回的东西。

2. 没有一等函数时,Java 怎么做“把处理逻辑登记到路径上”?痛在哪?

定义接口、写匿名类或 lambda 实例,或者打注解、靠 Spring 容器启动时反射扫描。注解本身不做事,行为取决于容器,比如自调用不走代理。

3. 坑 1 的 422 是从 ④ 哪条原理来的?

④-3:装饰后名字指向 wrapper,FastAPI 读的是 wrapper 的签名。没有 wraps 就没有 __wrapped__,inspect.signature 看到的是 *args, **kwargs。

4. 预测:fs = [lambda: ms * 2 for ms in (10, 20, 30)],[f() for f in fs] 是多少?怎么改成 [20, 40, 60]?

[60, 60, 60]。三个 lambda 共享同一个变量 ms,调用时它已经是 30。改成 lambda ms=ms: ms * 2。

从 ④-2 推出:cell 装的是变量,不是当时的值。

5. 预测:@app.get 在上、@timed 在下,timed 没用 wraps,但 wrapper 写成了 async def wrapper(service: str): return await fn(service),只收一个参数。请求 /x?service=order&minutes=5 会怎样?

FastAPI 读到的签名只有 service,minutes 被当成多余的查询参数忽略;原函数用默认值 10。接口 200,但 minutes 永远是 10。

从 ④-3 推出:FastAPI 只认登记进去那个函数的签名。

6. 放回全景:第 3 站前后各发生了什么?如果有人在 @app.get 上面加了一个 @timed,第 9 站的 elapsed_ms 会怎样?

之前是第 2 站:uvicorn 把字节解析成 scope,调用 app(scope, receive, send)。之后是第 4 站:FastAPI 按登记函数的签名校验参数。

顺序反了,路由表里登记的是没计时的原函数,第 3 站查表拿到的就是它,请求一路走到第 9 站都不经过 timed。如果 elapsed_ms 从计时装饰器取值,它会一直是空的或默认值,接口不报错,只有看监控的人会发现数字不对。

见 全景第 3 站:路由表在 import 时就建好。