Введение: Почему ваш код нуждается в Dependency Injection?
Каждый разработчик JavaScript рано или поздно сталкивается с проблемой "жёстко связанного" (tightly coupled) кода. Представьте себе класс UserService, который внутри себя создаёт экземпляр Database и Logger. Что произойдёт, когда вы захотите протестировать UserService без реальной базы данных? Или заменить Logger на другую реализацию? В лучшем случае — вы перепишете весь класс, в худшем — сломаете всё приложение.
Решение этой проблемы — Dependency Injection (DI), или внедрение зависимостей. Это один из ключевых принципов проектирования, который лежит в основе концепции Inversion of Control (IoC). В этой статье мы разберём, что такое DI, как его реализовать в чистом JavaScript (Node.js) без использования фреймворков, и почему это критически важно для создания масштабируемых и тестируемых приложений.
Что такое Dependency Injection простыми словами?
Представьте, что вы строите дом. Вместо того чтобы вмуровывать розетки в стены навсегда, вы устанавливаете стандартные разъёмы. Сегодня вы подключаете к ним старую лампу, завтра — умный светильник. Вы не меняете стены — вы меняете зависимости.
В программировании Dependency Injection — это паттерн, при котором объект получает свои зависимости (другие объекты, сервисы, конфигурации) извне, а не создаёт их сам. Вместо того чтобы писать:
// ❌ Плохо: жёсткая связь
class UserService {
constructor() {
this.db = new Database('mongodb://...');
this.logger = new ConsoleLogger();
}
}
Мы пишем:
// ✅ Хорошо: внедрение зависимостей
class UserService {
constructor(database, logger) {
this.db = database;
this.logger = logger;
}
}
Теперь UserService не знает, как создаются Database и Logger. Его задача — только использовать их. Это и есть инверсия управления: контроль над созданием зависимостей переходит от класса к вызывающему коду.
Типы внедрения зависимостей
Существует три основных способа внедрения:
- Через конструктор (Constructor Injection) — самый распространённый и предпочтительный способ. Зависимости передаются при создании объекта.
- Через свойства (Property Injection) — зависимости устанавливаются после создания объекта. Удобно для опциональных зависимостей.
- Через методы (Method Injection) — зависимость передаётся в конкретный метод, когда она нужна только для одного действия.
Пишем простой DI-контейнер на Node.js
В больших проектах управлять зависимостями вручную становится сложно. На помощь приходит DI-контейнер — объект, который знает, как создавать и хранить все зависимости вашего приложения. Давайте напишем простой, но рабочий контейнер на чистом JavaScript.
Наш контейнер будет поддерживать два режима: singleton (один экземпляр на всё приложение) и transient (новый экземпляр при каждом запросе).
// di-container.js
class DIContainer {
constructor() {
this.registry = new Map();
this.instances = new Map();
}
// Регистрируем зависимость с фабричной функцией
register(name, factory, { singleton = true } = {}) {
this.registry.set(name, { factory, singleton });
if (singleton) {
this.instances.delete(name); // Сбрасываем кеш при перерегистрации
}
}
// Получаем экземпляр зависимости
resolve(name) {
const item = this.registry.get(name);
if (!item) {
throw new Error(`Зависимость "${name}" не найдена`);
}
// Для singleton возвращаем закешированный экземпляр
if (item.singleton) {
if (!this.instances.has(name)) {
this.instances.set(name, item.factory(this));
}
return this.instances.get(name);
}
// Для transient создаём новый каждый раз
return item.factory(this);
}
// Очистка контейнера (полезно для тестов)
clear() {
this.instances.clear();
}
}
module.exports = DIContainer;
Теперь давайте используем его на практике. Создадим простой сервис для работы с пользователями.
// app.js
const DIContainer = require('./di-container');
// Создаём контейнер
const container = new DIContainer();
// Регистрируем зависимости
container.register('config', () => ({
dbUrl: 'mongodb://localhost:27017/mydb',
logLevel: 'info'
}), { singleton: true });
container.register('logger', (c) => {
const config = c.resolve('config');
return {
log: (message) => console.log(`[${config.logLevel}] ${message}`)
};
}, { singleton: true });
container.register('database', (c) => {
const config = c.resolve('config');
const logger = c.resolve('logger');
logger.log(`Подключение к БД: ${config.dbUrl}`);
return {
query: (sql) => {
logger.log(`Выполнение запроса: ${sql}`);
return [{ id: 1, name: 'Иван' }];
}
};
}, { singleton: true });
container.register('userService', (c) => {
const db = c.resolve('database');
const logger = c.resolve('logger');
return {
getUsers: () => {
logger.log('Запрос списка пользователей');
return db.query('SELECT * FROM users');
}
};
}, { singleton: false }); // Каждый раз новый экземпляр
// Используем
const userService1 = container.resolve('userService');
const users = userService1.getUsers();
console.log('Пользователи:', users);
// Проверка singleton
const logger1 = container.resolve('logger');
const logger2 = container.resolve('logger');
console.log('Один и тот же logger?', logger1 === logger2); // true
// Проверка transient
const service2 = container.resolve('userService');
console.log('Разные userService?', userService1 === service2); // false
Преимущества DI и реальные кейсы использования
1. Тестирование (Mock-объекты)
Главное преимущество DI — возможность легко подменять зависимости во время тестирования. Вместо реальной базы данных вы передаёте mock или stub.
// userService.test.js
const UserService = require('./userService');
// Создаём моки
const mockDb = {
query: () => Promise.resolve([{ id: 1, name: 'Test' }])
};