《TypeScript编程实战》6.3 配置与生命周期

本节讲解 NestJS 应用的两件「非业务」大事:配置如何按环境分层、用 schema 在启动时校验、并以类型安全的方式读取;生命周期钩子如何在模块与应用的启停节点执行初始化与清理,配合优雅关闭让进程在收到信号后体面退出。读完本节,你能让应用在配置错误时快速失败,也能保证关停时不丢连接、不漏任务。

本节目标:掌握 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 与类型生成 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes