J7 · 配置与日志
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
| Spring | NestJS | 要点 |
|---|---|---|
application.yml、application-{profil}.yml | 由 ConfigModule.forRoot() 加载的 .env 和 .env.${NODE_ENV} | 扁平的 键=值 文件,没有层级,也没有 YAML 合并 |
spring.profiles.active=dev | NODE_ENV=development + .env.development | 同一时间只有一个“profile”,TaskFlow 只接受三个值 |
@Value("${jwt.secret}") | config.get('JWT_SECRET') | 声明了 schema 时值经过校验,否则是原始字符串 |
@ConfigurationProperties(prefix = "database") | registerAs('database', () => ({ ... })) + ConfigType | 映射要手写,没有自动绑定 |
属性类上的 @Validated | validate: validateEnv(class-validator)或 validationSchema | 启动时就失败,而不是第一次读取时 |
@Profile("dev")、@ConditionalOnProperty | ConditionalModule.registerWhen()、useFactory provider | 在模块里显式选择 |
| SLF4J + Logback、MDC | Nest 的 Logger + nestjs-pino、AsyncLocalStorage | 没有 ThreadLocal:上下文跟着异步调用链走 |
@PostConstruct | onModuleInit()(OnModuleInit) | 可以是 async:Nest 会等待这个 Promise |
@PreDestroy | onModuleDestroy()(OnModuleDestroy) | 只有经由 Nest 关闭时才会调用 |
| JVM 关闭钩子,Spring Boot 默认开启 | app.enableShutdownHooks() | 需要显式开启 |
原理
ConfigModule.forRoot
// 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() 依次做这些事:
- 读取
envFilePath中的文件。同一个变量出现在多个文件里时,第一个生效:.env.development优先于.env。 不存在的文件会被跳过。 - 把结果和
process.env合并。真实环境里(Docker、CI、shell)已经定义的变量优先于文件,除非设置了override: true。 - 把合并后的对象交给
validate()。如果这个函数抛错,应用就不会启动。 - 把校验后的值写回
process.env,只写那些还不存在的键:schema 里的默认值因此在任何地方都可见。 - 注册
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 与带类型的配置
// 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 类描述每个变量,启动时先转换再校验:
// 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 时,启动会停在:
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 里的其他变量,并一次性报告所有错误。
// 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,级别是数字(
30info、40warn、50error),在日志聚合系统里 很好过滤。 - 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 自己的类里没有定义钩子。一个用法示例:
// 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 纳入版本控制。它为每个变量写了说明:
# 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其他文件在本地从它复制出来:
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):
# .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=typeormTaskFlow 的 .gitignore(examples/.gitignore)排除了除示例之外的所有 .env* 文件:
node_modules/
dist/
coverage/
*.log
*.tsbuildinfo
.env*
!.env.examplegit check-ignore -v .env.test 会显示是哪条规则排除了这个文件。
带类型的配置:数据库、JWT、端口
每处需求都用最直接的方式读取配置:
- 数据库:
TypeOrmModule.forRootAsync()拿到的是已经带类型的database命名空间。
@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读取校验后的值:
// 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)
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 并开启关闭钩子:
// 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 和其他请求头):
{
"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。