Pull to refresh

Метафизика Dependency Injection

Reading time9 min
Views9.6K
image


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

Материал написан по мотивам разработки IoC-контейнера Hypo, о котором я упоминал в предыдущей статье. В миниатюрных примерах кода я буду использовать Ruby, как один из самых лаконичных объектно-ориентированных языков для написания коротких примеров. Это не должно вызвать проблем в понимании у разработчиков на других языках.

Уровень 1: Dependency Inversion Principle


Разработчики в объектно-ориентированной парадигме ежедневно сталкиваются с созданием объектов, которые, в свою очередь, могут зависеть от других объектов. Это приводит к возникновению графа зависимостей. Предположим, что мы имеем дело с объектной моделью вида:
image

— некоторый сервис обработки счетов (InvoiceProcessor) и сервис уведомлений (NotificationService). Сервис обработки счетов отправляет уведомления при выполнений определённых условий, вынесем эту логику за рамки. В принципе, данная модель уже неплоха тем, что за разные ответственности отвечают отдельные компоненты. Проблема скрывается в том, как мы реализуем эти зависимости. Частой ошибкой является инициализация зависимости там, где эта зависимость используется:

class InvoiceProcessor
  def process(invoice)
    # инициализация зависимости внутри зависимого объекта
    notificationService = NotificationService.new
    notificationService.notify(invoice.owner)
  end
end  

Это является ошибкой ввиду того, что мы получаем высокую связность логически независимых объектов (High Coupling). Это приводит нарушению принципа единственной ответственности (Single Responsibility Principle) — зависимый объект помимо своих непосредственных ответственностей должен инициализировать свои зависимости; а также «знать» интерфейс конструктора зависимости, что приведёт к дополнительной причине для изменения («reason to change», R. Martin). Правильнее передавать подобного рода зависимости, инициализированные вне зависимого объекта:

class InvoiceProcessor
  def initialize(notificationService)
    @notificationService = notificationService
  end
  
  def process(invoice)
    @notificationService.notify(invoice.owner)
  end
end

notificationService = NotificationService.new
invoiceProcessor = InvoiceProcessor.new(notificationService)

Такой подход соответствует принципу инверсии зависимостей (Dependency Inversion Principle). Теперь мы передаём объект с интерфейсом отправки сообщений — сервису обработки счетов уже нет необходимости «знать», как конструировать объект сервиса уведомлений. При написании модульных тестов для сервиса обработки счетов разработчику не нужно ломать голову о том, как подменить реализацию интерфейса сервиса уведомлений заглушкой. В языках с динамической типизацией, типа Ruby, можно подставить любой объект отвечающий методу notify; со статической же типизацией, типа C#/Java, можно использовать интерфейс INotificationService, для которого легко создать Mock. Детально вопрос инверсии зависимостей раскрыт Александром Бындю (AlexanderByndyu) в статье, которая совсем недавно отметила 10-летие!

Уровень 2: реестр связанных объектов


Использование принципа инверсии зависимости не выглядит сложной практикой. Но со временем из-за роста количества объектов и связей появляются новые вызовы. NotificationService может использоваться другими сервисами кроме InvoiceProcessor. Помимо этого, он сам может зависеть от других сервисов, которые, в свою очередь, зависят о третьих и т.д. Также некоторые компоненты не всегда могут быть использованы в единственном экземпляре. Главной задачей становится поиск ответа на вопрос — «когда создавать зависимости?».
Для решения этого вопроса можно попробовать построить решение, в основе которого лежит ассоциативный массив зависимостей. Примерный интерфейс его работы мог бы выглядеть так:

registry.add(InvoiceProcessor)
  .depends_on(NotificationService)

registry.add(NotificationService)
  .depends_on(ServiceX)

invoiceProcessor = registry.resolve(InvoiceProcessor)
invoiceProcessor.process(invoice)

Это не трудно реализовать практически:

image

При каждом вызове container.resolve() мы будем обращаться к фабрике, которая будет создавать экземпляры зависимостей, рекурсивно обходя граф зависимостей, описанных в реестре. В случае `container.resolve(InvoiceProcessor)` будет выполнено следующее:

  1. factory.resolve(InvoiceProcessor) — фабрика запрашивает зависимости InvoiceProcessor в регистре, получает NotificationService, который тоже нужно собрать.
  2. factory.resolve(NotificationService) — фабрика запрашивает зависимости NotificationService в регистре, получает ServiceX, который тоже нужно собрать.
  3. factory.resolve(ServiceX) — не имеет зависимостей, создаём, возвращаемся по стеку вызовов к шагу 1, получаем собранный объект типа InvoiceProcessor.

Каждый компонент может зависеть от нескольких других, поэтому очевиден вопрос — «как корректно провести соответствие параметров конструктора с полученными экземплярами зависимостей?». Пример:

class InvoiceProcessor
  def initialize(notificationService, paymentService)
    # ...
  end
end

В языках со статической типизацией в качестве селектора может служить тип параметра:

class InvoiceProcessor {
  constructor(notificationService: NotificationService, paymentService: PaymentService) {
    // ...
  }
}

В рамках Ruby можно использовать соглашение — просто используем имя типа в формате snake_case, это и будет ожидаемым именем параметра.

Уровень 3: управление временем жизни зависимостей


Мы уже получили неплохое решение для управления зависимостями. Единственным его ограничением является необходимость создания нового экземпляра зависимости при каждом обращении. А что если мы не можем создавать более одного экземпляра какого-либо компонента? Например, пула соединений к БД. Копнём глубже, а если нам требуется обеспечить управляемое время жизни зависимостей? Например, закрывать соединение к БД после завершения HTTP-запроса.
Становится очевидным, что кандидатом на замену в изначальном решении является InstanceFactory. Обновлённая диаграмма:

image

И логичным решением является использованием набора стратегий (Strategy, GoF) для получения экземпляров компонентов. Теперь мы не всегда создаём новые экземпляры при обращении Container::resolve, поэтому уместно переименовать Factory в Resolver. Обратите внимание, у метода Container::register появился новый параметр — life_time (время жизни). Этот параметр является необязательным — по умолчанию его значением является «transient» (скоротечный), что соответствует ранее реализованному поведению. Стратегия «singleton» также является очевидной — с её использованием создаётся лишь один экземпляр компонента, который будет возвращаться каждый раз.
«Scope» является несколько более сложной стратегией. Вместо «скоротечек» и «одиночек» зачастую требуется использовать нечто среднее — компонент, который существует на протяжении жизни другого компонента. Подобным примером может быть объект запроса веб-приложения, который является контекстом существования таких объектов, как, например, HTTP-параметры, соединение с БД, агрегаты модели. На протяжении жизни запроса мы собираем и используем эти зависимости, а после его уничтожения ожидаем, что все они будут также уничтожены. Для реализации такой функциональности потребуется разработать достаточно сложную, замкнутую объектную структуру:

image

На схеме показан фрагмент, отражающий изменения в классах Component и LifetimeStrategy в контексте реализации времени жизни Scoped. Получился этакий «двойной мост» (по аналогии с шаблоном Bridge, GoF). С помощью хитросплетения приёмов наследования и агрегации Component становится ядром контейнера. Кстати, на диаграмме присутствует множественное наследование. Там, где это позволяет язык программирования и совесть, можно так и оставить. В Ruby я использую примеси, в других языках можно заменить наследование ещё одним мостом:
image

На диаграмме последовательности показан жизненный цикл компонента session, который привязан к времени жизни компонента request:

image

Как видно из диаграммы, в определённый момент времени, когда компонент request завершает свою миссию, вызывается метод release, который запускает процесс уничтожения scope.

Уровень 4: Dependency Injection


До сих пор я рассказывал о том, как определить реестр зависимостей, и, затем, как создавать и уничтожать компоненты в соответствии с графом образовавшихся связей. А для чего это вообще нужно? Предположим, что мы используем это в рамках Ruby on Rails:

class InvoiceController < ApplicationController
  def pay(params)
    invoice_repository = registry.resolve(InvoiceRepository)
    invoice_processor = registry.resolve(InvoiceProcessor)
    
    invoice = invoice_repository.find(params[:id])
    invoice_processor.pay(invoice)
  end
end

Код, который будет написан таким образом, не будет более читаемым, тестируемым и гибким. Мы не можем “заставить” Rails внедрять зависимости контроллера через его конструктор, это не предусмотрено фреймворком. Но, например, в ASP.NET MVC это реализовано на базовом уровне. Для получения максимальной отдачи от использования механизма автоматического разрешения зависимостей необходимо реализовать технику Inversion of Control (IoC, инверсия управления). Это такой подход, при котором ответственность за разрешение зависимостей выходит за рамки прикладного кода и ложится на фреймворк. Рассмотрим пример.
Представим, что мы проектируем что-то наподобие Rails с нуля. Реализуем следующую схему:

image

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

class App
  # Инициализация приложения - место, где происходит создание и настройка контейнера.
  def initialize
    @container = Container.new

    @container
      .register(Controller)
      .using_lifetime(:transient) # короткоживущий, создаётся при каждом обращении

    @container
      .register(InvoiceService)
      .using_lifetime(:singleton) # одиночка, существует пока работает приложение

    @container
      .register(Router)
      .using_lifetime(:singleton) # одиночка
  end

  # Точка входа в приложение - эталонное и желательно единственное место,
  # где происходит прямое обращение к контейнеру.
  def call(env)
    router = @container.resolve(Router)
    router.handle(env.path, env.method, env.params)
  end
end

В любом приложении есть точка входа, например, метод main. В рамках данного примера точкой входа является метод call. Задачей этого метода является вызов маршрутизатора для обработки входящих запросов. Точка входа должна быть единственным местом вызова контейнера напрямую — с этого момента контейнер должен уйти на второй план, вся последующая магия должна происходить «под капотом». Реализация контроллера в рамках такой архитектуры действительно выглядит необычно. Несмотря на то, что мы не создаём его экземпляры явно, он имеет конструктор с параметрами:

class Controller
  # Зависимости внедряются автоматически.
  # Конструктор вызывается средой исполнения.
  def initialize(invoice_service)
    @invoice_service = invoice_service
  end

  def create_invoice(params)
    @invoice_service.create(params)
  end
end

Среда «понимает» как создавать экземпляры контроллера. Это возможно благодаря механизму внедрения зависимостей, которую обеспечивает IoC-контейнер, встроенный в сердце веб-приложения. В конструкторе контроллера теперь можно перечислять всё, что требуется для его работы. Главное, чтобы в контейнере были зарегистрированы соответствующие компоненты. Теперь обратимся к реализации маршрутизатора:

class Router
  # Зависит от компонента с заевдомо меньшим временем жизни - контейнер
  # самостоятельно обеспечивает производство экземпляров зависимостей
  # в соответствии с заданной стратегией.
  def initialize(controller)
    @controller = controller
  end

  def handle(path, method, params)
    # реализация "супер"-маршрутизатора
    if path == '/invoices' && method == 'POST'
      @controller.create(params)
    end
  end
end

Обратите внимание, что Router зависит от Controller. Если вспомнить параметры настройки зависимостей, то Controller — это короткоживущий компонент, а Router — постоянная одиночка. Как же такое может быть? Разгадка заключается в том, что компоненты не являются экземплярами соответствующих классов, как это выглядит внешне. На самом деле это proxy-объекты (Proxy, GoF) с фабричным методом (Factory Method, GoF) instance; они возвращают экземпляр компонента в соответствии с назначенной стратегией. Поскольку Controller зарегистрирован как «transient», то Router при обращении всегда будет иметь дело с его новым экземпляром. На диаграмме последовательности отражён примерный механизм работы:

image

Т.е. помимо управления зависимостями хороший фреймворк на основе IoC-контейнера также берёт на себя ответственность за корректное управление временем жизни компонентов.

Заключение


Техника Dependency Injection может иметь достаточно изощрённую внутреннюю реализацию. Это цена переноса сложности реализации гибких приложений в ядро фреймворка. Пользователю подобных фреймворков можно не беспокоиться о сугубо технических аспектах, а уделять больше времени комфортной разработке бизнес-логики прикладных программ. С использованием качественной реализации DI прикладной программист изначально пишет тестируемый, хорошо поддерживаемый код. Наглядным примером реализации Dependency Injection служит фреймворк Dandy, описанный в моей предыдущей статье Ортодоксальный Backend.
Tags:
Hubs:
+8
Comments0

Articles