Nest JS配置最佳实践

主要内容

  1. 配置方式类型
  2. 配置实践
  3. 官方典型方案分析
  4. 优化后的配置方案
  5. 配置的单元测试

我们的应用程序可能被运行在各种不同的环境中。以开发过程为维度,可以分为开发时和运行时;以运行环境为维度,又可以分为开发环境、测试环境和生产环境;不同的环境中,配置会有不同的设定。特别是在生产环境中,有相当一部分的配置属于敏感信息亦或是机密信息,不适合让所有开发者接触到的。另外,程序中如果缺少某些配置(或允许起缺失),那么可以有默认的配置启用;例如缓存,如果没有特别配置,那就应该采用localhost:6379,如果这样的逻辑写入代码,会变成:

ini 复制代码
const client = redis.createClient(options??'redis://localhost:6379')

虽然在逻辑上没有问题,但不够优雅。

配置方式类型

NestJS框架提供@nestjs/config组建,让 我们可以通过三种类型的配置,来给应用程序定义环境变量。而变量的加载过程和权重应该是:应用程序内配置(默认值) < 系统环境变量 < .env 文件。

应用程序内配置

通过将变量加载到应用程序内实例类时,"顺手"将没有获取到的环境配置或dotenv 配置,赋予一个默认的值。例如:

arduino 复制代码
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会话有效。

配置的方式如:

ini 复制代码
export key='value'

注意:在修改配置文件后不会立刻生效,需要执行命令使之生效。

bash 复制代码
source /etc/profile

容器配置的环境文件

如果应用程序在容器中运行,相比上面的方式,额外有三处可以加入配置:

  1. Dockerfile在构建镜像的时候就设置环境变量,例如:
ini 复制代码
ENV key='value'
  1. 如果通过docker compose 启动应用在docker-compose.yml文件中加入:
yaml 复制代码
version: '3'
services:
  web:
    image: app
    environment:
      - key=value
  1. 在启动容器的命令中设置环境变量:
ini 复制代码
docker run -d --name app -e key=value app:latest

应用程序启动时进行配置

也可以是通过在应用程序启动之前,配置临时的系统变量。由于Linux、Unix、Mac或Windows系统各有不同,可以利用cross-env组件,并在启动脚本之前加入所需要的配置,例如:

json 复制代码
// package.json
"scripts":{
    "dev":"cross-env key=value && node ./bin/www"
}

或者在类Linux系统中直接设置环境

json 复制代码
"scripts":{
    "dev":"export key=value && node ./bin/www"
}

dotenv 文件

NestJS官方库推荐@nestjs/config组件,其中依赖dotenv 组件,可以加载.env的配置文件,并经由module文件注册到程序中,能在程序的任意处获取配置。这里先简单介绍一下.env 文件的作用。

.env文件是用于存储环境变量的配置文件,它可以用来配置不同环境下(如开发环境、测试环境、生产环境等)的变量。

主要的作用有:

  1. 隔离环境之间的配置差异。通过.env可以为不同的环境设置不同的配置,避免在代码中硬编码这些配置。
  2. 保护敏感信息。可以在.env中存储一些敏感信息如数据库密码、API密钥等,将其从代码中抽离出来。
  3. 方便部署。可以根据不同部署环境创建不同的.env文件,使部署时可以很方便地切换环境配置。
  4. 统一管理配置。将相关配置集中放在.env文件中,便于查阅和修改。

在代码中,可以通过进程环境变量访问.env文件中的变量。例如在Node.js中可以通过process.env.XXX来获取。

一个.env文件的常见内容示例:

ini 复制代码
APP_NAME=my-app 
PORT=3000
HOST=localhost
URL=http://${HOST}:${PORT}
DB_HOST=localhost
DB_USER=root
DB_PASS=123456

实践中使用配置方案

对应用程序(或系统)配置时,还需要考虑两个方面:

  1. 在什么样的环境中进行配置:开发、测试还是生产;
  2. 配置分层;
  3. 安全性和保密性;

首先,在不同的环境中,配置内容可能有较大差异,例如数据库地址:在开发是,可能就随便设置的本地服务,如mysql://localhost:5678,而在生产环境中,就严谨的多。两者有较大差异。让应用程序根据不同的环境加载不同配置文件。

然而对环境的定义本身也是一类配置,这类型的配置就不适合与应用程序产生较强的耦合性(不管是用.env文件,还是放在启动命令之前)。可以将配置放在系统层的环境变量中,例如profiledockerfile文件中,如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文件加载以文件形式存在的配置。

定义配置(接口),注册到配置域中

typescript 复制代码
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对象,便可以实现加载配置后自动验证。

先定义一个配置法则:

css 复制代码
// 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(),
});

将此引入到模块中:

typescript 复制代码
// 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,并提取某个域或某个配置值:

typescript 复制代码
// 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<string>('redis.host');
    console.log(redisHost);
    return this.appService.getHello();
  }

  @Get('env')
  getEnv(): string {
    const env = this.configService.get<RedisConfig>('redis');
    return env;
  }
}

最佳实践

使用折磨了一段时间后,渐渐地摸索出一套高效又不失严谨的配置方案,让程序的源代码看上去既简洁又优雅。

封装ConfigModule

在官方案例中,每个配置(域)采用工厂模式,经由ConfigModule先执行加载配置,后执行验证工作,如果遇到错误,则输出相关的提示并抛出异常。这个过程相对固定,所以我们可以将其解耦,再用封装一级动态模块:

typescript 复制代码
// 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<ConfigSchema>} 配置域
   * @returns {DynamicModule} 动态模块
   */
  static register(
    options: ConfigOptions | ConfigSchema,
    ...args: Array<ConfigSchema>
  ): 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],
    };
  }
}

然后再约束配置域类的定义方式,是其更严谨:

typescript 复制代码
// 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<any>;
}

// 约束开发者在定义配置域所采用的接口要用静态方式实现
export function StaticImplements<T>() {
  return <U extends T>(constructor: U) => {
    constructor;
  };
}

因为配置都采用的是静态类,所以要让接口的方法和属性以静态方式实现,定义工具方法StaticImplements

定义一个配置域:

typescript 复制代码
// 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<ConfigSchema>()
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<EnvConfig> = 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'),
  });
}

到这里,我们就可以"优雅"地配置环境变量了:

python 复制代码
// 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 {}

单元测试配置

测试主要分为两个方向:

  1. 正常情况下加载配置文件是否能够被加载;
  2. 在配置中缺失了某些定义或格式不正确,能否触发错误提示;
javascript 复制代码
// /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>(ConfigService);
    env = configService.get<EnvConfig>(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.

相关推荐
2401_857610031 小时前
SpringBoot社团管理:安全与维护
spring boot·后端·安全
凌冰_1 小时前
IDEA2023 SpringBoot整合MyBatis(三)
spring boot·后端·mybatis
码农飞飞2 小时前
深入理解Rust的模式匹配
开发语言·后端·rust·模式匹配·解构·结构体和枚举
一个小坑货2 小时前
Rust 的简介
开发语言·后端·rust
monkey_meng2 小时前
【遵守孤儿规则的External trait pattern】
开发语言·后端·rust
Estar.Lee2 小时前
时间操作[计算时间差]免费API接口教程
android·网络·后端·网络协议·tcp/ip
新知图书3 小时前
Rust编程与项目实战-模块std::thread(之一)
开发语言·后端·rust
盛夏绽放3 小时前
Node.js 和 Socket.IO 实现实时通信
前端·后端·websocket·node.js
Ares-Wang4 小时前
Asp.net Core Hosted Service(托管服务) Timer (定时任务)
后端·asp.net
uzong4 小时前
7 年 Java 后端,面试过程踩过的坑,我就不藏着了
java·后端·面试