Провайдер

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

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

Проблему частично решает стандартизация процесса создания экземпляров. Например, если все модули будут предоставлять однотипную фабричную функцию или однотипный конструктор, принимающую только ссылку на контейнер экземпляров, тогда логику контейнера можно обобщить и вместо написания методов под каждый тип экземпляра реализовать общий метод get(key) для выборки нужного экземпляра по ключу. Ключом может быть уникальное название модуля.

// Реэкспорт фабричных функций всех модулей под нужными ключами. По сути здесь подключается модуль.
export { createHttpClient as httpClient } from 'http-client';  
export { createLogger as logger } from 'logger';  
export { createSomeService as someService } from 'some-service';

Для работы контейнера экземпляров потребуется описать карту фабричный функций (удобно делается реэкспортом и последующим импортом через звездочку). Главное чтобы у всех фабричных функций был одинаковый интерфейс — могли принять ссылку на контейнер (так как через него модуль сможет выбирать свои зависимости).

import * as modules from './modules'; // Импорт всех фабричных функций подключенных модулей
  
const createContainer = () => {  
  const instances = new Map();  
  
  const container = {  
    // Выборка экземпляра модуля по ключу, напрмиер "httpClient"
    get(key) {  
      if (!instances.has(key)) instances.set(key, modules[key]({ container }));  
      return instances.get(key);  
    },  
  };
  
  return container;  
}

Но с таким подходом пропадает гибкость для подключения нестандартных модулей. Например модуль httpClient ожидает параметр baseUrl. Но кто его сейчас передаст? Никто. Контейнер экземпляров не должен знать про особые параметры модулей. Контейнер даже не должен знать про модули — не должен импортировать их фабричные функции. Необходимо устранить жесткую связь. Правильная гибкость и универсальность достигается за счёт устранения лишних обязанностей, а не через наделения дополнительных.

Вместо импорта фабричных функций можно реализовать метод register({key, factory}) в контейнере для добавления фабричных функций от всех необходимых модулей в реалтайме .

const createContainer = () => {  
  const instances = new Map();
  const factories = new Map();  
  
  const container = {  
    // Регистарция фабричной функции по ключу
    register({ key, factory }) {
      factories.set(key, factory);
    },
    // Выборка экземпляра модуля по ключу, напрмиер "httpClient"
    get(key) {  
      if (!factories.has(key)) throw new Error(`Factory for key "${key}" not registered`);
      if (!instances.has(key)) {
        const factory = factories.get(key)!;
        instances.set(key, factory(container));
      }
      return instances.get(key);  
    },  
  };  
  
  return container;  
}

Добавление или регистрация будет осуществляться в том же месте исходного кода, где создается контейнер экземпляров — в корне приложения. Контейнер освободится от жёстких связей, и его можно будет повторно использовать с разными модулями в разных окружениях и в разных проектах.

const container = createContainer();

// Регистрируем фабрики
container.register({ key: "logger", factory: createLogger });
container.register({ key: "someService", factory: createSomeService });
container.register({ key: "store", factory: createStore });
// Нестандартная фабрика
container.register({
  key: "httpClient",
  factory: (container) => createHttpClient({ manager, baseUrl: '/api/v1'})
});

render(<App store={container.get('store')}/>);

Абстрагирование контейнера от конкретных экземпляров (модулей) достигается за счёт ограничений. Все модули предоставляют однотипную фабричную функцию. Для внутренних модулей проекта это правило несложно соблюдать, а для сторонних можно применять адаптер или просто обернуть их фабричную функцию в свою. Однако остается другая проблема: в качестве зависимостей приходится всем передавать контейнер экземпляров. В предыдущей теме уже было сказано, что это ведёт к неявным зависимостям. Модуль не должен сам выбирать свои зависимости из контейнера. Но если зависимости для каждого модуля передавать явно, то как абстрагировать логику контейнера? Как контейнер должен узнать, что передавать каждому модулю?

Лучшее решение — при регистрации модулей контейнере в методе register указывать не только ключ и фабричную функцию, но и список зависимостей. Например, передавать их в виде карты ключей в depends. Тогда при первом запросе экземпляра методом get() контейнер сможет сам выбрать все зависимые экземпляры по указанным в регистрации ключам и передать их в фабричную функцию. В результате будет получен новый экземпляр, и метод get вернёт его.

const createContainer = () => {  
  const instances = new Map();
  const providers = new Map();  
  
  const container = {  
    // Регистарция провайдера
    register(provider) {
      providers.set(provider.key, provider);
    },
    // Выборка экземпляра модуля по ключу, напрмиер "httpClient"
    get(key) {  
      if (!providers.has(key)) throw new Error(`Provider for key "${key}" not registered`);
      if (!instances.has(key)) {
        const provider = providers.get(key)!;
        const dependencies = {};
        // Разрешаем зависимости
        if (provider.depends) {
          for (const [depName, depKey] of Object.entries(provider.depends)) {
            dependencies[depName] = this.get(depKey);
          }
        }
        instances.set(key, provider.factory(dependencies));
      }
      return instances.get(key);  
    },  
  };  
  
  return container;  
}

Объект, содержащий ключ, фабричную функцию и список зависимостей, называется провайдером. Провайдер определяет, как создаётся экземпляр и какие зависимости ему нужны. Но самое главное — провайдер предоставляет экземпляр, а процесс создания экземпляра остаётся скрытым от контейнера экземпляров.

const httpClientProvider = {
  key: "httpClient",
  factory: ({ baseUrl, logger }) => createHttpClient({ baseUrl, logger }),
  depends: {
    baseUrl: 'baseURL',
    logger: 'logger'
  }
} 

Фабричная функция в провайдере не обязательно должна быть частью регистрируемого модуля. Её можно реализовать непосредственно в провайдере с любым алгоритмом подготовки экземпляра. Можно пойти дальше и создать специализированные провайдеры. Провайдер с фабричной функцией — универсальный, и на его основе можно сделать провайдер с конструктором класса. Конструктор — это тоже функция, но её нужно вызывать с оператором new.

function classProvider({ key, depends, constructor }) {  
  return {  
    key, 
    depends,
    factory: (depends) => new constructor(depends)
  };  
}

Ещё один вариант — провайдер готового значения. Готовым значением могут быть константы, настройки или другие ресурсы. Они уже существуют в коде, их не нужно создавать, но их можно оформить через провайдер, чтобы не делать глобальными и не импортировать напрямую.

function valueProvider({ key, value }) {  
  return {  
    key, 
    depends: {},
    factory: () => value,  
  };  
}

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

 const container = createContainer();

// Регистрируем провайдеры
//...
container.register(httpClientProvider);
container.register(valueProvider({ key: 'baseURL', value: '/api/v1' }));

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

Главная функциональная особенность в указании зависимостей в провайдере и регистрации всех зависимостей в контейнере в виде провайдеров заключается в том, что каждый модуль будет получать только необходимые ему зависимости. Модулям теперь не нужна ссылка на контейнер экземпляров, модулям не нужно самостоятельно выбирать зависимости. Зависимые модули будут переданы контейнером при создании экземпляра модуля (при исполнении провайдера). Полностью устраняется проброс свойств вложенным модулям. Потому что пропадает само понятие вложенного модуля. Если некий модуль А должен взаимодействовать с модулем B, то экземпляр модуля B будет передан в зависимостях уже подготовленным. Модуль А не импортирует модуль B и не знает про контейнер экземпляров — просто ожидает его в аргументах своей фабричной функции.

Применение провайдеров с явным указанием зависимостей превращает контейнер экземпляров в контейнер для внедрения зависимостей (DI-контейнер). DI-контейнер по праву становится главным звеном в архитектуре приложения. С одной стороны это точка доступа ко всем программным решениям проекта, с другой стороны в исходном коде прямое обращение к контейнеру будет редким исключением. Внедрение зависимостей придает архитектуре проекта гибкость, расширяемость и значительно облегчает тестирование. Избавляет от жестких связей и четко распределяет ответственность. Каждый модуль получает только те экземпляры, которые действительно нужны. Нет проблемы с пробросом зависимостей — модули работают с уже подготовленными зависимостями и не заботятся о чужих зависимостях.

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

Содержание