返回博客
NestJSConfigModuleZod环境变量模块系列

为什么我们没有直接使用 @nestjs/config,而是封装了自己的 ConfigModule

结合实际项目经验,讲解为什么我们没有直接使用 @nestjs/config,而是封装了自己的 ConfigModule,以更好地处理多环境加载、Zod 校验、配置覆盖与部署稳定性问题。

RaytonX
2 min read

为什么我们不直接用 @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 转换过的类型——PORTnumber,不再是 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 项目之间重复写配置初始化,欢迎直接用或者参考设计自己封装一套。