Введение: Проблема жесткой связанности кода
Каждый разработчик Node.js рано или поздно сталкивается с ситуацией, когда изменение в одном модуле тянет за собой цепочку правок в десятке других. Особенно остро это проявляется при написании юнит-тестов: чтобы протестировать один сервис, приходится поднимать базу данных, внешние API и кэш. Корень проблемы — жесткая связанность (tight coupling).
Dependency Injection (DI) — это паттерн проектирования, который решает эту проблему. Вместо того чтобы класс или функция создавали свои зависимости самостоятельно, они получают их извне. Это делает код гибким, тестируемым и соответствующим принципу инверсии зависимостей (Dependency Inversion Principle) из SOLID.
В этой статье мы разберем DI на практике: от простой ручной инъекции до использования профессиональных контейнеров в Node.js.
1. Ручное внедрение зависимостей: первый шаг
Плохой подход: жесткая связь
Рассмотрим типичный пример сервиса для отправки email:
// userService.js
const emailService = require('./emailService');
class UserService {
async registerUser(email) {
// Жесткая зависимость от конкретной реализации
await emailService.sendWelcomeEmail(email);
console.log('Пользователь зарегистрирован');
}
}
module.exports = UserService;
Проблема: при тестировании UserService невозможно подменить emailService на мок. Придется использовать сложные инструменты вроде proxyquire или jest.mock.
Хороший подход: инъекция через конструктор
Перепишем сервис, передавая зависимость через конструктор:
// userService.js
class UserService {
constructor(emailService) {
this.emailService = emailService;
}
async registerUser(email) {
await this.emailService.sendWelcomeEmail(email);
console.log('Пользователь зарегистрирован');
}
}
module.exports = UserService;
Теперь мы можем легко тестировать сервис:
// userService.test.js
const UserService = require('./userService');
const mockEmailService = {
sendWelcomeEmail: jest.fn().mockResolvedValue(true)
};
test('должен отправить приветственное письмо', async () => {
const service = new UserService(mockEmailService);
await service.registerUser('test@test.com');
expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalledWith('test@test.com');
});
2. Типы Dependency Injection в JavaScript
В JavaScript/Node.js существует три основных способа внедрения зависимостей:
2.1. Инъекция через конструктор
Самый распространенный и предсказуемый способ. Зависимости передаются при создании объекта:
class DatabaseService {
constructor(connection) {
this.connection = connection;
}
async query(sql) {
return this.connection.execute(sql);
}
}
// Использование
const db = new DatabaseService(new MySQLConnection());
2.2. Инъекция через сеттеры
Подходит, когда зависимости можно менять в процессе работы объекта:
class LoggerService {
setLogger(logger) {
this.logger = logger;
}
log(message) {
this.logger.write(`[${new Date().toISOString()}] ${message}`);
}
}
const logger = new LoggerService();
logger.setLogger(new ConsoleLogger());
logger.log('Приложение запущено');
2.3. Инъекция через параметры функции
Идеально для функционального подхода и утилит:
function sendNotification(user, emailService, smsService) {
emailService.send(user.email, 'Уведомление');
smsService.send(user.phone, 'Уведомление');
}
// Тестирование
const mockEmail = { send: jest.fn() };
const mockSms = { send: jest.fn() };
sendNotification({ email: 'a@b.com', phone: '123' }, mockEmail, mockSms);
3. Контейнеры DI для Node.js: Awilix
Когда приложение разрастается, ручное управление зависимостями становится утомительным. На помощь приходят DI-контейнеры. Один из лучших для Node.js — Awilix.
Установка и настройка
npm install awilix awilix-express
Пример: приложение с Awilix
// container.js
const { createContainer, asClass, asFunction, asValue } = require('awilix');
const container = createContainer();
// Регистрируем зависимости
container.register({
dbConnection: asFunction(() => require('./db').connect(), {
lifetime: 'SINGLETON'
}),
userRepository: asClass(require('./repositories/userRepository')),
emailService: asClass(require('./services/emailService')),
userService: asClass(require('./services/userService'))
});
module.exports = container;
Использование в Express-маршрутах
// app.js
const express = require('express');
const { scopePerRequest } = require('awilix-express');
const app = express();
app.use(scopePerRequest(container));
app.post('/register', (req, res) => {
const userService = req.container.resolve('userService');
userService.registerUser(req.body.email);
res.json({ success: true });
});
Как это работает?
- asClass — регистрирует класс, Awilix сам создает экземпляры
- asFunction — регистрирует фабричную функцию
- asValue — регистрирует готовое значение (например, конфигурацию)
- lifetime — управляет временем жизни:
SINGLETON(один на все приложение),SCOPED(на каждый запрос),TRANSIENT(новый при каждом запросе)
4. Практические преимущества DI
4.1. Тестирование без боли
С DI вы можете подменять любую зависимость, не изменяя код самого сервиса:
// Используем тот же контейнер, но подменяем сервис
container.register({
emailService: asValue({
sendWelcomeEmail: jest.fn().mockRejectedValue(new Error('SMTP down'))
})
});
const userService = container.resolve('userService');
await expect(userService.registerUser('test@test.com')).rejects.toThrow('SMTP down');
4.2. Легкая замена реализаций
Хотите перейти с MySQL на PostgreSQL? Просто замените регистрацию в контейнере:
// Было
container.register({
dbConnection: asFunction