开发服务端应用,配置肯定是少不了的。端口号、数据库账号、Redis 密码、各种业务白名单……这些东西如果硬编码在代码里,不仅不安全,每次切环境还得改代码,简直是灾难。
配置的来源五花八门:简单的可以直接读环境变量(env),稍微复杂点的用 JSON、YAML 配置文件,遇到敏感信息或者微服务架构,可能还得去 Nacos、Apollo 这样的远端配置中心拉取。
在 NestJS 里,写配置的方式有很多。如果团队没个统一的规范,大家各写各的,虽然代码也能跑,但后期维护起来绝对会让人抓狂。这几年再使用 NestJS 过程中踩了不少坑,摸索出了一套比较舒服的配置管理姿势,今天就来系统地梳理一遍。
1. 刀耕火种:直接硬刚 process.env
最原始、最暴力的写法,就是直接在代码里读 process.env:
// 这种代码散落在项目的各个角落,后期维护极其痛苦
const port = process.env.PORT || 3000;
const dbHost = process.env.DATABASE_HOST;
这种写法最大的问题是毫无约束。字段名字全靠脑子记,类型全都是 string | undefined,还得自己手动转数字、转布尔值。哪天要是改个环境变量的名字,你得全局搜索一遍才能确认是不是都改全了。所以,这只能算是个起点,正经项目千万别这么搞。
2. 官方标配:@nestjs/config 模块
为了解决配置问题,NestJS 官方提供了一个 @nestjs/config 包。它的底层其实就是我们熟悉的 dotenv,这也是目前处理配置的标准入口。
先装个包(注意:它要求 TypeScript 4.1 及以上版本):
npm i --save @nestjs/config
2.1 怎么用起来?
最基础的用法,就是在根模块 AppModule 里引入 ConfigModule,调一下 forRoot()。它会自动去项目根目录找 .env 文件并解析:
// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // 强烈建议设为全局,这样其他模块就不用反复 import 了
}),
],
})
export class AppModule {}
💡 踩坑预警:当系统的环境变量和
.env文件里出现了同名的 key 时,系统环境变量的优先级更高,.env里的值会被无情覆盖。这是 dotenv 的默认规则,在线上部署排查问题时一定要记住这一点。
设置了 isGlobal: true 后,你在任何 Service 里都可以直接注入 ConfigService 来拿配置了。
2.2 应对多环境
真实项目肯定不止一个环境,起码有开发(dev)、测试(staging)、生产(prod)。我们可以传个数组给 envFilePath,让它按需加载。通常配合 NODE_ENV 动态决定加载哪个文件:
ConfigModule.forRoot({
envFilePath: [
`.env.${process.env.NODE_ENV}.local`, // 优先级最高:本地覆盖文件
`.env.${process.env.NODE_ENV}`, // 当前环境配置
'.env', // 兜底默认配置
],
isGlobal: true,
});
💡 踩坑预警:数组里越靠前的文件优先级越高。如果几个文件里有相同的 key,它只会认第一个找到的值,后面的文件里重复的 key 会被静默忽略。
3. 进阶:把"散装字符串"变成结构化对象
只用 .env 有个很烦人的点:读出来的全是一层扁平的字符串。如果配置一多,找起来费劲,而且到处都要写 parseInt。
NestJS 允许我们写配置工厂函数,把这些零散的环境变量组装成有结构、有类型、有默认值的对象。
// config/database.config.ts
export default () => ({
port: parseInt(process.env.PORT, 10) || 3000,
database: {
host: process.env.DATABASE_HOST || 'localhost',
port: parseInt(process.env.DATABASE_PORT, 10) || 5432,
user: process.env.DATABASE_USER,
password: process.env.DATABASE_PASSWORD,
},
});
然后在 AppModule 里通过 load 属性把这个工厂函数塞进去:
import databaseConfig from './config/database.config';
ConfigModule.forRoot({
isGlobal: true,
load: [databaseConfig], // 这里是个数组,可以塞多个配置进去,互不干扰
});
用的时候,就可以用点号(.)来读取嵌套属性了:
const dbHost = this.configService.get<string>('database.host');
const dbPort = this.configService.get<number>('database.port');
这比到处散落的 process.env.DATABASE_HOST 好多了,至少做到了集中管理。但这种写法还有个问题:字符串路径 'database.host' 没有类型提示,写错了只能靠运行时暴露。下面这个方案才是真正的终极解法。
4. 终极利器:命名空间(Namespace)
registerAs() 函数是 @nestjs/config 里最值得推荐的功能。它不只是给配置起个名字,更重要的是它返回的对象带有 .KEY 属性,可以直接用于依赖注入——这意味着你可以注入整个配置对象,而不是一个个字符串地去 get()。
4.1 划分命名空间
把不同业务的配置拆到各自的文件里:
// 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,
}));
// 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,
}));
然后在根模块统一加载:
ConfigModule.forRoot({
isGlobal: true,
load: [databaseConfig, redisConfig],
});
4.2 享受强类型的快感
用了命名空间后,可以直接注入整个配置对象,TypeScript 类型推导完整,IDE 有完整提示,字段名写错了编译阶段就报错:
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>,
) {}
getConnection() {
// 敲下 this.dbConfig. 的时候,IDE 会完整提示 host, port, name
return `${this.dbConfig.host}:${this.dbConfig.port}/${this.dbConfig.name}`;
}
}
💡 小贴士:
ConfigType<typeof databaseConfig>是官方提供的工具类型,它能自动反推工厂函数的返回值结构,省得你再去手写一遍 Interface。
4.3 丝滑对接第三方模块
命名空间配置还有个杀手锏:.asProvider() 方法。
平时配置 TypeORM 或 Redis 这类第三方模块时,往往要写一长串 useFactory、inject、imports。用了 .asProvider() 后,这些样板代码全部省掉——它的本质是把依赖声明和工厂函数打包成了标准的 forRootAsync 入参,NestJS 会据此建立正确的模块初始化顺序:
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, load: [databaseConfig] }),
// 等价于手写 imports/inject/useFactory 的完整异步配置
TypeOrmModule.forRootAsync(databaseConfig.asProvider()),
],
})
export class AppModule {}
关于为什么这能保证初始化顺序,以及手写 useFactory 时要注意什么,我们在第 7.2 节会展开说。
5. 换个口味:YAML、JSON 怎么搞?
.env 用起来最顺手,但它有个天然的短板:所有值读出来都是字符串,端口、超时时间这些数字都得自己手动 parseInt,布尔值也要写 === 'true' 来判断,一不小心就出错。
相比之下,YAML 和 JSON 本身就支持数字、布尔等原生类型,写配置的时候是什么类型读出来还是什么类型,完全不需要转换。而且层级结构更清晰,改起来更直观,后期维护成本也低得多。如果项目的配置项比较多,主动选择 YAML 或 JSON 来管理是个很合理的决定。
@nestjs/config 的 load 机制非常灵活,只要你的工厂函数最终返回一个普通对象,它才不管数据是从哪来的。
5.1 玩转 YAML
先装解析库:npm i js-yaml 和 npm i -D @types/js-yaml。
// config/yaml.config.ts
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import * as yaml from 'js-yaml';
export default () => {
return yaml.load(
readFileSync(join(__dirname, 'config.yaml'), 'utf8'),
) as Record<string, any>;
};
💡 踩坑预警:Nest CLI 默认打包时不会拷贝非 TS 文件。如果你用了 YAML,一定要去
nest-cli.json里配置assets,不然打完包跑起来绝对报找不到文件的错。{ "compilerOptions": { "assets": [{ "include": "../config/*.yaml", "outDir": "./dist/config" }] } }
5.2 远端配置中心(如 Nacos、Apollo)
如果你们用了配置中心,最常见的有两种接入方式:
方式一:CD 流程在容器启动前下发配置文件,把配置写到本地磁盘,应用启动时当普通本地文件读,和 5.1 的 YAML 方式一模一样,没有额外复杂度。
方式二:应用启动时主动拉取,把工厂函数写成异步的,在 ConfigModule 初始化期间完成拉取:
import { registerAs } from '@nestjs/config';
export default registerAs('secret', async () => {
// 去远端拉配置,拉不到就抛异常,阻止应用启动
const data = await fetchFromConfigCenter('/api/config/secret');
return { apiKey: data.apiKey, jwtSecret: data.jwtSecret };
});
两种方式的选择取决于你们的部署流程,不存在好坏之分。
6. 防患未然:配置校验
没有校验的配置就像是一颗定时炸弹。万一线上环境少配了一个数据库密码,应用可能照样启动,直到用户发起请求才原地爆炸。
我们必须让应用在启动阶段就把缺少的配置暴露出来。NestJS 给了两套方案:
6.1 简单粗暴的 Joi 校验
import * as Joi from 'joi';
ConfigModule.forRoot({
validationSchema: Joi.object({
NODE_ENV: Joi.string().valid('dev', 'prod').default('dev'),
PORT: Joi.number().default(3000),
DATABASE_HOST: Joi.string().required(), // 必填项,没有就不准启动!
}),
validationOptions: {
allowUnknown: true, // 允许出现没在 schema 里定义的变量
abortEarly: false, // 别一遇到错就停,把所有错一块报出来
},
});
💡 踩坑预警:如果你传了
validationOptions,那些没写的选项会回退到 Joi 的默认值,而不是@nestjs/config的默认值!比如allowUnknown,Joi 默认是false,但 Nest 默认是true。为了不被坑,建议把这俩属性老老实实写清楚。
6.2 面向对象的 class-validator
如果你更喜欢用类和装饰器,也可以自己写个校验函数:
import { plainToInstance } from 'class-transformer';
import { IsNumber, IsString, validateSync } from 'class-validator';
class EnvVariables {
@IsNumber() PORT: number;
@IsString() DATABASE_HOST: string;
}
export function validate(config: Record<string, unknown>) {
const validated = plainToInstance(EnvVariables, config, { enableImplicitConversion: true });
const errors = validateSync(validated, { skipMissingProperties: false });
if (errors.length > 0) throw new Error(errors.toString());
return validated;
}
// app.module.ts
ConfigModule.forRoot({ validate });
两种校验方式效果相同,Joi 更简洁,class-validator 在项目里已经有 DTO 校验体系时风格更统一,按喜好选就行。
7. 特殊场景
7.1 局部注册(forFeature)的生命周期坑
如果你的项目比较大,不想把所有配置都塞在 AppModule 里,可以在各自的业务模块里按需加载:
@Module({
imports: [ConfigModule.forFeature(databaseConfig)],
})
export class DatabaseModule {}
💡 踩坑预警:用
forFeature注册的配置,千万别在构造函数(constructor)里去读!因为模块初始化的顺序是不确定的,这时候配置可能还没加载完。正确的做法是用属性注入,然后在onModuleInit生命周期钩子里读取。
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigType } from '@nestjs/config';
import databaseConfig from './database.config';
@Injectable()
export class DatabaseService implements OnModuleInit {
// 属性注入,不在构造函数里触碰配置
@Inject(databaseConfig.KEY)
private readonly dbConfig: ConfigType<typeof databaseConfig>;
onModuleInit() {
// ✅ 到这里所有依赖模块都已初始化完毕,安全读取
console.log('db host:', this.dbConfig.host);
}
// ❌ 下面这种写法会在某些场景下读到 undefined
// constructor(
// @Inject(databaseConfig.KEY)
// private readonly dbConfig: ConfigType<typeof databaseConfig>,
// ) {
// console.log(this.dbConfig.host); // 危险!配置可能还没就绪
// }
}
7.2 配置之间的依赖链问题
这是一个很容易被忽略但实际开发中会真实踩到的坑。
以 TypeORM 为例,完整的启动依赖链是这样的:
ConfigModule 加载 .env / 配置文件
↓
databaseConfig 工厂函数读取环境变量,生成配置对象
↓
TypeOrmModule 拿到配置,建立数据库连接池
↓
各业务 Service / Repository 可以正常使用
↓
应用启动完成,开始接受请求
这条链上的顺序必须是确定的。如果配置还没就绪,TypeOrmModule 就开始初始化,轻则连接参数是 undefined,重则应用直接起不来。
第 4.3 节提到的 .asProvider() 能自动保证这个顺序,因为它展开后等价于:
{
imports: [ConfigModule.forFeature(databaseConfig)], // 声明:我依赖这个配置
useFactory: (config: ConfigType<typeof databaseConfig>) => config,
inject: [databaseConfig.KEY],
}
imports 字段就是向 NestJS 声明依赖关系的地方。框架看到这个声明,就会等 databaseConfig 就绪后再初始化 TypeOrmModule,顺序由框架保证,不需要你操心。
如果不用 .asProvider(),自己手写 useFactory,一定要记得写 imports,不然就是在赌运气:
TypeOrmModule.forRootAsync({
imports: [ConfigModule], // ← 必须写!告诉框架:等 ConfigModule 好了再来初始化我
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
type: 'mysql',
host: configService.get<string>('database.host'),
port: configService.get<number>('database.port'),
// ...
}),
}),
还有一个更细的场景:两个配置文件之间能互相依赖吗?比如 databaseConfig 想引用 appConfig 里的某个值。答案是不行,@nestjs/config 不支持配置工厂之间的注入。正确的做法是所有工厂函数都平级地从 process.env 取原始值,谁需要哪个环境变量就自己读,不要试图跨工厂函数共享:
// ❌ 错误:试图在配置工厂里引用另一个配置对象
export default registerAs('database', () => {
const appConfig = someHowGetAppConfig(); // 根本不存在这种 API
return { host: appConfig.defaultHost };
});
// ✅ 正确:直接从 process.env 取,各配置工厂平级独立
export default registerAs('database', () => ({
host: process.env.APP_DEFAULT_HOST || 'localhost',
}));
7.3 在 main.ts 里怎么拿配置?
像监听端口、CORS 域名这种配置,在 main.ts 里就要用到,这时候可以通过 app.get() 拿到 ConfigService 实例:
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
const port = configService.get<number>('PORT') ?? 3000;
await app.listen(port);
}
8. 总结:到底该怎么选?
写了这么多,咱们来拉个表对比一下:
| 姿势 | 类型安全 | 结构化 | 评价 |
|---|---|---|---|
直接硬刚 process.env | ❌ 纯盲写 | ❌ 扁平 | 适合写完就扔的临时脚本 |
ConfigModule + .env | 弱(全靠泛型强转) | ❌ 扁平 | 适合小型项目快速起步 |
| 自定义工厂函数 | ✅(需手写类型) | ✅ 嵌套对象 | 中规中矩,能处理默认值和类型转换 |
registerAs() 命名空间 | ✅✅(全自动推导) | ✅ 按业务隔离 | 强烈推荐!中大型项目的标准答案 |
💡 个人最推荐的实战组合:
registerAs() 命名空间 + isGlobal: true + 启动时 Joi 强校验。
这套组合的核心逻辑是:配置在哪定义,就在哪描述它的结构;业务代码只管用,根本不需要关心底层到底是 .env 还是 YAML。 这样哪怕以后要把配置迁移到云端,业务代码也一行都不用改,这才叫真正的优雅。
以上是我学习 NestJS 过程中的一些整理与理解,欢迎在评论区补充/讨论;如果哪里有偏差,也欢迎直接指出,我会及时修正。