Awilix для TypeScript: Полное руководство по DI-контейнеру
Awilix — это мощный и гибкий контейнер внедрения зависимостей (Dependency Injection, DI) для Node.js и браузеров, написанный на TypeScript. Он предоставляет элегантный способ управления зависимостями в вашем приложении, делая код более модульным, тестируемым и поддерживаемым. В этой статье мы подробно разберем, как использовать Awilix в проектах на TypeScript, от установки до продвинутых техник.
1. Что это и зачем нужна
В современной разработке программного обеспечения принцип инверсии управления (IoC) и внедрение зависимостей (DI) стали стандартом де-факто для создания масштабируемых и тестируемых приложений. Awilix — это реализация DI-контейнера, которая берет на себя создание и управление жизненным циклом объектов (сервисов, репозиториев, контроллеров и т.д.). Вместо того чтобы вручную создавать экземпляры классов внутри других классов (что приводит к сильной связанности), вы регистрируете свои зависимости в контейнере, а он автоматически "внедряет" их туда, где они нужны.
Зачем это нужно? Во-первых, это радикально упрощает тестирование. Вы можете легко подменить реальную реализацию (например, базу данных) на mock-объект во время юнит-тестов, просто зарегистрировав другую зависимость в контейнере. Во-вторых, это улучшает архитектуру приложения, заставляя вас следовать принципу единственной ответственности (SRP) и явно объявлять свои зависимости. В-третьих, Awilix поддерживает различные "стили жизни" (lifetime) объектов (синглтон, временный, скоуп), что позволяет гибко управлять памятью и состоянием.
Awilix особенно хорош для TypeScript благодаря своей строгой типизации. Он предоставляет встроенные типы для регистрации и разрешения зависимостей, что позволяет отлавливать ошибки на этапе компиляции, а не в рантайме. Библиотека поддерживает асинхронное разрешение зависимостей, работу с классами, фабричными функциями и уже существующими экземплярами объектов.
2. Установка
Для установки Awilix в ваш проект на TypeScript выполните следующую команду в терминале:
npm install awilix
Awilix имеет встроенную поддержку TypeScript, поэтому вам не нужно устанавливать отдельные пакеты с типами (@types/awilix). Все типы уже включены в основную библиотеку.
3. Быстрый старт — минимальный рабочий пример
Давайте создадим простейшее приложение, которое приветствует пользователя. У нас будет класс Greeter, который зависит от Logger.
import { createContainer, asClass, Lifetime } from 'awilix';
// 1. Определяем зависимости
class Logger {
log(message: string): void {
console.log(`[LOG]: ${message}`);
}
}
class Greeter {
private logger: Logger;
// Зависимость будет внедрена через конструктор
constructor({ logger }: { logger: Logger }) {
this.logger = logger;
}
greet(name: string): void {
this.logger.log(`Hello, ${name}!`);
}
}
// 2. Создаем контейнер
const container = createContainer();
// 3. Регистрируем зависимости
container.register({
logger: asClass(Logger, { lifetime: Lifetime.SINGLETON }),
greeter: asClass(Greeter, { lifetime: Lifetime.TRANSIENT }),
});
// 4. Разрешаем (получаем) экземпляр Greeter
const greeter = container.resolve<Greeter>('greeter');
// 5. Используем
greeter.greet('World'); // Вывод: [LOG]: Hello, World!
В этом примере мы создали контейнер, зарегистрировали два класса (Logger как синглтон и Greeter как временный объект), а затем разрешили Greeter. Awilix автоматически создал экземпляр Logger и передал его в конструктор Greeter.
4. Основные методы/функции/классы
Рассмотрим ключевые API Awilix. Для каждого метода приведена сигнатура, описание и пример.
4.1. createContainer()
Сигнатура: createContainer(options?: AwilixContainerOptions): AwilixContainer
Что делает: Создает новый экземпляр DI-контейнера. Это главная точка входа в библиотеку.
import { createContainer } from 'awilix';
const container = createContainer();
// Можно передать опции, например, для кастомного резолвера имен
const containerWithOptions = createContainer({
injectionMode: 'CLASSIC' // или 'PROXY'
});
4.2. container.register()
Сигнатура: register(name: string, registration: Registration): AwilixContainer (или register(registrations: Record<string, Registration>): AwilixContainer)
Что делает: Регистрирует одну или несколько зависимостей в контейнере. Принимает либо пару "имя-регистрация", либо объект с множеством регистраций.
import { createContainer, asClass, asFunction, asValue } from 'awilix';
const container = createContainer();
// Регистрация одного класса
container.register('myService', asClass(MyService));
// Регистрация нескольких зависимостей сразу
container.register({
config: asValue({ apiUrl: 'https://api.example.com' }),
createUser: asFunction(({ config }) => new UserService(config)),
});
4.3. container.resolve()
Сигнатура: resolve<T>(name: string): T
Что делает: Разрешает (создает или возвращает) экземпляр зарегистрированной зависимости по её имени. Это основной способ получения объектов из контейнера.
import { createContainer, asClass } from 'awilix';
class Database {
connect() { console.log('Connected!'); }
}
const container = createContainer();
container.register('db', asClass(Database));
const dbInstance = container.resolve<Database>('db');
dbInstance.connect(); // 'Connected!'
4.4. asClass()
Сигнатура: asClass(Constructor: Class<T>, opts?: RegistrationOptions): Registration
Что делает: Создает регистрацию на основе класса. Awilix будет создавать новые экземпляры класса, автоматически внедряя зависимости в конструктор. Опции позволяют задать время жизни (lifetime), режим инъекции и другие параметры.
import { createContainer, asClass, Lifetime } from 'awilix';
class EmailService {
send(email: string) { /* ... */ }
}
const container = createContainer();
container.register('emailService', asClass(EmailService, {
lifetime: Lifetime.SINGLETON, // Один экземпляр на всё приложение
injectionMode: 'CLASSIC' // Внедрение через параметры конструктора
}));
4.5. asFunction()
Сигнатура: asFunction(fn: Function, opts?: RegistrationOptions): Registration
Что делает: Регистрирует фабричную функцию. Каждый раз при разрешении зависимости будет вызываться эта функция. Это полезно для создания объектов, требующих сложной логики инициализации или для интеграции с кодом, не основанным на классах.
import { createContainer, asFunction } from 'awilix';
const container = createContainer();
container.register('getCurrentTime', asFunction(() => {
return new Date().toISOString();
}));
const time = container.resolve<string>('getCurrentTime');
console.log(time); // 2024-01-15T10:30:00.000Z
4.6. asValue()
Сигнатура: asValue(value: any): Registration
Что делает: Регистрирует уже существующее значение (объект, строку, число и т.д.) как зависимость. Это идеально подходит для конфигураций, констант или внешних экземпляров (например, подключение к базе данных).
import { createContainer, asValue } from 'awilix';
const container = createContainer();
const appConfig = { port: 3000, env: 'development' };
container.register('config', asValue(appConfig));
const config = container.resolve<typeof appConfig>('config');
console.log(config.port); // 3000
4.7. container.createScope()
Сигнатура: createScope(): AwilixContainer
Что делает: Создает дочерний контейнер (scope). Дочерний контейнер наследует все регистрации от родителя, но может переопределять их. Зависимости с Lifetime.SCOPED будут создаваться один раз на каждый scope. Это полезно для обработки HTTP-запросов (один scope = один запрос).
import { createContainer, asClass, Lifetime } from 'awilix';
class RequestContext {
userId: number = 0;
}
const container = createContainer();
container.register('requestContext', asClass(RequestContext, {
lifetime: Lifetime.SCOPED
}));
// Для каждого HTTP-запроса создаем новый scope
function handleRequest(userId: number) {
const requestScope = container.createScope();
const ctx = requestScope.resolve<RequestContext>('requestContext');
ctx.userId = userId;
// ... обработка запроса
}
4.8. container.hasRegistration()
Сигнатура: hasRegistration(name: string): boolean
Что делает: Проверяет, зарегистрирована ли зависимость с указанным именем в контейнере (или в его родительских контейнерах). Полезно для условной логики или валидации.
import { createContainer, asValue } from 'awilix';
const container = createContainer();
container.register('db', asValue({}));
if (container.hasRegistration('db')) {
console.log('Database is registered!');
}
if (!container.hasRegistration('cache')) {
console.log('Cache is not registered, registering default...');
container.register('cache', asValue(new Map()));
}
4.9. container.dispose()
Сигнатура: dispose(): Promise<void>
Что делает: Асинхронно освобождает все ресурсы, связанные с контейнером. Вызывает методы dispose() на всех зарегистрированных объектах, которые его реализуют. Важно вызывать при завершении работы приложения для предотвращения утечек памяти.
import { createContainer, asClass, Lifetime } from 'awilix';
class DatabaseConnection {
async connect() { /* ... */ }
async dispose() {
console.log('Closing database connection...');
// Закрываем соединение
}
}
async function main() {
const container = createContainer();
container.register('db', asClass(DatabaseConnection, {
lifetime: Lifetime.SINGLETON
}));
const db = container.resolve<DatabaseConnection>('db');
await db.connect();
// ... работа приложения
await container.dispose(); // 'Closing database connection...'
}
main();
5. Реальный практический пример (100-200 строк кода)
Давайте создадим мини-приложение для управления пользователями. Оно будет состоять из контроллера, сервиса и репозитория, работающего с "базой данных" (в памяти).
import {
createContainer,
asClass,
asValue,
Lifetime,
AwilixContainer
} from 'awilix';
// --- Модели ---
interface User {
id: number;
name: string;
email: string;
}
// --- Репозиторий (работа с данными) ---
class UserRepository {
private users: User[] = [];
private nextId = 1;
async findAll(): Promise<User[]> {
return this.users;
}
async findById(id: number): Promise<User | undefined> {
return this.users.find(u => u.id === id);
}
async create(userData: Omit<User, 'id'>): Promise<User> {
const newUser: User = { id: this.nextId++, ...userData };
this.users.push(newUser);
return newUser;
}
}
// --- Сервис (бизнес-логика) ---
class UserService {
private userRepository: UserRepository;
constructor({ userRepository }: { userRepository: UserRepository }) {
this.userRepository = userRepository;
}
async getAllUsers(): Promise<User[]> {
return this.userRepository.findAll();
}
async getUserById(id: number): Promise<User | undefined> {
return this.userRepository.findById(id);
}
async createUser(name: string, email: string): Promise<User> {
// Простейшая валидация
if (!name || !email) {
throw new Error('Name and email are required');
}
return this.userRepository.create({ name, email });
}
}
// --- Контроллер (обработка запросов) ---
class UserController {
private userService: UserService;
constructor({ userService }: { userService: UserService }) {
this.userService = userService;
}
async handleListUsers(): Promise<User[]> {
console.log('Handling list users request...');
return this.userService.getAllUsers();
}
async handleCreateUser(name: string, email: string): Promise<User> {
console.log(`Handling create user request for ${name}...`);
return this.userService.createUser(name, email);
}
}
// --- Настройка контейнера и запуск ---
async function bootstrap() {
// 1. Создаем контейнер
const container: AwilixContainer = createContainer();
// 2. Регистрируем все зависимости
container.register({
// Репозиторий - синглтон, чтобы данные сохранялись
userRepository: asClass(UserRepository, {
lifetime: Lifetime.SINGLETON
}),
// Сервис - временный (можно и синглтон, но для примера)
userService: asClass(UserService, {
lifetime: Lifetime.TRANSIENT
}),
// Контроллер - временный
userController