Andriy Shyrokoryadov

.Net developer, data scientist

Внедрение зависимостей, Dependency Injection - вопрос №4 на собеседование по программированию

Текст к видео "Что такое внедрение зависимостей, Dependency Injection / Inversion of Control?" на канале YouTube

Приветствую Вас на моём канале. Сегодня мы рассмотрим тему внедрения зависимости. Если вы будете искать информацию по этой теме на англоязычных ресурсах, то там это тема называется dependency injection или inversion of control. В начале мы познакомимся с внедрением зависимости в более широком контексте, а далее перейдем к рассмотрению этого вопроса непосредственно в контексте приложение Asp.Net Core.

alt text

Давайте представим, что у нас есть некоторый класс, который зависит от нескольких других классов. Для удобства восприятия информации я подготовил схему зависимостей нашего класса, который называется Calculator. Классы обозначены прямоугольниками – в заглавии написано название класса и указан интерфейс, который данный класс реализует. Для начала давайте обратим внимание только на связи, обозначенные красными стрелками. Это наша исходная ситуация. Класс Calculator зависит принимает 2 зависимости IPrinter и IConnector, которые представлены классами Printer и DatabaseConnector соответственно. В свою очередь класс DatabaseConnector зависит от интерфейсов IConfigurationReader и ILogger, которые представлены классами XmlConfigurationReader и Logger соответственно. Классы Printer и Logger не зависят от других классов. Класс Calculator – это класс, который является основным в нашем приложении. Для того, чтобы его создать на необходимо воссоздать всю цепочку зависимостей, начиная с классов без зависимостей таких как Printer и Logger, далее создавая классы XmlConfigurationReader и за ним – класс DatabaseConnector. Все эти классы необходимы для создания класса Calculator. Для удобства эта вся логика может быть размещена в классе – фабрике. Данный класс будет иметь один публичный метод – Order, который будет возвращать объект, который имплементирует интерфейс ICalculator.

Проверим как работает данный класс – для этого запустим наше приложения с откомментированным методом ShowFactoryExample и в качестве аргумента передадим объект CalculatorFactory.

Как мы видим по сообщениям на консоли, методы всех классов, которые использовались при создании класса Calculator были вызваны как минимум 1 раз. Давайте подведем итог, что мы имеем на данный момент. Есть несколько классов, одни классы зависят от других, основным классом является класс Calculator, который реализует интерфейс ICalculator. Созданием класса Calculator занимается специально созданный класс – фабрика.

Теперь рассмотрим ситуацию – нам необходимо заменить реализацию интерфейса IConfigurationReader для чтения конфигурации из файлов XML (напомню, что это был класс XmlConfigurationReader) на чтение конфигурации из файлов JSON – это класс JsonConfigurationReader. Вернёмся к нашей схеме зависимостей, однако сейчас нас интересуют зависимости, обозначенные зеленым цветом. Данный класс, JsonConfigurationReader, зависит от объектов, которые реализуют интерфейсы IPrinter и ILogger. Данные объекты представлены классами Printer и Logger. Далее класс JsonConfigurationReader, как объект, который реализует интерфейс IConfigurationReader, передается в класс DatabaseConnector. В остальном структура зависимостей остается без изменений.

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

  • добавить изменения в интерфейс ICalculatorFactory и класс CalculatorFactory. Изменения будут состоять в добавлении еще одного метода Order, но уже с использованием класса JsonConfigurationReader. Данный метод нам не очень подходит по нескольким причинам: прежде всего изменения в интерфейсах скорей всего приведут к изменениям в других частях кода и проектах, которые используют нашу библиотеку и реализуют интерфейс ICalculatorFactory. Вторая причина – это то, что наш класс CalculatorFactory получает 2 ответственности: создание 2 объектов с разным набором зависимостей при помощи 2 реализаций методов Order. Естественно, здесь есть место дискуссии – на самом ли деле класс CalculatorFactory имеет 2 ответственности – всегда можно сказать, что данный класс имеет одну ответственность - создание объектов ICalculator и реализует данную ответственности несколькими методами. Я лично придерживаюсь мнения, что если 2 метода выполняют одно и то же задание, но разным способом, то это 2 ответственности. Поэтому в этому примере мы не будем изменять интерфейс ICalculatorFactory и класс CalculatorFactory.

  • второй способ реализации уже был немного раскрыт при объяснения почему мы не можем использовать первый способ. Если 2 метода выполняют одно и то же задание, но разным способом, то здесь напрашивается создание интерфейса с этим методом и реализация этого интерфейса в 2 разных классах. Такой интерфейс у нас уже есть – это интерфейс ICalculatorFactory. У нас уже есть одна реализация этого интерфейса – это класс CalculatorFactory. Мы можем написать еще одну реализацию интерфейса ICalculatorFactory. Такая реализация, CalculatorFactoryWithJsonConfigReader, уже написана мной для данного примера. В методе Order данного класса создаётся каждый объект, который необходим для создания класса, который реализует интерфейса ICalculator. Мы видим, что в теле метода Order используется класс JsonConfigurationReader.

Теперь обратим внимание на наш основной код, то есть класс Programm. Метод ShowFactoryExample принимает в качестве аргумента объект, который реализует интерфейс ICalculatorFactory. Мы можем вызвать этот метод с аргументом типа CalculatorFactoryWithJsonConfigReader. После запуска приложения мы видим, что всё работает так как мы и ожидали. Был возвращен объект, который реализует интерфейс ICalculator и был вызван метод Calculate.

Давайте попытаемся оценить «стоимость» нашего технического решения. Все наше изменения сводятся к тому, что мы создали новый класс, который реализует интерфейс ICalculatorFactory и внесли изменения во все места в коде, где использовался данный интерфейс, то есть заменили CalculatorFactory на CalculatorFactoryWithJsonConfigReader. В нашем конкретном примере замена касалась только одного места в коде, однако в реальных приложениях таких мест может быть множество. Чем больше изменений в коде, тем больше вероятность ошибки. Какой из этого следует вывод, чтобы при внесении изменений было меньше ошибок, таких изменений должно быть как можно меньше. Как это достичь? Здесь нам на помощь приходят техника внедрения зависимостей. Можно написать свой фреймворк внедрения зависимости или воспользоваться уже доступными библиотеками для внедрения зависимости. Вот несколько примеров:

  • Autofac - - данный фреймворк я знаю лучше всего и использую в большинстве проектов с моим участием. В данном видео для примера будет использован именно этот фреймворк. Также в документации данного фреймоворка очень подробно описано, как интегрировать данный фреймворк с приложением Asp.Net Core - инструкция В данной документации мы уже видим знакомые нам понятия: класс Program с методом Main в котором создаётся хост, а также пример класса Startup.

  • Unity - этот фреймворк также мне знаком, так как он используется в фреймворке Prism для создания приложений в технологии WPF.

  • Castle Windsor - я не работал с этим фреймоворком но мне кажется, что он в той или иной степень распространен, так как на собеседования часто приходят кандидаты, которые декларируют опыт работы с этим фреймворком.

  • Ninject - я работал с этим фреймворком только один раз, когда много лет назад я сам пытался понять чем является внедрение зависимости и документация этого фреймворка была первым документом, что мне попался в руки.

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

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

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

Сегодня, в качестве примера мы будем использовать фреймворк Autofac. Для регистрации зависимостей и работы с этой библиотекой создадим класс, через который мы будем реализовывать доступ к зависимостям. Это будет класс ContainerPreparer. Контейнер в фреймворках внедрения зависимостей — это место, в котором у нас сосредоточена информация о всех зарегистрированных зависимостях, то есть интерфейсах, и способах их реализации, то есть классах, которые реализуют данные интерфейсы. Желательно, чтобы в системе, у нас был только один контейнер – так сказать один единственный источник правды на тему всех зависимостей нашей системе. В связи с этим класс ContainerPreparer реализован с использованием шаблона проектирования «синглтон». Обращаясь к свойствам Builder или Container, мы всегда получаем один и тот же объект из этих свойств.

Регистрация зависимостей осуществляется при помощи объекта Builder, которые имеет несколько методов, которые начинаются словом Register. Данную регистрацию удобно сделать в отдельном классе – в нашем примере это класс ContainerPreparerExample. Внутри класса ContainerPreparerExample регистрации также разделены на несколько приватных методов для удобства.

Для получения объектов зарегистрированных типов используется объект контейнера IContainer. В свою очередь из объекта контейнера мы получаем объект области действия scope и уже на этом объекте мы можем вызвать метод Resolve с указанием интерфейса, который нам необходим. На основании зарегистрированных зависимостей мы получаем объект, который реализует нужный нам интерфейс. Далее мы работаем с этим полученным объектом. По сути, нашему приложению не известно, какой тип имеет объект, который мы получили из контейнера внедрения зависимостей – самое главное, что этот объект реализует определенный интерфейс.

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

нашего приложения. У нас есть 2 возможности:

  • первая возможность – мы можем зарегистрировать в контейнере интерфейс ICalculatorFactory и в зависимости от ситуации возвращать имплементацию, связанную с чтением конфигурации из файлов XML или из файлов JSON. В классе ContainerPreparerExample есь 2 приватных метода RegisterSimpleFactory и RegisteFactoryWithJsonConfig. Каждый из этих методов регистрирует определенную имплементацию интерфейса ICalculatorFactory. В данный момент у нас регистрируется класс CalculatorFactory в качестве имплементации интерфейса ICalculatorFactory. Давайте запустим наше приложение и воспользуемся контейнером зависмостей для получения объекта ICalculatorFactory. Если в классе ContainerPreparerExample мы закомментируем вызов метода RegisterSimpleFactory и откомментируем метод RegisteFactoryWithJsonConfig, то при запуске нашего приложения мы заметим, что из контейнера уже возвращается другой объект – CalculatorFactoryWithJsonConfigReader.

  • вторая возможность – мы можем зарегистрировать все зависимости класса Calculator. Это уже сделано в имплементации метода RegisterCalculator класса ContainerPreparerExample. Здесь мы каким-то образом повторяем функциональность класса фабрики, но несколько иначе. Мы просто регистрируем всё, что нам может понадобиться для создания класса Calculator - интерфейсы и типы, их реализующие - в контейнере. Теперь, когда мы попытаемся получить из контейнера имплементацию интерфейса ICalculator, контейнер попытается создать класс Calculator в соответствии с регистрацией. Для создания класса Calculator будут необходимы реализации интерфейсов IPrinter и IConnector. Контейнер опять-таки будет искать эти зависимости в регистрациях, пока не создаст всё необходимые объекты по цепочке. Если мы запустим наше приложение в текущем его состоянии, то класс Calculator будет создан контейнером на основании регистраций. Давайте остановимся на этой возможности.

А что, если мы попробуем закомментировать одну регистрации зависимостей и посмотрим, что будет. Появляется ошибка при попытке получить объект имплементирующий интерфейс ICalculator. В большом упрощении ошибка гласит: «Не могу создать объект «DatabaseConnector», используя зависимости, зарегистрированные в контейнере». Мы закомментировали регистрацию интерфейса ILogger, а он необходим для создания объекта типа DatabaseConnector.

Теперь вернемся к нашей первоначальной задаче. В нашем приложении мы используем фреймворк внедрения зависимости Autofac. Нам необходимо заменить имплементацию XmlConfigurationReader на имплементацию JsonConfigurationReader. Снова мы можем сделать это 2 способами. Способ первый – необходимо немного изменить имплементацию метода RegisterCalculator и подменить регистрацию интерфейса IConfigurationReader с XmlConfigurationReader на JsonConfigurationReader. Способ второй – добавить регистрацию интерфейса IConfigurationReader с классом JsonConfigurationReader. Я выбрал второй способ и для удобства чтения кода это регистрация была добавлена в отдельном методе. Данный метод RegisterJsonConfigReader вызывается после основного метода RegisterCalculator. После запуска приложения для проверки наших изменений мы видим, что при получении класса Calculator используется класс JsonConfigurationReader. Всё работает как мы и ожидали. Вы, наверное, заметили, что мы регистрируем интерфейс IConfigurationReader 2 раза: первый раз в методе RegisterCalculator с классом XmlConfigurationReader и второй раз в методе RegisterJsonConfigReader с классом JsonConfigurationReader. Откуда контейнер знает какой класс использовать? В данном конкретном случае будет использована регистрация, которая была добавлена последней, то есть регистрация с классом JsonConfigurationReader. Вы можете скачать код данного примера с моей страницы github, попробовать запустить пример с откомментированными методами RegisterSimpleFactory и RegisteFactoryWithJsonConfig в классе ContainerPreparerExample и посмотреть какой будет результат. О результатах данного эксперимента напишите в комментариях к данному видео. В фреймворке Autofac есть более продвинутые методы для определения какую реализацию интерфейса использовать, но рассмотрение этого вопроса выходит за рамки темы этого видео.

Давайте подведем итог, какие преимущества мы получили при использовании внедрения зависимости. По сути, первая часть наших изменений подобна способу с использованием фабрик. Мы добавили новые измененные зависимости в контейнер, также, как и был добавлен новый класс фабрики с использованием конфигурации из файлов JSON. Однако далее процесс отличается. В случае использования класса фабрики нам было необходимо изменить в коде все места с использованием первой фабрики на объект новой фабрики, а в случае внедрения зависимости не было необходимости проводить такую работу. Везде, где требовался объект реализующий интерфейс ICalculatorFactory или ICalculator мы получали новый тип объекта после изменений. Меньше изменений в коде – меньше ошибок. В этом было преимущество использования фреймворка внедрения зависимостей.

На данный момент по вопросу внедрения зависимостей в нашей приложении у меня всё. В следующем видео мы рассмотрим, как внедрение зависимости используется в приложения ASP.Net Core: как выглядит встроенный фреймворк для внедрения зависимостей и как можно интегрировать приложение ASP.Net Core с фреймворком Autofac

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

Пример кода из видео на GitHub

Для открытия файла проекта необходимо Visual Studio 2019.