本节目标:掌握 NestJS 的配置管理全流程——用
@nestjs/config按环境分层加载、用 Zod schema 在启动时校验、用registerAs命名空间与泛型get做到类型安全读取;掌握OnModuleInit/OnApplicationShutdown等生命周期钩子的执行时机,并能结合enableShutdownHooks实现优雅关闭。读完本节,你能让应用「配置错就起不来、收到信号就体面退出」。
6.3 配置与生命周期
前两节讲的是「请求进来之后」的事。但一个服务还有两段生命周期同样关键:启动时如何拿到正确的配置、关停时如何不丢数据。这两件事做不好,线上就会以最难受的方式暴露问题——配置写错却在运行半小时后才崩,或者滚动发布时正在处理的请求被硬切断。本节把它们一起收束。
6.3.1 配置从哪来:分层加载
配置的第一原则是分层:默认值 < 环境文件 < 环境变量 < 命令行。越靠后的优先级越高,这样同一份代码能在本地、测试、生产用不同参数运行。第 2 章 环境变量与配置的类型化 已经从通用角度讲过这套思路,这里看它在 NestJS 里的落地。
先装依赖并注册 ConfigModule:
pnpm add @nestjs/config
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // 全应用可注入 ConfigService
envFilePath: [`.env.${process.env.NODE_ENV ?? "development"}`, ".env"],
cache: true, // 缓存读取结果,避免重复解析
}),
],
})
export class AppModule {}
envFilePath 是数组,从左到右优先级递减:先加载 .env.production,再用 .env 兜底。这样提交一份公共 .env,各环境再补差异项即可。
6.3.2 启动即校验:让配置错误快速失败
默认情况下 ConfigModule 不会校验变量是否存在——少写一个 DATABASE_URL,应用照样启动,直到第一次访问数据库才报错,而那时你可能已经发布了。用 schema 在启动时校验,让错误提前到进程启动那一刻:
import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
});
export type Env = z.infer<typeof envSchema>;
接进 ConfigModule:
ConfigModule.forRoot({
isGlobal: true,
validate: (raw: Record<string, unknown>) => {
const parsed = envSchema.safeParse(raw);
if (!parsed.success) {
throw new Error(`环境变量校验失败:\n${parsed.error.toString()}`);
}
return parsed.data;
},
});
现在若漏配 JWT_SECRET,启动会立刻失败并打印:
Error: 环境变量校验失败:
ZodError: [
{ "code": "too_small", "minimum": 32, "path": ["JWT_SECRET"], "message": "String must contain at least 32 character(s)" }
]
注意 z.coerce.number()——环境变量永远是字符串,coerce 负责转成数字;而 z.infer 让 Env 类型和运行时校验共用一份真源,杜绝「类型写的是 number、实际拿到的是字符串」这类裂缝。这套「启动即校验」的思路与 TypeScript 严格配置
一脉相承:把错误从运行时前移到启动时。
6.3.3 类型安全地读取配置
最朴素的读取方式是 configService.get("PORT"),但它返回 string | undefined,拿不到类型信息。有三个递进的改善手段。
手段一:泛型 + 默认值
const port = configService.get<number>("PORT", 3000);
手段二:registerAs 命名空间,把相关配置聚成一个对象:
import { registerAs } from "@nestjs/config";
export const databaseConfig = registerAs("database", () => ({
url: process.env.DATABASE_URL!,
poolSize: Number(process.env.DB_POOL_SIZE ?? 10),
}));
注册后在模块里 load: [databaseConfig],读取时带命名空间前缀:
const url = configService.get<string>("database.url");
手段三:自定义类型化包装,把命名空间的类型写死,彻底消灭字符串路径:
@Injectable()
export class AppConfigService {
constructor(private readonly config: ConfigService) {}
get databaseUrl(): string {
return this.config.getOrThrow<string>("DATABASE_URL");
}
get port(): number {
return this.config.get<number>("PORT", 3000);
}
}
用 getOrThrow 而不是 get,可以让「必填项缺失」在读取点立即抛错,而不是悄悄返回 undefined 一路传下去。建议业务代码只依赖这种包装类,而不是直接用 ConfigService,这样配置的键名与类型都集中在一处,重构时不用全库搜索字符串。
6.3.4 动态模块与异步配置
当模块的初始化依赖配置(比如数据库连接串),就需要异步注册。NestJS 的约定是提供 forRootAsync,它接受 useFactory 与 inject:
@Module({
imports: [
DatabaseModule.forRootAsync({
inject: [AppConfigService],
useFactory: (cfg: AppConfigService) => ({
url: cfg.databaseUrl,
poolSize: 10,
}),
}),
],
})
export class AppModule {}
forRootAsync 背后是动态模块:模块类上的 @Module 装饰器可以返回一个对象(而非静态字面量),从而在运行时决定 providers 与 exports。这也是第三方库(如 TypeOrmModule、BullModule)统一暴露的配置入口。第 7 章 Prisma schema 与类型生成
的数据访问层也会沿用同样的异步注册模式。
6.3.5 生命周期钩子
NestJS 在应用启停的各个节点会调用实现了对应接口的 provider。按执行顺序排列:
| 钩子 | 时机 | 典型用途 |
|---|---|---|
OnModuleInit | 模块依赖全部就绪后 | 建立连接、预热缓存 |
OnApplicationBootstrap | 所有模块初始化完成 | 启动后台任务 |
OnModuleDestroy | 收到关闭信号后 | 释放本模块资源 |
beforeApplicationShutdown | 关闭前(连接仍可用) | 停止接收新任务 |
OnApplicationShutdown | 所有连接关闭后 | 最终清理 |
一个真实例子:连接池需要在模块初始化时建立,在关闭时释放。
import {
Injectable,
OnModuleInit,
OnApplicationShutdown,
Logger,
} from "@nestjs/common";
@Injectable()
export class DatabaseService implements OnModuleInit, OnApplicationShutdown {
private readonly logger = new Logger(DatabaseService.name);
private pool?: Pool;
constructor(private readonly config: AppConfigService) {}
async onModuleInit() {
this.pool = await createPool(this.config.databaseUrl);
this.logger.log("数据库连接池已就绪");
}
async onApplicationShutdown(signal?: string) {
this.logger.log(`收到 ${signal},正在关闭连接池`);
await this.pool?.end();
}
}
钩子的执行顺序是有保证的:onModuleInit 从被依赖的模块开始(叶子先、根后),而 onApplicationShutdown 顺序相反(根先、叶子后)。这正好符合「后创建的先销毁」的资源管理直觉。
6.3.6 优雅关闭
写了钩子还不够——默认情况下 NestJS 不监听系统信号,onApplicationShutdown 根本不会被触发。必须显式开启:
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks(); // 监听 SIGTERM / SIGINT
await app.listen(3000);
}
bootstrap();
开启后,进程收到 SIGTERM(Docker / K8s 停止容器的默认信号)时会依次执行各模块的关闭钩子,再退出。这对滚动发布至关重要:容器先停止接收新流量,等在途请求处理完,再断开数据库连接,最后退出。
几个必须注意的坑:
- 只调用一次
listen不够,enableShutdownHooks是独立的开关,漏掉它钩子形同虚设。 - 钩子里不要执行无限期等待的操作,否则进程永远退不出去,最终被
SIGKILL强杀,反而丢失数据。 - 健康检查要配合:K8s 的 readiness 探针应在关闭前先转为不健康,让流量先撤走。这部分与第 5 章 优雅关闭与健康检查 讲的是同一套机制,NestJS 里只是换成了钩子的形态。
完整的容器化关停流程(信号传递、超时时间、探针配置)可延伸阅读 优雅关闭与健康检查 。
6.3.7 环境分层与特性开关
配置不止是数据库连接串。真实项目还需要特性开关(feature flag)——让同一份构建在不同环境打开不同功能,从而把「发布」与「启用」解耦。最简单的做法是把开关也纳入配置:
// .env.production
FEATURE_NEW_CHECKOUT=true
FEATURE_BETA_DASHBOARD=false
const flags = z.object({
FEATURE_NEW_CHECKOUT: z.coerce.boolean().default(false),
FEATURE_BETA_DASHBOARD: z.coerce.boolean().default(false),
});
但要小心 z.coerce.boolean() 的陷阱:非空字符串一律为 true,所以 FEATURE_X=false 也会得到 true。稳妥写法是显式判断:
const boolFlag = z
.enum(["true", "false"])
.default("false")
.transform((v) => v === "true");
当开关变多,就该引入专门的配置中心或特性开关服务,把「配置」与「代码」进一步解耦,思路可参考 配置管理与特性开关 。分层配置的通用设计(跨语言)也可对照 Go 环境变量配置分层 阅读,原理完全相通。
小结
- 配置要分层:默认值 < 环境文件 < 环境变量 < 命令行;
envFilePath数组从左到右优先级递减,公共.env兜底、各环境补差异。 - 启动即校验:用 Zod(或 Joi)schema 在
validate里校验环境变量,配置写错就让进程起不来,而不是运行半小时后崩。 - 类型安全读取有三招:泛型
get<T>、registerAs命名空间、自定义包装类;业务代码优先依赖包装类,键名与类型集中一处,杜绝字符串满天飞。 z.coerce是双刃剑:数字转换很好用,但z.coerce.boolean()会把"false"也当成true,布尔开关务必用enum+transform显式判断。- 异步配置用
forRootAsync,它基于动态模块,让 provider 在运行时按配置装配,这也是第三方库统一暴露的入口。 - 生命周期钩子顺序有保证:初始化从叶子到根,关闭从根到叶子,正好是「后创建的先销毁」。
enableShutdownHooks必须显式开启,否则onApplicationShutdown永不触发;钩子内切忌无限期等待,否则进程会被SIGKILL强杀。- 至此第 6 章收束:模块与 DI 决定装配、流水线决定请求处理、配置与生命周期决定启停。下一章 Prisma schema 与类型生成 起,我们进入数据访问层,把这些装配好的服务真正接上数据库。
阅读导航:上一节:6.2 管道、守卫与拦截器 · 下一节:7.1 Prisma schema 与类型生成 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。