Pull to refresh

Zenject: Как IoC контейнер может убить Внедрение Зависимостей на вашем проекте

Reading time 10 min
Views 39K
Откуда же начинаются опасности? Допустим вы твердо решили, что будете разрабатывать проект, придерживаясь определенной концепции или подхода. В нашей ситуации это DI, хотя на его месте также может оказаться, например, Реактивное Программирование. Вполне логично, что для реализации вашей цели, вы обратитесь к готовым решениям (в нашем примере — контейнер DI Zenject). Вы ознакомитесь с документацией и начнете строить каркас приложения, используя основной функционал. Если на первых порах использования решения у вас не возникнет неприятных ощущений, то скорее всего, оно задержится на вашем проекте на всю его жизнь. По мере работы с базовыми функциями решения (контейнера) у вас могут возникать вопросы или желания сделать некоторый функционал более красивым или эффективным способом. Наверняка, в первую очередь вы обратитесь за этим к более продвинутым «фичам» решения (контейнера). И на этом этапе может возникнуть следующая ситуация: вы уже неплохо знаете и доверяете выбранному решению, в силу чего многие могут не задуматься насколько идеологически правильным может быть использование того или иного функционала в решении, или переход к другому решению уже является достаточно дорогим и нецелесообразным (например приближается deadline). Вот на этом этапе и может возникнуть самая опасная ситуация — функционал решения применяется с малой осторожностью, или в редких случаях просто на автомате (бездумно).

Кому это может быть интересно?


Данная статья будет полезна как тем, кто хорошо знаком с DI, так и начинающим адептам DI. Для понимания достаточно базовых знаний о том, какие паттерны используются DI, цель DI и функций, которые выполняет IoC контейнер. Речь пойдет не о тонкостях реализации Zenject, а о применении части его функционала. Статья опирается только на официальную документацию Zenject и примеры кода из нее, а также на книгу Марка Симана «Внедрение Зависимостей в .NET», являющуюся классическим исчерпывающим трудом на тему теории DI. Все цитаты в этой статье являются выдержками из книги Марка Симана. Несмотря на то, что речь пойдет о конкретном контейнере, статья может быть полезна и тем, кто использует другие контейнеры.

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

Disclaimer: целью статьи не является критика самого Zenject или его авторов. Zenject может быть использован строго по назначению и служить отличным инструментом реализации DI, при условии, что вы будете использовать не полный набор его функций, определив для себя некоторые ограничения.

Введение


Zenject — контейнер внедрения зависимостей с открытым исходным кодом, нацеленный на применение с игровым движком Unity3D, обеспечивающий работу на большинстве платформ, поддерживаемых Unity3D. Стоит заметить, что Zenject можно применять и для С# приложений, разработанных без Unity3D. Этот контейнер является довольно популярным среди Unity разработчиков, активно поддерживается и развивается. Кроме того, Zenject обладает всем необходимым контейнеру DI функционалом.

Я использовал Zenject в 3 крупных Unity проектах, а также общался с большим количеством разработчиков, использовавших его. Причиной написания статьи является часто задаваемые вопросы:

  • Является ли использование Zenject хорошим решением?
  • Что плохого в Zenject?
  • Какие трудности возникают при использовании Zenject?

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

Давайте разберемся, почему у разработчиков возникают такие вопросы и проблемы. Ответить можно следующим образом:
По иронии контейнеры DI сами по себе имеют тенденцию к тому, чтобы быть стабильными зависимостями. … Когда вы принимаете решение разрабатывать свое приложение на основе того или иного контейнера DI, вы рискуете оказаться ограниченным этим выбором для всего жизненного цикла приложения.
Стоит сделать замечание, что при правильном и ограниченном использовании контейнера, переход к использованию другого контейнера в приложении (или отказ от использования контейнера в пользу «внедрения для бедных») вполне возможен и не займет много времени. Правда, в такой ситуации, мало вероятно, что у вас возникнет потребность в этом.

Перед тем, как начать разбирать потенциально опасный функционал Zenject, имеет смысл поверхностно освежить несколько основных аспектов DI.

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

  1. Внедрение конструктора — Как можно гарантировать, что требуемая зависимость будет всегда доступна разрабатываемому классу?
  2. Внедрение свойства — Как можно разрешить DI как опцию в классе, если имеется подходящее локальное умолчание?
  3. Внедрение метода — Как можно внедрить зависимости в класс, если они различны для каждой операции?
  4. Окружающий контекст — Как можем мы сделать зависимость доступной в каждом модуле без включения в каждый API компонента сквозных аспектов приложения?

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

Опасный функционал.


Внедрение Свойства


Это второй по распространенности паттерн DI, после внедрения конструктора, однако применяется гораздо реже. Реализуется в Zenject следующим образом:

public class Foo
{
    [Inject]
    public IBar Bar
    {
        get;
        private set;
    }
}

Кроме того, в Zenject есть еще такое понятие как «Внедрение поля» (Field Injection). Давайте разберемся почему во всем Zenject данный функционал является наиболее опасным.

  • Чтобы показать контейнеру какое поле внедрить, используется атрибут. Это вполне понятное решение, с точки зрения простоты и логики реализации самого контейнера. Однако, мы видим атрибут (а также namespace) в коде класса. То есть, хотя бы косвенным образом, но класс начинает знать о том, откуда он получает зависимость. Плюс, мы начинаем сильно завязывать код класса на контейнер. Иными словами, мы уже не сможем без манипуляций с кодом класса отказаться от использования Zenject.
  • Сам по себе паттерн используется в ситуации, когда у зависимости есть локальное умолчание. То есть, это необязательная зависимость, и если контейнер не сможет ее предоставить, то в проекте не возникнут ошибки, и все будет работать. Однако, используя Zenject, вы получаете эту зависимость всегда — зависимость становится не опциональной.
  • Так как зависимость в данном случае не опциональна — она начинает портить вам всю логику внедрения конструктора, потому что только там должны внедряться обязательные зависимости. Внедряя неопциональные зависимости через свойства, вы получаете возможность создать циклические зависимости в коде. Они будут не столь явными, потому что в Zenject сначала отрабатывает внедрение конструктора, а потом внедрения свойства, и вы не получите предупреждение от контейнера.
  • Использование контейнера DI подразумевает собой реализацию паттерна Корень Компоновки (Composition Root), однако, использование атрибута для настройки внедрения свойства ведет к тому, что вы конфигурируете код не только в Корне Компоновки, но и по мере надобности в каждом классе.

Фабрики (и MemoryPool)


В документации к Zenject есть целый раздел посвященный фабрикам. Этот функционал реализован на уровне самого контейнера, а также есть возможность создавать свои custom factories. Давайте взглянем на первый пример из документации:

public class Enemy
{
    DiContainer Container;

    public Enemy(DiContainer container)
    {
        Container = container;
    }

    public void Update()
    {
        ...
        var player = Container.Resolve<Player>();
        WalkTowards(player.Position);
        ...
        etc.
    }
}

Уже в этом примере есть грубое нарушение DI. Но это скорее пример, как сделать полностью custom фабрику. В чем здесь основная проблема?
Контейнер DI может ошибочно считаться локатором сервисов, но он должен использоваться только как механизм компоновки графов объектов. Если рассматривать контейнер с этой точки зрения, то имеет смысл ограничивать его применения только корнем компоновки. Такой подход имеет важное достоинство, заключающееся в том, что он исключает любое связывание между контейнером и остальным кодом приложения.
Давайте обратимся к тому, как работают «встроенные» фабрики из Zenject. Для этого есть интерфейс IFactory, реализация которого приводит нас к классу PlaceholderFactory:

    public abstract class PlaceholderFactory<TValue> : IPlaceholderFactory
    {
        [Inject]
        void Construct(IProvider provider, InjectContext injectContext)

В нем мы видим параметр InjectContext у которого множество конструкторов, вида:

        public InjectContext(DiContainer container, Type memberType)
            : this()
        {
            Container = container;
            MemberType = memberType;
        }

И опять же получаем передачу самого контейнера как зависимости классу. Данный подход является грубым нарушением DI и частичным превращением контейнера в Локатор Сервисов.
Кроме того, недостаток этого решения заключается в том, что контейнер используется для создания краткосрочных зависимостей, а должен создавать только долгосрочные зависимости.

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

Внедрение Метода


Логика работы Внедрения Метода в Zenject такова: сначала во всех классах происходит внедрение конструктора, затем внедрение свойств, и наконец внедрение метода. Рассмотрим пример реализации, приведенный в документации:

public class Foo
{
    [Inject]
    public Init(IBar bar, Qux qux)
    {
        _bar = bar;
        _qux = qux;
    }
}

Какие минусы есть здесь:

  • Можно написать сколь угодно методов, которые будут внедрятся в рамках одного класса. Таким образом, как и в случае с внедрением свойства, получаем возможность делать сколько угодно циклических зависимостей.
  • Как и внедрение свойства, внедрение метода реализуется по средствам атрибута, что связывает ваш код с кодом самого контейнера.
  • Внедрение метода в Zenject используется только как альтернатива конструкторам, что удобно в случае MonoBehaviour классов, но абсолютно противоречит теории, описанной Марком Симаном. Классическим примером каноничного внедрения метода можно считать использование фабрик(фабричных методов).
  • Если внедренных методов в классе будет несколько, или кроме метода будет присутствовать еще и конструктор, получится, что зависимости, необходимые классу будут раскиданы по разным местам, что будет мешать воспринимать картину в целом. То есть, если у класса 1 конструктор, то количество его параметров может наглядно показывать, нет ли ошибки проектирования в классе, и не нарушается ли принцип единственной ответственности, а если зависимости раскиданы по нескольким методам, конструктору, а может еще и по паре свойств, то картина будет уже не столь очевидна, как могла бы быть.

Отсюда следует, что наличие такой реализации внедрения метода в контейнере, противоречащей теории DI, не имеет ни одного плюса. С большой оговоркой, за плюс можно считать только возможность использования внедренного метода, как конструктора у MonoBehaviour. Но это довольно спорный момент, так как с точки зрения логики контейнера, паттернов DI и внутреннего устройства Unity3D по работе с памятью все MonoBehaviour объекты вашего приложения можно считать управляемыми ресурсами, и, в таком случае, будет гораздо более эффективно делегировать управление жизненным циклом таких объектов не контейнеру DI, а вспомогательному классу (будь то Wrapper, ViewModel, Fasade или что-то иное).

Global Bindings


Это довольно удобный вспомогательный функционал, позволяющий задать глобальные биндинги, которые могут жить независимо от перехода между сценами. Подробней можно почитать в документации. Данный функционал является крайне удобным и достаточно полезным. Стоит заметить, что он не нарушает паттернов и принципов DI, однако, обладает неочевидной и некрасивой реализацией. Суть заключается в том, что вы создаете префаб специального вида, прикрепляете к нему скрипт с конфигурацией контейнера (Installer) и сохраняете его в строго определенной папке проекта, без возможности переместить куда-либо и без каких-либо ссылок на него. Минус этого инструмента заключается исключительно в его неявности. Когда речь идет об обычных инсталлерах, все довольно просто: на сцене у вас есть объект, на нем висит скрипт инсталлера. Если на проект приходит новый разработчик, то инсталлер становится отличной точкой для погружения в проект. Разработчик может на основе единственного инсталлера сделать представление о том, из каких модулей состоит проект и как строится граф объектов. Но с использованием глобальных биндингов, инсталлер на сцене перестает быть достаточным источником этой информации. На глобальный биндинг нет ни одной ссылки в коде других инсталлеров (присутствующих на сценах) и поэтому, вы не видите полный граф объектов. И только по ходу разбора классов вы понимаете, что части биндингов не хватает в инстеллере на сцене. Еще раз оговорюсь, что данный недостаток является чисто косметическим.

Идентификаторы


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

Container.Bind<IFoo>().WithId("foo").To<Foo1>().AsSingle();
Container.Bind<IFoo>().To<Foo2>().AsSingle();
public class Bar1
{
    [Inject(Id = "foo")]
    IFoo _foo;
}

public class Bar2
{
    [Inject]
    IFoo _foo;
}

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

Сигналы и ITickable


Сигналы — аналог механизма «Event Aggregator», встроенный в контейнер. Идея реализации этого функционала, несомненно, благородна, так как нацелена на то, чтобы уменьшить количество связей между объектам, которые общаются посредством механизма событий-подписок. Довольно объемный пример можно посмотреть в документации, однако, его не будет в статье, потому что конкретная реализация не имеет значения.

Поддержка интерфейса ITickable — замена стандартных методов Update, LateUpdate и FixedUpdate в Unity путем делегирования вызовова методов обновления объектов с интерфейсом ITickable конейнеру. Пример также есть в документации, а его реализация в контексте статьи также не имеет значения.

Проблема Сигналов и ITickable не касается аспектов их реализации, ее корень заключается в использовании побочных эффектов работы контейнера. По своей сути контейнер знает практически обо всех классах и их экземплярах в рамках проекта, но его ответственностью является создание графа объектов и управление их жизненным циклом. Добавляя механизмы по типу Сигналов, ITickable и так далее, мы добавляем все больше ответственностей контейнеру, и все больше завязываем код приложения на него, делая исключительной и незаменимой частью кода, практически «Божественным объектом».

Вместо вывода


Самое важное касательно контейнеров — понять, что применение DI не зависит от использования контейнера DI. Приложение может быть построено из многих слабо связанных классов и модулей, и никакие из этих модулей не должны знать ничего о контейнере.
Будьте бдительны при использовании готовых (коробочных) решений или небольших плагинов. Используйте их вдумчиво. Ведь подобными теоретическими ошибками и помарками могут грешить и более грандиозные вещи на которые вы полагаетесь (например игровые движки масштаба самой Unity3D). И это, в конечном итоге, повлияет не на работу решения, которое вы используете, а на поддерживаемость, работу и качество вашего конечного продукта. Надеюсь всем, кто дочитал до конца, статья окажется полезной или, по крайней мере, не будет жалко потраченного на ее прочтение времени.
Tags:
Hubs:
0
Comments 12
Comments Comments 12

Articles