手摸手,教你如何优雅的书写 NestJS 服务配置

0 阅读11分钟

开发服务端应用,配置肯定是少不了的。端口号、数据库账号、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 这类第三方模块时,往往要写一长串 useFactoryinjectimports。用了 .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/configload 机制非常灵活,只要你的工厂函数最终返回一个普通对象,它才不管数据是从哪来的。

5.1 玩转 YAML

先装解析库:npm i js-yamlnpm 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 过程中的一些整理与理解,欢迎在评论区补充/讨论;如果哪里有偏差,也欢迎直接指出,我会及时修正。