配置 Configuration

导读

应用程序通常在不同的环境中运行。根据环境,应使用不同的配置设置。例如,通常本地环境依赖于特定的数据库凭据,仅对本地数据库实例有效。生产环境将使用一组单独的数据库凭据。由于配置变量会发生变化,最佳做法是将配置变量存储在环境中

外部定义的环境变量通过process.env全局变量在Node.js内部可见。我们可以尝试通过在每个环境中分别设置环境变量来解决多个环境的问题。这很快就会变得难以处理,特别是在开发和测试环境中,这些值需要轻松模拟和/或更改。

在Node.js应用程序中,通常使用.env文件来表示每个环境,其中包含键值对,其中每个键代表一个特定值。在不同的环境中运行应用程序只需交换正确的.env文件即可。

在 Nest 中使用此技术的一个好方法是创建一个 ConfigModule,它公开一个 ConfigService,该服务加载适当的 .env 文件。虽然您可以选择自己编写这样的模块,但为了方便起见,Nest 提供了现成的 @nestjs/config 包。我们将在本章中介绍这个包。

安装

要开始使用它,我们首先安装所需的依赖项。

bash
$ npm i --save @nestjs/config
提示

@nestjs/config 包内部使用 dotenv

注意

@nestjs/config 需要 TypeScript 4.1 或更高版本。

入门

安装过程完成后,我们可以导入 ConfigModule。通常,我们会将其导入根 AppModule,并使用 .forRoot() 静态方法控制其行为。在此步骤中,将解析和解析环境变量键/值对。稍后,我们将在其他功能模块中看到用于访问 ConfigModuleConfigService 类的几个选项。

app.module
ts
import { Module } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'

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

上述代码将从默认位置(项目根目录)加载并解析 .env 文件,将 .env 文件中的键/值对与分配给 process.env 的环境变量合并,并将结果存储在可以通过 ConfigService 访问的私有结构中。forRoot() 方法注册 ConfigService 提供程序,它提供 get() 方法来读取这些已解析/合并的配置变量。由于 @nestjs/config 依赖于 dotenv,因此它使用该包的规则来解决环境变量名称中的冲突。当一个键作为环境变量存在于运行时环境中(例如,通过 OS shell 导出,如 export DATABASE_USER=test)和 .env 文件中时,运行时环境变量优先。

示例 .env 文件如下所示:

bash
DATABASE_USER=test
DATABASE_PASSWORD=test

自定义 env 文件路径

默认情况下,包会在应用程序的根目录中查找 .env 文件。要为 .env 文件指定另一个路径,请设置传递给 forRoot() 的(可选)选项对象的 envFilePath 属性,如下所示:

ts
ConfigModule.forRoot({
  envFilePath: '.development.env',
})

您还可以为 .env 文件指定多个路径,如下所示:

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

如果在多个文件中发现一个变量,则第一个文件优先。

禁用环境变量加载

如果您不想加载 .env 文件,而是只想从运行时环境访问环境变量(如 OS shell 导出,如 export DATABASE_USER=test),请将选项对象的 ignoreEnvFile 属性设置为 true,如下所示:

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

全局使用模块

当您想在其他模块中使用 ConfigModule 时,您需要导入它(这是任何 Nest 模块的标准配置)。或者,通过将选项对象的 isGlobal 属性设置为 true 将其声明为 全局模块,如下所示。在这种情况下,一旦在根模块(例如 AppModule)中加载了 ConfigModule,您就不需要在其他模块中导入它。

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

自定义配置文件

对于更复杂的项目,您可以使用自定义配置文件返回嵌套配置对象。这允许您按功能对相关配置设置进行分组(例如,与数据库相关的设置),并将相关设置存储在单独的文件中以帮助独立管理它们。

自定义配置文件导出返回配置对象的工厂函数。配置对象可以是任意嵌套的纯 JavaScript 对象。process.env 对象将包含完全解析的环境变量键/值对(.env 文件和外部定义的变量按照上文所述进行解析和合并)。由于您可以控制返回的配置对象,因此您可以添加任何所需的逻辑以将值转换为适当的类型、设置默认值等。例如:

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

我们使用传递给 ConfigModule.forRoot() 方法的选项对象的 load 属性来加载此文件:

ts
import configuration from './config/configuration'

@Module({
  imports: [
    ConfigModule.forRoot({
      load: [configuration],
    }),
  ],
})
export class AppModule {}
通知

赋给load属性的值是一个数组,允许你加载多个配置文件(例如load: [databaseConfig, authConfig]

通过自定义配置文件,我们还可以管理自定义文件,例如YAML文件。下面是一个使用YAML格式的配置示例:

yaml
http:
  host: localhost
  port: 8080

db:
  postgres:
    url: localhost
    port: 5432
    database: yaml-db

  sqlite:
    database: sqlite.db

要读取和解析 YAML 文件,我们可以利用js-yaml包。

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

一旦包安装完毕,我们就使用yaml#load函数来加载我们刚刚创建的YAML文件。

config/configuration
ts
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import * as yaml from 'js-yaml'

const YAML_CONFIG_FILENAME = 'config.yaml'

export default () => {
  return yaml.load(
    readFileSync(join(__dirname, YAML_CONFIG_FILENAME), 'utf8'),
  ) as Record<string, any>
}
注意

Nest CLI 不会在构建过程中自动将您的资产(非 TS 文件)移动到 dist 文件夹。要确保您的 YAML 文件被复制,您必须在 nest-cli.json 文件中的 compilerOptions#assets 对象中指定这一点。例如,如果 config 文件夹与 src 文件夹处于同一级别,请添加 compilerOptions#assets,其值为 "assets": [{{ '{' }}"include": "../config/*.yaml", "outDir": "./dist/config"{{ '}' }}]。阅读更多 此处

使用 ConfigService

要从我们的 ConfigService 访问配置值,我们首先需要注入 ConfigService。与任何提供程序一样,我们需要将其包含的模块 - ConfigModule - 导入到将使用它的模块中(除非您将传递给 ConfigModule.forRoot() 方法的选项对象中的 isGlobal 属性设置为 true)。 将其导入到功能模块中,如下所示。

feature.module
ts
@Module({
  imports: [ConfigModule],
  // ...
})

然后我们可以使用标准构造函数注入来注入它:

ts
constructor(private configService: ConfigService) {}
提示

ConfigService 是从 @nestjs/config 包导入的。

并在我们的类中使用它:

ts
// 获取环境变量
const dbUser = this.configService.get<string>('DATABASE_USER')

// 获取自定义配置值
const dbHost = this.configService.get<string>('database.host')

如上所示,使用 configService.get() 方法通过传递变量名来获取一个简单的环境变量。您可以通过传递类型来执行 TypeScript 类型提示,如上所示(例如 get<string>(...))。get() 方法还可以遍历嵌套的自定义配置对象(通过 自定义配置文件 创建),如上面的第二个示例所示。

您还可以使用接口作为类型提示来获取整个嵌套的自定义配置对象:

ts
interface DatabaseConfig {
  host: string
  port: number
}

const dbConfig = this.configService.get<DatabaseConfig>('database')

// you can now use `dbConfig.port` and `dbConfig.host`
const port = dbConfig.port

get() 方法还接受一个可选的第二个参数,该参数定义一个默认值,当键不存在时将返回该默认值,如下所示:

ts
// use "localhost" when "database.host" is not defined
const dbHost = this.configService.get<string>('database.host', 'localhost')

ConfigService 有两个可选的泛型(类型参数)。第一个是为了帮助防止访问不存在的配置属性。如下所示使用它:

ts
interface EnvironmentVariables {
  PORT: number;
  TIMEOUT: string;
}

// somewhere in the code
constructor(private configService: ConfigService<EnvironmentVariables>) {
  const port = this.configService.get('PORT', { infer: true });

  // TypeScript Error: this is invalid as the URL property is not defined in EnvironmentVariables
  const url = this.configService.get('URL', { infer: true });
}

infer 属性设置为 true 后,ConfigService#get 方法将根据接口自动推断属性类型,例如 typeof port === "number"(如果您未使用 TypeScript 中的 strictNullChecks标志),因为PORTEnvironmentVariables接口中具有number` 类型。

此外,使用 infer 功能,您可以推断嵌套自定义配置对象属性的类型,即使使用点符号也是如此,如下所示:

ts
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方法可以返回的所有未定义类型。例如:

ts
// ...
constructor(private configService: ConfigService<{ PORT: number }, true>) {
  const port = this.configService.get('PORT', { infer: true });
  //  端口类型将为`数字`,因此您不再需要 TS 类型断言
}

配置命名空间

ConfigModule允许您定义和加载多个自定义配置文件,如上文自定义配置文件所示。您可以使用嵌套配置对象管理复杂的配置对象层次结构,如该部分所示。或者,您可以使用registerAs()函数返回命名空间配置对象,如下所示:

config/database.config
ts
export default registerAs('database', () => ({
  host: process.env.DATABASE_HOST,
  port: process.env.DATABASE_PORT || 5432
}))

与自定义配置文件一样,在 registerAs() 工厂函数中,process.env 对象将包含完全解析的环境变量键/值对(.env 文件和外部定义的变量按上述方式解析和合并)。

提示

registerAs 函数从 @nestjs/config 包中导出。

使用 forRoot() 方法的选项对象的 load 属性加载命名空间配置,方式与加载自定义配置文件相同:

ts
import databaseConfig from './config/database.config'

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

现在,要从database命名空间获取host值,请使用点表示法。使用database作为属性名称的前缀,对应于命名空间的名称(作为第一个参数传递给registerAs()函数):

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

一个合理的替代方案是直接注入数据库命名空间。这使我们能够从强类型中受益:

ts
constructor(
  @Inject(databaseConfig.KEY)
  private dbConfig: ConfigType<typeof databaseConfig>,
) {}
提示

ConfigType@nestjs/config 包导出。

缓存环境变量

由于访问 process.env 可能会很慢,您可以设置传递给 ConfigModule.forRoot() 的选项对象的 cache 属性,以提高 ConfigService#get 方法在处理存储在 process.env 中的变量时的性能。

ts
ConfigModule.forRoot({
  cache: true,
})

部分注册

到目前为止,我们已经使用 forRoot() 方法处理了根模块(例如 AppModule)中的配置文件。也许您的项目结构更复杂,功能特定的配置文件位于多个不同的目录中。@nestjs/config 包提供了一项名为 部分注册 的功能,而不是在根模块中加载所有这些文件,该功能仅引用与每个功能模块关联的配置文件。使用功能模块中的 forFeature() 静态方法执行此部分注册,如下所示:

ts
import databaseConfig from './config/database.config'

@Module({
  imports: [ConfigModule.forFeature(databaseConfig)],
})
export class DatabaseModule {}
警告

在某些情况下,您可能需要使用 onModuleInit() 钩子访问通过部分注册加载的属性,而不是在构造函数中。这是因为 forFeature() 方法在模块初始化期间运行,并且模块初始化的顺序是不确定的。如果您在构造函数中以这种方式访问由另一个模块加载的值,则配置所依赖的模块可能尚未初始化。onModuleInit() 方法仅在其所依赖的所有模块都已初始化后才运行,因此这种技术是安全的。

架构验证

如果未提供所需的环境变量或它们不符合某些验证规则,则在应用程序启动期间抛出异常是标准做法。@nestjs/config 包支持两种不同的方式来实现此目的:

  • Joi 内置验证器。使用 Joi,您可以定义对象架构并根据它验证 JavaScript 对象。
  • 自定义 validate() 函数,以环境变量作为输入。

要使用 Joi,我们必须安装 Joi 包:

bash
$ npm install --save joi

现在我们可以定义一个 Joi 验证模式,并通过 forRoot() 方法的选项对象的 validationSchema 属性传递它,如下所示:

app.module
ts
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().port().default(3000),
      }),
    }),
  ],
})
export class AppModule {}

默认情况下,所有架构键都被视为可选的。在这里,我们为 NODE_ENVPORT 设置默认值,如果我们没有在环境(.env 文件或进程环境)中提供这些变量,则将使用这些默认值。或者,我们可以使用 required() 验证方法来要求必须在环境(.env 文件或进程环境)中定义一个值。在这种情况下,如果我们没有在环境中提供变量,验证步骤将引发异常。有关如何构建验证架构的更多信息,请参阅 Joi 验证方法

默认情况下,允许使用未知环境变量(其键不在架构中的环境变量),并且不会触发验证异常。默认情况下,会报告所有验证错误。您可以通过 forRoot() 选项对象的 validationOptions 键传递选项对象来更改这些行为。此 options 对象可以包含 Joi 验证选项 提供的任何标准验证选项属性。例如,要反转上述两个设置,请传递如下选项:

app.module
ts
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().port().default(3000),
      }),
      validationOptions: {
        allowUnknown: false,
        abortEarly: true,
      },
    }),
  ],
})
export class AppModule {}

@nestjs/config 包使用以下默认设置:

  • allowUnknown:控制是否允许环境变量中的未知键。默认值为 true
  • abortEarly:如果为 true,则在第一次错误时停止验证;如果为 false,则返回所有错误。默认值为 false

请注意,一旦您决定传递 validationOptions 对象,任何您未明确传递的设置都将默认为 Joi 标准默认值(而不是 @nestjs/config 默认值)。例如,如果您在自定义 validationOptions 对象中未指定 allowUnknowns,它将具有 Joi 默认值 false。因此,在自定义对象中指定这两者设置可能是最安全的。

自定义验证函数

或者,您可以指定一个同步validate函数,该函数接受一个包含环境变量(来自 env 文件和进程)的对象并返回一个包含已验证环境变量的对象,以便您可以在需要时转换/改变它们。如果该函数抛出错误,它将阻止应用程序引导。

在此示例中,我们将继续使用class-transformerclass-validator包。首先,我们必须定义:

  • 具有验证约束的类,
  • 使用plainToInstancevalidateSync函数的验证函数。
env.validation
ts
import { plainToInstance } from 'class-transformer'
import { IsEnum, IsNumber, Max, Min, validateSync } from 'class-validator'

enum Environment {
  Development = 'development',
  Production = 'production',
  Test = 'test',
  Provision = 'provision',
}

class EnvironmentVariables {
  @IsEnum(Environment)
  NODE_ENV: Environment

  @IsNumber()
  @Min(0)
  @Max(65535)
  PORT: number
}

export function validate(config: Record<string, unknown>) {
  const validatedConfig = plainToInstance(
    EnvironmentVariables,
    config,
    { enableImplicitConversion: true },
  )
  const errors = validateSync(validatedConfig, { skipMissingProperties: false })

  if (errors.length > 0) {
    throw new Error(errors.toString())
  }
  return validatedConfig
}

完成这些后,使用 validate 函数作为 ConfigModule 的配置选项,如下所示:

app.module
ts
import { validate } from './env.validation'

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

自定义 getter 函数

ConfigService 定义一个通用的 get() 方法,用于通过键检索配置值。我们还可以添加 getter 函数,以实现更自然的编码风格:

ts
TS
ts
@Injectable()
export class ApiConfigService {
  constructor(private configService: ConfigService) {}

  get isAuthEnabled(): boolean {
    return this.configService.get('AUTH_ENABLED') === 'true'
  }
}

现在我们可以如下使用 getter 函数:

app.service
ts
@Injectable()
export class AppService {
  constructor(apiConfigService: ApiConfigService) {
    if (apiConfigService.isAuthEnabled) {
      // Authentication is enabled
    }
  }
}
js
@Dependencies(ApiConfigService)
@Injectable()
export class AppService {
  constructor(apiConfigService) {
    if (apiConfigService.isAuthEnabled) {
      // Authentication is enabled
    }
  }
}

环境变量加载钩子

如果模块配置依赖于环境变量,并且这些变量是从 .env 文件加载的,则可以使用 ConfigModule.envVariablesLoaded 钩子来确保在与 process.env 对象交互之前已加载该文件,请参见以下示例:

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

这种构造保证在 ConfigModule.envVariablesLoaded Promise 解析后,所有配置变量都已加载。

条件模块配置

有时您可能希望有条件地加载模块并在环境变量中指定条件。幸运的是,@nestjs/config 提供了一个 ConditionalModule,允许您这样做。

ts
@Module({
  imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(FooModule, 'USE_FOO')],
})
export class AppModule {}

如果在 .env 文件中,环境变量 USE_FOO 的值不为 false,则上述模块将仅加载到 FooModule 中。您还可以自己传递自定义条件,即接收 process.env 引用的函数,该函数应返回一个布尔值,以供 ConditionalModule 处理:

ts
@Module({
  imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(FooBarModule, (env: NodeJS.ProcessEnv) => !!env.foo && !!env.bar)],
})
export class AppModule {}

重要的是要确保在使用 ConditionalModule 时,您还在应用程序中加载了 ConfigModule,以便可以正确引用和使用 ConfigModule.envVariablesLoaded 挂钩。如果用户在 registerWhen 方法的第三个选项参数中设置的 5 秒内或毫秒内的超时未将挂钩翻转为 true,则 ConditionalModule 将抛出错误,并且 Nest 将中止启动应用程序。

可扩展变量

@nestjs/config 包支持环境变量扩展。使用此技术,您可以创建嵌套环境变量,其中一个变量在另一个变量的定义中被引用。例如:

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

通过这种构造,变量 SUPPORT_EMAIL 解析为 'support@mywebsite.com'。请注意使用 ${{ '{' }}...{{ '}' }} 语法来触发解析 SUPPORT_EMAIL 定义中的变量 APP_URL 的值。

提示

对于此功能,@nestjs/config 包内部使用 dotenv-expand

使用传递给 ConfigModuleforRoot() 方法的选项对象中的 expandVariables 属性启用环境变量扩展,如下所示:

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

main.ts 中使用

虽然我们的配置存储在服务中,但它仍然可以在 main.ts 文件中使用。这样,您可以使用它来存储变量,例如应用程序端口或 CORS 主机。

要访问它,您必须使用 app.get() 方法,后跟服务引用:

ts
const configService = app.get(ConfigService)

然后您可以像往常一样使用它,通过使用配置键调用 get 方法:

ts
const port = configService.get('PORT')