Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Як керувати конфігурацією в NestJS за допомогою configmodule?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**`ConfigModule`** завантажує `.env` файли в DI контейнер NestJS і надає їх через singleton `ConfigService`. ```typescript ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env', cache: true }) // Потім інжектуй будь-де: const host = this.configService.get<string>('DB_HOST', 'localhost'); ``` **Ключове:** `isGlobal: true` один раз в `AppModule`, і `ConfigService` доступний скрізь. Joi `validationSchema` зупинить застосунок на старті, а не в runtime.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**`ConfigModule`** завантажує `.env` файли в контейнер залежностей NestJS і надає їх через singleton `ConfigService`, який можна інжектувати будь-де в застосунку. ## Теорія ### TL;DR - ConfigModule зчитує `.env` при старті застосунку, парсить пари key=value і зберігає їх у `ConfigService` - `isGlobal: true` - один імпорт в `AppModule`, доступ звідусіль без повторних імпортів - `config.get<T>('KEY', defaultValue)` повертає типізоване значення з опційним fallback - Joi schema в `validationSchema` не дасть застосунку стартувати, якщо обов'язкові змінні відсутні - `registerAs()` групує пов'язані налаштування в іменовані об'єкти на зразок `database.host` ### Швидке налаштування ```bash npm install @nestjs/config ``` ```typescript // app.module.ts import { ConfigModule } from '@nestjs/config'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, // ConfigService доступний без повторних імпортів envFilePath: '.env', cache: true, // O(1) зчитування після першого парсингу }), ], }) export class AppModule {} ``` Це весь setup. Тепер `ConfigService` доступний у будь-якому injectable класі застосунку. ### Чим відрізняється від звичайного dotenv Ручний `dotenv` записує все в глобальний `process.env` і дає сирі рядки без типів, без дефолтних значень, без валідації. `ConfigModule` використовує `dotenv` під капотом, але результат потрапляє в DI контейнер NestJS як singleton. Отримуєш типізовані читання, дефолти, Joi валідацію при старті і кешування. Жодного розкиданого `process.env` по всіх сервісах. ### Коли використовувати - Credentials БД, API ключі, номери портів: завантажуєш один раз в `AppModule`, інжектуєш де потрібно - Multi-environment: передай масив у `envFilePath`, NestJS вибере перший знайдений файл - Застосунки за 12-factor принципами: конфігурація живе поза кодом, змінюється без деплою - Не підходить для констант часу компіляції (тут краще `const`) або standalone Node скриптів поза NestJS ### Як це працює всередині При старті NestJS зчитує `envFilePath` (за замовчуванням `.env`), парсить кожен рядок `KEY=value` через `dotenv` і зберігає результат у внутрішній Map `ConfigService`. Коли викликаєш `config.get<T>('KEY')`, відбувається пошук по цій Map, рядок приводиться до типу `T`, і якщо ключ відсутній - повертається твій дефолт. З `cache: true` перше зчитування зберігає результат, наступні виклики пропускають пошук. Якщо передав `validationSchema`, схема запускається до старту застосунку. Будь-яке невалідне або відсутнє обов'язкове значення зупиняє процес з читабельним повідомленням про помилку. ### Типізована конфігурація через registerAs() Плоскі ключі на зразок `DB_HOST` і `DB_PORT` добре працюють у маленьких застосунках. Для більших `registerAs()` групує пов'язані змінні в об'єкти: ```typescript // config/database.config.ts import { registerAs } from '@nestjs/config'; export default registerAs('database', () => ({ host: process.env.DB_HOST || 'localhost', port: parseInt(process.env.DB_PORT || '5432', 10), name: process.env.DB_NAME || 'mydb', })); // app.module.ts ConfigModule.forRoot({ isGlobal: true, load: [databaseConfig] }) // any.service.ts const host = this.configService.get<string>('database.host'); const port = this.configService.get<number>('database.port'); ``` Крапкова нотація в `get()` відображається на вкладені об'єкти, створені через `registerAs()`. Набагато чистіше ніж вручну додавати префікс до кожного плоского ключа. ### Кілька .env файлів ```typescript ConfigModule.forRoot({ envFilePath: [ `.env.${process.env.NODE_ENV}.local`, `.env.${process.env.NODE_ENV}`, '.env.local', '.env', ], }) ``` NestJS обробляє масив зліва направо і зупиняється коли знаходить ключ. Раніші файли в масиві мають вищий пріоритет. Ключ у `.env.development.local` перемагає той самий ключ у `.env`. У production на Kubernetes встанови `ignoreEnvFile: true` і дай реальним змінним середовища з Secrets пріоритет. ### Типові помилки **1. Забув `isGlobal: true`** ```typescript // Неправильно: кожен модуль мусить імпортувати ConfigModule окремо ConfigModule.forRoot() // не глобальний // Правильно: один виклик в AppModule, доступно скрізь ConfigModule.forRoot({ isGlobal: true }) ``` Без `isGlobal` інжекція `ConfigService` у модуль, який не імпортує `ConfigModule`, дає помилку «provider not found». Легко пропустити при ручному bootstrap модулів у юніт тестах. **2. Немає валідації, застосунок крашиться пізніше** ```typescript // Неправильно: повертає NaN якщо DB_PORT='abc' в .env const port = this.configService.get<number>('DB_PORT'); // Правильно: Joi схема зупинить застосунок на старті з зрозумілою помилкою validationSchema: Joi.object({ DB_PORT: Joi.number().default(5432).required(), }) ``` Пропуск валідації - найшвидший спосіб отримати інцидент о третій ночі через відсутній `JWT_SECRET` у новому середовищі. Joi ловить це на старті з читабельним описом, а не криптичною помилкою підключення до БД через 10 хвилин деплою. **3. `config.get()` в конструкторі без `cache: true`** ```typescript // Зайве у застосунках з великим трафіком без cache: true constructor(private config: ConfigService) { this.dbHost = config.get('DB_HOST'); // повторне звернення до Map при кожному створенні } // Правильно: cache: true в forRoot() ConfigModule.forRoot({ isGlobal: true, cache: true }) ``` У більшості застосунків це не відчутно. У мікросервісах з великим трафіком, де сервіси часто створюються заново, різниця накопичується. **4. Жорстко задано шлях `.env` для Docker/Kubernetes** ```typescript // Проблема в production: K8s підставляє реальні env vars, .env файлу немає envFilePath: '.env' // Правильно: пропускай файл у production envFilePath: process.env.NODE_ENV !== 'production' ? '.env' : undefined // або: ignoreEnvFile: true при деплої на K8s ``` **5. Відсутній `abortEarly: false` в опціях Joi** За замовчуванням Joi зупиняється на першій помилці валідації. Якщо в `.env` відсутні п'ять змінних, фіксуватимеш їх по одній за кожен рестарт. `abortEarly: false` показує весь список одразу. ### Де застосовується - TypeORM: `TypeOrmModule.forRootAsync({ useFactory: (c: ConfigService) => ({ host: c.get('DB_HOST') }) })` - Prisma: `new PrismaClient({ datasources: { db: { url: config.get('DATABASE_URL') } } })` - BullMQ: Redis URL із `ConfigService` при налаштуванні черги - Stripe / Twilio: API ключі в `.env`, ніколи не в коді, зчитуються один раз у конструкторі - Тести: `ConfigModule.forRoot({ cache: false })` і `process.env.KEY` між тестами ### Питання на співбесіді **Q:** Що станеться якщо обов'язкова змінна відсутня і немає валідації? **A:** `config.get('KEY')` поверне `undefined`. Застосунок запуститься без помилок. Краш з'явиться пізніше, при першому запиті до БД або API, з повідомленням що не вказує на справжню причину. **Q:** Як працює масив у `envFilePath`? **A:** NestJS зчитує файли зліва направо і зупиняється коли знаходить ключ. Раніші записи в масиві мають вищий пріоритет. Ключ у `.env.development.local` перемагає той самий ключ у `.env`. **Q:** Чи працює `config.get('DB.HOST')` з плоским `.env` файлом? **A:** Ні. Крапкова нотація відображається на вкладені об'єкти, створені через `registerAs()` або кастомні `load[]` функції. Для плоского ключа пиши `config.get('DB_HOST')`. **Q:** Як тестувати код що залежить від `ConfigService`? **A:** Два варіанти. Передай `{ provide: ConfigService, useValue: { get: jest.fn().mockReturnValue('test-value') } }` у providers тестового модуля. Або використовуй `ConfigModule.forRoot({ ignoreEnvFile: true, cache: false })` і встановлюй `process.env` перед кожним тестом. **Q:** У мікросервісах зі спільним config repo, як перевизначати конфіг окремо для сервісу без повного деплою? **A:** Встанови `ignoreEnvFile: true` і використовуй Kubernetes ConfigMaps як реальні змінні середовища. Для специфічних налаштувань сервісу передай кастомну фабрику в `load: [configurationFactory]` що зчитує YAML або звертається до Vault/AWS SSM. Рестарт поду - і нова конфігурація активна. ## Приклади ### Базовий: зчитування credentials БД ```typescript // .env // DB_HOST=localhost // DB_PORT=5432 // DB_NAME=myapp // database.service.ts import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @Injectable() export class DatabaseService { constructor(private config: ConfigService) {} getConnectionString(): string { const host = this.config.get<string>('DB_HOST', 'localhost'); // fallback якщо відсутній const port = this.config.get<number>('DB_PORT', 5432); const name = this.config.get<string>('DB_NAME'); return `postgres://${host}:${port}/${name}`; // Результат: "postgres://localhost:5432/myapp" } } ``` `get<T>()` приймає параметр типу і дефолтне значення. Якщо `DB_PORT` відсутній у `.env`, метод поверне `5432`, а не `undefined`. ### Середній рівень: Joi валідація + інтеграція з TypeORM ```typescript // app.module.ts import * as Joi from 'joi'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, validationSchema: Joi.object({ NODE_ENV: Joi.string().valid('development', 'production', 'test').required(), DB_HOST: Joi.string().required(), DB_PORT: Joi.number().default(5432), DB_USER: Joi.string().required(), DB_PASS: Joi.string().required(), DB_NAME: Joi.string().required(), JWT_SECRET: Joi.string().min(32).required(), }), validationOptions: { abortEarly: false, // показати всі помилки, не тільки першу }, }), TypeOrmModule.forRootAsync({ imports: [ConfigModule], useFactory: (config: ConfigService) => ({ type: 'postgres', host: config.get('DB_HOST'), port: config.get<number>('DB_PORT'), username: config.get('DB_USER'), password: config.get('DB_PASS'), database: config.get('DB_NAME'), autoLoadEntities: true, synchronize: config.get('NODE_ENV') !== 'production', }), inject: [ConfigService], }), ], }) export class AppModule {} ``` Якщо `JWT_SECRET` коротший за 32 символи або `DB_HOST` відсутній, застосунок виводить повний список помилок валідації і завершує роботу до першого запиту. ### Розширений рівень: іменована конфігурація через registerAs() ```typescript // config/app.config.ts import { registerAs } from '@nestjs/config'; export default registerAs('app', () => ({ port: parseInt(process.env.PORT || '3000', 10), env: process.env.NODE_ENV || 'development', jwtSecret: process.env.JWT_SECRET, stripeKey: process.env.STRIPE_SECRET_KEY, })); // config/database.config.ts export default registerAs('database', () => ({ host: process.env.DB_HOST || 'localhost', port: parseInt(process.env.DB_PORT || '5432', 10), name: process.env.DB_NAME || 'mydb', })); // app.module.ts ConfigModule.forRoot({ isGlobal: true, load: [appConfig, databaseConfig], // обидва зливаються в ConfigService }) // payment.service.ts @Injectable() export class PaymentService { private stripeKey: string; constructor(private config: ConfigService) { // доступ через крапкову нотацію до іменованого об'єкта 'app' this.stripeKey = this.config.get<string>('app.stripeKey')!; } charge(amount: number) { console.log(`Charge with key: ${this.stripeKey.slice(0, 4)}****`); // Результат: "Charge with key: sk_l****" } } ``` `registerAs()` розділяє зони відповідальності. Auth сервіс читає з `app.*`, сервіс БД - з `database.*`. Ніхто не знає про конфіг сусіда і ніхто не чіпає голий `process.env`.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.