Способы проверки конфигурации среды в forFeature Config в NestJs

  • Введение
  • Предварительные условия
  • Методы обработки конфигурационных файлов
  • Валидация схемы
  • Подготовка нашего окружения
  • Использование Joi
    • Типы
    • Избегайте дублирования кода
      • Создание нового интерфейса
      • Мощность утилиты
    • Использование с несколькими модулями конфигурации
  • Использование пользовательской функции валидации
    • Пользовательский валидатор для заводской функции
      • Извлечение функции валидации
  • Использование базового класса
  • Заключение

Введение

Нормальной и лучшей практикой является наличие файла .env для быстрого изменения конфигурации на основе переменной окружения.

Слава Богу, что NestJS предоставляет ConfigModule, который раскрывает ConfigService, который загружает файл .env. Внутри модуля используется dotenv для загрузки переменных из файла в process.env.

Настройка модуля ConfigModule довольно проста, если следовать официальной документации.

Подробнее о конфигурации NestJS вы можете прочитать здесь.

Предварительные условия

Чтобы следовать за вами, убедитесь, что у вас есть базовые знания и опыт работы с:

  • NodeJS — среда выполнения JavaScript, построенная на движке V8 JavaScript в Chrome.
  • NestJS — Прогрессивный фреймворк Node.js для создания эффективных, надежных и масштабируемых приложений на стороне сервера.
  • TypeScript — JavaScript с синтаксисом для типов.
  • Переменные окружения — переменная, значение которой задается вне программы.

Методы обработки конфигурационных файлов

Вы можете обработать ваш файл в корневом модуле AppModule с помощью метода forRoot(). В официальной документации уже показано, как выполнять валидацию с помощью этого метода.

Если у вас более сложная структура проекта, с конфигурационными файлами, специфичными для каждой функции, пакет @nestjs/config предоставляет возможность частичной регистрации, которая ссылается только на конфигурационные файлы, связанные с каждым модулем функции. Используя метод forFeature() внутри функционального модуля, вы можете загрузить в модуль всего несколько переменных окружения.

В документации не упоминается, как применять валидацию при использовании метода forFeature(). На этом мы сосредоточимся в данной статье.

Валидация схемы

Пакет @nestjs/config позволяет выполнять валидацию двумя различными способами:

  1. Использование Joi, валидатора данных для JavaScript.
  2. Пользовательская функция валидации с использованием пакетов class-transformer и class-validator, которая принимает переменные окружения в качестве входных данных.

Мы рассмотрим каждый из них на примерах.

Подготовка нашего окружения

Установите необходимые зависимости:

npm i --save @nestjs/config
Войдите в полноэкранный режим Выйти из полноэкранного режима

Файл .env, который мы будем использовать, выглядит следующим образом:

NODE_ENV=development
PORT=3000
Вход в полноэкранный режим Выйти из полноэкранного режима

Давайте определим пространство имен конфигурации для загрузки нескольких пользовательских переменных окружения:

import { registerAs } from '@nestjs/config';

export default registerAs('my-app-config-namespace', () => ({
  nodeEnv: process.env.NODE_ENV,
  port: parseInt(process.env.PORT)
}));
Вход в полноэкранный режим Выход из полноэкранного режима

Как сказано в документации, внутри этой фабричной функции registerAs() объект process.env будет содержать полностью разрешенные пары ключ/значение переменной окружения.

Наконец, давайте создадим модуль со следующим:

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';

// This is our factory function from the step before.
import appConfig from './configuration';

@Module({
  imports: [
    ConfigModule.forFeature(appConfig)
  ],
  providers: [],
  exports: [],
})
export class AppConfigModule {}
Вход в полноэкранный режим Выход из полноэкранного режима

Метод forFeature() не имеет свойства validationSchema, как и forRoot(). Это свойство позволяет вам обеспечить валидацию Joi. У него также нет свойства validate, в которое можно передать пользовательскую функцию валидации.

В этот момент я растерялся и не знал, что делать. Давайте продолжим…

Использование Joi

Установите необходимые зависимости:

npm install --save joi
Войдите в полноэкранный режим Выйти из полноэкранного режима

Возьмем нашу фабричную функцию из предыдущей и применим некоторые валидации:

import { registerAs } from '@nestjs/config';
import * as Joi from 'joi';

export default registerAs('my-app-config-namespace', () => {
  // Our environment variables
  const values = {
    nodeEnv: process.env.NODE_ENV,
    port: parseInt(process.env.PORT),
  };

  // Joi validations
  const schema = Joi.object({
    nodeEnv: Joi.string().required().valid('development', 'production'),
    port: Joi.number().required(),
  });

  // Validates our values using the schema.
  // Passing a flag to tell Joi to not stop validation on the
  // first error, we want all the errors found.
  const { error } = schema.validate(values, { abortEarly: false });

  // If the validation is invalid, "error" is assigned a
  // ValidationError object providing more information.
  if (error) {
    throw new Error(
      `Validation failed - Is there an environment variable missing?
        ${error.message}`,
    );
  }

  // If the validation is valid, then the "error" will be
  // undefined and this will return successfully.
  return values;
});
Войти в полноэкранный режим Выход из полноэкранного режима

Надеюсь, комментарии помогут понять код.

Если мы удалим наш файл .env или передадим неверные значения, мы увидим в консоли что-то вроде этого:

Error: Validation failed — Is there a environment variable missing?
«nodeEnv» обязательна. «port» должно быть числом.

Типы

Если вы заметили, мы не используем никаких типов. Давайте создадим интерфейс в новом файле:

export interface IAppConfig {
  nodeEnv: string;
  port: number;
}
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь мы можем применить его к нашей заводской функции:

import { registerAs } from '@nestjs/config';
import * as Joi from 'joi';
import { IAppConfig } from './interface';

// Factory function now has a return type
export default registerAs('my-app-config-namespace', (): IAppConfig => {
  // Object with an interface
  const values: IAppConfig = {
    nodeEnv: process.env.NODE_ENV,
    port: parseInt(process.env.PORT),
  };

  // Joi uses generics that let us provide an interface in the
  // first position. In the second position, we provide -true-
  // to tell Joi that every key of the interface is mandatory
  // to be present in the schema.
  const schema = Joi.object<IAppConfig, true>({
    nodeEnv: Joi.string().required().valid('development', 'production'),
    port: Joi.number().required(),
  });

  // ...

  // ..

  return values;
});
Enter fullscreen mode Выйти из полноэкранного режима

Например, если мы удалим port из нашего объекта schema, мы увидим ошибку, подобную этой:

👍 Отличная работа!

Избегайте дублирования кода

Представьте, что у нас много конфигурационных модулей, каждый из которых имеет пространство имен. Мне лень дублировать весь предыдущий код в каждом файле. Кроме того, это плохая практика.

Кроме того, мне очень трудно написать одно и то же имя свойства дважды, внутри наших объектов values и schema.

const values = {
  nodeEnv: ...,
  port: ...
};

const schema = Joi.object({
  nodeEnv: ...,
  port: ...,
});
Вход в полноэкранный режим Выход из полноэкранного режима

🤔 Я не могу жить счастливо с этим.

Создание нового интерфейса

Что бы я хотел иметь:

  1. Записывать имена свойств только один раз
  2. Определять его значение из переменных окружения
  3. Рассказывать, каковы правила валидации Joi
  4. Сохранять свойство типа для безопасности

Мы можем придумать следующую технику:

Record<keyof IAppConfig, { value: unknown; joi: Schema }>
Войти в полноэкранный режим Выход из полноэкранного режима

Мы используем оператор типа Keyof и тип Schema, который приходит из библиотеки Joi и представляет правила валидации.

Пример использования:

const configs: Record<keyof IAppConfig, { value: any; joi: Schema }> = {
  nodeEnv: {
    value: process.env.NODE_ENV,
    joi: Joi.string().required().valid("development", "production"),
  },
  port: {
    value: parseInt(process.env.PORT),
    joi: Joi.number().required(),
  },
};
Вход в полноэкранный режим Выход из полноэкранного режима

😱 Это так круто…

Но, подождите минутку. Мы не можем передать Joi эту штуку в качестве входа!… и вы правы, нам предстоит еще много работы. 😂

Нам нужно придумать, как иметь объект с потребностями Joi, и другой объект, возвращающий то, что нужно заводской функции. Каждый объект имеет одинаковые свойства, но разные значения.

/*
  Result example;
  [
    { propName: ... },
    { propName: ... }
  ]
*/
const joiSchemaArr: SchemaMap<IAppConfig>[] = Object.keys(configs).map(
  (key) => {
    return {
      [key]: configs[key].joi, // Keep an eye on this
    };
  }
);

/*
  Result example;
  {
    propName: ...,
    propName: ...
  }
*/
const joiSchema: SchemaMap<IAppConfig> = Object.assign({}, ...joiSchemaArr);

const schema = Joi.object(joiSchema);
Вход в полноэкранный режим Выход из полноэкранного режима

Итак, теперь у нас есть то, что нужно Джою. Осталась только одна вещь — фабричная функция. Думал повторить этот код еще раз, чтобы извлечь из нашего интерфейса свойство value вместо свойства te joi, но лень снова настигла меня. 😂

Мощность утилиты

Давайте создадим файл утилиты joi-util.ts, который поможет нам избежать дублирования кода в каждом конфигурационном файле без необходимости. Кроме того, я делегирую ответственность за выброс ошибки, чтобы сохранить мою фабричную функцию как можно более чистой. Также, будем использовать некоторые типы и Generics. 💪🏻

import * as Joi from 'joi';
import { Schema, SchemaMap } from 'joi';

interface ConfigProps {
  value: unknown;
  joi: Schema;
}

export type JoiConfig<T> = Record<keyof T, ConfigProps>;

/**
 * Utility class to avoid duplicating code in the configuration of our namespaces.
 */
export default class JoiUtil {
  /**
   * Throws an exception if required environment variables haven't been provided
   * or if they don't meet our Joi validation rules.
   */
  static validate<T>(config: JoiConfig<T>): T {
    const schemaObj = JoiUtil.extractByPropName(config, 'joi') as SchemaMap<T>;
    const schema = Joi.object(schemaObj);
    const values = JoiUtil.extractByPropName(config, 'value') as T;

    const { error } = schema.validate(values, { abortEarly: false });
    if (error) {
      throw new Error(
        `Validation failed - Is there an environment variable missing?
        ${error.message}`,
      );
    }

    return values;
  }

  /**
   * Extract only a single property from our configuration object.
   * @param config    Entire configuration object.
   * @param propName  The property name that we want to extract.
   */
  static extractByPropName<T>(
    config: JoiConfig<T>,
    propName: keyof ConfigProps,
  ): T | SchemaMap<T> {
    /*
      Result example;
      [
        { propName: ... },
        { propName: ... }
      ]
     */
    const arr: any[] = Object.keys(config).map((key) => {
      return {
        [key]: config[key][propName],
      };
    });

    /*
      Result example;
      {
        propName: ...,
        propName: ...
      }
     */
    return Object.assign({}, ...arr);
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Я не очень хорошо разбираюсь в продвинутых типах, это можно улучшить.

Заметили ли вы что-нибудь новое в нашей функции валидации? Да, вещь, называемая as в TypeScript. Это утверждение типа, которое позволяет нам помочь компилятору узнать, какой тип мы ожидаем от нашей функции extractByPropName().

Я знаю, что этот файл длинный, но не волнуйтесь… вам не придется повторять его ни разу в жизни.

Пример использования:

import { registerAs } from '@nestjs/config';
import { IAppConfig } from './interface';
import * as Joi from 'joi';
import JoiUtil, { JoiConfig } from '../joi-util';

export default registerAs('my-app-config-namespace', (): IAppConfig => {
  const configs: JoiConfig<IAppConfig> = {
    nodeEnv: {
      value: process.env.NODE_ENV,
      joi: Joi.string().required().valid('development', 'production'),
    },
    port: {
      value: parseInt(process.env.PORT),
      joi: Joi.number().required(),
    },
  };

  return JoiUtil.validate(configs);
});
Вход в полноэкранный режим Выход из полноэкранного режима

😈 Это то, о чем я говорю, потрясающе!

Использование с несколькими модулями конфигурации

Теперь у нас есть новое бизнес-требование, и нам нужно общаться с базой данных. Давайте создадим еще один модуль конфигурации с определенными переменными окружения.

Сначала определите переменные окружения:

DATABASE_USERNAME=root
DATABASE_PASSWORD=123456789
DATABASE_NAME=mydb
DATABASE_PORT=3306
Вход в полноэкранный режим Выход из полноэкранного режима

Пространство имен конфигурации для загрузки нескольких пользовательских переменных окружения:

import { registerAs } from '@nestjs/config';
import * as Joi from 'joi';
import JoiUtil, { JoiConfig } from '../joi-util';

interface IDatabaseConfig {
  username: string;
  password: string;
  database: string;
  port: number;
}

export default registerAs('database-config-namespace', (): IDatabaseConfig => {
  const configs: JoiConfig<IDatabaseConfig> = {
    username: {
      value: process.env.DATABASE_USERNAME,
      joi: Joi.string().required(),
    },
    password: {
      value: process.env.DATABASE_PASSWORD,
      joi: Joi.string().required(),
    },
    database: {
      value: process.env.DATABASE_NAME,
      joi: Joi.string().required(),
    },
    port: {
      value: parseInt(process.env.DATABASE_PORT),
      joi: Joi.number().required(),
    },
  };

  return JoiUtil.validate(configs);
});
Вход в полноэкранный режим Выход из полноэкранного режима

Я только добавил несколько переменных, в реальной жизни у вас их будет больше.

Наконец, давайте создадим модуль со следующими параметрами:

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';

// This is our factory function from the step before.
import databaseConfig from './database-configuration';

@Module({
  imports: [
    ConfigModule.forFeature(databaseConfig)
  ],
  providers: [],
  exports: [],
})
export class DatabaseConfigModule {}
Вход в полноэкранный режим Выйти из полноэкранного режима

Вы повторите эти шаги для каждого модуля конфигурации и все 🙂 .

Использование пользовательской функции валидации

Для использования этого способа нам необходимо установить пакеты class-transformer и class-validator, который принимает переменные окружения в качестве входных данных.

npm i --save class-transformer class-validator
Вход в полноэкранный режим Выход из полноэкранного режима

В документации приведен пример, но он предназначен для использования с методом forRoot(). Давайте посмотрим, как можно использовать этот способ с помощью метода forFeature().

Пользовательский валидатор для фабричной функции

Давайте определим пространство имен конфигурации для загрузки нескольких пользовательских переменных окружения:

import { registerAs } from '@nestjs/config';
import { IAppConfig } from './interface';

export default registerAs('my-app-config-namespace', (): IAppConfig => ({
    nodeEnv: process.env.NODE_ENV,
    port: parseInt(process.env.PORT),
  }),
);
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь мы можем взять тот же пример из документации и адаптировать его к нашим требованиям. Создадим новый файл app-env.validation.ts со следующим:

import { plainToClass } from 'class-transformer';
import { IsEnum, IsNumber, validateSync } from 'class-validator';

enum Environment {
  Development = 'development',
  Production = 'production',
}

class AppEnvironmentVariables {
  @IsEnum(Environment)
  NODE_ENV: Environment;

  @IsNumber()
  PORT: number;
}

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

  if (errors.length > 0) {
    throw new Error(errors.toString());
  }
  return validatedConfig;
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Чтобы применить функцию валидации, сделаем следующее:

import { registerAs } from '@nestjs/config';
import { IAppConfig } from './interface';

// This is our custom validate function from the step before.
import { validate } from './app-env.validation';

export default registerAs('my-app-config-namespace', (): IAppConfig => {

  // Executes our custom function
  validate(process.env);

  // If all is valid, this will return successfully
  return {
    nodeEnv: process.env.NODE_ENV,
    port: parseInt(process.env.PORT),
  };
});
Войти в полноэкранный режим Выйти из полноэкранного режима

Если мы удалим наши переменные NODE_ENV и PORT из файла .env, мы увидим:

Error:
An instance of AppEnvironmentVariables has failed the validation:
 - property NODE_ENV has failed the following constraints: isEnum 
An instance of AppEnvironmentVariables has failed the validation:
 - property PORT has failed the following constraints: isNumber 
Вход в полноэкранный режим Выход из полноэкранного режима

Вам необходимо сделать пользовательские функции проверки для каждой фабричной функции с пространством имен.

🤔 Ммм… это попахивает дублированием кода пользовательской функции validate! Ну, это естественно, потому что в каждом случае будут свои правила.

Взглянув на созданный нами файл app-env.validation.ts, мы можем увидеть повторяющуюся часть, которую мы можем повторно использовать во всем проекте, функцию validate().

export function validate(config: Record<string, unknown>) {
  ...
}
Вход в полноэкранный режим Выход из полноэкранного режима

Извлечение функции validate

Создадим новый файл validate-util.ts:

import { plainToClass } from 'class-transformer';
import { validateSync } from 'class-validator';
import { ClassConstructor } from 'class-transformer/types/interfaces';

export function validateUtil(
  config: Record<string, unknown>, 
  envVariablesClass: ClassConstructor<any>
) {
  const validatedConfig = plainToClass(
    envVariablesClass,
    config,
    { enableImplicitConversion: true },
  );
  const errors = validateSync(validatedConfig, { skipMissingProperties: false });

  if (errors.length > 0) {
    throw new Error(errors.toString());
  }
  return validatedConfig;
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Наш старый app-env.validation.ts будет выглядеть так:

import { IsEnum, IsNumber } from 'class-validator';

enum Environment {
  Development = 'development',
  Production = 'production',
}

export class AppEnvironmentVariables {
  @IsEnum(Environment)
  NODE_ENV: Environment;

  @IsNumber()
  PORT: number;
}
Войти в полноэкранный режим Выход из полноэкранного режима

Наконец, наша фабричная функция будет выглядеть следующим образом:

import { registerAs } from '@nestjs/config';
import { IAppConfig } from './interface';

// This is our class that uses "class-validator" decorators
import { AppEnvironmentVariables } from './app-env.validation';

// Our new utility to apply the validation process
import { validateUtil } from '../validate-util';

export default registerAs('my-app-config-namespace', (): IAppConfig => {

  // Executes our custom function
  validateUtil(process.env, AppEnvironmentVariables);

  // If all is valid, this will return successfully
  return {
    nodeEnv: process.env.NODE_ENV,
    port: parseInt(process.env.PORT),
  };

});
Войти в полноэкранный режим Выход из полноэкранного режима

Основная часть процесса проверки извлечена и больше не нуждается в повторении. Кроме того, наш AppEnvironmentVariables стал более чистым и простым для понимания и поддержки. 😀

Использование базового класса

Другой способ применения валидации — использование базового класса. Вся заслуга в этом принадлежит Даррагу Ориордану и его статье «Как проверить конфигурацию для модуля в NestJs». Я рекомендую вам ознакомиться с ней!

Заключение

Я попытался в одном месте перечислить все способы, которыми вы можете выполнять валидацию при использовании метода forFeature() в NestJs.

Надеюсь, вам понравилась моя статья, и до скорой встречи с другими подобными советами.

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *