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

这篇笔记记录一下我学习 NestJS 和 FastAPI 时,对装饰器慢慢想通的过程。
我一开始学习 NestJS 时,有一个疑惑:
JavaScript 看起来是一门比较灵活的语言,为什么也能像 Java 一样,在类和方法上面写一些类似注解的东西,然后就自动拥有路由、参数注入、权限判断这些功能?
比如:
@Controller("users")
export class UserController {
@Get(":id")
findOne(@Param("id") id: string) {
return `用户 ID:${id}`;
}
}后来学习 Python 的 FastAPI,又看到了很像的写法:
from fastapi import FastAPI
app = FastAPI()
@app.get("/users/{user_id}")
async def find_one(user_id: int):
return {"user_id": user_id}这时我又重新去探究了一下,发现这些框架看起来很神奇,但底层思路其实没有那么神秘:
- 装饰器先记录一些额外信息,或者直接把函数注册到框架里。
- 框架在启动时整理这些信息。
- 请求进来以后,框架根据路由、参数、依赖等规则找到对应函数。
- 框架准备好参数,再调用我们写的函数。
不过 NestJS 和 FastAPI 的具体实现并不完全一样。这篇笔记就把它们拆开来看。
先理解最普通的高阶函数
在 JavaScript 和 Python 里,函数都可以像普通变量一样传来传去。
一个函数可以接收另一个函数,也可以返回一个新函数。这就是高阶函数。
JavaScript 版本:
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 版本:
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 里的:
@with_log
def say_hello(name: str):
...可以理解成:
say_hello = with_log(say_hello)所以我当时觉得装饰器是通过高阶函数做的,这个方向没有错。
但是框架里的装饰器不一定只是把原函数包一层。它还可以记录元数据,或者把函数注册到框架中。
装饰器可以做哪些事情
可以先把常见用途分成三类。
1. 包装函数
在真正调用原函数前后,加一些额外逻辑:
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 的关键。
什么是元数据
元数据可以简单理解成“描述代码的数据”。
比如我写了一个普通函数:
function findOne(id: string) {
return `用户 ID:${id}`;
}函数本身只知道怎么处理 id,它不知道自己应该响应哪个 HTTP 请求。
如果额外记录一份数据:
{
method: "GET",
path: "/users/:id",
handler: findOne,
}框架就能知道:
收到 GET /users/123 时,应该调用 findOne("123")。
这份额外记录的信息,就是元数据。
NestJS 里的装饰器
NestJS 很像 Java 里的 Spring。我们会在类、方法和参数上写装饰器:
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 据此创建路由映射。
可以把它粗略理解为:
@Get(":id")
findOne(@Param("id") id: string) {
return `用户 ID:${id}`;
}背后类似于记录:
{
controllerPrefix: "users",
requestMethod: "GET",
routePath: ":id",
handlerName: "findOne",
parameters: [
{
index: 0,
source: "path",
key: "id",
},
],
}这不是 NestJS 内部真实数据结构,只是为了帮助理解。
NestJS 为什么能读取元数据
NestJS 项目通常使用 TypeScript,并引入 reflect-metadata。
装饰器执行时,可以通过反射相关工具给类或方法附加信息。之后,框架再读取这些信息。
写一个非常简化的例子:
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 启动时大概做了什么
可以把过程粗略理解成:
加载模块
-> 找到 Controller 和 Provider
-> 扫描类、方法和参数上的元数据
-> 整理路由表和依赖关系
-> 在底层 HTTP 平台注册路由
-> 等待请求比如:
@Controller("users")
export class UserController {
@Get(":id")
findOne(@Param("id") id: string) {
return `用户 ID:${id}`;
}
}框架整理后,可以想象成:
GET /users/:id
-> 从请求路径中取出 id
-> 调用 userController.findOne(id)
-> 把返回值转换成 HTTP 响应所以不是 @Get() 自己在每次请求进来时临时猜测该做什么。
它先提供描述信息,框架在启动阶段整理好规则,请求进来后再按规则调用方法。
NestJS 里的权限装饰器
元数据不只能记录路由,也能记录权限。
例如:
import { SetMetadata } from "@nestjs/common";
export const Roles = (...roles: string[]) => {
return SetMetadata("roles", roles);
};使用时:
@Roles("admin")
@Get("settings")
getSettings() {
return "系统设置";
}@Roles("admin") 本身不负责拦截请求。它只是给方法附加一份元数据:
{
roles: ["admin"]
}真正检查权限的是 Guard。Guard 会读取这份元数据,再判断当前用户能不能访问。
这也是我后来理解到的一点:
装饰器可以声明规则,但真正执行规则的通常是框架里的其他部分。
FastAPI 里的装饰器
FastAPI 也大量使用装饰器:
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 装饰器可以理解成:
find_one = app.get("/users/{user_id}")(find_one)也就是说:
- 先调用
app.get("/users/{user_id}")。 - 它返回一个装饰器函数。
- 函数
find_one定义完成后,再交给这个装饰器。 - 装饰器把路由路径、请求方法和处理函数注册到
app中。
可以写一个极简版本帮助理解:
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 很方便的一个地方是,它会读取函数参数和类型标注:
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() 也是类似思路:
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我们没有手动调用:
get_current_user()而是通过 Depends(get_current_user) 告诉 FastAPI:
调用 get_profile() 以前,先解析这个依赖,再把结果传进来。
框架负责:
收到 GET /profile
-> 发现 get_profile 依赖 get_current_user
-> 调用 get_current_user
-> 取得返回值
-> 调用 get_profile(user=返回值)
-> 返回 HTTP 响应这算不算编译
我当时的理解是:装饰器提供元数据,框架会做一个编译动作,编译出基本语法以及装饰的数据,然后根据这些数据调用对应函数。
这个直觉已经比较接近了,但“编译”这个词容易让人想到 TypeScript 编译成 JavaScript,或者 Java 编译成字节码。
更准确一点,可以说框架在做:
- 注册。
- 扫描。
- 反射。
- 解析函数签名。
- 整理元数据。
- 构建路由表和依赖图。
NestJS 会在应用启动时扫描模块、控制器、方法和相关元数据,然后建立路由映射和依赖关系。
FastAPI 的路由装饰器会在 Python 模块加载、函数定义时注册路由。FastAPI 还会分析函数签名、类型标注和依赖,生成请求处理与 OpenAPI 文档需要的信息。
它们确实都在把“声明式写法”整理成框架可以执行的规则。把这个过程口语化地叫作“预处理”或者“整理配置”更合适。
为什么这种写法很好用
不用装饰器时,我们可能会手动写:
router.get("/users/:id", async (request, response) => {
const id = request.params.id;
const result = await userService.findOne(id);
response.json(result);
});使用 NestJS 后,可以写:
@Get(":id")
findOne(@Param("id") id: string) {
return this.userService.findOne(id);
}使用 FastAPI 时,可以写:
@app.get("/users/{user_id}")
async def find_one(user_id: int):
return await user_service.find_one(user_id)框架替我们做了很多重复工作:
- 匹配路由。
- 读取请求参数。
- 转换类型。
- 校验参数。
- 处理依赖注入。
- 整理返回值。
- 生成接口文档。
我们的代码只需要描述“我想要什么”,不用每个接口都手动写完整流程。
NestJS 和 FastAPI 的相似点
可以先用一张表总结:
| 相似点 | NestJS | FastAPI |
|---|---|---|
| 声明路由 | @Get(":id") | @app.get("/users/{user_id}") |
| 描述参数来源 | @Param("id") | 路径模板、类型标注、Query() 等 |
| 注入依赖 | 构造函数注入、Provider | Depends() |
| 保存额外信息 | 装饰器元数据 | 路由注册信息、函数签名、类型标注 |
| 框架调用函数 | 根据路由和元数据调用方法 | 根据路由和解析结果调用函数 |
它们的共同点是:
我们不是手动调用控制器函数,而是声明规则,让框架在合适的时候调用。
NestJS 和 FastAPI 的不同点
虽然写法看起来像,但不要把它们完全当成一回事。
NestJS 更依赖装饰器和反射元数据
NestJS 的风格很像 Java 框架:
@Controller("users")
@UseGuards(AuthGuard)
export class UserController {
@Get(":id")
findOne(@Param("id") id: string) {
// ...
}
}类、方法和参数上的装饰器都很重要。
FastAPI 更依赖 Python 函数本身
FastAPI 除了路由装饰器,还会大量使用函数签名和类型标注:
@app.get("/users/{user_id}")
async def find_one(user_id: int, active: bool = True):
# ...它能从普通 Python 代码里读出很多信息。
两边的“装饰器”语法背后不是同一套机制
Python 的装饰器本质上是函数调用和重新赋值。
TypeScript 的装饰器是语言层面的装饰器机制。NestJS 再结合元数据和反射能力,实现框架功能。
它们的共同思想类似,但不能简单理解成底层实现完全相同。
我最后想通的地方
我一开始看到 NestJS 的装饰器时,觉得像 Java 注解一样,有点像魔法。
后来学 FastAPI 又重新看了一遍,发现可以把整个过程拆成几步:
装饰器、类型标注、参数声明
-> 提供额外信息
-> 框架注册和整理规则
-> 请求到来
-> 框架匹配路由
-> 提取参数并解析依赖
-> 调用我们写的函数高阶函数解释了装饰器为什么能接收和处理函数。
元数据解释了框架为什么知道一个函数应该在什么时候被调用。
反射、签名解析和注册过程解释了框架为什么能自动完成路由、参数注入、权限判断和接口文档这些功能。
理解以后,再看到类似写法就没有那么神秘了。很多框架功能,本质上都是我们先声明规则,框架再根据规则统一处理。