定位:本篇讲五个生命周期钩子的顺序与触发条件、
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/useEffectcleanup(清定时器/关 WS),再加一个"页面要关了先把事做完"的beforeunload。记忆锚点:"初始化"管"起来之前准备好","终止"管"倒下去之前收拾干净"——先停接新请求 → 等在途请求完成 → 关连接 → 退出。
二、三个阶段:启动两步、停机镜像四步
2.1 启动:先逐模块 init,再全体就绪
app.listen() 之前:
① onModuleInit —— 每个模块的依赖一解析完就调它自己的钩子;
模块之间按 imports 顺序依次执行,前一个 <span>await</span> 完才轮下一个。
② onApplicationBootstrap —— 所有模块都 <span>init</span> 完、开始监听连接之前,调一次(全局<span>"全体就绪"</span>信号)。
app.listen() 之后:进入 running,对外服务。
OnModuleInit适合每模块内部的事:连自己的库、初始化模块资源、启动自检;OnApplicationBootstrap适合跨模块的"全体就绪后动作":种子数据、migration、预热缓存——都在接请求之前做完。
2.2 停机:镜像的"收拾干净再走"
收到 SIGTERM / 调用 app<span>.close</span>():
① onModuleDestroy —— 先各自释放模块级资源;
② beforeApplicationShutdown —— 全处理完后,准备关连接(此时还能写出去:刷缓冲/等事务);
③ (Nest 内部关闭所有现有连接)
④ onApplicationShutdown —— 连接都关完了,收最底层的尾(关 DB 连接池)。
如果某个钩子是 async,Nest 会等它 resolve/reject 完才继续下一步——整条链有序、可等待,不抢跑。②和④的分界线是**"还能不能写出去"**:刷缓冲放②(连接还在),关连接放④(连接已没)——顺序反了(先关池)就 flush 不出去了。
三、用法与两个实用细节
3.1 用法:implements OnXxx + 同名方法
<span>// 项目真实代码:config-validator.ts</span>
<span>@Injectable</span>()
<span>export</span> <span>class</span> <span>ConfigValidator</span> <span>implements</span> <span>OnModuleInit</span> {
<span>onModuleInit</span>(): <span>void</span> {
<span>this</span>.<span>assertNumericFieldsFinite</span>();
...
}
}
接口"技术上是可选的"(TS 编译后不存在),但强烈建议写 implements——换强类型 + 编辑器提示,防方法名拼错。拼错 = 静默不调,这是生命周期最常见的坑(§八 #2)。
3.2 async onModuleInit 能推迟启动——但这正是要想清楚的地方
<span>async</span> <span>onModuleInit</span>(): <span>Promise</span><<span>void</span>> {
<span>await</span> <span>this</span>.<span>fetchRemoteConfig</span>(); <span>// 配置没拉完,应用就不继续往下走</span>
}
onModuleInit 返回 Promise → Nest 等它完成才继续初始化。适合"启动前必须就绪"的资源。但反过来:别把"可用可不用"的重初始化塞进来——那会让"资源挂了 = 应用起不来"。 rag-server 在这一点上做了教科书级的取舍(§五、§六决策线②)。
四、停机三钩子 + enableShutdownHooks:为什么默认关
后三个钩子默认不触发,条件二选一:显式 app.close();或 收到系统信号 + 启动时调了 app.enableShutdownHooks()。
为什么默认关?监听系统信号会占资源、起监听器——一个 Node 进程跑多个 Nest 应用(比如 Jest 并行测试)时,监听器过多会被 Node 抱怨。所以默认不启用,要开自己开。
什么时候必须开:线上被进程管理器/编排系统管理时。 Kubernetes、PM2 这类平台靠发 SIGTERM 让应用优雅退出。没开 → 收到信号直接死,来不及关连接/等请求/刷日志;开了 → 触发 §2.2 那串有序钩子。rag-server 是 PM2 常驻,所以开了:
<span>// main.ts(真实代码,listen 之前)</span>
<span>// PM2 reload/stop 时优雅收尾:触发 OnModuleDestroy / OnApplicationShutdown 生命周期钩子,</span>
<span>// 让 Nest 先停止接收新请求、等处理中的请求完成再退出,不掐断进行中的请求。</span>
app.<span>enableShutdownHooks</span>();
收到信号时,信号名会作为第一个参数传给钩子: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 拒起"侧
<span>// config/config-validator.ts(节选)</span>
<span>@Injectable</span>()
<span>export</span> <span>class</span> <span>ConfigValidator</span> <span>implements</span> <span>OnModuleInit</span> {
<span>constructor</span>(<span><span>@Inject</span>(APP_CONFIG) <span>private</span> <span>readonly</span> config: AppConfig</span>) {}
<span>onModuleInit</span>(): <span>void</span> {
<span>this</span>.<span>assertNumericFieldsFinite</span>(); <span>// CHUNK_SIZE=abc → NaN → throw</span>
<span>this</span>.<span>assertHttpRanges</span>();
<span>this</span>.<span>assertHttpStrings</span>();
<span>this</span>.<span>warnMissingTokens</span>(); <span>// 空 AUTH_TOKEN/ADMIN_TOKEN 分别告警(10 期讲过)</span>
}
}
设计动机写在头注释里:core 的 config 数值字段全由 parseInt(process.env.X ?? default) 生成,env 设成 CHUNK_SIZE=abc 会静默变 NaN,带着 NaN 一路流到 retriever/端口监听才炸。这里在模块 init 阶段自检,非法配置 throw → Nest 中止启动——把"运行时才炸"提前到 boot。这正是"挂了该拒起"的资源放 init 钩子的标准姿势。
5.2 AppBootstrapHook:OnApplicationBootstrap 的"就绪快照"
<span>// app.bootstrap.ts(节选)</span>
<span>@Injectable</span>()
<span>export</span> <span>class</span> <span>AppBootstrapHook</span> <span>implements</span> <span>OnApplicationBootstrap</span> {
<span>private</span> <span>readonly</span> startedAt = <span>Date</span>.<span>now</span>(); <span>// 构造时 = boot 早期</span>
<span>onApplicationBootstrap</span>(): <span>void</span> {
<span>const</span> elapsed = <span>Date</span>.<span>now</span>() - <span>this</span>.<span>startedAt</span>;
<span>this</span>.<span>logger</span>.<span>log</span>(<span>`Application ready in <span>${elapsed}</span>ms · http :<span>${httpPort}</span> · `</span> +
<span>`auth=<span>${authToken ? <span>"on"</span> : <span>"OFF(dev)"</span>}</span> ... · about to listen`</span>);
}
}
两个细节值得点破:
- 时序自证:该钩子在所有
onModuleInit完成后、app.listen()绑定端口前触发——所以这条日志必然先于 main.ts 的listening on行。看启动日志的顺序,就能亲手验证钩子的时序定位; - 构造时刻 vs 钩子时刻:构造函数跑在模块树 init 阶段(boot 早期),钩子跑在"全体就绪"——
startedAt记构造、钩子里算差值,一个类里两个时点各取所需,这是"构造 ≠ 初始化完成"的活例子。
5.3 VectorDbService:OnApplicationShutdown 的"关连接"侧(项目唯一)
<span>// vector-db/vector-db.service.ts(节选)</span>
<span>@Injectable</span>()
<span>export</span> <span>class</span> <span>VectorDbService</span> <span>implements</span> <span>OnApplicationShutdown</span> {
<span>private</span> <span>conn</span>: <span>Connection</span> | <span>null</span> = <span>null</span>;
<span>async</span> <span>onApplicationShutdown</span>(): <span>Promise</span><<span>void</span>> {
<span>if</span> (<span>this</span>.<span>conn</span>) {
<span>this</span>.<span>logger</span>.<span>debug</span>(<span>"Closing LanceDB connection on shutdown"</span>);
<span>this</span>.<span>conn</span>.<span>close</span>();
<span>this</span>.<span>conn</span> = <span>null</span>;
}
}
}
配套两个设计决策(都写在头注释里):
- 连接懒建缓存,故意不塞
onModuleInit:首次调用才 connect,成功缓存;失败清空 pending、下次可重试。库里没有/打不开时,应用照常 boot,由/ready上报not_ready——"boot 不依赖外部资源"的哲学。若塞 init 钩子,库一挂整个服务起不来; - 选"类 provider"而非
useFactory返回裸对象:就是为了让生命周期钩子必然被调用(04/17 期的 provider 形态问题)。"要不要被 Nest 管生死"影响你选 provider 形态——想被回调,就让它当 DI 容器实例化的类。
5.4 DurableStrategyRegistrar:OnModuleInit 的"启动期一次性副作用"(学习 demo)
<span>// durable-demo/durable-strategy-registrar.ts(学习 demo 模块)</span>
<span>@Injectable</span>()
<span>export</span> <span>class</span> <span>DurableStrategyRegistrar</span> <span>implements</span> <span>OnModuleInit</span> {
<span>onModuleInit</span>(<span></span>) {
<span>ContextIdFactory</span>.<span>apply</span>(<span>AggregateByTenantContextIdStrategy</span>); <span>// 25 期的多租户策略注册</span>
}
}
把"策略注册"放 onModuleInit(启动期、listen 之前)而不是模块顶层代码——时机交给框架,顺序可预期。这是学习 demo 不计入业务统计,但它是"钩子不只管资源,也管'启动期一次性副作用'"的又一形态。
5.5 RagService:OnModuleInit / OnApplicationBootstrap 的"最小样本"(业务侧)
<span>// rag/rag.service.ts(节选)</span>
<span>@Injectable</span>()
<span>export</span> <span>class</span> <span>RagService</span> {
<span>async</span> <span>onModuleInit</span>(<span></span>) {
<span>console</span>.<span>log</span>(<span>"RagService onModuleInit"</span>);
}
<span>async</span> <span>onApplicationBootstrap</span>(<span></span>) {
<span>console</span>.<span>log</span>(<span>"RagService onApplicationBootstrap"</span>);
}
}
这是全库唯一一处业务类挂钩子的落点,也是本篇最朴素的一条证据链,值得单独点破两件事:
- 它没有
implements OnModuleInit——Nest 判定"你要不要这个钩子"靠的是实例上有没有同名方法(约定式),接口只是给 TS 做签名检查。和 10 期DebugGuard不写@Injectable()照样当全局守卫是同一类"约定 > 声明"的机制; - 两行
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 模块是应用活到一半才出生,两者都错过了那套仪式。
八、常见坑
- 忘了
enableShutdownHooks()→ 线上收到SIGTERM直接死,OnApplicationShutdown白写(不触发); - 方法名拼错 → 不写
implements又拼成onModuleInIt,静默不调。写implements靠编译器兜住; - 重初始化塞
OnModuleInit→ "资源挂了 = 应用起不来"。可用性敏感的走懒连 + 探针(§5.3); - 给 request-scoped 类写钩子 → 不触发(§边界一);
- 在懒加载模块里依赖启动钩子 → 不触发(§边界二),
load()后手动初始化; - 以为
app.close()会退出进程 → 不会;有定时器吊着就不退,要真退自己process.exit(); - 用
useFactory返回裸对象、却期待它被生命周期回调 → 不保证;要"被管理生死"就当类 provider(§5.3); - 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 题(先自己答,再看答案):
- 五个钩子的顺序和触发时点?哪三个默认不触发?→
OnModuleInit(模块依赖解析完)→OnApplicationBootstrap(全模块 init 完、监听前)→ 运行 →OnModuleDestroy→BeforeApplicationShutdown→OnApplicationShutdown。后三个只在app.close()或"开 hooks + 系统信号"时触发。 - 为什么
enableShutdownHooks()默认不开?rag-server 为什么开?→ 监听信号占资源起监听器,一个进程多个 Nest 实例(Jest 并行)会被 Node 抱怨。rag-server 是 PM2 常驻,reload/stop 发信号时想优雅收尾(停接新请求、等在途完成、关连接),main.ts 开了。 VectorDbService为什么连接用懒建而不是onModuleInit里 connect?又为什么选类 provider?→ 懒建缓存:库挂了应用照常 boot、/ready报not_ready、失败可重试;塞 init 则库一挂起不来。类 provider:Nest 生命周期钩子必然被调用,useFactory裸对象不保证——"要被管理生死"影响 provider 形态选择。- 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 数组逐个判。
把钩子时序、默认关闭的取舍和真实落点讲透,还给出“挂了是否拒起”的决策线,适合正被启动初始化与优雅停机困扰的 NestJS 后端开发者。