Привет, Хабр! Меня зовут Роман Мельник, я фронтенд-разработчик во «ВКонтакте для Бизнеса». Наша команда создаёт инструменты, которые помогают владельцам сообществ управлять и развивать свои проекты. Сегодня я расскажу про 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 — хранилище сущностей которые будут удалены при вызове функции размонтирования.

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

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

  • 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 ещё не стал частью вашего проекта, самое время попробовать.

Комментарии (0)