Статьи

Не вините структуру внедрения зависимостей

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

Почему люди используют их?

Рассмотрим (надуманную) схему ниже.

В таком дизайне я могу придумать несколько причин, по которым разработчики решают использовать инфраструктуру внедрения зависимостей. С одной стороны, они могут поощрять разработчиков, разрабатывающих свои классы таким образом, что они зависят только от абстракций (1). На мой взгляд, это хорошая вещь, поскольку зависимость от более стабильных вещей   помогает предотвратить эффект пульсации. А поскольку абстракция более стабильна, чем реализация, мы хороши. Приятным побочным эффектом всего этого является то, что он позволяет вам переключать одну реализацию с другой (2), также известной как  шаблон стратегии, Это работает особенно хорошо, если вам нужно, чтобы ваши модульные тесты работали против реализации этой абстракции, удобной для тестирования (4). И не забудьте систему, которая любит динамически загружать реализации (или плагины) из внешних модулей (6).

В большинстве реальных проектов разные реализации разных абстракций регулируются разными жизненными циклами (3). Вот где может появиться структура внедрения зависимостей. Они дают вам большой контроль над тем, когда следует создавать реализацию абстракции, при каких обстоятельствах ее можно использовать повторно, и что-то, что часто упускается, когда распоряжаться экземпляром.  Autofac  — один из тех примеров, который делает это действительно хорошо. Он рассматривает одноразовые товары как IDisposable первостепенную задачу и предполагает, что любой реализуемый объект  должен быть удален, когда (вложенный) контейнер выходит из области видимости.

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

Тогда почему некоторые люди так презирают их?

Примеры, которые я только что привел, являются действительными и полезными решениями для реальных проблем. Так почему же некоторые разработчики думают, что структуры внедрения зависимостей — такая плохая вещь? Ну, так же, как и Test Driven Development, которая может действительно повредить вам, если вы не сделаете это  правильнонеправильное использование структуры DI может причинить боль. Что-то, что я часто слышал на протяжении многих лет, — это магия, которую внедряет такая структура, в частности, в течение жизненного цикла экземпляров, предоставляемых этой структурой. И если вам нужно знать, каков настроенный жизненный цикл, поиск оригинальной регистрации может быть утомительным. Если у вас всего пара регистраций, вам, вероятно, повезло. Но большинство монолитов, которые я видел, содержат сотни таких регистраций, что приводит к громоздкому загрузочному коду. Особенно, если типы регистрируются динамически или при представлении типа через все его открытые интерфейсы, может оказаться практически невозможным найти правильную регистрацию.

Еще одна проблема, которую я слышал (и наблюдал из первых рук) — это глубоко вложенные стеки вызовов, включаемые, когда не удается разрешить какую-либо зависимость. Autofac делает это вполне нормально, но я видел несколько довольно запутанных дампов стека с другими фреймворками. Вы можете заметить, что я продолжаю использовать термин  каркас,  а не  библиотека . Это потому, что большинство этих библиотек не ведут себя как библиотеки. Другими словами, если вы не будете осторожны, их пресловутые щупальца, как правило, заражают всю вашу кодовую базу. И я уже упоминал эту тенденцию вводить  Iwhatever интерфейсы для всего, что используется в качестве зависимости. Не то, что мне нравится видеть в моей базе кода, учитывая, что мы стараемся следовать проверенным методам, таким как  ролевые интерфейсы  и Принцип разделения интерфейса . Не говоря уже о чрезмерной фальсификации в ваших  юнит-тестах,  которая обычно является результатом этого

Как правильно понимать терминологию

Прежде чем мы поговорим о том, как правильно сделать внедрение зависимостей, я думаю, что мне нужно сначала уточнить некоторую терминологию, начиная с  Inversion of Control  (IoC). Это процесс перемещения ответственности за создание зависимости вне класса в зависимости от нее. Другими словами, вместо того, чтобы иметь  Order Processing модуль (из рисунка вверху этого поста)  new— конкретную реализацию  IOrderRepository, мы делегируем это коду, который создает и / или потребляет  Order Processing модуль. Вам не нужна DI-инфраструктура для этого. Но поскольку это основная работа инфраструктуры DI, вполне логично, что очень многие люди называют их  контейнерами IoC . В некотором смысле, контейнер становится ответственным за  инъекцию правильная зависимость от модуля обработки заказов (такого как конструктор), следовательно, смешанное использование этих терминов.

Тем не менее, многие люди все еще думают, что «D» в SOLID также означает  внедрение зависимости  . Тем не менее, «D» обозначает  принцип инверсии зависимости  (или DIP для краткости), который ортогональн к концепциям, которые я только что объяснил. DIP говорит об изменении зависимости между объектами таким образом, чтобы абстракции более высокого уровня не зависели от абстракций более низкого уровня. За этим стоит идея, что низкоуровневые абстракции имеют тенденцию быть более общими и многократно используемыми, и поэтому они более подвержены изменениям. Это также является одним из принципов  успешного управления пакетами  (которое также вдохновлено SOLID). В качестве примера рассмотрим различия между левой и правой сторонами изображения:

Хотя они кажутся похожими, есть небольшая разница. Левая сторона определяет довольно общий  IStoreOrders<T> интерфейс, который принадлежит тому, что, вероятно, находится на уровне данных. Основываясь на названии, я предполагаю, что он будет определять методы общего назначения для создания, удаления и запроса заказов. Обратите внимание, что зависимость передается от модуля обработки заказов, абстракции более высокого уровня, к  IStoreOrders интерфейсу, абстракции более низкого уровня. Это также означает, что реализация этого интерфейса не может делать какие-либо предположения о том, что пытается выполнить вызывающая сторона. Конечно, вы можете добавить больше функциональных методов к этому интерфейсу (например, как показано на правой стороне), но это, вероятно, означает, что у абстракции будет огромное количество методов.

DIP устраняет эту зависимость, позволяя модулю Order Order определить, что ему нужно для функционирования, и позволяя низкоуровневому уровню реализовать их. Это отделяет модуль от любых низкоуровневых деталей, которые будут часто меняться. Это также хорошо согласуется с   мышлением чистой архитектуры . Приятным побочным эффектом этого является то, что он помогает избежать тех ужасных интерфейсов, которые являются не чем иным, как именем реализации класса с префиксом  I.

Правильно делать инъекции зависимостей

Теперь, когда у нас есть разработанная терминология, и прежде чем прийти к какому-либо заключению, позвольте мне сначала поделиться некоторыми рекомендациями о том, как сделать внедрение зависимостей таким образом, чтобы вам не было больно. Например, статическое изменяемое состояние (все, что является  staticили ведет себя так) всегда плохо. Это вызывает странные проблемы в многопоточных средах, особенно теперь мы все обнявшись  async и  await. Зная это, я убиваюсь, когда вижу, что Microsoft так активно продвигает   шаблон Service Locator . Предполагается, что вы можете разрешить зависимость с помощью следующего оператора:

ServiceLocator.Current.GetInstance<IStoreOrders>();

Но это статический объект. Использование этого в производстве, где все потоки будут повторно использовать те же самые зависимости, в основном хорошо. Но просто попробуйте переключить ваш тестовый фреймворк с MSTest на  Xunit,  где модульные тесты выполняются параллельно и каждый из них должен иметь свой собственный (поддельный) экземпляр зависимости. Так  что не используйте сервисные локаторы .

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

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

interface IDiscountPolicy
{
    float GetDiscount(float originalPrice);
    bool AllowsCombiningWithOtherDiscounts { get; }
}

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

public delegate IDiscountPolicy GetDiscountPolicy(OrderType orderType);

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

В  Autofac  (мой предпочтительный контейнер) вы можете использовать следующий регистрационный код, чтобы позволить кому-то получить зависимость от этого делегата:

internal class OrderManagementModule : Module
{
    protected override void Load(ContainerBuilder builder)
    {
        builder.RegisterType<SmallOrderDiscountPolicy>().Keyed<IDiscountPolicy>(OrderType.Small);
        builder.RegisterType<LargeOrderDiscountPolicy>().Keyed<IDiscountPolicy>(OrderType.Large);

        builder.Register<GetDiscountPolicy>(ctx =>
        {
            var container = ctx.Resolve<IComponentContext>();

            return orderType => container.ResolveKeyed<IDiscountPolicy>(orderType);
        });
    }
}

Этот  регистрационный код  или  подключение  является  частью вашего  производственного кода , поэтому убедитесь, что вы  избегаете  любого вида текстовой конфигурации во время развертывания   и  включаете регистрации  в свои  модульные тесты . Нет ничего более раздражающего, чем иметь набор «зеленых» модульных тестов, а затем наблюдать, как приложение взрывается при запуске, потому что регистрации не были покрыты тестами. Чтобы  сохранить  этот  провод вверх код близко  к  функции , увидеть , если ваш контейнер поддерживает  герметизирующие регистрации из примера. Например, указанный выше модуль может быть зарегистрирован в Autofac следующим образом:

var builder = new ContainerBuilder();
builder.RegisterModule<OrderManagementModule>();

IContainer container = builder.Build();

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

builder.RegisterType<SmallOrderDiscountPolicy>().AsImplementedInterfaces();

Но это скрывает имена интерфейсов, которые предоставляет этот класс. Это будет работать во время выполнения, но попытка найти регистрацию  IDiscountPolicy станет довольно сложной, если вы не знаете, куда вам следует обратиться. Вместо этого я также делаю это очень явно:

builder.RegisterType<SmallOrderDiscountPolicy>().As<IDiscountPolicy>();

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

Вопрос, который я часто слышал, заключается в том, брать ли зависимости в конструкторе класса, свойстве setter или где-то еще. Мой личный совет — держать область зависимости как можно более локальной. Поэтому, если эта зависимость требуется только одному методу класса, передайте эту  зависимость непосредственно  в этот  метод . Типичный пример этого — когда такой метод должен получить текущую дату и время. В C # вы можете легко сделать это, отменив ссылки  DateTime.Now. Но это  static свойство, которое может вызвать те же проблемы, что и использование сервисного локатора. В большинстве случаев я решаю это, определяя делегата для этого:

public delegate DateTime GetNow()

При соответствующей регистрации выглядит так:

builder.Register<GetNow>(_ => () => DateTime.Now);

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

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

Другой способ — использовать вложенные контейнеры. Autofac позволяет создавать  вложенную область  , вызывая  BeginLifetimeScope интерфейс контейнера. Это возвращает одноразовое использование,  ILifetimeScope которое можно использовать для ограничения доступности зависимости, пока существует эта область. В зависимости от того, как вы зарегистрировали свою зависимость, вы можете даже разрешить отдельные экземпляры для  ILifetimeScope, например, для каждого HTTP-запроса. И даже если вам не нужны вложенные контейнеры,   настоятельно рекомендуется делегировать  ответственность за  размещение ваших зависимостей в контейнере . Зрелый контейнер — это очень продвинутый и оптимизированный кусок кода, который гораздо лучше отслеживает ссылки на объекты и понимает, когда его вызывать Dispose метод. Даже не пытайтесь писать свои собственные (если вас не зовут  ДжеремиНиколас  или  Стивен ).

Так? Вы должны использовать контейнеры или нет?

Покрывая хорошее, плохое и уродливое внедрение зависимости, остается ответить на первоначальный вопрос этого поста. Является ли контейнер IoC или структура внедрения зависимостей хорошей вещью или нет? Ну, я думаю, что это глупый вопрос. У любого инструмента есть свои достоинства. Просто используйте это с ответственностью. Но тебе это нужно? Что ж, вы можете избежать необходимости в контейнере, составив вашу систему из небольших хорошо сфокусированных автономных компонентов в нечто полезное. Но обычно это легче сказать, чем сделать.  Если  вы находитесь в ситуации, когда вам нужно подключить зависимости  и  подумать о жизненном цикле объектов  и вы хотите построить слабо связанную систему, тогда вам все равно не понадобится водопровод. И почему бы не использовать что-то проверенное в бою, вместо того, чтобы бросать свой собственный контейнер для бедняков? Конечно, вы можете попробовать последнее, если вам не нужно ничего особенного. Но будьте честны с самим собой и переходите на настоящий контейнер, когда вам нужно что-то более сложное. Я сам предпочитаю использовать  Autofac  в корне сложной системы, потому что она чрезвычайно быстрая, очень гибкая и позволяет мне следовать моим собственным рекомендациям. В компонентах и ​​библиотеках я предпочитаю  TinyIoc,  поскольку это пакет NuGet только для исходного кода, который не вызывает у меня никакой  зависимости . Но что бы вы ни делали, не забывайте руководящие принципы. Они возникли за годы неправильной работы. Не позволяйте истории повториться.

Как насчет вас?

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