Как стать автором
Поиск
Написать публикацию
Обновить
VK
Технологии, которые объединяют

Внедрение зависимостей (DI) через библиотеку Tsyringe

Уровень сложностиСредний
Время на прочтение12 мин
Количество просмотров1.8K

Привет, Хабр! Меня зовут Роман Мельник, я фронтенд-разработчик во «ВКонтакте для Бизнеса». Наша команда создаёт инструменты, которые помогают владельцам сообществ управлять и развивать свои проекты. Сегодня я расскажу про Dependency Injection (DI) через библиотеку Tsyringe.

Почему это важно? Крупные проекты сталкиваются со следующими проблемами: разрастающимся глобальным стором, сложностями тестирования, масштабирования и переиспользования кода. Внедрение зависимостей помогает решить эти вопросы, делая код гибким и управляемым. На практике это выглядит гораздо интереснее. Давайте разберёмся!

Начнём с архитектурных принципов и паттернов.

Архитектурные принципы и паттерны

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

  1. Принципы, такие как Inversion of Control (IoC) и Dependency Inversion Principle (DIP).

  2. Паттерны, например Dependency Injection (DI).

  3. Библиотеки (IoC-контейнеры).

Inversion of Control (IoC)

Inversion of Control передаёт управление созданием зависимостей внешним компонентам, меняя поток управления от классов низкого уровня к классам высокого уровня — и наоборот.

На диаграмме видно, как абстракция связывает классы различных уровней, а IoC инвертирует поток управления. В одном случае класс сам создаёт зависимости, а в другом — получает их извне, следуя принципу.

Пример без IoC:

export class UserService { 
  private authService: AuthService; 
  public constructor() { 
    this.authService = new AuthService(); 
  } 
  public registerUser(username: string, password: string) { 
    this.authService.login(username, password); 
  } 
}

Здесь класс жёстко связан с зависимостью.

Пример с IoC:

export class UserService { 
  private authService: AuthService; 
  public constructor(authService: AuthService) { 
    this.authService = authService; 
  } 
  public registerUser(username: string, password: string) { 
    this.authService.login(username, password); 
  } 
}

Теперь зависимости передаются извне, при необходимости можем их подменить, что упрощает тестирование и масштабирование.

Помимо Dependency Injection, принцип Inversion of Control включает в себя и другие подходы к управлению зависимостями. Среди них Dependency Lookup, Template Method, Service Locator и Event-Driven Architecture — каждая из этих техник решает специфические задачи и используется в разных архитектурных сценариях.

Dependency Inversion Principle (DIP)

Dependency Inversion Principle (DIP) — один из ключевых принципов SOLID, который уменьшает связанность между компонентами системы. Он обеспечивает независимость модулей высокого уровня от подробностей реализации низкоуровневых модулей, позволяя им взаимодействовать через абстракции.

В традиционном подходе классы напрямую связаны друг с другом, создавая жёсткие зависимости. DIP решает эту проблему, вводя промежуточный слой абстракции, через который происходит взаимодействие. Это делает систему гибкой: если нужно заменить класс высокого, не потребуется делать изменения в классе низкого уровня, так как он зависит от абстракции.

Сверху -- без DIP, снизу -- с DIP
Сверху — без DIP, снизу — с DIP

На диаграмме видно, как без DIP UserService напрямую зависит от EmailService, что затрудняет изменение логики. Внедрение DIP позволяет UserService работать через абстрактный MessageService, и при необходимости можно заменить EmailService без необходимости переписывать код UserService.

Пример без DIP:

export class UserService {
  private emailService: EmailService;

  public constructor(emailService: EmailService) {
    this.emailService = emailService;
  }

  public sendUserMessage(to: string, message: string) { 
    void this.emailService.sendMessage(to, message);
  }
}

Здесь UserService жёстко привязан к конкретной реализации EmailService.

Пример с DIP:

interface MessageService {
  sendMessage: (to: string, message: string) => void;
}

class EmailService implements MessageService {
  public sendMessage(to: string, message: string)Так в чём же разница между Inverse of Control и DIP? {
    // Логика отправки сообщений
   }
 }

export class UserService {
  private emailService: MessageService;

  public constructor(emailService: MessageService) {
    this.emailService = emailService;
  }

  public sendUserMessage(to: string, message: string) {
    void this.emailService.sendMessage(to, message);
  }
}

Теперь UserService зависит не от конкретной реализации EmailService, а от абстракции MessageService. Это позволяет легко заменить EmailService на другой сервис без изменений в UserService.

Так DIP делает код гибче и удобнее для поддержки, позволяя разделять ответственность и избегать жёсткой связанности между модулями.

Так в чём же разница между Inversion of Control и DIP?

IoC управляет потоком выполнения программы, определяя, кто контролирует создание и управление зависимостями. DIP же сосредоточен на снижении зависимости модулей от конкретных реализаций, обеспечивая взаимодействие через абстракции.

IoC реализуется через библиотеки и фреймворки, которые передают контроль над зависимостями внешним механизмам. DIP, в свою очередь, применяется через интерфейсы, позволяя заменять компоненты без изменения их связей. В первом случае речь идёт о проектировании управления потоком, во втором — о проектировании зависимостей.

Использование обоих принципов делает архитектуру гибче, упрощает поддержку и снижает связанность модулей. Реализация этих принципов осуществляется паттерном Dependency Injection который избавляет классы от создания собственных зависимостей, получая их извне.

Помимо Constructor Injection, который мы уже рассмотрели, существуют и другие способы передачи зависимостей. Setter Injection выполняется через метод класса, а Property Injection — через поле класса, зачастую с использованием декораторов.

Завершающий элемент системы — IoC-контейнер. Он берёт на себя регистрацию зависимостей, управление их жизненным циклом и их внедрение в классы. Таким образом, архитектура становится предсказуемой и легко управляемой.

Теперь, когда мы рассмотрели все элементы системы, можем перейти к готовым решениям IoC-контейнера.

Библиотеки с готовыми решениями IoC-контейнера

Популярных библиотек много, но наиболее востребованные — InversifyJS, Tsyringe и TypeDI. Они работают по схожему принципу, используя декораторы для управления зависимостями.

На графике видно, что InversifyJS — наиболее популярная библиотека, а Tsyringe и TypeDI занимают второе и третье места. Разница заметна не только в количестве скачиваний, но и в размере: InversifyJS — самая тяжёлая из трёх, а Tsyringe, напротив, компактнее, что делает её удобной для использования в больших проектах.

Во ВКонтакте мы выбрали Tsyringe по нескольким причинам. Она поддерживает TypeScript, что для нас критично, имеет достаточную функциональность для наших задач, а её компактность важна для оптимизации размера бандла.

Теперь пойдём дальше.

Принцип работы Tsyringe

Структура контейнера
Структура контейнера
  • parent — ссылка на родительский контейнер;

  • registry — хранилище зарезовленных сущностей;

  • interceptors — хранилище перехватчиков;

  • disposables — хранилище сущностей в которых будут вызван dispose метод при при вызове функции размонтирования контейнера.

Tsyringe управляет зависимостями через dependency-контейнер, который рекурсивно обходит, регистрирует и возвращает экземпляры сущностей. Помимо основного контейнера, возможна мультиконтейнеризация, позволяющая одному контейнеру наследовать другой. Они связываются через ссылку parent, а зависимости хранятся в registry. В системе также предусмотрены interceptors в виде функций обратного вызова, которые будут вызваны до или после создания экземпляров сущностей, и disposables для вызов dispose метода сущностей при размонтировании.

Контейнер поддерживает методы создания, регистрации и получения зависимостей, а также настройки жизненного цикла. Последний делится на четыре режима:

  • Transient (новый экземпляр при каждом запросе);

  • Singleton (единый экземпляр для всех контейнеров);

  • ContainerScoped (один экземпляр на контейнер);

  • ResolutionScoped (общий экземпляр для контейнера и его дочерних контейнеров).

Для установки жизненного цикла используются декораторы: Injectable для Transient, Singleton — одноимённый режим, а Scoped управляет ResolutionScoped и ContainerScoped.

Tsyringe позволяет внедрять зависимости как классовые, так и неклассовые сущности. В первом случае библиотека использует метаданные TypeScript, а во втором необходимо явно указать нужную сущность в виде ключа, который может быть строкой или символом (symbol), через декораторы параметров, такие как inject, injectAll, injectWithTransform и injectAllWithTransform.

Для корректной работы требуется включить в конфигурации Typescript emitDecoratorMetadata и experimentalDecorators (обязательно для версий ниже пятой) и подключить reflect-metadata для чтения метаданных классов.

С теорией разобрались, теперь можно переходить к практике.

Пример реализации DI, используя Tsyringe

Tsyringe позволяет эффективно управлять зависимостями в проекте, интегрируя DI-контейнер в архитектуру приложения. В нашем случае используется связка React, TypeScript и MobX, а в качестве IoC-контейнера — Tsyringe.

Здесь следует сказать про использование MobX в качестве (state management), изначально мы использовали Effector, но его использование оказалось неудачным. Подробнее об этом можно узнать из доклада «Особенности Effector, которые почему-то никто не обсуждает: опыт ВКонтакте спустя год использования» или из статьи на Хабре. В итоге выбор пал на MobX, который обеспечивает удобное управление состоянием и хорошо интегрируется с Tsyringe.

export const App = () => {
  return (
    <BrowserRouter>
      <Header />
       <Routes>
         <Route path="cats" element={<Cats />} />
         <Route path="users" element={<Users />} />
       </Routes>
    </BrowserRouter>
  );
};
const imagesStore = new CatsImagesStore();
const catsFactsService = new CatsFactsService(imagesStore);
const catsStore = new CatsModel(catsFactsService)

export const Сats = observer(() => {
  const cats = catsStore.catsList;
  const isLoading = catsStore.isLoading;

  useEffect(() => {
    catsStore.fetchCatsInfo();
  }, []);

  return (
    <>
      {isLoading? (<Spinner />) : (<CatsList cats={cats} />)}
   </>
  );
});

Базовая структура приложения включает страницы «Котиков» и «Пользователей». В примере с котиками видно, как компоненты зависят друг от друга: модель CatsModel получает данные из CatsFactsService, который использует CatsImagesStore для загрузки изображений.

export class CatsModel {
  public constructor(private catsFactsService: CatsFactsService) {
    makeAutoObservable(this);
  }
 
  public fetchCatsInfo = () => {
    void this.catsFactsService.fetchFacts();
  }

  public get catsList() {
    return this.catsFactsService.factsList;
  }

  public get isLoading() {
    return this.catsFactsService.isLoading;
  }
}

Чтобы подключить Tsyringe к этим моделям, их необходимо обернуть в декораторы, в данном случае это @injectable с жизненным циклом Transient, которые обеспечивают автоматическое управление зависимостями.

import { injectable } from 'tsyringe’;

@injectable()
export class CatsFactsService {
  public constructor(private imagesStore: CatsImagesStore) {
    makeAutoObservable(this);
  }
  // Код сервиса
}

@injectable()
export class CatsModel {
  public constructor(private catsFactsService: CatsFactsService) {
    makeAutoObservable(this);
  }
 // Код сервиса
}

Теперь мы можем создание экземпляров класса и внедрение зависимости переложить на tsyringe, заменим явное создание экземпляров на автоматическое регистрируя модель страницы в DI контейнере tsyringe.

import { container } from 'tsyringe’;

const catsStore = container.resolve(CatsModel);

export const Сats = observer(() => {
  // Код компонента
});

Благодаря этому процесс регистрации и передачи зависимостей происходит без явного создания экземпляров классов — их управление берёт на себя DI-контейнер.

Но есть две проблемы. Во-первых, регистрация абстрактных сервисов требует дополнительной настройки. Во-вторых, все зависимости хранятся в root-контейнере, что может приводить к утечке памяти при смене страниц.

Начнем с создания базового провайдера для доступа к DI контейнеру tsyringe в любом месте приложения.

import { container } from 'tsyringe’;

const ContainerContext = createContext<DependencyContainer>(container);

export const useContainer = () => useContext(ContainerContext);

export const DIProvider = ({ children }: PropsWithChildren<{}>) => {
  const container = useContainer();
  
  return (
    <ContainerContext.Provider value={container}>
      {children}
    </ContainerContext.Provider>
  )
};

Теперь для решения первой проблемы добавим в базовый провайдер регистрацию общих сервисов.

import { singleton } from 'tsyringe’;

@singleton()
export abstract class Logger {
  public abstract log(message: string): void;
}

@singleton()
export class LoggerService implements Logger {
  public log(message: string) {
    // Код исполнения
  }
}

export interface MessageService {
  sendMessage: (to: string, message: string) => void;
}

@singleton() 
export class EmailService implements MessageService {
  public sendMessage(to: string, message: string) {
    // Логика отправки сообщений
  }
}

@singleton() 
export class SmsService implements MessageService {
  public sendMessage(to: string, message: string) {
    // Логика отправки сообщений
  }
}

Абстракции могут быть представлены как интерфейсы (например, MessageService с реализациями EmailService и SmsService) или классы (Logger). Добавим регистрацию общих сервисов в наш базовый провайдер.

import { container } from 'tsyringe’;

const ContainerContext = createContext<DependencyContainer>(container);

export const useContainer = () => useContext(ContainerContext);

export const DIProvider = ({ children }: PropsWithChildren<{}>) => {
  const container = useContainer();
  
  container.register<MessageService>("MessageService", EmailService);
  container.register(Logger, LoggerService);
  
  return <ContainerContext.Provider value={container}>{children}</ContainerContext.Provider>;
};

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

@injectable() 
export class CatsFactsService {
  public constructor(
    private imagesStore: CatsImagesStore, 
    private logger: Logger,
    @inject("MessageService") private messageService: MessageService
  ) {
    makeAutoObservable(this);
  }
  
  public async fetchFacts() { 
    // Код исполнения
  }
  
  public get factsList() { 
    // Код исполнения 
  }
}

Чтобы корректно внедрять интерфейсы, необходимо явно указывать токен через @inject().

C первой проблемой мы справились теперь перейдем ко второй. Все зависимости хранятся в root-контейнере, и при переходе между страницам не будет очистки зависимостей, так root-контейнер будет существовать на протяжении всего времени работы приложения.

Вторая проблема решается путём создания дочерних контейнеров для отдельных страниц. DI-контейнер должен создаваться при загрузке страницы и автоматически уничтожаться при её закрытии. Для этого пишут провайдер, который регистрирует модели, а затем передаёт их в контекст.

export const createProvider = <ClassType, ModelType = InstanceType<ClassType>>(
  ModelClass: ClassType,
) => {
  const ModelContext = createContext<ModelType | null>(null);

  const Provider = (props: PropsWithChildren) => {
    const di = useContainer();
    const childContainer = di.createChildContainer();
    const instance = childContainer.resolve(ModelClass);
    
    useEffect(() => () => {
      childContainer.dispose();
    }, [],);
    
    return (
      <ModelContext.Provider value={instance}>
        {props.children}
      </ModelContext.Provider>;
  };

  const useModel = (): ModelType => useContext(ModelContext);

  return { Provider, useModel };
};

Передаем модель страницы в функцию, и получаем Provider и хук для страницы

@injectable()
export class CatsModel {
 public constructor(private catsFactsService: CatsFactsService) {
    makeAutoObservable(this);
  }
  
 // Код сервиса
}


export const { 
  Provider: CatsModelProvider, useModel: useCatsModel
} = createProvider(CatsModel);

Теперь мы можем обернуть нашу страницу в провайдер, и получить внутри компонента доступ к данным модели, также можно сделать HOC для оборачивания компонента страницы в провайдер

import { CatsModelProvider, useCatsModel } from ‘./model';

const CatsInner = observer(() => {
  const { catsList, fetchCatsInfo, isLoading } = useCatsModel();
  
  useEffect(() => {
    fetchCatsInfo();
  }, []);

  // Код компонента
});

export const Cats = createWidget(CatsModelProvider, CatsInner);

Теперь все зависимости внедряются корректно, и приложение функционирует без проблем

Использование Tsyringe позволяет автоматизировать регистрацию зависимостей, управлять их жизненным циклом и легко заменять реализации. В результате система становится гибкой, поддерживаемой и удобной для работы с DI в React-приложениях.

Общая структура DI, используемая во ВКонтакте

Во ВКонтакте DI используют на трёх уровнях: Platform Layer, Domain Layer и View Layer.

Platform Layer управляет платформозависимыми сервисами, разделяя их для веба и сервера. Здесь применяются плагины, которые адаптируются под окружение.

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

View Layer отвечает за логику отображения. Здесь есть модели, управляющие интерфейсом, виджеты — React-компоненты с ViewModel, и обычные компоненты, которые взаимодействуют с ViewModel через родительскую структуру.

Рассмотрим простую страницу которая имеет ViewModel и виджет для отображения пользователей, который имеет также свою ViewModel. Для виджетов предусмотрены свои собственные провайдеры, но которые имеют схожую структуру с провайдерами страниц.

Если разобрать верхнеуровнево взаимодействия зависимостей, то получим следующую схему

На иллюстрации видно, как эти слои связаны. Чем выше уровень, тем больше переиспользуемости: плагины Platform Layer наиболее гибкие, Domain Layer более специализированы, а View Layer сосредоточен на интерфейсе.

Пример структуры реальной страницы показывает, как DI управляет зависимостями. В модели страницы используются GroupsService и StatsService, а также плагины локализации и конфигурации. StatsService зависит от GroupsApi и StatsApi, которые работают через API-клиент. Виджет пользователей использует UsersService, который в свою очередь взаимодействует с errorLogger и API-клиентом.

Вся работа по управлению зависимостями выполняется DI-контейнером — разработчику нужно лишь указать нужные сервисы. Дальше Tsyringe берёт на себя регистрацию, инкапсуляцию и связывание компонентов.

Основные преимущества DI: масштабируемость, контейнеризация и упрощённое тестирование. На практике этот подход доказал свою эффективность, сделав архитектуру более гибкой и удобной для поддержки.

Если DI ещё не стал частью вашего проекта, самое время попробовать.

Теги:
Хабы:
+36
Комментарии7

Публикации

Информация

Сайт
team.vk.company
Дата регистрации
Дата основания
Численность
свыше 10 000 человек
Местоположение
Россия
Представитель
Дмитрий Головин