主要内容
我们的应用程序可能被运行在各种不同的环境中。以开发过程为维度,可以分为开发时和运行时;以运行环境为维度,又可以分为开发环境、测试环境和生产环境;不同的环境中,配置会有不同的设定。特别是在生产环境中,有相当一部分的配置属于敏感信息亦或是机密信息,不适合让所有开发者接触到的。另外,程序中如果缺少某些配置(或允许起缺失),那么可以有默认的配置启用;例如缓存,如果没有特别配置,那就应该采用localhost:6379
,如果这样的逻辑写入代码,会变成:
const client = redis.createClient(options??'redis://localhost:6379')
虽然在逻辑上没有问题,但不够优雅。
配置方式类型
NestJS
框架提供@nestjs/config
组建,让 我们可以通过三种类型的配置,来给应用程序定义环境变量。而变量的加载过程和权重应该是:应用程序内配置(默认值) < 系统环境变量 < .env
文件。
应用程序内配置
通过将变量加载到应用程序内实例类时,“顺手”将没有获取到的环境配置或dotenv
配置,赋予一个默认的值。例如:
register(): ConfigFactory {
return registerAs(
Env.registerKey,
(): EnvConfig => ({
appName: process.env.APP_NAME,
port: parseInt(process.env.PORT, 10) || 3000,
host: process.env.HOST || 'localhost',
nodeEnv: process.env.NODE_ENV || 'dev',
logDir: process.env.LOG_DIR || '/var/log',
rootDir: process.env.ROOT_DIR,
}),
);
}
处理一些不是很关键,通常有明显的默认参数;
系统环境变量
(本文主要以Linux系统为例,Mac和Windows相关的设置不难找到)
系统的配置文件
- /etc/profile - 这是系统范围内的配置文件,设置这里的变量对所有用户和shell都有效
- /etc/environment - 这是一个系统范围内的配置文件,用于定义系统环境变量。
- ~/.bashrc - 用户Home目录下的Bash的配置文件,对该用户的bash shell会话有效。
配置的方式如:
export key='value'
注意:在修改配置文件后不会立刻生效,需要执行命令使之生效。
source /etc/profile
容器配置的环境文件
如果应用程序在容器中运行,相比上面的方式,额外有三处可以加入配置:
Dockerfile
在构建镜像的时候就设置环境变量,例如:ENV key='value'
docker compose
启动应用在docker-compose.yml
文件中加入:version: '3'
services:
web:
image: app
environment:
- key=value
docker run -d --name app -e key=value app:latest
应用程序启动时进行配置
也可以是通过在应用程序启动之前,配置临时的系统变量。由于Linux、Unix、Mac或Windows系统各有不同,可以利用cross-env
组件,并在启动脚本之前加入所需要的配置,例如:
// package.json
"scripts":{
"dev":"cross-env key=value && node ./bin/www"
}
或者在类Linux系统中直接设置环境
"scripts":{
"dev":"export key=value && node ./bin/www"
}
dotenv
文件
NestJS官方库推荐@nestjs/config
组件,其中依赖dotenv
组件,可以加载.env
的配置文件,并经由module
文件注册到程序中,能在程序的任意处获取配置。这里先简单介绍一下.env
文件的作用。
.env
文件是用于存储环境变量的配置文件,它可以用来配置不同环境下(如开发环境、测试环境、生产环境等)的变量。
主要的作用有:
.env
可以为不同的环境设置不同的配置,避免在代码中硬编码这些配置。.env
中存储一些敏感信息如数据库密码、API密钥等,将其从代码中抽离出来。.env
文件,使部署时可以很方便地切换环境配置。.env
文件中,便于查阅和修改。在代码中,可以通过进程环境变量访问.env
文件中的变量。例如在Node.js中可以通过process.env.XXX
来获取。
一个.env
文件的常见内容示例:
APP_NAME=my-app
PORT=3000
HOST=localhost
URL=http://${HOST}:${PORT}
DB_HOST=localhost
DB_USER=root
DB_PASS=123456
实践中使用配置方案
对应用程序(或系统)配置时,还需要考虑两个方面:
首先,在不同的环境中,配置内容可能有较大差异,例如数据库地址:在开发是,可能就随便设置的本地服务,如mysql://localhost:5678
,而在生产环境中,就严谨的多。两者有较大差异。让应用程序根据不同的环境加载不同配置文件。
然而对环境
的定义本身也是一类配置,这类型的配置就不适合与应用程序产生较强的耦合性(不管是用.env
文件,还是放在启动命令之前)。可以将配置放在系统层的环境变量中,例如profile
或dockerfile
文件中,如NODE_ENV=prod
。
最后,因为大多数程序代码是交给诸如git
等版本管理工具管理,一定要将.env
类的文件进行隔离。试想:如果将prod.env
文件也一并托管给git
,万一源代码泄露,必然会造成更严重的后果。但是又免不了在新的环境中进行一番配置,所以合适的做法是在.gitignore
文件中将.env
文件设置为忽略,但是在源代码中创建一个xxx.env.bak
的文件作为环境配置样本文件,将一些无关要紧的配置或配置样本作为参考,方便开发者和运维人员在初次运行时配置。
官方典型方案分析
NestJS的官方文档中有一章专门讲解如何使用配置,并推荐@nestjs/config
库。有兴趣的同学可以前往官方文档了解具体内容。这里简单地说一下@nestjs/config
原理。
Service
主要负责读取配置的工作,并交由NestJS托管实例的依赖。
Module
主要负责配置文件的加载和验证,以及作用域和持久化等设定。其中,验证逻辑主要由joi
库负责。在配置放面,除了读取.env
文件用了dotenv
库以外,开发者也可以自定义一些配置或通过自行解析yaml
文件加载以文件形式存在的配置。
定义配置(接口),注册到配置域中
import { registerAs } from '@nestjs/config';
export interface RedisConfig {
port: string;
host: string;
password: string;
username: string;
}
export default registerAs('redis', (): RedisConfig => {
return {
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || '6379',
password: process.env.REDIS_PASSWORD,
username: process.env.REDIS_USERNAME,
};
});
配置加载和验证
在模块配置程序中(官方的案例在根模块下)通过ConfigModule
对象的validationSchema
的参数传入Joi
对象,便可以实现加载配置后自动验证。
先定义一个配置法则:
// env.schema.ts
import * as Joi from 'joi';
export default Joi.object({
PORT: Joi.number().min(1000).max(65535).required().label('监听端口'),
NODE_ENV: Joi.string().valid('deve', 'prod', 'test').default('deve'),
HOST: Joi.string().hostname().default('localhost'),
URL: Joi.string().uri(),
// EMAIL: Joi.string().email(),
});
将此引入到模块中:
// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import validator from './config/env.schema'
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // 全局作用域
cache: true, // 缓存配置
expandVariables: true, // 启用循环配置
envFilePath: 'dev.env', // `.env`配置文件地址
validationSchema: validator, // 验证器
validationOptions: {
abortEarly: true,
},
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
使用配置
在需要使用配置的地方,引入ConfigService
,并提取某个域或某个配置值:
// app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { ConfigService } from '@nestjs/config';
import { Env, EnvConfig } from './config';
@Controller()
export class AppController {
constructor(
private readonly appService: AppService,
private readonly configService: ConfigService,
) {}
@Get()
getHello(): string {
const redisHost = this.configService.get('redis.host');
console.log(redisHost);
return this.appService.getHello();
}
@Get('env')
getEnv(): string {
const env = this.configService.get('redis');
return env;
}
}
最佳实践
使用折磨了一段时间后,渐渐地摸索出一套高效又不失严谨的配置方案,让程序的源代码看上去既简洁又优雅。
封装ConfigModule
在官方案例中,每个配置(域)采用工厂模式,经由ConfigModule
先执行加载配置,后执行验证工作,如果遇到错误,则输出相关的提示并抛出异常。这个过程相对固定,所以我们可以将其解耦,再用封装一级动态模块:
// config-plus.module.ts
import { DynamicModule, Module } from '@nestjs/common';
import { ConfigSchema } from './config-schema.interface';
import { join, resolve } from 'path';
import { ConfigModule } from '@nestjs/config';
export interface ConfigOptions {
/**
* 配置文件名
*/
envFilePath?: string;
}
@Module({})
export class ConfigPlus {
/**
* 注册自定义配置
* @param options {ConfigOptions | ConfigSchema} 配置选项或者配置域
* @param args {Array} 配置域
* @returns {DynamicModule} 动态模块
*/
static register(
options: ConfigOptions | ConfigSchema,
...args: Array
): DynamicModule {
let envFilePath: string = resolve(
join(process.cwd(), `${process.env.NODE_ENV || 'dev'}.local.env`),
);
// 如果options符合ConfigSchema接口,那说明它是一个注册配置域
if ('envFilePath' in options) {
envFilePath = options.envFilePath;
} else {
args.unshift(options as ConfigSchema);
}
const configs = args.map((env) => env.register());
const validations = args.map((env) => env.validation);
const Config = ConfigModule.forRoot({
isGlobal: true,
cache: true,
expandVariables: true,
envFilePath,
load: configs,
validate: () => {
const results = configs.map((config, index) => {
return validations[index].validate(config(), { abortEarly: false });
});
// 从 Joi 的 ValidationError 中提取错误信息
const errors = results
.map((result) => result.error?.message)
.filter((str) => str)
.join('\n');
if (errors) throw new Error(errors);
const result = Object.assign(
{},
...results.map((result) => result.value),
);
return result;
},
});
return {
module: ConfigPlus,
imports: [Config],
};
}
}
然后再约束配置域类的定义方式,是其更严谨:
// config-schema.interface.ts
import * as Joi from 'joi';
import { ConfigFactory } from '@nestjs/config';
/**
* 通用配置接口
*
* 推荐所有配置域都要遵循标准
*/
export interface ConfigSchema {
/**
* 注册的名称
*/
registerKey: string;
/**
* 用工厂模式注册配置
*
* @returns {ConfigFactory} 配置工厂
*/
register(): ConfigFactory;
/**
* Joi的验证器
*/
validation: Joi.ObjectSchema;
}
// 约束开发者在定义配置域所采用的接口要用静态方式实现
export function StaticImplements() {
return (constructor: U) => {
constructor;
};
}
因为配置都采用的是静态类,所以要让接口的方法和属性以静态方式实现,定义工具方法StaticImplements
。
定义一个配置域:
// src/config/env.ts
// 基础环境配置
import * as Joi from 'joi';
import { ConfigSchema, StaticImplements } from 'libs/config-schema.interface';
import { ConfigFactory, registerAs } from '@nestjs/config';
export type EnvConfig = {
/**
* 应用程序名
*/
appName: string;
// 服务绑定
port: number;
host: string;
// 基本环境预报
nodeEnv: string;
logDir: string;
// 应用程序位置
rootDir: string;
};
@StaticImplements()
export class Env {
static registerKey = 'env';
static register(): ConfigFactory {
return registerAs(
Env.registerKey,
(): EnvConfig => ({
appName: process.env.APP_NAME,
port: parseInt(process.env.PORT, 10) || 3000,
host: process.env.HOST || 'localhost',
nodeEnv: process.env.NODE_ENV || 'dev',
logDir: process.env.LOG_DIR || '/var/log',
rootDir: process.env.ROOT_DIR,
}),
);
}
static validation: Joi.ObjectSchema = Joi.object({
appName: Joi.string().required().label('应用程序名APP_NAME'),
port: Joi.number().required().min(1000).max(65535).label('服务端口号HOST'),
host: Joi.string().required().label('服务器主机HOST'),
nodeEnv: Joi.string()
.required()
.valid('dev', 'prod', 'test')
.label('运行环境NODE_ENV'),
logDir: Joi.string()
.regex(/^/(?:[^/]+/)*[^/]+/?$/)
.required()
.label('日志目录LOG_DIR'),
rootDir: Joi.string()
.regex(/^/(?:[^/]+/)*[^/]+/?$/)
.required()
.label('应用程序根目录ROOT_DIR'),
});
}
到这里,我们就可以“优雅”地配置环境变量了:
// app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigPlus } from 'libs/config-plus.module';
import { Env, Front } from './config';
import { join, resolve } from 'path';
import { ConfigService } from '@nestjs/config';
@Module({
imports: [
ConfigPlus.register(
{ envFilePath: resolve(join(process.cwd(), 'dev.local.env')) },
Env,
Front,
),
],
controllers: [AppController],
providers: [AppService, ConfigService],
})
export class AppModule {}
单元测试配置
测试主要分为两个方向:
// /src/config/env.spec.ts
import { ConfigPlus } from '../../libs/config.module';
import { Env, EnvConfig } from './env';
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
describe('Env配置正向测试', () => {
let env: EnvConfig;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
imports: [ConfigPlus.register({ envFilePath: './dev.local.env' }, Env)],
}).compile();
const configService = app.get(ConfigService);
env = configService.get(Env.registerKey);
});
describe('默认配置加载', () => {
it('可以获取到配置实例', () => {
expect(env.appName).toBeDefined();
});
});
});
describe('Env配置的反向测试', () => {
it('缺失配置参数', () => {
expect(Env.validation.validate({}).error).toBeDefined();
});
it('配置参数类型错误', () => {
expect(Env.validation.validate({ port: 1 }).error).toBeDefined();
});
});
总结
配置是每个成熟的项目必不可少要素之一,而配置管理的工作,做好了可能并不能感觉到什么,但是做不好一定会有各种“妖孽”问题。正在本地开发的程序连着线上数据库的情况在一些开发管理经验缺乏的公司中也时有出现。优雅、高效和规范地使用配置方案,够极大避免生产事故的同时,也能提升不少开发和运维工作的效率。希望世间不再有“删库跑路“,peace and love.