javascript

Nexus-IoC — хорошо знакомый незнакомец в мире TypeScript и DI

  • понедельник, 28 октября 2024 г. в 00:00:06
https://habr.com/ru/articles/853722/

Предыстория

В одном из моих проектов мы использовали библиотеку Inversify для внедрения зависимостей (DI). Хотя это мощное и гибкое решение, его избыточная гибкость со временем обернулась против нас: управление зависимостями становилось всё более запутанным по мере роста приложения. С каждым новым модулем или компонентом код усложнялся, а процесс рефакторинга становился всё более болезненным.

Я выделил несколько ключевых требований, которые хотел бы видеть в новом решении:

  • Прозрачность зависимостей: Нужно было ясно понимать, какие зависимости требуются каждому компоненту, без лишней магии в коде.

  • Иерархичность: Важно было поддерживать строгую структуру, где модули и зависимости чётко организованы и легко управляемы.

  • Расширяемость: Код должен оставаться легко расширяемым без необходимости переписывать существующие части.

  • Раннее обнаружение ошибок: Ловить ошибки на этапе разработки, а не во время выполнения приложения.

После изучения популярных решений вроде Angular и NestJS, я понял, что эти фреймворки предлагают отличные возможности для управления зависимостями, но они слишком тесно интегрированы в свою экосистему, что затрудняет их применение вне этого контекста. Мне нужно было что-то универсальное. Так родилась идея Nexus-IoC — легковесного и гибкого инструмента для управления зависимостями в любых TypeScript-проектах.

как нейронка видит мою библиотеку
как нейронка видит мою библиотеку

Nexus-IoC

Для начала давайте рассмотрим пример простого приложения, чтобы познакомиться с библиотекой. Если вы уже работали с Angular или NestJS, этот код будет вам хорошо знаком.

import { NsModule, Injectable } from '@nexus-ioc/core';
import { NexusApplicationsBrowser } from '@nexus-ioc/core/dist/server';


// Деклaрация модуля
@Injectable()
class AppService {}

@NsModule({
  providers: [AppService]
})
class AppModule {}
// Деклaрация модуля


// Точка старта приложения
async function bootstrap() {
  const app = await NexusApplicationsBrowser
    .create(AppModule)
    .bootstrap();
}

bootstrap();

Основные концепции

  1. Модульная архитектура
    В Nexus-IoC вся логика приложения организована вокруг модулей — изолированных единиц кода, которые могут включать провайдеры (зависимости) и другие модули. Это помогает структурировать приложение и упростить управление зависимостями.

  2. Провайдеры и зависимости
    Провайдеры — это объекты, которые могут быть внедрены в другие части приложения. В каждом модуле регистрируются свои провайдеры, и система автоматически разрешает зависимости между ними, что упрощает логику внедрения.

  3. Граф зависимостей
    При запуске приложения Nexus-IoC автоматически строит граф зависимостей между модулями и провайдерами. Это помогает видеть, какие зависимости требуются каждому модулю, и находить ошибки на этапе сборки, такие как циклические зависимости или отсутствующие провайдеры.

  4. Асинхронная загрузка модулей
    Nexus-IoC поддерживает асинхронную загрузку модулей, что помогает оптимизировать работу приложения. Только необходимые части кода загружаются в нужный момент, что особенно важно для производительности крупных приложений.

  5. Плагины для расширения функциональности
    Система плагинов позволяет легко добавлять новые возможности, не изменяя основную библиотеку. Например, можно подключить плагины для визуализации графа зависимостей или для статического анализа кода.

Реализация модулей и провайдеров

Основная концепция Nexus-IoC — это модуль. С помощью декоратора @NsModule, который принимает три ключевых параметра, вы можете объявить модуль:

  • imports — список модулей, используемых внутри текущего.

  • providers — список провайдеров, предоставляемых этим модулем.

  • exports — список провайдеров, доступных для других модулей.

Типы провайдеров

  • UseClass провайдер — предоставляет класс для создания экземпляра зависимости.

    {
      provide: "classProvider",
      useClass: class ClassProvider {}
    }
  • Class провайдер — простой провайдер, который регистрирует класс.

    @Injectable()
    class Provider {}
  • UseValue провайдер — предоставляет конкретное значение или объект.

    {
      provide: "value-token",
      useValue: 'value'
    }
  • UseFactory провайдер — позволяет создавать зависимости через фабричную функцию.

    {
      provide: "factory-token",
      useFactory: () => {
          // Поддерживается синхронный так и асинхронный вариант фабрики
      },
    },

Проверка целостности графа зависимостей

Nexus-IoC проверяет целостность графа зависимостей ещё до запуска приложения. Подобно NestJS, библиотека анализирует граф зависимостей и выявляет такие проблемы, как циклические зависимости или отсутствующие провайдеры. Но в Nexus-IoC этот процесс более гибкий: граф строится с учётом ограничений между модулями, и фактические экземпляры зависимостей создаются только при их обращении.

Данное изображение было сгенерировано с помощью плагина nexus-ioc-graph-visualizer, который автоматически визуализирует граф зависимостей вашего приложения.
Данное изображение было сгенерировано с помощью плагина nexus-ioc-graph-visualizer, который автоматически визуализирует граф зависимостей вашего приложения.

Также Nexus-IoC предоставляет список ошибок, что позволяет заранее обнаруживать проблемы перед запуском приложения.

async function bootstrap() {
  const app = await NexusApplicationsBrowser
    .create(AppModule)
    .bootstrap();

  console.log(app.errors) // Здесь хранятся ошибки обнаруженные при построении графа
}

bootstrap();

Тестирование

Был реализован пакет @nexus-ioc/testing, который значительно облегчает процесс тестирования контейнеров и их компонентов. С ее помощью можно довольно легко писать unit тесты на модули и/или провайдеры.

import { Injectable } from '@nexus-ioc/core';
import { Test } from '@nexus-ioc/testing';

describe('AppModule', () => {
  it('should create AppService instance', async () => {
    @Injectable()
    class AppService {}

    const appModule = await Test.createModule({
      providers: [AppService],
    }).compile();

    const appService = await appModule.get<AppService>(AppService);
    expect(appService).toBeInstanceOf(AppService);
  });
});
Подмена зависимостей внутри сервиса
import { Injectable, Inject } from '@nexus-ioc/core';
import { Test } from '@nexus-ioc/testing';

describe('AppModule', () => {
  it('should create AppService instance', async () => {
    const MOCK_SECRET_KEY = 'secret-key'
    @Injectable()
    class AppService {
      constructor(@Inject('secret-key') public readonly secretKey: string) {}
    }

    const appModule = await Test.createModule({
      providers: [AppService, { provide: 'secret-key', useValue: MOCK_SECRET_KEY }],
    }).compile();

    const appService = await appModule.get<AppService>(AppService);
    expect(appService?.secretKey).toEqual(MOCK_SECRET_KEY);
  });
});

Переиспользуемость

В Nexus-IoC реализованы знакомые методы для создания переиспользуемых модулей — forRoot и forFeature. Они позволяют гибко настраивать модули в зависимости от нужд приложения.

Отличия forRoot и forFeature

  • forRoot: Эти методы регистрируют провайдеров на глобальном уровне. Они особенно полезны для сервисов, которые должны быть доступны в любом модуле приложения.

  • forFeature: Эти методы регистрируют провайдеров только в пределах текущего модуля, что делает их идеальными для локальных или специализированных сервисов.

Пример использования

Вы можете использовать forRoot, чтобы зарегистрировать глобальные сервисы, такие как логирование, и forFeature для локальных обработчиков, которые нужны только в конкретных модулях.

Пример forRoot модуля
import { NsModule, Injectable, DynamicModule } from '@nexus-ioc/core';

interface ConfigOptions {
  apiUrl: string;
}

// Сервис настроек
@Injectable()
class ConfigService {
  async getOptions(): Promise<ConfigOptions> {
    // симулируем загрузку данных из API
    return new Promise((resolve) => {
      setTimeout(() => resolve({ apiUrl: 'https://api.async.example.com' }), 1000);
    });
  }
}

@NsModule()
export class ConfigModule {
  // Обьявления модуля для глобального инстанцирования
  static forRoot(): DynamicModule {
    return {
      module: ConfigModule,
      providers: [
        ConfigService,
        {
          provide: 'CONFIG_OPTIONS',
          // Поддерживаются синхронные и асинхронные обьявления фабрик
          useFactory: (configService: ConfigService) =>
            configService.getOptions(),
          inject: [ConfigService], // Описываем зависимости фабрики
        },
      ],
      exports: ['CONFIG_OPTIONS'],
    };
  }
  // Обьявление модуля для локального инстанцирования
  static forFeature(): DynamicModule {
    return {
      module: ConfigModule,
      providers: [
        ConfigService,
        {
          provide: 'CONFIG_OPTIONS',
          useFactory: (configService: ConfigService) =>
            configService.getOptions(),
          inject: [ConfigService], // Описываем зависимости фабрики
        },
      ],
      exports: ['CONFIG_OPTIONS'],
    };
  }
}

Плагины для расширения функциональности

Одной из важных особенностей Nexus-IoC является возможность расширять функциональность с помощью плагинов. Они позволяют добавлять новые возможности без изменения основного кода библиотеки.

Один из примеров — это интеграция с инструмента для анализа и визуализации графа зависимостей.

Для этого Nexus-IoC предоставляет метод addScannerPlugin, с помощью которого можно подключать плагины на этапе сканирования графа зависимостей. Этот метод позволяет интегрировать сторонние инструменты, которые могут взаимодействовать с графом во время его построения.

Как работает addScannerPlugin

Метод addScannerPlugin принимает плагин в виде функции, которая будет вызываться после этапа построения графа зависимостей. Плагин получает информацию о графе, его узлах и ребрах. Можно реализовать доп. проверки или модифицировать граф.

Первый плагин, который был создан - это GraphScannerVisualizer . Его задача в том, чтобы визуализировать граф.

import { NexusApplicationsServer } from '@nexus-ioc/core/dist/server';
import { GraphScannerVisualizer } from 'nexus-ioc-graph-visualizer';
import { AppModule } from './apps'; 

// Добавляем плагин визуализации
const visualizer = new GraphScannerVisualizer("./graph.png");

async function bootstrap() {
	await NexusApplicationsServer.create(AppModule)
		.addScannerPlugin(visualizer)
		.bootstrap();
}

bootstrap();

Сравнение с другими вариантами

Пример на Nexus-IoC
import { Injectable, NsModule, Scope } from '@nexus-ioc/core';
import { NexusApplicationsServer } from '@nexus-ioc/core/dist/server'; 

@Injectable({ scope: Scope.Singleton })
class LoggerService {
  log(message: string) {
    console.log(message);
  }
}

@Injectable()
class UserService {
  constructor(private logger: LoggerService) {}

  printUser(userId: string) {
    this.logger.log(`logger: ${userId}`);
  }
}

@NsModule({
  providers: [LoggerService, UserService],
})
class AppModule {}

async function bootstrap() {
  const container = new NexusApplicationsServe.create(AppModule).bootstrap();
  const userService = await container.get<UserService>(UserService);
  
  userService.printUser('log me!');
}

bootstrap();

пример на inversify
import 'reflect-metadata';
import { Container, injectable, inject } from 'inversify';

@injectable()
class LoggerService {
  log(message: string) {
    console.log(message);
  }
}

@injectable()
class UserService {
  constructor(@inject(LoggerService) private logger: LoggerService) {}

  printUser(userId: string) {
    this.logger.log(`User ID: ${userId}`);
  }
}

const container = new Container();
container.bind(LoggerService).toSelf();
container.bind(UserService).toSelf();

const userService = container.get(UserService);
userService.printUser('123');

пример на Tsyringe:
import 'reflect-metadata';
import { container, injectable } from 'tsyringe';

@injectable()
class LoggerService {
  log(message: string) {
    console.log(message);
  }
}

@injectable()
class UserService {
  constructor(private logger: LoggerService) {}

  printUser(userId: string) {
    this.logger.log(`User ID: ${userId}`);
  }
}

container.registerSingleton(LoggerService);
container.registerSingleton(UserService);

const userService = container.resolve(UserService);
userService.printUser('123');

Как вы видите, тут нет какой-то вундервафли, которая бы меняла правила игры и уничтожала конкурентов, библиотека управляет зависимостями, просто чуть-чуть делая это по другому. Главное отличие от других решений - это декларативное объявление модулей открывает большие возможности для статического анализа кода, что помогает при разработке больших приложений.

Напоследок

Кому пригодится данное решение: Nexus-IoC особенно хорошо подходит для крупных приложений (enterprise уровня), где важно не только управление зависимостями, но и ясность структуры приложения. Я бы не рекомендовал это решение для маленьких и средних приложений — здесь вы вполне сможете обойтись без DI, особенно на начальных этапах. Однако, когда проект становится масштабным, с десятками разработчиков и командами, взаимодействующими через контракты, Nexus-IoC может снять множество проблем, связанных с управлением зависимостями, предоставив при этом мощные инструменты для поддержки и анализа кода.

В планах:

  • API уже стабилен и меняться не будет, но еще предстоит работа по оптимизации и полному покрытию тестами, чтобы довести библиотеку до версии 1.0

  • Разработка CLI для упрощения работы с библиотекой

  • Создание статического анализатора графа зависимостей, чтобы выявлять ошибки ещё до этапа сборки

  • Разработка плагинов для IDE для улучшения интеграции с редакторами

  • Улучшение документации и создания сайта для удобства разработчиков

Ссылка на репозиторий: https://github.com/Isqanderm/ioc

Ссылка на npm пакеты: https://www.npmjs.com/settings/nexus-ioc/packages

Github Wiki: https://github.com/Isqanderm/ioc/wiki