Node.js 后端对照结果:Fastify 契约与 NestJS 组织
核验日期:2026-07-16。本文以 Node.js 24 LTS、Fastify 5 与 NestJS 11 作为主要讲解版本。Fastify 5 要求 Node 20+ 并要求完整 JSON Schema;NestJS 11 同样要求 Node 20+。生产采用受支持 Node LTS,并核对框架 Release/LTS。
Node 后端对前端开发者的优势是语言、包和调试经验可迁移;风险也是过度熟悉:把任意对象当输入、把 event loop 当无限并发、把 TypeScript 当运行时验证、把 controller 直接连 ORM。本文用 Fastify 展开请求热路径,用 NestJS 解释大型应用的装配边界;两者不是必须叠加的“标准架构”。
请求链与框架所有权
Node 拥有 socket、event loop、stream 和进程生命周期;Fastify 拥有路由、hook、schema 编译、request/reply 和 plugin encapsulation;业务代码拥有不变量;驱动拥有连接协议;部署拥有资源和信号。NestJS 可在 Express 或 Fastify adapter 上提供 module、DI、guard、pipe、interceptor、filter,但抽象不能消除底层 adapter 差异。
Fastify 5:schema 是协议而非装饰
import Fastify from "fastify";
const app = Fastify({ logger: true, requestIdHeader: "x-request-id" });
const querySchema = {
type: "object",
additionalProperties: false,
properties: {
query: { type: "string", maxLength: 100 },
limit: { type: "integer", minimum: 1, maximum: 100, default: 20 },
},
} as const;
app.get("/technologies", {
schema: {
querystring: querySchema,
response: {
200: {
type: "object",
required: ["items", "dataVersion"],
properties: {
items: { type: "array", items: { type: "object" } },
dataVersion: { type: "string" },
},
},
},
},
}, async (request) => {
const { query = "", limit = 20 } = request.query as { query?: string; limit?: number };
return catalog.list({ query, limit });
});
await app.listen({ host: "127.0.0.1", port: 3000 });环境为 Node 24、Fastify 5;安装使用 pnpm,运行后请求 /technologies?limit=200 应得到验证失败,合法请求得到固定 response shape。示例的 item schema 为节省篇幅省略字段,生产契约必须完整列出,防止未审查字段泄露。
Fastify 使用 Ajv 类 JSON Schema validator 和高性能 serializer。TypeScript assertion 只帮助编辑器,恶意 JSON 仍由 schema 拒绝。输入与输出 schema 分开:输入允许的字段不等于输出,数据库实体也不等于公开 DTO。additionalProperties 策略、coercion、默认值、日期和 bigint 必须明确;自动 coercion 可能把字符串变数字,方便但会改变协议。
Schema 编译与动态输入
schema 通常在启动时编译,不要把用户输入变成 schema,也不要在请求热路径生成大量独特 schema。正则和 format 可能造成 CPU 风险,审查复杂表达式。验证失败由统一 error handler 映射为项目 problem 格式,不把 Ajv 内部路径直接稳定成公共 API。
响应 serialization schema 同时是性能和安全边界:只输出声明字段,降低意外 secret 泄露。但 schema 缺字段会静默丢失预期数据,因此 contract test 必须比较完整 body。不能只以“返回 200”验收。
Plugin encapsulation 与依赖生命周期
Fastify plugin 创建封装作用域,decorator/hook/routes 可以只对后代可见。数据库连接、repository、auth 和 routes 按真实生命周期装配;不要为每个文件建 plugin。
import fp from "fastify-plugin";
export default fp(async function databasePlugin(app) {
const pool = createPool({ connectionString: app.config.databaseUrl });
app.decorate("db", pool);
app.addHook("onClose", async () => pool.end());
});共享能力若要穿过 encapsulation,使用明确 plugin metadata/fastify-plugin。启动连接失败应让 readiness/启动失败,不要继续运行到首个请求。关闭 hook 在停止接收请求后释放 pool;migration 不应由每个副本无协调同时执行。
NestJS:何时需要容器与模块
NestJS 适合需要统一 module/DI、guard、pipe、interceptor、exception filter、OpenAPI、测试装配和团队规范的应用。小只读服务使用 Fastify 函数与 plugin 已足够;引入 Nest 会增加反射、decorator、启动和学习成本。
@Controller("technologies")
export class TechnologyController {
constructor(private readonly catalog: CatalogService) {}
@Get()
list(@Query() query: ListTechnologyQuery) {
return this.catalog.list(query);
}
}Controller 只适配 HTTP,service 不依赖 Request/Response。Pipe 完成运行时验证/转换,Guard 做认证授权,Interceptor 做跨切面包装,Filter 映射异常。不要把业务授权放在 interceptor 的字符串 metadata 拼装中;核心 service 仍接收 principal/resource 并验证 tenant。
Nest module 是依赖图边界,不等于业务领域边界。动态 module 在 v11 的 identity/dedup 行为变化会影响测试中的 provider 实例,升级时根据官方 migration guide 验证。默认 singleton provider 不能保存 request-specific mutable state;request scope 会增加对象创建和传播成本,只为确有需要的上下文使用。
TypeScript 的四层正确性
- TypeScript 编译期类型:阻止本项目中明显错误,不存在于 runtime。
- Runtime schema:验证 HTTP/queue/config 的不可信 bytes。
- 数据库 constraint:unique、foreign key、check、not null 对并发写兜底。
- 业务不变量:跨实体/权限/状态迁移,由 use case + transaction 保证。
不要从同一 class decorator 自动生成所有层然后假设语义一致。optional 在 patch、create、database nullable、response 中含义不同。日期是带时区字符串还是 epoch,64 位整数是否超过 JS 安全范围,都要在协议显式。
异步 I/O、event loop 与连接池
Node handler 的 async 不会把 JSON.parse、加密、模板或同步 ORM 移出主线程。event-loop p99 高时用 CPU profile/GC/ELU;外部等待高时看连接池 wait、上游 trace。Promise.all 无界 fan-out 会耗尽连接、内存和下游。
const results = [];
for (const batch of chunks(ids, 20)) {
results.push(...await Promise.all(batch.map((id) => repository.get(id, signal))));
}固定批次只是简单上限;真正实现应有 deadline、错误策略和可取消队列。数据库 pool 大小乘副本数不能超过数据库预算。请求取消要传播到 fetch/driver;并非所有数据库驱动都支持立即取消,超时后连接是否可复用需测试。
CPU 任务使用有界 worker pool 或外部任务服务,不按请求创建 worker。AsyncLocalStorage 适合 request/trace context,不保存业务状态。跨 worker/queue 显式传 trace/idempotency。
数据库、事务与 ORM
Node 可选原生 driver、query builder 或 ORM。选型看 SQL 表达、类型映射、migration、transaction、pool、observability 和团队,不按“类型安全”单项决定。ORM 类型无法证明数据库中没有并发冲突。
await db.transaction(async (tx) => {
const inserted = await tx.idempotency.tryInsert({ key, requestHash });
if (!inserted) return tx.idempotency.getResult(key);
const item = await tx.technologies.create(command);
await tx.outbox.insert({ type: "TechnologyCreated", aggregateId: item.id });
await tx.idempotency.complete(key, item);
return item;
});代码表达逻辑顺序,实际 API 取决于库。unique constraint 防相同 key 并发;requestHash 防 key 被不同 payload 重用;业务写、outbox、结果同事务。不要在事务内调用 HTTP broker;publisher 在提交后异步发送并标记。
transaction callback 被驱动自动 retry 时,回调必须无外部副作用。隔离级别按异常选择;读取后写入的检查需要 constraint、锁或 serializable/retry,不能只靠 JavaScript if。
缓存
进程内 Map 仅单实例,必须有容量/TTL;Redis 等共享 cache 也不是权威数据。cache-aside:读 cache,miss 查 DB,写带版本 key;写路径提交 DB 后失效/更新。删除失败会短暂陈旧,定义窗口并记录指标。不要把用户/tenant 数据放入缺范围 key。
缓存 JSON parse 仍占 event loop CPU。大对象/高并发要测序列化与内存。single-flight 只合并同实例或使用分布式锁,锁有 lease、owner 和超时;源站也要限流,避免锁失效后击穿。
认证与授权
认证 middleware/guard 验证 token 签名、issuer、audience、expiry、算法/key rotation,生成最小 principal。授权依据 action、resource、tenant 和当前状态,最好在 service/repository 查询约束中体现,防先查出跨租户数据再过滤。401/403/404 策略统一。
cookie session 处理 Secure、HttpOnly、SameSite、CSRF 与 rotation;Bearer token 防日志和 URL 泄露。不要自己实现密码哈希/JWT crypto。permission cache 失效和权限变更窗口要明确。
错误、日志与可观测性
定义 AppError(code, status, details, cause) 或判别联合;只有预期错误映射 4xx/503,未知 bug 记录 cause 并返回通用 500。不能按错误 message 字符串判断。Node Error cause 保留链;日志序列化 whitelist 字段。
Fastify logger 具 request id,上下文记录 route pattern(非原始高基数 URL)、status、duration、bytes、user/tenant 的不可逆或内部 id。metrics label 不放 request id/user/query。OpenTelemetry/agent 必须验证 AsyncLocalStorage、worker 和数据库 instrumentation;采样不丢错误 trace。
测试
Fastify inject 可不监听端口测试完整路由/hook/schema;Nest TestingModule 测装配,但不要把所有测试变成沉重容器。核心 service 使用普通对象和 fake clock/id generator。数据库集成用真实数据库;contract suite 黑盒运行五个实现。
const response = await app.inject({ method: "GET", url: "/technologies?limit=101" });
assert.equal(response.statusCode, 400);
assert.equal(response.json().code, "INVALID_QUERY");还要测重复 idempotency、两个并发写、pool exhausted、upstream timeout、client abort、SIGTERM in-flight 和旧/新 schema rollout。mock ORM 返回值不能证明 SQL/constraint。
配置、部署与优雅关闭
启动时验证 config,区分 public config/secret;缺失即失败,不在请求中懒读。容器以非 root、只读 rootfs、固定 Node/lock,镜像不含 dev secret/source map(除受控上传)。health 与 readiness 分开。
SIGTERM:标记 unready,停止新请求,关闭 keep-alive/idle,等待有界 in-flight,停止 consumer/worker,关闭 pool,flush 有界 telemetry,超时非零退出。Nest/Fastify lifecycle hooks 只是入口,仍需与代理 grace period 演练。
migration 使用 expand/contract:先加向后兼容 schema,发布能读新旧的代码,回填,再收缩。不能启动每个 pod 自动执行 destructive migration。回滚要考虑数据已由新版本写入。
Fastify 与 NestJS 选择
| 约束 | Fastify 直接使用 | NestJS + adapter |
|---|---|---|
| 小服务/少团队 | 更少抽象、启动快、边界可见 | 固定成本可能不值 |
| 多模块/统一组织 | plugin 足够但规范需团队约定 | module/DI/guard/filter 更一致 |
| 性能热路径 | 较直接,schema/serializer 强 | adapter/DI 有成本,需实测 |
| 团队来自 Angular/Java | 需学习 plugin/hook | decorator/DI 心智更熟悉 |
| 高度自定义 | 显式 hook/plugin | 可扩展但层次更多 |
选择 Nest 不是选择不同 Node 并发模型。底层 Fastify adapter 的 schema、request/reply 与 Express adapter 不完全相同;跨 adapter 切换需 contract/performance test。
故障排查
高频错误:TypeScript DTO 没运行时验证;response schema 丢字段;Nest request scope 滥用;Promise.all 压垮 pool;transaction 中发 HTTP;catch 后继续未知状态;重试非幂等写;SIGTERM 立即 exit;日志打印 token/body;migration 与旧版本不兼容。
版本与来源
- Fastify 5 文档、v5 Migration、LTS
- NestJS Docs、NestJS 11 Migration
- Node.js 24 API、Node Releases
- JSON Schema 2020-12、RFC 9457
交付检查
保存 Node/Fastify/Nest 精确版本、完整 schemas/OpenAPI、contract tests、plugin/module graph、pool/transaction/idempotency/outbox 证据、auth negative tests、event-loop/pool/DB trace、SIGTERM 时间线、migration expand/contract、p95/p99/CPU/RSS 与回滚。若只是 controller 和 ORM CRUD,没有这些边界,就还不是生产后端对照结果。
运行演练:过载、取消与部分响应
先建立三条只在测试环境启用的故障路径:同步计算占用主线程、数据库连接延迟、上游返回慢流。对每条固定最大持续时间和调用权限。对照结果负载记录 event-loop delay、ELU、CPU、RSS、pool wait、in-flight 与 p99;一次只注入一种故障。
同步计算时所有请求延迟一起升高,profile 应指向函数;若只看 CPU 平均可能被多核稀释。数据库慢时 event loop 未必高,但 pool wait/in-flight 上升;增加 Node 副本会乘连接并恶化。慢流时检查 backpressure:响应内存不应随完整 body×并发增长;client abort 后上游和转换是否取消。已写 header 后发生错误不能改成 JSON problem,只能结束 stream并记录稳定事件。
设置应用并发上限/队列。队列满返回429或503并按语义选择Retry-After,不继续接受到OOM。限流维度考虑IP、principal、tenant与route,但代理来源可信链要配置,不能相信任意X-Forwarded-For。全局、实例和下游bulkhead结合,避免单tenant占满pool。
API兼容与版本化
优先向后兼容演进:添加可选字段,客户端忽略未知;删除/重命名/类型改变走新版本或迁移窗口。不要把URL版本、header版本和npm package版本混用。OpenAPI diff只是提示,contract consumer测试确认真实使用。
部署新旧版本同时存在时,数据库schema和消息必须双向兼容。新增not-null字段先nullable/default+回填,再让代码写入,最后constraint;事件添加字段时consumer先兼容。response serializer schema也要允许旧实例读取新数据。回滚前检查新版本是否已写入旧代码无法解析的值。
ETag/If-None-Match用于缓存读取,If-Match/版本字段可做optimistic concurrency。409/412选择按HTTP语义统一。不要只在Node内存维护版本。分页cursor包含稳定排序键和data/schema version,验证签名/长度;更换排序时旧cursor明确过期。
进程、集群与部署拓扑
Node单进程利用一个主JavaScript线程,CPU配额大于一核不自动让handler并行。可由容器多副本或worker/cluster利用多核。优先平台副本,因为故障隔离、滚动和指标清楚;单容器多worker会乘pool并使日志/健康复杂。CPU任务才用worker pool,普通I/O请求不需worker。
每实例资源预算包含V8 heap、Buffer/native、code、worker、连接和sidecar;--max-old-space-size不是容器总内存。接近限制时heap snapshot可能造成额外OOM,预先设计采样/profile和诊断报告。uncaught exception后进程状态未知,记录并退出,让supervisor拉起;不能无限“保持可用”。
部署证据包含artifact/lock/Node/framework/schema版本、config hash(不含secret)、migration、feature flags。feature flag也需要owner/expiry,不长期保留双实现。canary比较error code/p99/ELU/RSS/pool,不只健康。
NestJS大型项目边界
按业务能力组织module,而非Controllers/Services/Repositories全局技术目录。module导出最小public providers,内部实现不被其他模块随意注入。跨module交互用明确service/interface或event,不循环forwardRef成为常态。循环依赖通常说明owner不清。
CQRS/event sourcing不是Nest应用必需。普通transactional service足够时不加command bus十层。只有读写模型、异步流程和审计确有需求才引入,并定义一致性/失败。decorator metadata方便但隐藏控制流,关键权限/事务在测试和文档显式。
供应链与运行时更新
pnpm lock frozen安装,package scripts审计,production依赖最小,插件拥有进程权限。Fastify/Nest plugin/module升级既看semver也看安全Release;Fastify LTS说明安全修复有时可能在minor带breaking变化,不能冻结易受攻击版本回避。Node LTS patch同样纳入镜像更新。
升级测试包括schema compiler/serializer、HTTP parser、auth/crypto、ORM/driver/native addon、OpenTelemetry、source map和shutdown。双版本CI与staging,保留旧镜像/lock。依赖漏洞若不可利用也要记录证据和复审日期,不永久忽略。