框架

从 NestJS 到 FastAPI 理解装饰器和框架元数据

这篇笔记记录一下我学习 NestJS 和 FastAPI 时,对装饰器慢慢想通的过程。 我一开始学习 NestJS 时,有一个疑惑: JavaScript 看起来是一门比较灵活的语言,为什么也能像 Java 一样,在类和方法上面写一些类似注解的东西,然后就自动拥有路由、参数注入、权限判断这些功能? 比如: 后来学习 P

2026年6月12日16 分钟阅读
从 NestJS 到 FastAPI 理解装饰器和框架元数据

这篇笔记记录一下我学习 NestJS 和 FastAPI 时,对装饰器慢慢想通的过程。

我一开始学习 NestJS 时,有一个疑惑:

JavaScript 看起来是一门比较灵活的语言,为什么也能像 Java 一样,在类和方法上面写一些类似注解的东西,然后就自动拥有路由、参数注入、权限判断这些功能?

比如:

ts
@Controller("users")
export class UserController {
  @Get(":id")
  findOne(@Param("id") id: string) {
    return `用户 ID:${id}`;
  }
}

后来学习 Python 的 FastAPI,又看到了很像的写法:

python
from fastapi import FastAPI

app = FastAPI()


@app.get("/users/{user_id}")
async def find_one(user_id: int):
    return {"user_id": user_id}

这时我又重新去探究了一下,发现这些框架看起来很神奇,但底层思路其实没有那么神秘:

  1. 装饰器先记录一些额外信息,或者直接把函数注册到框架里。
  2. 框架在启动时整理这些信息。
  3. 请求进来以后,框架根据路由、参数、依赖等规则找到对应函数。
  4. 框架准备好参数,再调用我们写的函数。

不过 NestJS 和 FastAPI 的具体实现并不完全一样。这篇笔记就把它们拆开来看。

先理解最普通的高阶函数

在 JavaScript 和 Python 里,函数都可以像普通变量一样传来传去。

一个函数可以接收另一个函数,也可以返回一个新函数。这就是高阶函数。

JavaScript 版本:

ts
function withLog(fn: (...args: unknown[]) => unknown) {
  return (...args: unknown[]) => {
    console.log("开始执行");
    const result = fn(...args);
    console.log("执行结束");
    return result;
  };
}

const sayHello = withLog((name: string) => {
  return `你好,${name}`;
});

console.log(sayHello("小明"));

Python 版本:

python
from collections.abc import Callable
from typing import Any


def with_log(fn: Callable[..., Any]):
    def wrapper(*args: Any, **kwargs: Any):
        print("开始执行")
        result = fn(*args, **kwargs)
        print("执行结束")
        return result

    return wrapper


@with_log
def say_hello(name: str):
    return f"你好,{name}"


print(say_hello("小明"))

Python 里的:

python
@with_log
def say_hello(name: str):
    ...

可以理解成:

python
say_hello = with_log(say_hello)

所以我当时觉得装饰器是通过高阶函数做的,这个方向没有错。

但是框架里的装饰器不一定只是把原函数包一层。它还可以记录元数据,或者把函数注册到框架中。

装饰器可以做哪些事情

可以先把常见用途分成三类。

1. 包装函数

在真正调用原函数前后,加一些额外逻辑:

python
def with_log(fn):
    def wrapper(*args, **kwargs):
        print("调用前")
        result = fn(*args, **kwargs)
        print("调用后")
        return result

    return wrapper

这种适合日志、计时、重试、缓存。

2. 修改或者替换对象

装饰器可以返回一个新函数、新类,甚至直接改动原对象。

3. 记录元数据或者注册信息

装饰器不一定要修改原函数。它也可以只记录:

  • 这是一个 GET 路由。
  • 路径是 /users/:id
  • 第一个参数来自路径参数。
  • 这个方法需要管理员权限。
  • 返回状态码是 201

框架之后再统一读取这些信息。

这一类是理解 NestJS 和 FastAPI 的关键。

什么是元数据

元数据可以简单理解成“描述代码的数据”。

比如我写了一个普通函数:

ts
function findOne(id: string) {
  return `用户 ID:${id}`;
}

函数本身只知道怎么处理 id,它不知道自己应该响应哪个 HTTP 请求。

如果额外记录一份数据:

ts
{
  method: "GET",
  path: "/users/:id",
  handler: findOne,
}

框架就能知道:

收到 GET /users/123 时,应该调用 findOne("123")

这份额外记录的信息,就是元数据。

NestJS 里的装饰器

NestJS 很像 Java 里的 Spring。我们会在类、方法和参数上写装饰器:

ts
import { Controller, Get, Param } from "@nestjs/common";

@Controller("users")
export class UserController {
  @Get(":id")
  findOne(@Param("id") id: string) {
    return `用户 ID:${id}`;
  }
}

这些装饰器分别表达:

装饰器含义
@Controller("users")这个类是一组控制器,公共路径前缀是 /users
@Get(":id")这个方法处理 GET /users/:id
@Param("id")调用方法时,这个参数从路径参数中取得

NestJS 官方文档里有一句很直接的话:装饰器把类和必要的元数据关联起来,Nest 据此创建路由映射。

可以把它粗略理解为:

ts
@Get(":id")
findOne(@Param("id") id: string) {
  return `用户 ID:${id}`;
}

背后类似于记录:

ts
{
  controllerPrefix: "users",
  requestMethod: "GET",
  routePath: ":id",
  handlerName: "findOne",
  parameters: [
    {
      index: 0,
      source: "path",
      key: "id",
    },
  ],
}

这不是 NestJS 内部真实数据结构,只是为了帮助理解。

NestJS 为什么能读取元数据

NestJS 项目通常使用 TypeScript,并引入 reflect-metadata

装饰器执行时,可以通过反射相关工具给类或方法附加信息。之后,框架再读取这些信息。

写一个非常简化的例子:

ts
import "reflect-metadata";

const PATH_METADATA = "demo:path";

function Get(path: string): MethodDecorator {
  return (target, propertyKey) => {
    Reflect.defineMetadata(PATH_METADATA, path, target, propertyKey);
  };
}

class UserController {
  @Get(":id")
  findOne(id: string) {
    return `用户 ID:${id}`;
  }
}

const path = Reflect.getMetadata(
  PATH_METADATA,
  UserController.prototype,
  "findOne",
);

console.log(path); // ":id"

这里的 @Get(":id") 没有真正处理请求。它只是先把路径信息放在 findOne 方法上。

真正处理请求的是框架后续的启动逻辑。

NestJS 启动时大概做了什么

可以把过程粗略理解成:

text
加载模块
  -> 找到 Controller 和 Provider
  -> 扫描类、方法和参数上的元数据
  -> 整理路由表和依赖关系
  -> 在底层 HTTP 平台注册路由
  -> 等待请求

比如:

ts
@Controller("users")
export class UserController {
  @Get(":id")
  findOne(@Param("id") id: string) {
    return `用户 ID:${id}`;
  }
}

框架整理后,可以想象成:

text
GET /users/:id
  -> 从请求路径中取出 id
  -> 调用 userController.findOne(id)
  -> 把返回值转换成 HTTP 响应

所以不是 @Get() 自己在每次请求进来时临时猜测该做什么。

它先提供描述信息,框架在启动阶段整理好规则,请求进来后再按规则调用方法。

NestJS 里的权限装饰器

元数据不只能记录路由,也能记录权限。

例如:

ts
import { SetMetadata } from "@nestjs/common";

export const Roles = (...roles: string[]) => {
  return SetMetadata("roles", roles);
};

使用时:

ts
@Roles("admin")
@Get("settings")
getSettings() {
  return "系统设置";
}

@Roles("admin") 本身不负责拦截请求。它只是给方法附加一份元数据:

ts
{
  roles: ["admin"]
}

真正检查权限的是 Guard。Guard 会读取这份元数据,再判断当前用户能不能访问。

这也是我后来理解到的一点:

装饰器可以声明规则,但真正执行规则的通常是框架里的其他部分。

FastAPI 里的装饰器

FastAPI 也大量使用装饰器:

python
from fastapi import FastAPI

app = FastAPI()


@app.get("/users/{user_id}")
async def find_one(user_id: int):
    return {"user_id": user_id}

这段代码看起来和 NestJS 很像,但实现思路有一些区别。

Python 装饰器可以理解成:

python
find_one = app.get("/users/{user_id}")(find_one)

也就是说:

  1. 先调用 app.get("/users/{user_id}")
  2. 它返回一个装饰器函数。
  3. 函数 find_one 定义完成后,再交给这个装饰器。
  4. 装饰器把路由路径、请求方法和处理函数注册到 app 中。

可以写一个极简版本帮助理解:

python
class App:
    def __init__(self):
        self.routes = []

    def get(self, path: str):
        def decorator(fn):
            self.routes.append(
                {
                    "method": "GET",
                    "path": path,
                    "handler": fn,
                }
            )
            return fn

        return decorator


app = App()


@app.get("/users/{user_id}")
def find_one(user_id: int):
    return {"user_id": user_id}


print(app.routes)

最后 app.routes 里就保存了一条路由记录。

所以 FastAPI 的 @app.get() 不只是“在函数上放一份元数据”,它会在函数定义时直接完成路由注册。

FastAPI 还会读取函数签名

FastAPI 很方便的一个地方是,它会读取函数参数和类型标注:

python
from fastapi import FastAPI, Query

app = FastAPI()


@app.get("/users/{user_id}")
async def find_one(
    user_id: int,
    keyword: str | None = Query(default=None),
):
    return {
        "user_id": user_id,
        "keyword": keyword,
    }

框架可以从函数签名中整理出:

信息来源
请求方法@app.get()
路由路径"/users/{user_id}"
user_id 来自路径路径模板中存在 {user_id}
user_id 需要转换成整数类型标注 int
keyword 来自查询参数Query(default=None)
keyword 可以为空类型标注 str | None

请求进来以后,FastAPI 会按照这些规则提取参数、转换类型、做校验,再调用我们的函数。

如果参数不符合要求,比如 user_id 不是整数,框架会直接返回校验错误。

FastAPI 的依赖注入

FastAPI 的 Depends() 也是类似思路:

python
from typing import Annotated

from fastapi import Depends, FastAPI

app = FastAPI()


def get_current_user():
    return {"name": "小明"}


@app.get("/profile")
async def get_profile(
    user: Annotated[dict, Depends(get_current_user)],
):
    return user

我们没有手动调用:

python
get_current_user()

而是通过 Depends(get_current_user) 告诉 FastAPI:

调用 get_profile() 以前,先解析这个依赖,再把结果传进来。

框架负责:

text
收到 GET /profile
  -> 发现 get_profile 依赖 get_current_user
  -> 调用 get_current_user
  -> 取得返回值
  -> 调用 get_profile(user=返回值)
  -> 返回 HTTP 响应

这算不算编译

我当时的理解是:装饰器提供元数据,框架会做一个编译动作,编译出基本语法以及装饰的数据,然后根据这些数据调用对应函数。

这个直觉已经比较接近了,但“编译”这个词容易让人想到 TypeScript 编译成 JavaScript,或者 Java 编译成字节码。

更准确一点,可以说框架在做:

  • 注册。
  • 扫描。
  • 反射。
  • 解析函数签名。
  • 整理元数据。
  • 构建路由表和依赖图。

NestJS 会在应用启动时扫描模块、控制器、方法和相关元数据,然后建立路由映射和依赖关系。

FastAPI 的路由装饰器会在 Python 模块加载、函数定义时注册路由。FastAPI 还会分析函数签名、类型标注和依赖,生成请求处理与 OpenAPI 文档需要的信息。

它们确实都在把“声明式写法”整理成框架可以执行的规则。把这个过程口语化地叫作“预处理”或者“整理配置”更合适。

为什么这种写法很好用

不用装饰器时,我们可能会手动写:

ts
router.get("/users/:id", async (request, response) => {
  const id = request.params.id;
  const result = await userService.findOne(id);
  response.json(result);
});

使用 NestJS 后,可以写:

ts
@Get(":id")
findOne(@Param("id") id: string) {
  return this.userService.findOne(id);
}

使用 FastAPI 时,可以写:

python
@app.get("/users/{user_id}")
async def find_one(user_id: int):
    return await user_service.find_one(user_id)

框架替我们做了很多重复工作:

  • 匹配路由。
  • 读取请求参数。
  • 转换类型。
  • 校验参数。
  • 处理依赖注入。
  • 整理返回值。
  • 生成接口文档。

我们的代码只需要描述“我想要什么”,不用每个接口都手动写完整流程。

NestJS 和 FastAPI 的相似点

可以先用一张表总结:

相似点NestJSFastAPI
声明路由@Get(":id")@app.get("/users/{user_id}")
描述参数来源@Param("id")路径模板、类型标注、Query()
注入依赖构造函数注入、ProviderDepends()
保存额外信息装饰器元数据路由注册信息、函数签名、类型标注
框架调用函数根据路由和元数据调用方法根据路由和解析结果调用函数

它们的共同点是:

我们不是手动调用控制器函数,而是声明规则,让框架在合适的时候调用。

NestJS 和 FastAPI 的不同点

虽然写法看起来像,但不要把它们完全当成一回事。

NestJS 更依赖装饰器和反射元数据

NestJS 的风格很像 Java 框架:

ts
@Controller("users")
@UseGuards(AuthGuard)
export class UserController {
  @Get(":id")
  findOne(@Param("id") id: string) {
    // ...
  }
}

类、方法和参数上的装饰器都很重要。

FastAPI 更依赖 Python 函数本身

FastAPI 除了路由装饰器,还会大量使用函数签名和类型标注:

python
@app.get("/users/{user_id}")
async def find_one(user_id: int, active: bool = True):
    # ...

它能从普通 Python 代码里读出很多信息。

两边的“装饰器”语法背后不是同一套机制

Python 的装饰器本质上是函数调用和重新赋值。

TypeScript 的装饰器是语言层面的装饰器机制。NestJS 再结合元数据和反射能力,实现框架功能。

它们的共同思想类似,但不能简单理解成底层实现完全相同。

我最后想通的地方

我一开始看到 NestJS 的装饰器时,觉得像 Java 注解一样,有点像魔法。

后来学 FastAPI 又重新看了一遍,发现可以把整个过程拆成几步:

text
装饰器、类型标注、参数声明
  -> 提供额外信息
  -> 框架注册和整理规则
  -> 请求到来
  -> 框架匹配路由
  -> 提取参数并解析依赖
  -> 调用我们写的函数

高阶函数解释了装饰器为什么能接收和处理函数。

元数据解释了框架为什么知道一个函数应该在什么时候被调用。

反射、签名解析和注册过程解释了框架为什么能自动完成路由、参数注入、权限判断和接口文档这些功能。

理解以后,再看到类似写法就没有那么神秘了。很多框架功能,本质上都是我们先声明规则,框架再根据规则统一处理。