- Введение
- Предварительные условия
- Методы обработки конфигурационных файлов
- Валидация схемы
- Подготовка нашего окружения
- Использование 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
позволяет выполнять валидацию двумя различными способами:
- Использование Joi, валидатора данных для JavaScript.
- Пользовательская функция валидации с использованием пакетов
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;
});
Например, если мы удалим port
из нашего объекта schema
, мы увидим ошибку, подобную этой:
👍 Отличная работа!
Избегайте дублирования кода
Представьте, что у нас много конфигурационных модулей, каждый из которых имеет пространство имен. Мне лень дублировать весь предыдущий код в каждом файле. Кроме того, это плохая практика.
Кроме того, мне очень трудно написать одно и то же имя свойства дважды, внутри наших объектов values
и schema
.
const values = {
nodeEnv: ...,
port: ...
};
const schema = Joi.object({
nodeEnv: ...,
port: ...,
});
🤔 Я не могу жить счастливо с этим.
Создание нового интерфейса
Что бы я хотел иметь:
- Записывать имена свойств только один раз
- Определять его значение из переменных окружения
- Рассказывать, каковы правила валидации Joi
- Сохранять свойство типа для безопасности
Мы можем придумать следующую технику:
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.
Надеюсь, вам понравилась моя статья, и до скорой встречи с другими подобными советами.