16 · NestJS LifecycleEvents 生命周期事件:五个钩子、三条边界,和 rag-server 的优雅停机

承接上篇 :上一篇《自定义装饰器》拆完"暗号的写侧",09--15 期的请求管线闭环了。但专栏开头(08 期)那张生命周期图,其实只画了"一个请求进来之后"的半张------另一半是应用自身 的生老病死:启动时连库/自检、停机时关连接/等在途请求。 这篇拆它:Nest 用五个钩子把"开机/运行/关机"三个时点交给你写代码,而 rag-server 恰好把启动钩子和停机钩子各用了一个真实落点。
定位 :本篇讲五个生命周期钩子的顺序与触发条件、enableShutdownHooks 为什么默认关、rag-server 五个真实钩子落点(ConfigValidator fail-fast / RagService 时序打点 / AppBootstrapHook 就绪快照 / VectorDbService 停机关连接 / DurableStrategyRegistrar 启动注册),以及三条新手边界(request-scoped 无钩子 / 懒加载模块无钩子 / app.close() 不退进程)。不讲 DI 细节(04 期)、不讲懒加载机制(24 期)。读完你能回答"我的 XX 初始化/收尾该放哪个钩子,失败了该不该让应用起不来"。

一、一句话回答 + 五个钩子总表

Nest 把应用的整个生命周期分成"初始化 → 运行 → 终止"三阶段,在关键时点按固定顺序调用你注册的钩子方法。 你只要让类 implements 对应的接口(模块/provider/controller 都行),Nest 到点就会调。

钩子接口 触发时机 阶段
OnModuleInit 宿主模块的依赖都解析完后,调用一次 初始化
OnApplicationBootstrap 所有模块都初始化完 ,但还没开始监听连接时 初始化 → 运行交界
OnModuleDestroy * 收到终止信号(如 SIGTERM)后 终止
BeforeApplicationShutdown * 所有 OnModuleDestroy 处理完后;它完成后 Nest 关闭现有连接 终止
OnApplicationShutdown * 连接关闭后 终止

(* 的三个只在"显式调用 app.close()"或"开启 shutdown hooks 后收到系统信号"时触发,见 §四。)

前端对照:初始化 ≈ 组件 mount + 首次数据加载;终止 ≈ componentWillUnmount/useEffect cleanup(清定时器/关 WS),再加一个"页面要关了先把事做完"的 beforeunload。记忆锚点:"初始化"管"起来之前准备好","终止"管"倒下去之前收拾干净"------先停接新请求 → 等在途请求完成 → 关连接 → 退出。

二、三个阶段:启动两步、停机镜像四步

2.1 启动:先逐模块 init,再全体就绪

csharp 复制代码
app.listen() 之前:
① onModuleInit          ------ 每个模块的依赖一解析完就调它自己的钩子;
                          模块之间按 imports 顺序依次执行,前一个 await 完才轮下一个。
② onApplicationBootstrap ------ 所有模块都 init 完、开始监听连接之前,调一次(全局"全体就绪"信号)。

app.listen() 之后:进入 running,对外服务。
  • OnModuleInit 适合每模块内部的事:连自己的库、初始化模块资源、启动自检;
  • OnApplicationBootstrap 适合跨模块的"全体就绪后动作":种子数据、migration、预热缓存------都在接请求之前做完。

2.2 停机:镜像的"收拾干净再走"

scss 复制代码
收到 SIGTERM / 调用 app.close():
① onModuleDestroy          ------ 先各自释放模块级资源;
② beforeApplicationShutdown ------ 全处理完后,准备关连接(此时还能写出去:刷缓冲/等事务);
③ (Nest 内部关闭所有现有连接)
④ onApplicationShutdown    ------ 连接都关完了,收最底层的尾(关 DB 连接池)。

如果某个钩子是 async,Nest 会等它 resolve/reject 完才继续下一步------整条链有序、可等待,不抢跑。②和④的分界线是**"还能不能写出去"**:刷缓冲放②(连接还在),关连接放④(连接已没)------顺序反了(先关池)就 flush 不出去了。

三、用法与两个实用细节

3.1 用法:implements OnXxx + 同名方法

ts 复制代码
// 项目真实代码:config-validator.ts
@Injectable()
export class ConfigValidator implements OnModuleInit {
    onModuleInit(): void {
        this.assertNumericFieldsFinite();
        ...
    }
}

接口"技术上是可选的"(TS 编译后不存在),但强烈建议写 implements ------换强类型 + 编辑器提示,防方法名拼错。拼错 = 静默不调,这是生命周期最常见的坑(§八 #2)。

3.2 async onModuleInit 能推迟启动------但这正是要想清楚的地方

ts 复制代码
async onModuleInit(): Promise<void> {
    await this.fetchRemoteConfig();   // 配置没拉完,应用就不继续往下走
}

onModuleInit 返回 Promise → Nest 等它完成才继续初始化。适合"启动前必须就绪"的资源。但反过来:别把"可用可不用"的重初始化塞进来------那会让"资源挂了 = 应用起不来"。 rag-server 在这一点上做了教科书级的取舍(§五、§六决策线②)。

四、停机三钩子 + enableShutdownHooks:为什么默认关

后三个钩子默认不触发,条件二选一:显式 app.close() ;或 收到系统信号 + 启动时调了 app.enableShutdownHooks()。

为什么默认关?监听系统信号会占资源、起监听器------一个 Node 进程跑多个 Nest 应用(比如 Jest 并行测试)时,监听器过多会被 Node 抱怨。所以默认不启用,要开自己开。

什么时候必须开:线上被进程管理器/编排系统管理时。 Kubernetes、PM2 这类平台靠发 SIGTERM 让应用优雅退出。没开 → 收到信号直接死,来不及关连接/等请求/刷日志;开了 → 触发 §2.2 那串有序钩子。rag-server 是 PM2 常驻,所以开了:

ts 复制代码
// main.ts(真实代码,listen 之前)
// PM2 reload/stop 时优雅收尾:触发 OnModuleDestroy / OnApplicationShutdown 生命周期钩子,
// 让 Nest 先停止接收新请求、等处理中的请求完成再退出,不掐断进行中的请求。
app.enableShutdownHooks();

收到信号时,信号名会作为第一个参数传给钩子:onApplicationShutdown(signal: string) 里能拿到 "SIGTERM"/"SIGINT"。

最容易被坑的点:app.close() 只触发钩子,不会自己退出进程。 它只负责走钩子链 + 关 HTTP server。如果还有 setInterval、长驻任务这类让事件循环保持活跃的东西,进程不退------要真退自己 process.exit(),或把句柄清干净让 Node 自然退。

前端直觉:"收拾顺序" ≈ beforeunload 里先关 WebSocket、再清定时器、再 flush 日志------Nest 把顺序定死并逐个 await,你只管写"我这层收拾什么"。"close 不退进程" ≈ 页面上清完资源但还有个 setInterval 吊着,浏览器就不会真正销毁。

五、项目落地:五个真实钩子落点逐个拆

rag-server 现在有五个类 挂了生命周期钩子(其中 OnModuleInit 三处:5.1 / 5.4 / 5.5),每个都踩在钩子的真实语义上:

5.1 ConfigValidator:OnModuleInit 的"fail-fast 拒起"侧

ts 复制代码
// config/config-validator.ts(节选)
@Injectable()
export class ConfigValidator implements OnModuleInit {
    constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {}

    onModuleInit(): void {
        this.assertNumericFieldsFinite();  // CHUNK_SIZE=abc → NaN → throw
        this.assertHttpRanges();
        this.assertHttpStrings();
        this.warnMissingTokens();          // 空 AUTH_TOKEN/ADMIN_TOKEN 分别告警(10 期讲过)
    }
}

设计动机写在头注释里:core 的 config 数值字段全由 parseInt(process.env.X ?? default) 生成,env 设成 CHUNK_SIZE=abc 会静默变 NaN ,带着 NaN 一路流到 retriever/端口监听才炸。这里在模块 init 阶段自检,非法配置 throw → Nest 中止启动------把"运行时才炸"提前到 boot。这正是"挂了该拒起"的资源放 init 钩子的标准姿势。

5.2 AppBootstrapHook:OnApplicationBootstrap 的"就绪快照"

ts 复制代码
// app.bootstrap.ts(节选)
@Injectable()
export class AppBootstrapHook implements OnApplicationBootstrap {
    private readonly startedAt = Date.now();   // 构造时 = boot 早期

    onApplicationBootstrap(): void {
        const elapsed = Date.now() - this.startedAt;
        this.logger.log(`Application ready in ${elapsed}ms · http :${httpPort} · ` +
            `auth=${authToken ? "on" : "OFF(dev)"} ... · about to listen`);
    }
}

两个细节值得点破:

  1. 时序自证 :该钩子在所有 onModuleInit 完成后、app.listen() 绑定端口前触发------所以这条日志必然先于 main.ts 的 listening on 行。看启动日志的顺序,就能亲手验证钩子的时序定位;
  2. 构造时刻 vs 钩子时刻 :构造函数跑在模块树 init 阶段(boot 早期),钩子跑在"全体就绪"------startedAt 记构造、钩子里算差值,一个类里两个时点各取所需,这是"构造 ≠ 初始化完成"的活例子。

5.3 VectorDbService:OnApplicationShutdown 的"关连接"侧(项目唯一)

ts 复制代码
// vector-db/vector-db.service.ts(节选)
@Injectable()
export class VectorDbService implements OnApplicationShutdown {
    private conn: Connection | null = null;

    async onApplicationShutdown(): Promise<void> {
        if (this.conn) {
            this.logger.debug("Closing LanceDB connection on shutdown");
            this.conn.close();
            this.conn = null;
        }
    }
}

配套两个设计决策(都写在头注释里):

  • 连接懒建缓存,故意不塞 onModuleInit :首次调用才 connect,成功缓存;失败清空 pending、下次可重试。库里没有/打不开时,应用照常 boot ,由 /ready 上报 not_ready------"boot 不依赖外部资源"的哲学。若塞 init 钩子,库一挂整个服务起不来;
  • 选"类 provider"而非 useFactory 返回裸对象 :就是为了让生命周期钩子必然被调用(04/17 期的 provider 形态问题)。"要不要被 Nest 管生死"影响你选 provider 形态------想被回调,就让它当 DI 容器实例化的类。

5.4 DurableStrategyRegistrar:OnModuleInit 的"启动期一次性副作用"(学习 demo)

ts 复制代码
// durable-demo/durable-strategy-registrar.ts(学习 demo 模块)
@Injectable()
export class DurableStrategyRegistrar implements OnModuleInit {
    onModuleInit() {
        ContextIdFactory.apply(AggregateByTenantContextIdStrategy);  // 25 期的多租户策略注册
    }
}

把"策略注册"放 onModuleInit(启动期、listen 之前)而不是模块顶层代码------时机交给框架,顺序可预期。这是学习 demo 不计入业务统计,但它是"钩子不只管资源,也管'启动期一次性副作用'"的又一形态。

5.5 RagService:OnModuleInit / OnApplicationBootstrap 的"最小样本"(业务侧)

ts 复制代码
// rag/rag.service.ts(节选)
@Injectable()
export class RagService {
    async onModuleInit() {
        console.log("RagService onModuleInit");
    }

    async onApplicationBootstrap() {
        console.log("RagService onApplicationBootstrap");
    }
}

这是全库唯一一处业务类挂钩子的落点,也是本篇最朴素的一条证据链,值得单独点破两件事:

  1. 它没有 implements OnModuleInit ------Nest 判定"你要不要这个钩子"靠的是实例上有没有同名方法 (约定式),接口只是给 TS 做签名检查。和 10 期 DebugGuard 不写 @Injectable() 照样当全局守卫是同一类"约定 > 声明"的机制;
  2. 两行 console.log 的价值在启动日志里 :RagService onModuleInit 必然出现在 Application ready in Xms ... about to listen(5.2 的钩子)之前------启动期两站"每模块 init → 全体就绪"的顺序,不用读源码,看日志就能亲眼验证(这是 2026-09-09 懒加载落地时留下的最小示例)。

三个 OnModuleInit 落点正好是三种用法:ConfigValidator 拒起(fail-fast)、DurableStrategyRegistrar 一次性副作用、RagService 打点验证时序------同一个钩子,三种语义,选哪种取决于"失败了要不要把整个应用拖下水"。

五个落点连起来读 :ConfigValidator(拒起)→ RagService(时序打点)→ AppBootstrapHook(就绪快照)→ 运行 → VectorDbService(停机关连接)------启动三钩子用了前两个(OnModuleInit 在每模块 init 期、OnApplicationBootstrap 在全模块 init 完),停机用了最后一个;OnModuleDestroy/BeforeApplicationShutdown 项目没写(没有"关连接前要 flush 的缓冲",走默认即可)。

六、场景决策:你的需求该放哪个钩子

判断标准只有一条:这个动作依赖什么就绪了、失败了该不该让应用起不来。

6.1 作用域线(启动半场)

需求 放哪 理由
连数据库 / 建连接池 OnModuleInit 模块私有硬依赖
启动本模块 cron OnModuleInit 调度归本模块
灌种子数据 / 跑 migration OnApplicationBootstrap 常要注入别的模块的 service,只有"全体 init 完"才有资格;且必须在接请求前做完
预热缓存 / 订阅 Pub/Sub OnApplicationBootstrap 跨模块数据,serve 前热好

6.2 可用性线(再滤一道):挂了拒起,还是挂了也活着?

  • 应该拒起 (配置坏了、必连资源没了)→ init 钩子 + async 阻塞 + 坏就 throw。rag-server 实例:ConfigValidator;
  • 不该拒起 (资源没了别的活还能干,可用性交给探针)→ 懒连 + /ready。rag-server 实例:VectorDbService。

同一个应用里两种哲学并存 ------ConfigValidator fail-fast、VectorDbService 懒连,选哪边取决于"这资源挂了,应用还算不算活着"。rag-server 的答案:配置坏了不算活着(拒起),向量库暂时没有还活着(探针上报)。

七、三条让新手懵的边界

边界一:request-scoped 类没有生命周期钩子。 官方明确:钩子不作用于 request-scoped 类------它们每请求一个、响应后即 GC,不绑应用生命周期。请求级类的"初始化"就是构造本身。→ 22 期《InjectionScopes》会展开:REQUEST scope 换来的每请求新实例,代价之一就是退出应用级生命周期。

边界二:懒加载模块没有生命周期钩子。 懒加载模块是应用已经启动之后 才被塞进模块图的,错过了启动那一轮钩子调用 。rag-server 的 indexing.service.ts 头注释原话:"生命周期钩子(OnModuleInit 等)不会执行------初始化只能靠调用方显式触发"。→ 24 期《LazyLoadingModules》展开。

边界三:app.close() 不终止进程。(§四,最容易线上踩。)

一句话收三条:"应用级生命周期钩子"只对"跟着应用一起出生、一起走"的对象有意义------request-scoped 对象每请求出生死,lazy 模块是应用活到一半才出生,两者都错过了那套仪式。

八、常见坑

  1. 忘了 enableShutdownHooks() → 线上收到 SIGTERM 直接死,OnApplicationShutdown 白写(不触发);
  2. 方法名拼错 → 不写 implements 又拼成 onModuleInIt,静默不调 。写 implements 靠编译器兜住;
  3. 重初始化塞 OnModuleInit → "资源挂了 = 应用起不来"。可用性敏感的走懒连 + 探针(§5.3);
  4. 给 request-scoped 类写钩子 → 不触发(§边界一);
  5. 在懒加载模块里依赖启动钩子 → 不触发(§边界二),load() 后手动初始化;
  6. 以为 app.close() 会退出进程 → 不会;有定时器吊着就不退,要真退自己 process.exit();
  7. 用 useFactory 返回裸对象、却期待它被生命周期回调 → 不保证;要"被管理生死"就当类 provider(§5.3);
  8. Windows 上期待 SIGTERM 生效 → 平台限制,SIGTERM 在 Windows 上永远不会被应用感知;SIGINT/SIGBREAK 可用。

九、前端心智对照 + 自测

前端概念 对应 Nest Lifecycle 本质
componentDidMount onModuleInit 依赖就绪后初始化自己
全树 mount 完再干的事 onApplicationBootstrap 全体就绪、监听前
componentWillUnmount / cleanup onModuleDestroy 先释放各模块资源
beforeunload(先发埋点再关 WS) BeforeApplicationShutdown → OnApplicationShutdown 优雅收尾,"还能不能写出去"
挂个 setInterval 页面销毁不了 app.close() 不退进程 事件循环还有活
非关键 SDK 挂了不该白屏 重初始化别塞 init 钩子 降级 + 探针,而非连带崩

自测 4 题(先自己答,再看答案):

  1. 五个钩子的顺序和触发时点?哪三个默认不触发? → OnModuleInit(模块依赖解析完)→ OnApplicationBootstrap(全模块 init 完、监听前)→ 运行 → OnModuleDestroy → BeforeApplicationShutdown → OnApplicationShutdown。后三个只在 app.close() 或"开 hooks + 系统信号"时触发。

  2. 为什么 enableShutdownHooks() 默认不开?rag-server 为什么开? → 监听信号占资源起监听器,一个进程多个 Nest 实例(Jest 并行)会被 Node 抱怨。rag-server 是 PM2 常驻,reload/stop 发信号时想优雅收尾(停接新请求、等在途完成、关连接),main.ts 开了。

  3. VectorDbService 为什么连接用懒建而不是 onModuleInit 里 connect?又为什么选类 provider? → 懒建缓存:库挂了应用照常 boot、/ready 报 not_ready、失败可重试;塞 init 则库一挂起不来。类 provider:Nest 生命周期钩子必然被调用,useFactory 裸对象不保证------"要被管理生死"影响 provider 形态选择。

  4. request-scoped 类和懒加载模块为什么都没有钩子?rag-server 里有现成证据吗? → 钩子是"应用级生命周期"的回调:前者每请求生死、后者启动后才出生,都错过了那套仪式。证据:rag-server 的 IndexingService(懒加载)头注释明写"生命周期钩子不会执行,初始化只能靠调用方显式触发"。

十、主线收官与下一篇

生命周期是"非请求驱动"的最后一块横切拼图:它讲的不是"一个请求怎么流过管线"(08--15 期),而是"承载管线的应用本身怎么生、怎么死"。至此主线二收口------请求管线(09--15)+ 应用生命周期(16),一张图的两半都齐了 :rag-server 五个钩子落点(ConfigValidator 拒起 / RagService 时序打点 / AppBootstrapHook 就绪快照 / DurableStrategyRegistrar 注册 / VectorDbService 关连接)加一句 enableShutdownHooks(),就是这套知识的最小完备实证。

下一篇起进入主线三(DI 与模块系统进阶,难度开始爬坡)。17 期《自定义 Provider 四形态》先接住本篇埋的那根线:useValue/useFactory/useClass/别名------为什么"要被生命周期回调"就必须是类 provider?为什么 APP_CONFIG 用 useValue、AUTH_TOKENS 用 useFactory、VectorDbService 用普通类?四形态的选择红线,下篇用 rag-server 的真实 providers 数组逐个判。

相关推荐
VIP_CQCRE1 小时前
把 OpenCode 接入 Ace Data Cloud:让 VS Code、Cursor、Windsurf 统一调用 AI 编程模型
vscode·大模型·ai编程·opencode·acedatacloud
GetcharZp2 小时前
定位不再一个个吐坐标:英伟达 LocateAnything 上手全攻略
后端
今年下半年3 小时前
【VUE】整合腾讯地图、自定义区域边界、村委名称及资产统计(放大显示资产点位)
前端·javascript·vue.js
郑州光合科技余经理3 小时前
同城电商系统:库存变更怎么同步到订单
java·开发语言·前端·后端·uni-app·php·ai编程
我叫黑大帅3 小时前
Go日志库工程选型与逃逸分析评测报告
后端·面试·go
三水写代码3 小时前
手写一个 Claude Code(1):从 Agent Loop 到工具、权限、Hooks 与任务规划
python·ai编程·claude·ai agent·claudecode
AI砖家4 小时前
Claude Code Skill 质量检查实战:用 /skill-doctor + Plugin Evals 找出“看似能用、实际没被调用“的问题
人工智能·ai编程·claude·codex·skill
前端snow4 小时前
ai agent --- DeepAgents 中间件
前端