为什么我们不直接用 @nestjs/config——以及我们如何封装了自己的配置模块
在多个 NestJS 项目里,我们发现配置初始化的代码几乎每次都要重写一遍:加载多个 env 文件、按环境区分、用 Zod 做校验、处理优先级覆盖。@nestjs/config 能解决大部分问题,但有几个地方每次都要自己补,久了就决定把这些约定封装成 @raytonx/config。
这篇文章不是在说官方包不好,而是聊聊我们在实际项目里遇到了什么、做了哪些设计决策,以及最终的结果是什么样的。
问题一:每个项目都在重复写 env 文件加载顺序
@nestjs/config 支持传入 envFilePath,但不内置"按环境自动加载"的约定。每次我们都要手动写:
envFilePath: [
'.env',
'.env.local',
`.env.${process.env.NODE_ENV}`,
`.env.${process.env.NODE_ENV}.local`,
]
这段代码出现在了我们几乎每一个项目里。@raytonx/config 把这个约定变成了一个关键字:
ConfigModule.forRoot({
envFilePath: 'auto',
})
"auto" 会按以下顺序加载,后加载的覆盖先加载的,process.env 优先级最高:
.env
.env.local
.env.${NODE_ENV}
.env.${NODE_ENV}.local
process.env ← 最高优先级
这个约定参考了 Vite 和 Create React App 的做法,对前端背景的开发者来说几乎零学习成本。
问题二:配置没有类型,getOrThrow 返回的是 string
官方 ConfigService 的类型支持依赖手动传泛型,而且拿到的值往往还是 string——比如 PORT 你需要自己 parseInt。
我们的解法是在模块初始化阶段接入 Zod,同时完成校验和类型转换:
import { z } from 'zod'
const configSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
})
type AppConfig = z.infer<typeof configSchema>
ConfigModule.forRoot<AppConfig>({
isGlobal: true,
envFilePath: 'auto',
schema: configSchema,
})
之后通过 ConfigService<AppConfig> 拿到的值是已经被 Zod 转换过的类型——PORT 是 number,不再是 string:
@Injectable()
export class AppService {
constructor(private readonly config: ConfigService<AppConfig>) {}
get port(): number {
return this.config.getOrThrow('PORT') // 类型是 number,不是 string
}
}
校验失败时会在应用启动阶段直接抛出 ConfigValidationError,包含失败的字段路径和 Zod 的错误信息,而不是等到运行时才发现某个环境变量缺失或格式错误。
让错误在启动时暴露,而不是在运行时暴露——这是这个设计最重要的出发点。
问题三:测试和特殊环境需要覆盖部分配置
有时候我们需要在模块初始化时显式覆盖某些值,比如在集成测试里强制指定数据库连接,或者在 monorepo 里的某个子应用里覆盖 APP_NAME。
@nestjs/config 没有直接支持这种场景。我们通过 values 字段来解决:
ConfigModule.forRoot({
envFilePath: 'auto',
values: {
APP_NAME: 'api',
},
})
优先级是明确的,不存在歧义:
env 文件 < values < process.env
values 比 env 文件优先级高,但低于 process.env。这意味着 CI/CD 环境里通过系统环境变量注入的值永远能覆盖代码里的默认值,而代码里的 values 又能覆盖本地的 env 文件——符合大多数项目的直觉。
完整示例
.env.development:
NODE_ENV=development
PORT=3000
DATABASE_URL=https://db.example.com
支持变量展开(默认开启):
APP_HOST=localhost
APP_PORT=3000
APP_URL=http://${APP_HOST}:${APP_PORT}
src/app.module.ts:
import { Module } from '@nestjs/common'
import { ConfigModule } from '@raytonx/config'
import { z } from 'zod'
const configSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
})
export type AppConfig = z.infer<typeof configSchema>
@Module({
imports: [
ConfigModule.forRoot<AppConfig>({
isGlobal: true,
envFilePath: 'auto',
schema: configSchema,
}),
],
})
export class AppModule {}
src/app.service.ts:
import { Injectable } from '@nestjs/common'
import { ConfigService } from '@raytonx/config'
import type { AppConfig } from './app.module'
@Injectable()
export class AppService {
constructor(private readonly config: ConfigService<AppConfig>) {}
get port(): number {
return this.config.getOrThrow('PORT')
}
}
总结
@raytonx/config 在 @nestjs/config 的基础上做了三件事:
envFilePath: "auto"把多环境加载约定变成一个关键字,消除重复代码- Zod schema 把类型校验提前到启动阶段,
PORT拿到的是number而不是string values字段提供了清晰的优先级覆盖机制,测试和多应用场景更容易管理
安装:
pnpm add @raytonx/config
源码和完整文档在 npm。如果你们团队也在多个 NestJS 项目之间重复写配置初始化,欢迎直接用或者参考设计自己封装一套。