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

文章来源声明: 原文作者:颜进强; 来源站点:掘金; 原文链接:https://juejin.cn/post/7690398241331560474; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

把钩子时序、默认关闭的取舍和真实落点讲透,还给出“挂了是否拒起”的决策线,适合正被启动初始化与优雅停机困扰的 NestJS 后端开发者。

> **承接上篇**:上一篇《自定义装饰器》拆完"暗号的写侧",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,再全体就绪

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>);
    }
}

两个细节值得点破:

  1. 时序自证:该钩子在所有 onModuleInit 完成后、app.listen() 绑定端口前触发——所以这条日志必然先于 main.ts 的 listening on 行。看启动日志的顺序,就能亲手验证钩子的时序定位;
  2. 构造时刻 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>);
    }
}

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

  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 数组逐个判。