Для того чтобы следовать принципам ООП и SOLID часто используют библиотеки внедрения зависимостей. Этих библиотек много, и всех их объединяет набор общих функции:

  • API для определения графа зависимостей

  • композиция объектов

  • управление жизненным циклом объектов

Мне было интересно разобраться как это работает, а лучший способ сделать это - написать свою библиотеку внедрения зависимостей IoC.Container. Она позволяет делать сложные вещи простыми способами: неплохо работает с общими типами - другие так не могут, позволяет создавать код, без зависимостей на инфраструктуру и обеспечивает хорошую производительность, по сравнению с другими похожими решениям, но НЕ по сравнению с чистым DI подходом.

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

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

Что если оставить только лучшее от этих подходов:

  • определять граф зависимостей используя простой API

  • эффективная композиция объектов как при чистом DI

  • решение проблем и "нестыковок" на этапе компиляции

На мой взгляд, эти задачи мог бы на себя взять язык программирования. Но пока большинство языков программирования заняты копированием синтаксического сахара друг у друга, предлагается следующее не идеальное решение - использование генератора кода. Входными данными для него будут .NET типы, ориентированные на внедрение зависимостей и метаданные/API описания графа зависимостей. А результатом работы будет автоматически созданный код для композиции объектов, который проверяется и оптимизируется компилятором и JIT.

Итак, представляю вам бета-версию библиотеки Pure.DI! Цель этой статьи - собрать фидбек, идеи как сделать ее лучше. На данный момент библиотека состоит из двух NuGet пакетов beta версии, с которыми уже можно поиграться:

  • первый пакет Pure.DI.Contracts это API чтобы дать инструкции генератору кода как строить граф зависимостей

  • и генератор кода Pure.DI

Пакет Pure.DI.Contracts не содержит выполняемого кода, его можно использовать в проектах .NET Framework начиная с версии 3.5, для всех версий .NET Standard и .NET Core и, кончено, же для проектов .NET 5 и 6, в будущем можно добавить поддержку и .NET Framework 2, если это будет актуально. Все типы и методы этого пакета это API, и нужны исключительно для того, чтобы описать граф зависимостей, используя обычный синтаксис языка C#. Основная часть этого API был позаимствована у IoC.Container.

.NET 5 source code generator и Roslyn стали основой для генератора кода из пакета Pure.DI. Анализ метаданных и генерация происходит на лету каждый раз когда вы редактируете свой код в IDE или автоматически, когда запускаете компиляцию своих проектов или решений. Генерируемый код не “отсвечивается” рядом с обычным кодом и не попадает в системы контроля версий. Пример ниже показывает, как это работает.

Возьмем некую абстракцию, которая описывает коробку с произвольным содержимым, и кота с двумя состояниями “жив” или “мертв”:

interface IBox<out T> { T Content { get; } }

interface ICat { State State { get; } }

enum State { Alive, Dead }

Реализация этой абстракции для “кота Шрёдингера” может быть такой:

class CardboardBox<T> : IBox<T>
{
    public CardboardBox(T content) => Content = content;

    public T Content { get; }
}

class ShroedingersCat : ICat
{
  // Суперпозиция состояний
  private readonly Lazy<State> _superposition;

  public ShroedingersCat(Lazy<State> superposition) =>
    _superposition = superposition;

  // Наблюдатель проверяет кота 
  // и в этот момент у кота появляется состояние
  public State State => _superposition.Value;

  public override string ToString() => $"{State} cat";
}

Это код предметной области, который не зависит от инфраструктурного кода. Он написан по технологии DI, а в идеале SOLID.

Композиция объектов создается в модуле с инфраструктурным кодом, который отвечает за хостинг приложения, поближе к точке входа. В этот модуль и необходимо добавить ссылки на пакеты Pure.DI.Contracts и Pure.DI. И в нём же следует оставить генератору кода “подсказки” как строить граф зависимостей:

static partial class Glue
{
  // Моделирует случайное субатомное событие,
  // которое может произойти, а может и не произойти
  private static readonly Random Indeterminacy = new();

  static Glue()
  {
    DI.Setup()
      // Квантовая суперпозиция двух состояний
      .Bind<State>().To(_ => (State)Indeterminacy.Next(2))
      // Абстрактный кот будет котом Шрёдингера
      .Bind<ICat>().To<ShroedingersCat>()
      // Коробкой будет обычная картонная коробка
      .Bind<IBox<TT>>().To<CardboardBox<TT>>()
      // А корнем композиции тип в котором находится точка входа
      // в консольное приложение
      .Bind<Program>().As(Singleton).To<Program>();
  }
}

Первый вызов статического метода Setup() класса DI начинает цепочку “подсказок”. Если он находится в static partial классе, то весь сгенерированный код станет частью этого класса, иначе будет создан новый статический класс с постфиксом “DI”. Метод Setup() имеет необязательный параметр типа string чтобы переопределить имя класса для генерации на свое. В примере в настройке использована переменная “Indeterminacy”, поэтому класс Glue является static partial, чтобы сгенерированный код мог ссылаться на эту переменную из сгенерированной части класса.

Следующие за Setup() пары методов Bind<>() и To<>() определяют привязки типов зависимостей к их реализациям, например в:

.Bind().To()

ICat - это тип зависимости, им может быть интерфейс, абстрактный класс и любой другой не статический .NET тип. ShroedingersCat - это реализация, ей может быть любой не абстрактный не статический .NET тип. По моему мнению, типами зависимостей лучше делать интерфейсы, так как они имеют ряд преимуществ по сравнению с абстрактными классами. Одна из них - это возможность реализовать одним классом сразу несколько интерфейсов, а потом использовать его для нескольких типов зависимостей. И так, в Bind<>() мы определяем тип зависимости, а в To<>() его реализацию. Между этими методами могут быть и другие методы для управления привязкой:

  • дополнительные методы Bind<>(), если нужно привязать более одного типа зависимости к одной и той же реализации

  • метод As(Lifetime) для определения времени жизни объекта, их может быть много в цепочке, но учитывается последний

  • и метод Tag(object), который принимает тег привязки, их может быть несколько на одну привязку, и учитываются все

На данный момент существует несколько времен жизни, а их список будет пополняться:

  • Transient - значение по умолчанию, когда объект типа будет создаваться каждый раз

  • Singleton - будет создан единственный объект типа, в отличие от классических контейнеров здесь это будет статическое поле статического класса

  • PerThread - будет создано по одному объекту типа на поток

  • PerResolve - в процессе одной композиции объект типа будет переиспользован

  • Binding - позволяет использовать свою реализацию интерфейса ILifetime в качестве времени жизни

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

.Bind().Tag(“Fat”).Tag(“Fluffy”).To()

Обратите внимание, что методы Bind<>() и To<>() - это универсальные методы. Каждый содержит один параметр типа для определения типа зависимости, и ее реализации соответственно. Вместо неудобных открытых типов, как например, typeof(IBox<>) в API применяются маркеры универсальных типов, как “TT”. В нашем случае абстрактная коробка - это IBox<TT>, а ее картонная реализация это CardboardBox<TT>. Почему открытые типы менее удобны? Потому что по открытым типа невозможно однозначно определить требуемую для внедрения реализацию, в случае когда параметры универсальных типов это другие универсальные типы. Помимо обычных маркеров TT, TT1, TT2 и т.д. в API можно использовать маркеры типов с ограничениями. Их достаточно много в наборе готовых маркеров. Если нужен свой маркер c ограничениями, создайте свой тип и добавьте атрибут [GenericTypeArgument] и он станет маркером универсального типа, например:

[GenericTypeArgument]
public class TTMy: IMyInterface { }

Метод To<>() завершает определение привязки. В общем случае реализация будет создаваться автоматически. Будет найден конструктор, пригодный для внедрения “через конструктор” с максимальным количеством параметров. При его выборе предпочтения будут отданы конструкторам без атрибута [Obsolete]. Иногда нужно переопределить то, как будет создаваться объект или, предположим, вызвать дополнительно еще какой-то метод инициализации. Для этого можно использовать другую перегрузку метода To<>(factory). Например, чтобы создать картонную коробку самостоятельно, привязка

.Bind<IBox>().To<CardboardBox>()

может быть заменена на

.Bind<IBox>().To(ctx => new CardboardBox(ctx.Resolve()))

To<>(factory) принимает аргументом lambda функцию, которая создает объект. А единственный аргумент lambda функции, здесь это - ctx, помогает внедрить зависимости самостоятельно. Генератор в последствии заменит вызов ctx.Resolve() на создание объекта типа TT на месте для лучшей производительности. Помимо метода Resolve() возможно использовать другой метод с таким же названием, но с одним дополнительным параметром - тегом типа object.

Время открывать коробки!

class Program
{
  // Создаем граф объектов в точке входа
  public static void Main() => Glue.Resolve<Program>().Run();

  private readonly IBox<ICat> _box;

  internal Program(IBox<ICat> box) => _box = box;

  private void Run() => Console.WriteLine(_box);
}

В точке входа void Main() вызывается метод Glue.Resolve<Program>() для создания всей композиции объектов. Этот пример соответствует шаблону Composition Root, когда есть единственное такое место, очень близкое к точке входа в приложение, где выполняется композиция объектов, граф объектов четко определен, а типы предметной области не связаны с инфраструктурой. В идеальном случае метод Resolve<>() должен использоваться один раз в приложении для создания сразу всей композиции объектов:

static class ProgramSingleton
{
  static readonly Program Shared = 
    new Program(
      new CardboardBox<ICat>(
        new ShroedingersCat(
          new Lazy<State>(
            new Func<State>(
              (State)Indeterminacy.Next(2))))));
}

Вследствии того, что привязка для Program определена со временем жизни Singleton то метод Resolve<>() для типа Program каждый раз будет возвращать единственный объект этого типа. О многопоточности и ленивой инициализации не стоит беспокоится, так как этот объект будет создан гарантированно один раз и только при первой загрузке типа в момент первого обращения к статическому полю Shared класса статического приватного класса ProgramSingleton, вложенного в класс Glue.

Есть еще несколько интересных вопросов, которые хотелось бы обсудить. Обратите внимание, что конструктор кота Шрёдингера

ShroedingersCat(Lazy<State> superposition)

требует внедрения зависимости типа Lazy<> из стандартной библиотеки классов .NET. Как же это работает, когда в примере не определена привязка для Lazy<>? Дело в том, что пакет Pure.DI из коробки содержит набор привязок BCL типов таких как Lazy<>, Task<>, Tuple<..>, типы кортежей и другие. Конечно же, любую привязку можно переопределить при необходимости. Метод DependsOn(), с именем набора в качестве аргумента, позволяет использовать свои наборы привязок.

Другой важный вопрос, какую зависимость нужно внедрить чтобы иметь возможность создавать множество объектов определенного типа и делать это в некоторый момент времени? Все просто - Func<>, как и другие BCL типы поддерживается из коробки. Например, если вместо ICat, тип-потребитель запросит зависимость Func<ICat>, то станет возможным получить столько котов сколько и когда нужно.

Еще одна задача. Есть несколько реализаций с разными тегами, требуется получить все их. Чтобы получить всех котов, требуемую для внедрения зависимость можно определить как IEnumerable<ICat>, ICat[] или другой интерфейс коллекций из библиотеки классов .NET, например IReadOnlyCollection<T>. Конечно же, для IEnumerable<ICat> коты будут создаваться лениво.

Для простого примера, как кот Шрёдингера, такого API будет вполне достаточно. Для других случаев помогут привязки To<>(factory) c lambda функцией в аргументе, где объект можно создать вручную, хотя они не всегда удобны.

Рассмотрим ситуацию, когда есть множество реализаций для внедряемой зависимости, тип-потребитель имеет предпочтения по реализациям и мы хотим использовать автоматические привязки. В API есть все необходимое. Каждая реализация отмечается уникальным тегом в привязке, а внедряемая зависимость, например в параметре конструктора, отмечена атрибутом TagAttribute:

  • привязка: .Bind<ICat>().Tag(“Fat”).Tag(“Fluffy”).To<FatCat>()

  • и конструктор потребителя: BigBox([Tag(“Fat”)] T content) { }

Помимо TagAttribute есть другие предопределенные атрибуты:

  • TypeAttribute - когда нужно обозначить тип внедряемой зависимости вручную, не полагаясь, на тип параметра конструктора, метода, свойства или поля

  • OrderAttribute - для методов, свойств и полей указывается порядок вызова/инициализации

  • OrderAttribute - для конструкторов указывается на сколько один предпочтительнее других

Но, если использовать любой из этих предопределенных атрибутов в типах предметной области, то модуль предметной области будет зависеть от модуля Pure.DI.Contracts. Это может быть не очень большой проблемой, но все же рекомендуется использовать свои аналоги атрибутов, определенных в модуле предметной области, чтобы не добавлять лишних зависимостей и держать код в чистоте. Для этой цели есть три метода, оставляющие подсказки генератору кода о том какие атрибуты использовать в дополнение к предопределенным:

  • TypeAttribute<>()

  • TagAttribute<>()

  • OrderAttribute<>()

В качестве параметра типа они принимают тип атрибута, а в качестве единственного необязательного параметра - позицию аргумента в конструкторе атрибута откуда брать значение: типа, тега или порядка, соответственно. По умолчанию позиция аргумента равна 0, следовательно, значения будут взяты из первого аргумента конструктора. Позицию следует переопределять, например, когда вы добавили свой атрибут, такой как “InjectAttribute”, и его конструктор содержит и тег, и тип одновременно.

Теперь немного о генераторе и генерируемом коде. Генератор кода, используя синтаксические деревья и семантические модели Roslyn API, отслеживает изменения исходного кода в IDE на лету, строит граф зависимостей и генерирует эффективный код для композиции объектов. При анализе выявляются ошибки или недочеты и компилятор получает соответствующие уведомления. Например, при обнаружении циклической зависимости появится ошибка в IDE или в терминале командной строки при сборке проекта, которая не позволит компилировать код, пока она не будет устранена. Будет указано предположительное место проблемы. В другой ситуации, например, когда генератор не сможет найти требуемую зависимость, он выдаст ошибку компиляции с описанием проблемы. В этом случае необходимо либо добавить привязку для требуемой зависимости, либо переопределить fallback стратегию: привязать интерфейс IFallback к своей реализации. Её метод Resolve<>() вызывается каждый раз когда не удается найти зависимость и: возвращает созданный объект для внедрения, бросает исключение или возвращает значение null для того чтобы оставить поведение по умолчанию. Когда fallback стратегия будет привязана генератор изменит ошибку на предупреждение, полагая что ситуация под вашим контролем, и код станет компилируемым.

Надеюсь, это библиотека будет полезной. Любые ваши замечания и идеи очень приветствуются.