Nest系列(十一)NestJS环境变量使用大全(长文警告)

6,131 阅读12分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第13天,点击查看活动详情

Nestjs环境变量的内容比较多,包括了各种配置方式,以及各种校验方式,比较多,官方链接:docs.nestjs.com/techniques/…,大家可以对照官网文档进行食用效果更佳!

内容较多,慢慢来… take it easy.

一、基础配置

配置环境变量,一般用来定义一些配置信息,比如数据库连接信息的配置,开发和生产环境不一样,也可以用来做其它配置

1. 安装依赖

NestJS很贴心的帮我做了环境变量配置的工作,提供了@nestjs/config包来进行环境变量的配置

npm install @nestjs/config --save

其内部依赖的是dotenv这个包,要求TypeScript版本大于等于4.1

2. 使用

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
​
@Module({
  imports: [ConfigModule.forRoot({这里放置配置信息})],
})
export class AppModule {}

默认情况下,这样配置之后,系统会解析项目根目录下的.env文件,提取其中的key/value对信息,并附加到process.env对象中,后面可以通过ConfigService获取到这些key/value值,一个简单的.env示例如下:

DATABASE_USER=coderli
DATABASE_PASSWORD=12345

3. 自定义env文件路径

默认情况下直接找的是根目录下的.env文件,我们也可以自定义,自定义接收一个数组,如果有重复定义的变量,那么谁在前谁生效。

ConfigModule.forRoot({
  envFilePath: ['.env.development.local', '.env.development'],
});

4. 禁用环境变量文件

如果不想加载.env文件,比如想直接在运行时环境中加入环境变量,那么可以进行如下设置:

ConfigModule.forRoot({
  ignoreEnvFile: true,
});

5. 全局应用ConfigModule

如果想在其它模块使用ConfigModule,不用处处引入,只需要配置成全局生效就可以:

ConfigModule.forRoot({
  isGlobal: true,
});

6. 自定义配置文件

(1)工厂函数的方式

我们可以根据不同业务类型单独抽离出不同的配置文件,ConfigModule.forRoot({})load配置项允许我们传一个工厂函数的数组,这些工厂函数返回一个个配置对象,如下,我们把系统服务端口的配置和数据库服务的配置放在一个配置文件中

// config/configuration.ts
export default () => ({
  port: parseInt(process.env.PORT, 10) || 3000,
  database: {
    host: process.env.DATABASE_HOST,
    port: parseInt(process.env.DATABASE_PORT, 10) || 3306
  }
});

app.module.ts中进行引入

import configuration from './config/configuration';
@Module({
  imports: [
    ConfigModule.forRoot({
      load: [configuration, xxxxxconfiguration],
    }),
  ],
})
export class AppModule {}

这里需要注意,load数组元素需要是一个函数,返回一个对象。

(2)YAML文件的方式

我们也可以使用yaml文件的方式来自定义配置文件,这需要我们安装相关的依赖来解析文件

$ npm i js-yaml
$ npm i -D @types/js-yaml

配置文件如下:

# config.yaml
http:
  host: 'localhost'
  port: 8080

db:
  mysql:
    url: 'localhost'
    port: 3306
    database: 'yaml-db'

  sqlite:
    database: 'sqlite.db'

然后进行导出,导出的依然是一个工厂函数

// config/configuration.ts
import { readFileSync } from 'fs';
import * as yaml from 'js-yaml';
import { join } from 'path';
​
const YAML_CONFIG_FILENAME = 'config.yaml';
​
export default () => {
  return yaml.load(
    readFileSync(join(__dirname, YAML_CONFIG_FILENAME), 'utf8'),
  ) as Record<string, any>;
};

7. 使用ConfigService

要么全局配置ConfigModuleisGlobaltrue,要么在需要使用的模块内imports[ConfigModule],然后我们就可以直接在构造函数中注入使用了。

// .env
DATABASE_PASSWORD=12345// 配置文件configuration.ts
export default () => ({
  port: parseInt(process.env.PORT, 10) || 3000,
  database: {
    host: process.env.DATABASE_HOST || 'root',
    port: parseInt(process.env.DATABASE_PORT, 10) || 3306,
  },
});
​
// app.module.ts引入ConfigModule并配置
import configuration from './config/configuration';
@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [configuration],
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
​
// app.controller.ts中使用
@Controller()
export class AppController {
  constructor(private readonly configService: ConfigService) {}
  @Get()
  getHello(): string {
    console.log(this.configService.get('port')); // 3000
    console.log(this.configService.get('database')); // { host: 'root', port: 3306 }
    console.log(this.configService.get('key', 'defaultValue')); // defaultValue
    return this.configService.get<string>('DATABASE_PASSWORD'); // 12345
  }
}

获取变量值的时候使用configService.get<T>(key, default_value?)方法,可以指定泛型T,如果找到key对应的value那么返回那个值,如果没找到,可以指定第二个可选参数,为默认值。

ConfigService的两个泛型参数

  • 第一个表示的是配置对象的类型
interface ConfigType {
  PORT: number;
  HOST: string;
}
@Controller()
export class AppController {
  constructor(
    private readonly configService: ConfigService<ConfigType>,
  ) {}
​
  @Get()
  getHello(): string {
    // TS2345: Argument of type '"port"' is not assignable to parameter of type 'keyof ConfigType'.
    console.log(this.configService.get('port', {infer: true}));
    return this.configService.get('HOST', {infer: true});
  }
}

当使用ConfigService泛型参数时,我们可以在使用get取值的时候不指定泛型参数,而是在get第二个参数传输配置信息{infer: true}这意味着会自动帮我推断类型,如下:

constructor(private configService: ConfigService<{ database: { host: string } }>) {
  const dbHost = this.configService.get('database.host', { infer: true })!;
  // typeof dbHost === "string"                                          |
  //                                                                     +--> non-null        //                                                                    assertion operator
}
  • 第二个泛型参数依赖于第一个,用来做类型断言的,当strictNullChecks严格模式开启会去除ConfigService的方法返回的undefined类型
constructor(private configService: ConfigService<{ PORT: number }, true>) {
  //                                                               ^^^^
  const port = this.configService.get('PORT', { infer: true });
  //    ^^^ The type of port will be 'number' thus you don't need TS type assertions        //         anymore
}
​

二、进阶配置

1. 命名空间namespace

可以使用@nestjs/config中的registerAs函数来返回一个配置,这个配置项带有命名空间,导出的配置依然放到ConfigModuleload配置项里

// process.env会收集.env文件中的key/value对和外部环境中的export的key/value对
export default registerAs('database', () => ({
  host: process.env.DATABASE_HOST,
  port: process.env.DATABASE_PORT || 3306
}));

当使用ConfigService获取配置的时候需要加上前面的命名空间,如下:

const dbHost = this.configService.get<string>('database.host');

我们还可以使用依赖注入的方式来注入配置信息:

// .env
DATABASE_HOST=root
DATABASE_PORT=3310// configuration.ts
export default registerAs('database', () => ({
  host: process.env.DATABASE_HOST,
  port: process.env.DATABASE_PORT || 3306
}));
​
// app.module.ts
@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [databaseConfig],
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
​
// app.controller.ts
import { Controller, Get, Inject } from '@nestjs/common';
import databaseConfig from './config/configuration';
import { ConfigType } from '@nestjs/config';
@Controller()
export class AppController {
  // 在这里通过依赖注入的方式获取到配置信息,不用显示的指定命名空间,而是通过.KEY属性来获取
  constructor(
    @Inject(databaseConfig.KEY)
    private dbConfig: ConfigType<typeof databaseConfig>,
  ) {}
  @Get()
  getHello(): string {
    console.log(databaseConfig.KEY); //CONFIGURATION(database)
    console.log(this.dbConfig); // { host: 'root', port: '3310' }
    return 'hello';
  }
}
​

databaseConfig.KEY的值是CONFIGURATION(database),不用我们手动指定namespace

2. 缓存环境变量

因为每次从文件读取环境变量是比较缓慢的,所以Nest为我们提供了缓存机制,只用读取一次,下次直接从缓存中拿数据,需要进行一个配置:

// app.module.ts
ConfigModule.forRoot({
  cache: true,
});

Nestjs还是很牛的,我简单扫了一下他们的缓存机制,可以把数据库查到的数据都缓存起来,不知道性能怎么样,和redis的用法差不多,后面再开一篇深入学习一下。

3. 局部注册

随着我们系统越来越庞大,会有各个不同的功能模块,可能每个模块需要用到的环境变量各不相同,我们也就没必要把所有环境变量都放到一起,而是分门别类,各用个的,各取所需,NestJS为我们想到了这一点,可以使用forFeature()这个静态方法来实现:

// database.module.ts
import databaseConfig from './config/database.config';
@Module({
  imports: [ConfigModule.forFeature(databaseConfig)],
})
export class DatabaseModule {}

这个配置只在DatabaseModule中引入使用

注意点:有些情况下,我们需要在onModuleInit()钩子函数中获取配置而不是在constructor()中,这是因为forFeature是在模块初始化的时候执行,而各个模块的初始化完成顺序是不一定的,如果你需要获取一个其他模块的配置项,很有可能人家还没加载好,获取到的是空值,所以放到onModuleInit()钩子函数中最安全,等到所有依赖的模块都加载完毕再获取相应的配置,这是比较安全的。对于怎么使用生命周期钩子,在生命周期一节已经讲过了。

4. 类型验证

最佳实践中,如果应用启动的时候没有拿到需要的环境变量或者环境变量不符合一定的条件应该抛出异常,NestJS提供了两种方式来实现

(1)Joi

首先需要安装依赖

npm install --save joi

需要node版本大于等于12,一般也满足了

然后我们就可以传入验证条件了,传给ConfigModule.forRoot配置项的validationSchema属性

import * as Joi from 'joi';
@Module({
  imports: [
    ConfigModule.forRoot({
      validationSchema: Joi.object({
        NODE_ENV: Joi.string()
          .valid('development', 'production', 'test', 'provision')
          .default('development'),
        PORT: Joi.number().default(3000),
      }),
    }),
  ],
})
export class AppModule {}

默认情况下,Joi.object中的所有属性都是可选的,上面我们都设置了默认值,当然我们也可以设置为必填,如下:

PORT: Joi.number().required(),

此时,如果我们的.env文件中没有设置过这个环境变量,应用启动的时候就会报错

image-20221215213430509

如果在.env中加入PORT=xxxx就不会启动就不会报错了

  • 默认情况下,没在validationSchema中的属性不会触发验证
  • 默认情况下,所有验证错误信息都会抛出出来

以上两种默认配置可以通过修改配置来更改

@Module({
  imports: [
    ConfigModule.forRoot({
      validationOptions: {
        //这里设为false则不在schema中的属性就会报错
        allowUnknown: false,
        // 默认为false,所有错误都抛出,如果为true则遇到第一个错误抛出异常后就停止验证了
        abortEarly: true,
      },
    }),
  ],
})
export class AppModule {}

image-20221215214121987

我们一般一起自定义这两个值,直接定义成这样就行,嘿嘿。

validationOptions: {
  allowUnknown: false,
  abortEarly: true,
}

(2)自定义validate()函数

自定义验证函数的基本思路是:定义一个同步的validate()函数,参数是从env文件和运行时获得的变量,返回结果是经过验证的环境变量,我们可以根据需要进行转换,函数抛出异常,应用将会启动失败,下面的例子我们使用class-transformer、class-validator这两个库来辅助

npm install class-validator class-transformer
import { plainToInstance } from 'class-transformer';
import { IsEnum, IsNumber, validateSync } from 'class-validator';
​
// 环境枚举
enum Environment {
  Development = "development",
  Production = "production",
  Test = "test",
  Provision = "provision",
}
// 需要校验的规则
class EnvironmentVariables {
  // 枚举类型校验
  @IsEnum(Environment)
  NODE_ENV: Environment;
  // PORT必须是数字
  @IsNumber()
  PORT: number;
}
// 自定义校验函数(config是从env文件或运行时读到的key/value对,这里的config包含了所有的环境变量)
export function validate(config: Record<string, unknown>) {
  // plainToInstance是将config字面量对象转为EnvironmentVariables实例对象
  const validatedConfig = plainToInstance(
    EnvironmentVariables,
    config,
    { enableImplicitConversion: true },
  );
  // 这里将会把所有的环境变量对转出来放到EnvironmentVariables实例对象中,下面看一下打印结果截图
  console.log('validatedConfig', validatedConfig);
  const errors = validateSync(validatedConfig, { skipMissingProperties: false });
​
  if (errors.length > 0) {
    throw new Error(errors.toString());
  }
  return validatedConfig;
}

然后在ConfigModule.forRoot中进行配置

@Module({
  imports: [
    ConfigModule.forRoot({
      validate,
    }),
  ],
})
export class AppModule {}

下面进行一波分析:

这里用到的几个方法,简单看一下源码:

plainToInstance

plain字面量对象转为cls类的实例对象,options中可以进行一些转换的配置

export declare function plainToInstance<T, V>(cls: ClassConstructor<T>, plain: V, options?: ClassTransformOptions): T;

enableImplicitConversion:默认是false,配置为true意味着会进行强制类型转换,因env中的定义取到的值都是字符串,如果有必须是number的判断,如上面的PORT属性,如果将enableImplicitConversion设置为false则必会出错,这里设置为true则会按照给定类里面相应的类型进行转换,转换失败才会报错。

如果只配置上面一个选项,那validateConfig将会把所有的环境变量都取出来,如下图所示 :

image-20221215230926246

如果不想把所有环境变量都转出来,查看源码可以设置一个属性

image-20221215231411340

这个属性设置为true可以选择性的转换,但是到底转换哪些属性呢?这就需要我们在定义目标转换类型的时候加上装饰器标识,@Expose标识转换,@Exclude标识排除,如下图所示我做了相应配置后得到的结果就只含有两个属性

image-20221215231806590

可以看到转换出来的实例对象瞬间干净了只有我们要的两个东西!

validateSync

这是来自class-validator库的同步验证函数,第一个参数是要验证的对象(验证的时候会根据属性类型中每一个属性进行验证),第二个参数是验证配置项,skipMissingPropertiestrue表示为none或者undefined的属性将会跳过不做验证,我们这里需要设置为false,因为两个属性我们都要做类型验证的。

image-20221215232749892

看上图,如果我们设置为了true那么即使PORT属性我们没有传,env文件里只有NODE_ENV,也不会报错!

上面的内容我第一次也感觉有点复杂,需要一点点仔细研究才行,上面也只是我个人阅读官网的一些研究结果,不具备权威性。

5. 封装get()函数

我觉得官网这里的封装目的是在使用一些环境变量的时候不想直接暴露的使用get(key)这种方式,而是进行一层service封装,提供一个服务,如下:

// 封装
@Injectable()
export class ApiConfigService {
  constructor(private configService: ConfigService) {}
  get isAuthEnabled(): boolean {
    return this.configService.get('AUTH_ENABLED') === 'true';
  }
}
// 使用
@Injectable()
export class AppService {
  constructor(apiConfigService: ApiConfigService) {
    if (apiConfigService.isAuthEnabled) {
      // Authentication is enabled
    }
  }
}
​

官网把这一部分称为自定义getter函数,其实就像我们的ControllerService一样,操作数据库的事都放到Service中,Controller中的逻辑更具语义化,感觉应该是一个道理。

6.环境变量加载钩子(Environment variables loaded hook)

这是一个等待env文件加载完毕的异步函数

当一个模块的配置依赖于环境变量,这些环境变量需要从.env文件读取,可以使用ConfigModule.envVariablesLoaded这个钩子函数来保证.env文件以及读取完毕并附加在process.env对象上了,如下通过环境变量来做动态模块

export async function getStorageModule() {
  await ConfigModule.envVariablesLoaded;
  return process.env.STORAGE === 'S3' ? S3StorageModule : DefaultStorageModule;
}

7. 扩展变量(Expandable variables

即一个环境变量的值可以作为一个变量在另一个环境变量中使用,如下:

APP_URL=mywebsite.com
SUPPORT_EMAIL=support@${APP_URL}

这个特性,是因为@nestjs/config包内部使用了 dotenv-expand.

这个特性需要手动开启才能生效:

@Module({
  imports: [
    ConfigModule.forRoot({
      expandVariables: true,
    }),
  ],
})
export class AppModule {}

8. 在main.ts中使用

虽然配置被存储在了一个Service中,但是我们依然可以在mian.ts中使用,如此一来,我们可以将应用的端口号、跨域主机等等信息存放在里面了,如下:

const configService = app.get(ConfigService);
const port = configService.get('PORT');

三、小结

环境变量配置内容时真的很多啊,用到的时候来这里翻一翻吧,要全部记住还是有点费力的。

具体应用场景还有待后面进一步深入,本文只是阅读官网文档的学习笔记,仅代表个人观点,欢迎大家批评指正。 如果对你有所帮助,不要忘了点个赞!