Skip to content

J7 · 配置与日志 ​

第 7 天 · 11 月 18 日周三 · 交付物:带校验的 ConfigModule,按环境区分 env

Spring Boot 开箱就有 application.yml、profile、@ConfigurationProperties 和 Logback。在 Nest 里,配置只是一个普通模块 (@nestjs/config),日志则挂接到应用上。今天 TaskFlow 要做到:按 NODE_ENV 选择 .env 文件;变量缺失或格式不对就 拒绝启动;按领域提供带类型的配置;输出带 correlationId 的 JSON 日志;收到 SIGTERM 时正常关闭。

目标 ​

  • 用 ConfigModule.forRoot({ isGlobal, validate }) 加载配置,并清楚每个值从哪里来。
  • 用 registerAs 按领域组织配置,并以带类型的方式注入。
  • 启动时校验环境变量(TaskFlow 用 class-validator,Joi 是替代方案)。
  • 用 NODE_ENV 和 .env.* 文件取代 Spring 的 profile。
  • 用 nestjs-pino 输出结构化 JSON 日志,并理解 Nest 的 Logger。
  • 使用生命周期钩子(OnModuleInit、OnApplicationShutdown)和 enableShutdownHooks()。

Spring → NestJS ​

SpringNestJS要点
application.yml、application-{profil}.yml由 ConfigModule.forRoot() 加载的 .env 和 .env.${NODE_ENV}扁平的 键=值 文件,没有层级,也没有 YAML 合并
spring.profiles.active=devNODE_ENV=development + .env.development同一时间只有一个“profile”,TaskFlow 只接受三个值
@Value("${jwt.secret}")config.get('JWT_SECRET')声明了 schema 时值经过校验,否则是原始字符串
@ConfigurationProperties(prefix = "database")registerAs('database', () => ({ ... })) + ConfigType映射要手写,没有自动绑定
属性类上的 @Validatedvalidate: validateEnv(class-validator)或 validationSchema启动时就失败,而不是第一次读取时
@Profile("dev")、@ConditionalOnPropertyConditionalModule.registerWhen()、useFactory provider在模块里显式选择
SLF4J + Logback、MDCNest 的 Logger + nestjs-pino、AsyncLocalStorage没有 ThreadLocal:上下文跟着异步调用链走
@PostConstructonModuleInit()(OnModuleInit)可以是 async:Nest 会等待这个 Promise
@PreDestroyonModuleDestroy()(OnModuleDestroy)只有经由 Nest 关闭时才会调用
JVM 关闭钩子,Spring Boot 默认开启app.enableShutdownHooks()需要显式开启

原理 ​

ConfigModule.forRoot ​

ts
// application.yml + spring.profiles.active: .env.development, .env.test...
// picked by NODE_ENV, then checked by validateEnv before anything starts.
ConfigModule.forRoot({
  isGlobal: true,
  cache: true,
  envFilePath: [`.env.${process.env.NODE_ENV ?? 'development'}`, '.env'],
  load: [databaseConfig, jwtConfig],
  validate: validateEnv,
}),

启动时,forRoot() 依次做这些事:

  1. 读取 envFilePath 中的文件。同一个变量出现在多个文件里时,第一个生效:.env.development 优先于 .env。 不存在的文件会被跳过。
  2. 把结果和 process.env 合并。真实环境里(Docker、CI、shell)已经定义的变量优先于文件,除非设置了 override: true。
  3. 把合并后的对象交给 validate()。如果这个函数抛错,应用就不会启动。
  4. 把校验后的值写回 process.env,只写那些还不存在的键:schema 里的默认值因此在任何地方都可见。
  5. 注册 ConfigService 以及 load 里列出的命名空间。

isGlobal: true 让 ConfigService 在所有模块里都能注入,不用重复导入 ConfigModule(类似 @Global 模块,见 J3)。cache: true 会缓存读取结果。在 @nestjs/config 12 里,forRoot() 返回一个 Promise:不用做任何改动,Nest 会在 imports 里等待它。

ConfigService.get(键) 的查找顺序是:先找已加载的命名空间(config.get('database.host')),再找校验后的值 (config.get('PORT') 返回数字 8080),最后找 process.env(字符串)。getOrThrow() 在键不存在时抛错。 main.ts 里 getOrThrow<number>('PORT') 的类型参数不做任何转换:数字是校验过程产生的。

registerAs 与带类型的配置 ​

ts
// One namespace per concern, injected with @Inject(databaseConfig.KEY).
// Values come from process.env after validateEnv has checked them.
export const databaseConfig = registerAs('database', () => ({
  host: process.env.DB_HOST ?? 'localhost',
  port: Number(process.env.DB_PORT ?? 5432),
  username: process.env.DB_USER ?? 'taskflow',
  password: process.env.DB_PASSWORD ?? 'taskflow',
  database: process.env.DB_NAME ?? 'taskflow',
}));
export type DatabaseConfig = ConfigType<typeof databaseConfig>;

export const jwtConfig = registerAs('jwt', () => ({
  secret: process.env.JWT_SECRET ?? '',
  expiresIn: process.env.JWT_EXPIRES_IN ?? '15m',
}));
export type JwtConfig = ConfigType<typeof jwtConfig>;

registerAs('database', factory) 创建一个命名空间。这个 factory 像 provider 一样执行,时机是文件加载并校验之后。 它生成一个带类型的对象,由此带来两点:

  • databaseConfig.KEY 是注入 token:在 service 里写 @Inject(jwtConfig.KEY) config: JwtConfig,或在 forRootAsync() 里写 inject: [databaseConfig.KEY](动态模块)。
  • ConfigType<typeof databaseConfig> 从 factory 的返回值推导类型:不用再额外维护一份 interface。

在 Spring 里,这相当于一个标注了 @ConfigurationProperties(prefix = "database") 的 record。类比在绑定这一步就不成立了: Spring 会把 database.port 对应到 port 字段并转换类型;这里得自己写 Number(process.env.DB_PORT ?? 5432)。 变量名拼错,factory 发现不了,这正是校验 schema 的职责。

factory 里的默认值(?? 'localhost')主要给 TypeORM CLI 和种子数据用:data-source.ts 直接调用 databaseConfig(), 不经过 validateEnv(迁移)。

校验环境变量 schema ​

TaskFlow 用一个 class-validator 类描述每个变量,启动时先转换再校验:

ts
// Every variable the app reads, with its type and default. Like a
// @ConfigurationProperties class validated with @Validated at startup.
// Write the types out (`PORT: number = 3000`): enableImplicitConversion reads
// the emitted design:type metadata, and an inferred type emits `Object`, so
// "8080" would stay a string.
class EnvironmentVariables {
  @IsEnum(Environment)
  NODE_ENV: Environment = Environment.Development;

  @IsInt()
  @Min(1)
  @Max(65535)
  PORT: number = 3000;

  @IsString()
  DB_HOST: string = 'localhost';

  @IsInt()
  DB_PORT: number = 5432;

  @IsString()
  DB_USER: string;

  @IsString()
  DB_PASSWORD: string;

  @IsString()
  DB_NAME: string;

  @IsString()
  @MinLength(8)
  JWT_SECRET: string;

  @Matches(/^\d+[smhd]$/)
  JWT_EXPIRES_IN: string = '15m';

  @IsIn(['typeorm', 'memory'])
  PROJECTS_STORE: 'typeorm' | 'memory' = 'typeorm';

  @IsOptional()
  @IsString()
  REDIS_URL?: string;
}

export function validateEnv(config: Record<string, unknown>) {
  const validated = plainToInstance(EnvironmentVariables, config, {
    enableImplicitConversion: true,
  });
  const errors = validateSync(validated);
  if (errors.length > 0) {
    // Fail fast: the app refuses to start with a wrong configuration.
    throw new Error(`Invalid environment:\n${errors.map(String).join('\n')}`);
  }
  return validated;
}

plainToInstance(..., { enableImplicitConversion: true }) 把每个字符串转换成属性的类型,然后 validateSync() 执行装饰器上的校验。缺少 JWT_SECRET 时,启动会停在:

text
Error: Invalid environment:
An instance of EnvironmentVariables has failed the validation:
 - property JWT_SECRET has failed the following constraints: minLength, isString

写明类型:PORT: number = 3000

隐式转换读取的是 TypeScript 为每个带装饰器的属性生成的 design:type 元数据(emitDecoratorMetadata)。这个元数据 来自类型注解。写成 PORT = 3000 时,类型是推断出来的,不是注解的:TypeScript 生成的是 Object 而不是 Number,class-transformer 什么也不转,"8080" 仍然是字符串。于是 @IsInt() 失败,但只在定义了这个变量时 (.env 文件或环境变量)才会失败:没定义时,默认值 3000 本来就是数字,一切看起来都正常。这和构造函数参数类型 是 interface 时出现的 “Nest can't resolve dependencies” 是同一个根源(类型与元数据)。

测试 env.validation.spec.ts 验证了:PORT: '8080' 会变成 8080;默认值会被填上;缺少 JWT_SECRET、 JWT_EXPIRES_IN: 'soon' 或 NODE_ENV: 'staging' 都会让校验失败。因为它不启动 Nest,所以要自己导入 reflect-metadata(不启动 Nest 的测试)。

替代方案:Joi。 学习计划里提到 Joi 或 class-validator。在 @nestjs/config 12 中,validationSchema 选项要求 兼容 Standard Schema 的 schema:Zod、ArkType,或 Joi 18(@nestjs/config 12 测试时用的版本)。对 Joi,模块默认保留 allowUnknown: true 和 abortEarly: false,这样会忽略 process.env 里的其他变量,并一次性报告所有错误。

ts
// TaskFlow 之外的示例:用 Joi 做同样的校验
import * as Joi from 'joi';

ConfigModule.forRoot({
  isGlobal: true,
  validationSchema: Joi.object({
    NODE_ENV: Joi.string().valid('development', 'test', 'production').default('development'),
    PORT: Joi.number().integer().min(1).max(65535).default(3000),
    JWT_SECRET: Joi.string().min(8).required(),
  }),
});

Joi 把 "8080" 转成数字时不依赖 TypeScript 元数据。代价是多一个依赖,以及第二种描述类型的方式,而 TaskFlow 的 DTO 已经在用 class-validator 了。

Profile:NODE_ENV 与 .env.* 文件 ​

TaskFlow 的 envFilePath 列表(先 .env.<NODE_ENV>,再 .env)取代了 spring.profiles.active。那么 NODE_ENV 由谁设置?

场景NODE_ENV加载的文件
npm run start:dev未定义 → development.env.development
npm run test:e2e未定义时由 Jest 设为 test.env.test
Docker 镜像(Dockerfile、Compose 的 app 服务)production不加载:.dockerignore 排除了 .env*,变量来自环境

schema 只接受 development、test 和 production。Express 和很多库在 NODE_ENV=production 时行为会变;所以预发布 环境应该用 NODE_ENV=production 加上它自己的变量(数据库、密钥)启动,而不是 NODE_ENV=staging。

Spring 用 @Profile 激活 bean,TaskFlow 则在模块里显式决定:

  • ConditionalModule.registerWhen(DemoModule, (env) => env.NODE_ENV !== 'production'):生产环境里不存在 /demo/* 路由 (字符串写法的陷阱)。
  • PROJECTS_STORE 和 REDIS_URL 用来选择实现或启用队列:每个功能一个变量,而不是到处散落 if (NODE_ENV === ...) (store 的选择)。
  • 日志级别和格式(JSON 或易读格式)取决于 NODE_ENV(pino 案例)。

Docker Compose 以及 app 服务的变量,见 Docker 与 .env。

日志:Nest Logger 与 nestjs-pino ​

在代码里,TaskFlow 用 @nestjs/common 的 Logger 类写日志: private readonly logger = new Logger(TasksScheduler.name),然后调用 log、warn、error、debug、verbose 或 fatal。传给构造函数的名字会成为这一行日志的上下文。默认实现(ConsoleLogger)输出带颜色的文本;从 Nest 11 起, new ConsoleLogger({ json: true }) 可以输出 JSON,但不带请求上下文。

app.useLogger(...) 会替换所有 Logger 背后的实现,包括 Nest 的启动日志。NestFactory.create(AppModule, { bufferLogs: true }) 会把最早的这些日志暂存在内存里,直到调用 useLogger,这样它们也能以正确的格式输出。

nestjs-pino 带来两样东西:

  • pino,一个快速的 JSON logger:每个事件一行 JSON,级别是数字(30 info、40 warn、50 error),在日志聚合系统里 很好过滤。
  • pino-http,注册为中间件:它为每个请求创建一个子 logger,携带 req 对象(ID、方法、URL、请求头)。这个 logger 存放在 AsyncLocalStorage 里。请求处理期间,service 调用 this.logger.log(...) 时,nestjs-pino 会找到这个子 logger: 日志行自动带上 req.id,你什么都不用传。

这就是 Logback 中 MDC 的作用。类比在机制上不成立:MDC 是 ThreadLocal,当一个线程服务所有请求时就没法用了。 AsyncLocalStorage 跟随每个请求的异步调用链(await、回调)(事件循环)。 如果要输出结构化字段({ projectId }),nestjs-pino 还支持用 @InjectPinoLogger() 注入 PinoLogger。

生命周期 ​

Nest 会调用 provider、控制器和模块上的一些可选方法:

时机钩子Spring 对应
模块的依赖已解析onModuleInit()@PostConstruct
所有模块已初始化onApplicationBootstrap()ContextRefreshedEvent
开始关闭onModuleDestroy()@PreDestroy
关闭 HTTP 服务器之前beforeApplicationShutdown(signal)—
HTTP 服务器已关闭onApplicationShutdown(signal)—

钩子可以是 async:Nest 会等这个 Promise 完成再继续。关闭时,Nest 先调用 onModuleDestroy,再调用 beforeApplicationShutdown,然后关闭 HTTP 服务器,最后调用 onApplicationShutdown。请求作用域的 provider 收不到这些钩子。

关闭时,只有经由 Nest 的关闭流程才会调用这些钩子:app.close()(端到端测试在 afterAll 里就是这么做的),或者一个信号 (SIGTERM、SIGINT 等),前提是调用过 app.enableShutdownHooks()。Spring Boot 默认注册 JVM 关闭钩子;Nest 不这样做, 因为每个应用都会往进程上添加信号监听器(同一进程里有多个应用时,比如并行的 Jest 测试,监听器会越积越多)。TaskFlow 在 setupApp() 里开启它(pino 案例)。不开的话,SIGTERM 不会执行任何钩子,进程退出时不会关闭 PostgreSQL 连接池,而 @nestjs/typeorm 正是在它自己的 onApplicationShutdown() 里关闭连接池的。

TaskFlow 自己的类里没有定义钩子。一个用法示例:

ts
// TaskFlow 之外的示例:启动时准备状态,关闭时释放
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';

@Injectable()
export class RatesCache implements OnModuleInit, OnModuleDestroy {
  private readonly logger = new Logger(RatesCache.name);
  private timer?: NodeJS.Timeout;

  async onModuleInit() {
    await this.refresh(); // 缓存填好后应用才算启动完成
    this.timer = setInterval(() => void this.refresh(), 60_000);
  }

  onModuleDestroy() {
    clearInterval(this.timer); // 否则定时器在 app.close() 之后还活着(测试、脚本)
  }

  private async refresh() {
    try {
      // HTTP 调用,更新一个 Map...
    } catch (error) {
      this.logger.warn(`Refresh failed: ${String(error)}`);
    }
  }
}

真正的周期性任务,优先用 @nestjs/schedule 的 @Interval()(J9)。

TaskFlow 案例 ​

按环境区分的 .env 文件 ​

只有 .env.example 纳入版本控制。它为每个变量写了说明:

dotenv
# Copy to .env.development (npm run start:dev) and .env.test (npm run test:e2e).
# Only this example file is versioned.
NODE_ENV=development
PORT=3000
DB_HOST=localhost
DB_PORT=5432
DB_USER=taskflow
DB_PASSWORD=taskflow
DB_NAME=taskflow
JWT_SECRET=change-me
JWT_EXPIRES_IN=15m
# typeorm (PostgreSQL) or memory (the J2-J3 in-memory store)
PROJECTS_STORE=typeorm
# Set to enable the BullMQ notifications queue (docker compose --profile queue up -d)
# REDIS_URL=redis://localhost:6379
# Comma-separated origins allowed by CORS; unset = no cross-origin access
# CORS_ORIGIN=http://localhost:5173

其他文件在本地从它复制出来:

bash
cp .env.example .env.development   # npm run start:dev
cp .env.example .env.test          # npm run test:e2e,然后按需修改

端到端测试用的 .env.test 指向 Compose 里的 db-test 服务(端口 5433,数据库 taskflow_test):

dotenv
# .env.test:本地文件,不纳入版本控制
NODE_ENV=test
PORT=3000
DB_HOST=localhost
DB_PORT=5433
DB_USER=taskflow
DB_PASSWORD=taskflow
DB_NAME=taskflow_test
JWT_SECRET=test-secret-not-for-production
JWT_EXPIRES_IN=15m
PROJECTS_STORE=typeorm

TaskFlow 的 .gitignore(examples/.gitignore)排除了除示例之外的所有 .env* 文件:

text
node_modules/
dist/
coverage/
*.log
*.tsbuildinfo
.env*
!.env.example

git check-ignore -v .env.test 会显示是哪条规则排除了这个文件。

带类型的配置:数据库、JWT、端口 ​

每处需求都用最直接的方式读取配置:

  • 数据库:TypeOrmModule.forRootAsync() 拿到的是已经带类型的 database 命名空间。
ts
@Module({
  imports: [
    TypeOrmModule.forRootAsync({
      inject: [databaseConfig.KEY],
      useFactory: (db: DatabaseConfig) => ({
        ...postgresOptions(db),
        // Picks up every entity registered with TypeOrmModule.forFeature().
        autoLoadEntities: true,
      }),
    }),
  ],
})
export class DatabaseModule {}
  • JWT:JwtModule.registerAsync()、AuthService 和 JwtStrategy 注入 jwtConfig.KEY (J8)。JWT_EXPIRES_IN 在到达 @nestjs/jwt 之前先经过 @Matches(/^\d+[smhd]$/) 校验。
  • 端口:不值得单独建一个命名空间。main.ts 读取校验后的值:
ts
// SpringApplication.run(TaskflowApplication.class, args)
async function bootstrap() {
  const app = await NestFactory.create(AppModule, { bufferLogs: true });
  setupApp(app);
  setupSwagger(app);
  await app.listen(app.get(ConfigService).getOrThrow<number>('PORT'));
}
// `void` marks the promise as deliberately not awaited (no-floating-promises).
void bootstrap();

service 和控制器从不直接读 process.env。直接读取只出现在这些地方:选择 .env 文件(app.module.ts、data-source.ts)、 configuration.ts 里的 factory(在校验之后执行)、种子数据的密码,以及 setupApp() 里的 CORS_ORIGIN。后两个变量是可选的, 不在 schema 里,因此不经过校验。

带 correlationId 的 JSON 日志(nestjs-pino) ​

ts
LoggerModule.forRootAsync({
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({
    pinoHttp: {
      level: { production: 'info', test: 'warn' }[config.get<string>('NODE_ENV') ?? ''] ?? 'debug',
      // Same id as CorrelationIdMiddleware: every log line of a request carries it.
      genReqId: (req, res) => ensureCorrelationId(req, res),
      redact: ['req.headers.authorization', 'req.headers.cookie'],
      autoLogging: false, // LoggingInterceptor already logs each request
      transport:
        config.get('NODE_ENV') === 'development'
          ? { target: 'pino-pretty', options: { singleLine: true } }
          : undefined, // JSON lines in test and production
    },
  }),
}),
  • genReqId 复用了 ensureCorrelationId(),和 J6 的 CorrelationIdMiddleware 用的是同一个函数:日志里的 req.id、响应里的 x-correlation-id 和错误里的 correlationId 是同一个值。
  • redact 把 Authorization 请求头和 cookie 替换成 "[Redacted]":日志里如果有 JWT token,别人就能用它重放会话。
  • autoLogging: false:pino-http 不记录每个请求的结束,因为 LoggingInterceptor 已经做了(J6)。 结果是:被 guard 拒绝的请求不会产生 HTTP 日志行。
  • 生产环境级别为 info,测试环境为 warn(测试输出更清爽),其他情况为 debug。
  • pino-pretty(devDependency)只在开发环境把日志行变得易读。在测试和生产环境,输出仍是 JSON,每个事件一行。

setupApp() 由 main.ts 和端到端测试调用,它挂上这个 logger 并开启关闭钩子:

ts
// HTTP-level settings that are not providers. main.ts and the e2e tests both
// call it, so the tests exercise the same application as production.
export function setupApp(app: INestApplication): void {
  app.useLogger(app.get(Logger));
  app.use(helmet());
  app.enableCors({ origin: process.env.CORS_ORIGIN?.split(',') ?? false });
  app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' }); // /v1/...
  app.enableShutdownHooks(); // SIGTERM -> onModuleDestroy / onApplicationShutdown
}

带着 x-correlation-id: demo-42 发送 GET /v1/projects,会产生这样一行(JSON 已重新排版;去掉了 pid、hostname、 remoteAddress 和其他请求头):

json
{
  "level": 30,
  "time": 1793095173677,
  "req": {
    "id": "demo-42",
    "method": "GET",
    "url": "/v1/projects",
    "headers": { "x-correlation-id": "demo-42", "authorization": "[Redacted]" }
  },
  "context": "HTTP",
  "msg": "GET /v1/projects 200 0ms"
}

要追查客户端反馈的某个请求,就在日志里搜它的 correlationId:这个请求期间写下的所有日志行,不管来自哪个 service, 都带着同一个 req.id。

场景速查 ​

  • 新增一个环境变量 → 在 schema 里加带类型的属性,在 .env.example 里加一行带注释的说明,如果属于某个领域就放进命名空间(校验)。
  • 动态模块需要配置 → forRootAsync({ inject: [xxxConfig.KEY], useFactory })(带类型的配置)。
  • PORT=8080 被拒绝,而 3000 能通过 → 写明类型注解:PORT: number = 3000(校验)。
  • 某个功能在生产环境不能存在 → 用函数形式的 ConditionalModule.registerWhen()(profile)。
  • 要找出一个请求的所有日志 → 按 req.id 过滤,它等于 x-correlation-id(pino 案例)。
  • 密钥出现在日志里 → 把它的路径加进 redact(pino 案例)。
  • 收到 SIGTERM 时要关闭连接 → app.enableShutdownHooks() 加上 onModuleDestroy() / onApplicationShutdown()(生命周期)。

陷阱 ​

把 .env 文件纳入版本控制

提交了带真实密钥的 .env.development,即使之后删除,它仍留在 git 历史里。只把 .env.example(填假值)纳入版本控制, 并在第一次提交前检查 .gitignore。生产环境的密钥来自环境(编排平台、密钥管理器)。

环境变量里的布尔值

enableImplicitConversion 用 Boolean(值) 转换 boolean 属性:字符串 "false" 会变成 true。环境变量里的开关要显式转换: @Transform(({ value }) => value === 'true'),和 J2 里的 ?archived=false 一样。

  • schema 里用了推断类型(PORT = 3000):不会做转换(校验)。
  • 代码里到处读 process.env.X:值没有经过校验,永远是字符串,测试也不好替换。改用 ConfigService 或注入的命名空间。
  • NODE_ENV=staging:被 TaskFlow 的 schema 拒绝。用 production 加上别的变量。
  • 生产环境用 pino-pretty:它是 devDependency,用 npm ci --omit=dev 构建的镜像里没有它。TaskFlow 只在开发环境启用它。
  • 代码里的 console.log():这行日志绕过了 pino(不是 JSON,没有 req.id,不经过 redact)。
  • 忘了 enableShutdownHooks():收到 SIGTERM 时不执行任何关闭钩子,连接池和其他资源不能被正常释放。
  • schema 的单元测试没写 import 'reflect-metadata':报 “Reflect.getMetadata is not a function” (不启动 Nest 的测试)。

练习 ​

  • [ ] 从 .env.example 复制出 .env.development 和 .env.test;用 git status 确认只有 .env.example 纳入版本控制。
  • [ ] 配置 ConfigModule.forRoot({ isGlobal: true, validate }),不设 JWT_SECRET 启动:应用应该拒绝启动,并给出清晰的信息。
  • [ ] 为数据库、JWT 和端口编写带类型的配置(registerAs、ConfigType),并用 @Inject(xxxConfig.KEY) 注入。
  • [ ] 复现这个陷阱:先写 PORT = 3000,再改成 PORT: number = 3000,配一个传入 PORT: '8080' 的测试。
  • [ ] (变体)用 Joi 写同样的 schema 并比较。
  • [ ] 接入 nestjs-pino:测试环境输出 JSON 日志,开发环境输出易读格式;req.id 等于用 curl -H 'x-correlation-id: demo-42' 发送的 x-correlation-id;Authorization 请求头被遮蔽。
  • [ ] 添加一个带 onModuleInit() 和 onApplicationShutdown() 的 provider,调用 enableShutdownHooks(),然后用 Ctrl+C 停止, 观察日志顺序。
  • [ ] 在 LEARNINGS.md 里记下:@ConfigurationProperties ↔ registerAs,@PostConstruct ↔ OnModuleInit, @PreDestroy ↔ OnModuleDestroy,profile ↔ NODE_ENV。

延伸阅读 ​