第十三课:配置、日志与事件 — 应用基础设施

3 阅读9分钟

覆盖文档:Configuration, Logger, Events, Cookies, Session 前置知识:第9课(Dynamic Modules、注入作用域与生命周期) 源码重点:packages/config/, packages/common/services/logger.service.ts, packages/common/services/console-logger.service.ts


一、配置管理

1.1 @nestjs/config 基础

NestJS 推荐使用 @nestjs/config 管理配置,它基于 dotenv,将 .env 文件与环境变量合并为统一的配置源。

npm i --save @nestjs/config

注册 ConfigModule

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,  // 全局可用,无需在每个模块中 import
    }),
  ],
})
export class AppModule {}

forRoot() 默认加载项目根目录下的 .env 文件。

.env 文件

# .env
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=nestdb
APP_PORT=3000

重要.env 文件必须加入 .gitignore,不要将敏感信息提交到版本控制。

使用 ConfigService 读取配置

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class DatabaseService {
  constructor(private readonly configService: ConfigService) {}

  connect() {
    const host = this.configService.get<string>('DATABASE_HOST');
    const port = this.configService.get<number>('DATABASE_PORT');
    // host = 'localhost', port = 5432
  }
}

1.2 类型安全推断

使用 infer: true 选项,ConfigService 可以根据默认值自动推断返回类型:

// 返回类型自动推断为 number(基于默认值)
const port = this.configService.get('APP_PORT', { infer: true });

// 提供默认值,当配置不存在时使用
const timeout = this.configService.getOrThrow<number>('TIMEOUT');
// 如果 TIMEOUT 未定义,直接抛出异常

1.3 环境变量优先级

运行时环境变量(process.env)  ←  最高优先级
         ↓
    .env 文件中的变量          ←  较低优先级
# 运行时环境变量覆盖 .env 文件
DATABASE_HOST=production-db npm run start:prod

这意味着:生产环境通过系统环境变量或容器编排工具注入配置,.env 文件仅用于本地开发。

1.4 多环境 .env 文件

ConfigModule.forRoot({
  envFilePath: ['.env.local', '.env'],  // 按顺序加载,先找到的优先
  // 或指定单个文件
  // envFilePath: `.env.${process.env.NODE_ENV}`,
})

如果不需要 .env 文件(例如纯容器环境):

ConfigModule.forRoot({
  ignoreEnvFile: true,  // 完全忽略 .env,只从 process.env 读取
})

二、配置进阶

2.1 命名空间配置(registerAs)

当配置项增多时,使用命名空间分组管理:

// config/database.config.ts
import { registerAs } from '@nestjs/config';

export default registerAs('database', () => ({
  host: process.env.DATABASE_HOST || 'localhost',
  port: parseInt(process.env.DATABASE_PORT, 10) || 5432,
  name: process.env.DATABASE_NAME || 'nestdb',
}));
// config/redis.config.ts
import { registerAs } from '@nestjs/config';

export default registerAs('redis', () => ({
  host: process.env.REDIS_HOST || 'localhost',
  port: parseInt(process.env.REDIS_PORT, 10) || 6379,
  ttl: parseInt(process.env.REDIS_TTL, 10) || 300,
}));

注册命名空间配置:

import databaseConfig from './config/database.config';
import redisConfig from './config/redis.config';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [databaseConfig, redisConfig],  // 加载命名空间配置
    }),
  ],
})
export class AppModule {}

读取命名空间配置

@Injectable()
export class DatabaseService {
  constructor(private readonly configService: ConfigService) {}

  getConfig() {
    // 方式1:点号分隔路径
    const host = this.configService.get<string>('database.host');

    // 方式2:获取整个命名空间对象
    const dbConfig = this.configService.get('database');
    // dbConfig = { host: 'localhost', port: 5432, name: 'nestdb' }
  }
}

类型安全的命名空间注入

import { Inject, Injectable } from '@nestjs/common';
import { ConfigType } from '@nestjs/config';
import databaseConfig from './config/database.config';

@Injectable()
export class DatabaseService {
  constructor(
    @Inject(databaseConfig.KEY)
    private readonly dbConfig: ConfigType<typeof databaseConfig>,
  ) {
    // dbConfig.host — 完全类型安全,IDE 自动补全
    // dbConfig.port — number 类型
  }
}

2.2 模块局部配置(forFeature)

功能模块可以只加载自己需要的配置命名空间:

@Module({
  imports: [ConfigModule.forFeature(databaseConfig)],
  providers: [DatabaseService],
})
export class DatabaseModule {}

forFeature() 适用于非全局 ConfigModule 的场景,让模块声明自己的配置依赖。

2.3 Joi Schema 验证

在应用启动时验证配置的完整性和正确性,避免运行时才发现配置缺失:

npm i --save joi
import * as Joi from 'joi';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      validationSchema: Joi.object({
        NODE_ENV: Joi.string()
          .valid('development', 'production', 'test')
          .default('development'),
        APP_PORT: Joi.number().default(3000),
        DATABASE_HOST: Joi.string().required(),
        DATABASE_PORT: Joi.number().default(5432),
        DATABASE_NAME: Joi.string().required(),
      }),
      validationOptions: {
        allowUnknown: true,   // 允许 .env 中有未定义的变量
        abortEarly: false,    // 收集所有验证错误再报告
      },
    }),
  ],
})
export class AppModule {}

如果必填配置缺失,应用启动时会直接抛出错误并给出明确提示。

2.4 自定义 YAML 配置

npm i --save js-yaml
npm i --save-dev @types/js-yaml
# config.yaml
http:
  host: localhost
  port: 3000

database:
  host: localhost
  port: 5432
  name: nestdb
// config/configuration.ts
import { readFileSync } from 'fs';
import { join } from 'path';
import * as yaml from 'js-yaml';

const YAML_CONFIG_FILENAME = 'config.yaml';

export default () => {
  return yaml.load(
    readFileSync(join(__dirname, '..', YAML_CONFIG_FILENAME), 'utf8'),
  ) as Record<string, any>;
};
ConfigModule.forRoot({
  load: [configuration],  // 加载 YAML 配置
})

2.5 变量展开

支持在 .env 文件中使用变量引用:

# .env
APP_URL=mywebsite.com
SUPPORT_EMAIL=support@${APP_URL}
# SUPPORT_EMAIL 将解析为 support@mywebsite.com
ConfigModule.forRoot({
  expandVariables: true,  // 启用变量展开
})

2.6 条件模块加载

根据环境变量决定是否加载某个模块:

import { ConditionalModule } from '@nestjs/config';

@Module({
  imports: [
    // 仅当 ENABLE_MONITORING=true 时加载 MonitoringModule
    ConditionalModule.registerWhen(
      MonitoringModule,
      'ENABLE_MONITORING',
    ),
    // 支持自定义判断逻辑
    ConditionalModule.registerWhen(
      DebugModule,
      (env: NodeJS.ProcessEnv) => env.NODE_ENV === 'development',
    ),
  ],
})
export class AppModule {}

三、日志系统

3.1 内置 Logger 类

NestJS 提供了内置的 Logger 类,无需安装额外依赖:

import { Logger, Injectable } from '@nestjs/common';

@Injectable()
export class CatsService {
  private readonly logger = new Logger(CatsService.name);

  findAll() {
    this.logger.log('查询所有猫');           // [CatsService] 查询所有猫
    this.logger.error('查询失败', stack);     // 红色,包含堆栈
    this.logger.warn('数据量过大');           // 黄色警告
    this.logger.debug('SQL: SELECT * ...');  // 调试信息
    this.logger.verbose('详细请求数据...');   // 详细信息
    this.logger.fatal('数据库连接中断');       // 致命错误
  }
}

6 个日志级别(从低到高)

级别方法场景生产环境
verboselogger.verbose()最详细的跟踪信息关闭
debuglogger.debug()调试用的开发信息关闭
loglogger.log()常规运行信息开启
warnlogger.warn()警告,可能的问题开启
errorlogger.error()错误,需要关注开启
fatallogger.fatal()致命,系统无法继续开启

3.2 日志级别过滤

// 只输出 error 和 warn 级别的日志
const app = await NestFactory.create(AppModule, {
  logger: ['error', 'warn'],
});

// 完全禁用日志
const app = await NestFactory.create(AppModule, {
  logger: false,
});

3.3 ConsoleLogger JSON 输出

在生产环境中,JSON 格式的日志更适合被 ELK、Datadog 等日志聚合平台解析:

import { NestFactory } from '@nestjs/core';
import { ConsoleLogger } from '@nestjs/common';

const app = await NestFactory.create(AppModule, {
  logger: new ConsoleLogger({
    json: true,       // 输出 JSON 格式
    colors: false,    // 生产环境关闭颜色
    timestamp: true,  // 包含时间戳
  }),
});

JSON 输出示例:

{
  "level": "log",
  "message": "NestApplication successfully started",
  "context": "NestApplication",
  "timestamp": "2025-01-15T10:30:00.000Z"
}

四、自定义 Logger 与事件驱动

4.1 实现 LoggerService 接口

import { LoggerService, Injectable } from '@nestjs/common';

@Injectable()
export class MyLogger implements LoggerService {
  log(message: any, ...optionalParams: any[]) {
    // 自定义日志处理:写文件、发送到远程服务等
    console.log(`[INFO] ${new Date().toISOString()} ${message}`);
  }

  error(message: any, ...optionalParams: any[]) {
    console.error(`[ERROR] ${new Date().toISOString()} ${message}`);
  }

  warn(message: any, ...optionalParams: any[]) {
    console.warn(`[WARN] ${new Date().toISOString()} ${message}`);
  }

  debug?(message: any, ...optionalParams: any[]) {
    console.debug(`[DEBUG] ${new Date().toISOString()} ${message}`);
  }

  verbose?(message: any, ...optionalParams: any[]) {
    console.log(`[VERBOSE] ${new Date().toISOString()} ${message}`);
  }

  fatal?(message: any, ...optionalParams: any[]) {
    console.error(`[FATAL] ${new Date().toISOString()} ${message}`);
  }
}

4.2 继承 ConsoleLogger

更常见的做法是继承 ConsoleLogger,在保留默认行为的基础上扩展:

import { ConsoleLogger, Injectable } from '@nestjs/common';

@Injectable()
export class CustomLogger extends ConsoleLogger {
  error(message: any, stack?: string, context?: string) {
    // 扩展:将错误日志写入文件
    this.writeToFile('error', message, stack);
    // 保留原始控制台输出
    super.error(message, stack, context);
  }

  private writeToFile(level: string, message: string, stack?: string) {
    // 实际项目中使用 winston、pino 等专业日志库
    const logEntry = {
      level,
      message,
      stack,
      timestamp: new Date().toISOString(),
    };
    // fs.appendFileSync('logs/error.log', JSON.stringify(logEntry) + '\n');
  }
}

4.3 DI 注入自定义 Logger

让 NestJS 框架本身也使用自定义 Logger:

// logger.module.ts
import { Module } from '@nestjs/common';
import { CustomLogger } from './custom-logger.service';

@Module({
  providers: [CustomLogger],
  exports: [CustomLogger],
})
export class LoggerModule {}
// main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    bufferLogs: true,  // 缓冲启动期间的日志,直到自定义 Logger 就绪
  });

  // 获取 DI 管理的 Logger 实例
  app.useLogger(app.get(CustomLogger));

  await app.listen(3000);
}

bufferLogs: true 确保框架启动过程中的日志不会丢失——它们会被暂存,直到自定义 Logger 准备好后统一输出。

4.4 Transient Scope 实现每服务独立 Logger

import { ConsoleLogger, Injectable, Scope } from '@nestjs/common';

@Injectable({ scope: Scope.TRANSIENT })
export class PerServiceLogger extends ConsoleLogger {
  // 每次注入都会创建新实例
  // 可以通过 setContext() 设置不同的上下文名称
}
@Injectable()
export class CatsService {
  constructor(private readonly logger: PerServiceLogger) {
    this.logger.setContext(CatsService.name);
  }

  findAll() {
    this.logger.log('查询所有猫');  // [CatsService] 查询所有猫
  }
}

4.5 事件驱动(Event Emitter)

事件驱动模式用于解耦模块间的通信,发布者不需要知道谁在监听。

npm i --save @nestjs/event-emitter

注册 EventEmitterModule

import { Module } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';

@Module({
  imports: [
    EventEmitterModule.forRoot({
      // 可选配置
      wildcard: true,           // 启用通配符事件
      delimiter: '.',           // 事件名分隔符
      maxListeners: 20,         // 最大监听器数
      verboseMemoryLeak: true,  // 内存泄漏警告
    }),
  ],
})
export class AppModule {}

定义事件

// events/order-created.event.ts
export class OrderCreatedEvent {
  constructor(
    public readonly orderId: string,
    public readonly userId: string,
    public readonly totalAmount: number,
  ) {}
}

发射事件

import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { OrderCreatedEvent } from './events/order-created.event';

@Injectable()
export class OrdersService {
  constructor(private readonly eventEmitter: EventEmitter2) {}

  async createOrder(data: CreateOrderDto) {
    const order = await this.saveOrder(data);

    // 发射事件——不阻塞主流程
    this.eventEmitter.emit(
      'order.created',
      new OrderCreatedEvent(order.id, data.userId, data.totalAmount),
    );

    return order;
  }
}

监听事件

import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { OrderCreatedEvent } from './events/order-created.event';

@Injectable()
export class NotificationService {
  @OnEvent('order.created')
  handleOrderCreated(event: OrderCreatedEvent) {
    // 发送订单确认邮件
    console.log(`发送邮件: 订单 ${event.orderId} 已创建`);
  }
}

@Injectable()
export class AnalyticsService {
  // 通配符监听所有 order.* 事件
  @OnEvent('order.*')
  handleOrderEvents(event: any) {
    console.log('记录订单分析数据');
  }
}

事件监听选项

@OnEvent('order.created', {
  async: true,            // 异步执行(不阻塞发射者)
  prependListener: false, // 是否添加到监听器列表头部
  suppressErrors: false,  // 是否静默处理错误
})
handleOrderCreated(event: OrderCreatedEvent) {
  // ...
}

五、Cookie 与 Session

5.1 Cookie

Express 平台

npm i --save cookie-parser
npm i --save-dev @types/cookie-parser
// main.ts
import * as cookieParser from 'cookie-parser';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.use(cookieParser());
  await app.listen(3000);
}
import { Controller, Get, Res, Req } from '@nestjs/common';
import { Request, Response } from 'express';

@Controller('auth')
export class AuthController {
  @Get('login')
  login(@Res({ passthrough: true }) res: Response) {
    // 设置 Cookie
    res.cookie('token', 'abc123', {
      httpOnly: true,   // 禁止 JS 访问
      secure: true,     // 仅 HTTPS
      sameSite: 'lax',  // CSRF 防护
      maxAge: 3600000,  // 1 小时
    });
    return { message: '登录成功' };
  }

  @Get('profile')
  getProfile(@Req() req: Request) {
    // 读取 Cookie
    const token = req.cookies['token'];
    return { token };
  }
}

Fastify 平台

npm i --save @fastify/cookie
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import fastifyCookie from '@fastify/cookie';

async function bootstrap() {
  const app = await NestFactory.create<NestFastifyApplication>(
    AppModule,
    new FastifyAdapter(),
  );
  await app.register(fastifyCookie, {
    secret: 'my-cookie-secret',  // 签名密钥
  });
  await app.listen(3000, '0.0.0.0');
}

5.2 Session

Express 平台

npm i --save express-session
npm i --save-dev @types/express-session
// main.ts
import * as session from 'express-session';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.use(
    session({
      secret: 'my-session-secret',
      resave: false,
      saveUninitialized: false,
      cookie: {
        maxAge: 3600000,  // 1 小时
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
      },
    }),
  );
  await app.listen(3000);
}
import { Controller, Get, Session } from '@nestjs/common';

@Controller()
export class AppController {
  @Get('visit')
  incrementVisits(@Session() session: Record<string, any>) {
    session.visits = (session.visits || 0) + 1;
    return { visits: session.visits };
  }
}

Fastify 平台

npm i --save @fastify/secure-session
import secureSession from '@fastify/secure-session';
import { readFileSync } from 'fs';

async function bootstrap() {
  const app = await NestFactory.create<NestFastifyApplication>(
    AppModule,
    new FastifyAdapter(),
  );
  await app.register(secureSession, {
    key: readFileSync('secret-key'),
    cookie: { path: '/', httpOnly: true },
  });
  await app.listen(3000, '0.0.0.0');
}

5.3 生产环境 Session 存储

关键:默认的内存 Session 存储不适合生产环境——进程重启会丢失所有会话,多实例部署也无法共享。

npm i --save connect-redis ioredis
import RedisStore from 'connect-redis';
import { Redis } from 'ioredis';

const redisClient = new Redis({
  host: process.env.REDIS_HOST,
  port: parseInt(process.env.REDIS_PORT, 10),
});

app.use(
  session({
    store: new RedisStore({ client: redisClient }),
    secret: process.env.SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
    cookie: {
      maxAge: 86400000,  // 24 小时
      httpOnly: true,
      secure: true,
    },
  }),
);

六、源码解读:ConfigModule 实现

[资深] 本节面向希望深入理解框架内部机制的读者。

6.1 ConfigurableModuleBuilder 的典型应用

@nestjs/configConfigModuleConfigurableModuleBuilder 的一个典型实践。理解它的源码有助于创建自己的可配置模块。

ConfigModule
    │
    ├── forRoot(options)         → 静态配置,注册全局 ConfigService
    ├── forFeature(config)       → 注册命名空间配置到当前模块
    └── ConfigService            → 统一的配置读取接口

6.2 registerAs() 的实现原理

registerAs() 创建一个命名空间配置工厂,其核心是生成唯一的注入 token:

// @nestjs/config 内部简化逻辑
export function registerAs<T>(
  namespace: string,
  configFactory: () => T,
): ConfigFactory & { KEY: string } {
  // 1. 为命名空间生成唯一 token
  const configToken = `CONFIGURATION(${namespace})`;

  // 2. 将 token 附加到工厂函数
  Object.defineProperty(configFactory, 'KEY', { value: configToken });

  // 3. 返回增强后的工厂函数
  return configFactory as any;
}

这就是为什么可以通过 @Inject(databaseConfig.KEY) 注入特定命名空间的配置。

6.3 ConfigService 的类型推断机制

ConfigService 使用 TypeScript 的条件类型和泛型来实现 get() 的类型推断:

// 简化的 ConfigService 类型签名
class ConfigService<K = Record<string, unknown>> {
  get<T = any>(propertyPath: KeyOf<K>): T | undefined;
  get<T = any>(propertyPath: KeyOf<K>, defaultValue: T): T;
  get<T = any>(
    propertyPath: KeyOf<K>,
    options: { infer: true },
  ): T | undefined;

  getOrThrow<T = any>(propertyPath: KeyOf<K>): T;
}

当使用 infer: true 时,TypeScript 会根据 ConfigModule.forRoot()load 配置推断出完整的配置类型,实现编译时类型检查。

6.4 配置加载流程

应用启动
    │
    ├─→ ConfigModule.forRoot() 注册为动态模块
    │       │
    │       ├─→ 加载 .env 文件(dotenv.parse)
    │       ├─→ 合并 process.env
    │       ├─→ 执行 load[] 中的配置工厂
    │       ├─→ 执行 validationSchema 验证
    │       └─→ 注册 ConfigService 为全局 provider
    │
    └─→ ConfigModule.forFeature(config)
            │
            └─→ 执行配置工厂并注册为命名空间 provider

七、配置与日志架构设计

[架构] 本节面向技术负责人和架构师。

7.1 12-Factor App 配置原则

NestJS 的配置管理遵循 12-Factor App 的第三条原则——在环境中存储配置:

原则实践
环境变量优先process.env 覆盖 .env 文件
不硬编码配置使用 ConfigService 读取,不在代码中写死
区分构建与运行同一份构建产物,通过环境变量切换环境
配置与代码分离.env 不入库,敏感信息用密钥管理服务

7.2 结构化 JSON 日志

生产环境推荐结构化 JSON 日志,便于日志聚合平台(ELK、Datadog、CloudWatch)解析:

┌────────────────┐    ┌──────────────┐    ┌─────────────────┐
│   NestJS App   │───→│  日志收集器   │───→│   日志聚合平台   │
│  JSON Logger   │    │  (Filebeat/  │    │  (ELK/Datadog/  │
│                │    │   Fluentd)   │    │   CloudWatch)   │
└────────────────┘    └──────────────┘    └─────────────────┘

7.3 关联 ID(Correlation ID)

在分布式系统中,使用 Correlation ID 将同一请求在不同服务中的日志串联起来:

// correlation-id.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'crypto';

@Injectable()
export class CorrelationIdMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    const correlationId = req.headers['x-correlation-id'] as string
      || randomUUID();
    req['correlationId'] = correlationId;
    res.setHeader('x-correlation-id', correlationId);
    next();
  }
}

每条日志都附带 correlationId,在日志平台中可按此 ID 搜索完整请求链路。

7.4 日志分级策略

环境日志级别输出格式输出目标
开发verbose, debug, log, warn, error彩色文本控制台
测试warn, error文本控制台
预发布log, warn, errorJSON文件 + 聚合
生产log, warn, errorJSON聚合平台
// 根据环境动态配置日志级别
const logLevels: LogLevel[] = process.env.NODE_ENV === 'production'
  ? ['log', 'warn', 'error', 'fatal']
  : ['verbose', 'debug', 'log', 'warn', 'error', 'fatal'];

const app = await NestFactory.create(AppModule, {
  logger: logLevels,
});

7.5 事件驱动架构要点

场景同步调用事件驱动
订单创建后发邮件Service 直接调用 EmailService发射 order.created 事件
用户注册后初始化Controller 调用多个 Service发射 user.registered 事件
耦合度高(直接依赖)低(通过事件解耦)
可扩展性需修改调用方新增监听器即可
调试难度低(调用链清晰)中(需跟踪事件流)
适用场景核心业务流程副作用操作(通知、日志、统计)

八、课后实践

练习 1:配置驱动的数据库连接(基础)

将硬编码的数据库配置改为 .env 文件驱动:

# .env
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USER=postgres
DATABASE_NAME=cats_db
// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    // 使用 ConfigService 异步配置数据库
    // TypeOrmModule.forRootAsync({
    //   inject: [ConfigService],
    //   useFactory: (config: ConfigService) => ({
    //     type: 'postgres',
    //     host: config.get('DATABASE_HOST'),
    //     port: config.get('DATABASE_PORT'),
    //     username: config.get('DATABASE_USER'),
    //     database: config.get('DATABASE_NAME'),
    //   }),
    // }),
  ],
})
export class AppModule {}

练习 2:自定义 FileLogger(中阶)

继承 ConsoleLogger,将 error 级别日志同时写入文件:

import { ConsoleLogger, Injectable } from '@nestjs/common';
import { appendFileSync, mkdirSync, existsSync } from 'fs';
import { join } from 'path';

@Injectable()
export class FileLogger extends ConsoleLogger {
  private readonly logDir = join(process.cwd(), 'logs');

  constructor() {
    super();
    if (!existsSync(this.logDir)) {
      mkdirSync(this.logDir, { recursive: true });
    }
  }

  error(message: any, stack?: string, context?: string) {
    const logEntry = JSON.stringify({
      level: 'error',
      message,
      stack,
      context,
      timestamp: new Date().toISOString(),
    });
    appendFileSync(join(this.logDir, 'error.log'), logEntry + '\n');
    super.error(message, stack, context);
  }
}

main.ts 中注册,并使用 bufferLogs: true

练习 3:事件驱动解耦——注册后发送欢迎邮件(中阶)

// 1. 定义事件
export class UserRegisteredEvent {
  constructor(
    public readonly userId: string,
    public readonly email: string,
  ) {}
}

// 2. 在 UsersService 中发射事件
@Injectable()
export class UsersService {
  constructor(private readonly eventEmitter: EventEmitter2) {}

  async register(dto: RegisterDto) {
    const user = await this.saveUser(dto);
    this.eventEmitter.emit('user.registered', new UserRegisteredEvent(user.id, dto.email));
    return user;
  }
}

// 3. 在 EmailService 中监听事件
@Injectable()
export class EmailService {
  @OnEvent('user.registered')
  async sendWelcomeEmail(event: UserRegisteredEvent) {
    // 发送欢迎邮件
  }
}

练习 4:使用 Joi 验证配置(中阶)

为应用添加 Joi 验证,确保必要的配置项在启动时就被检查。故意删除 .env 中的必填项,观察应用启动时的错误信息。

练习 5:阅读 ConfigModule 源码(资深)

打开 @nestjs/config 包的源码,回答:

  1. registerAs() 如何生成命名空间的注入 token?
  2. ConfigService.get() 内部如何区分普通配置和命名空间配置?
  3. forRoot()forFeature() 返回的动态模块有什么结构差异?

九、本课知识点总结

知识点要点
ConfigModuleforRoot({ isGlobal: true }),基于 dotenv,运行时环境变量优先
ConfigService.get<T>(key)infer: true 类型推断,.getOrThrow()
命名空间配置registerAs() + forFeature()@Inject(config.KEY) 类型安全注入
配置验证Joi Schema,abortEarly: false 收集所有错误
Logger6 级日志,ConsoleLogger JSON 输出,new Logger(Context.name)
自定义 Logger实现 LoggerService 或继承 ConsoleLoggerbufferLogs: true
Event EmitterEventEmitterModule.forRoot()emit() + @OnEvent(),通配符
Cookiecookie-parser(Express)/ @fastify/cookie(Fastify)
Sessionexpress-session / @fastify/secure-session,生产用 Redis 存储
源码入口registerAs() 生成命名空间 token,ConfigService 类型推断

下一课预告:第十四课将学习异步处理的三大工具——缓存(cache-manager)、定时任务(@nestjs/schedule)和消息队列(BullMQ),掌握提升应用性能和处理耗时任务的核心技术。