Репозиторий проекта

Что такое DI?

Все конечно же знают, что DI — это акроним от слов Dependency Injection и что этот термин означает. Но как объяснить это значение человеку далекому от программирования? В книге Dependency Injection в.NET Марк Симон ссылается на вопрос на Stack Overflow «Как объяснить пятилетнему ребенку, что такое внедрение зависимостей?» Под этим постом есть комментарий за 2009 год Джона Манча. В нем он привел следующую аналогию:

Когда ребенок ищет в холодильнике, что бы такое съесть, могут возникнуть различные неприятности:

  • Ребенок может забыть закрыть дверь холодильника

  • Может взять то, что ему запрещено

  • Может даже наткнуться на просроченные продукты

Совет там такой: этих неприятностей можно легко избежать, если ребенок попросит все что нужно у своих родителей.

Применительно к написанию кода, этот совет можно перефразировать так: каждый компонент приложения перекладывает ответственность предоставления требуемых ему зависимостей на специальную инфраструктуру. Можно даже провести параллель “ребенок” - “компонент” ну или “класс”, а “родители” - “инфраструктура”.

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

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

  • Создавать композицию объектов.

  • Управлять временем жизни этих объектов. Например, какой-то объект должен создаваться каждый раз при его внедрении, какой-то должен быть один на всю композицию.

  • Перехватывать вызовы к объектам композиции.

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

Чистый DI

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

Представьте приложение с DI, но без контейнеров. Сложно … но возможно. Когда приложения создаются без DI-контейнера, но в парадигме DI, это, в некоторых источниках, называется чистым внедрением зависимостей, или pure DI. 

На определенном этапе разработки приложения, когда оно стало чуть сложнее чем “Hello World!”, но все еще очень простое, чистый DI подход, думаю, использовал каждый из вас, даже не задумываясь об этом. В чистом DI композиция объектов выполняется вручную: обычно это набор конструкторов как в строке кода #1:

var program = new Program(new Service(new Dependency()));
program.Run();

class Program
{
  private readonly Service _service;
  
  public Program(Service service) => _service = service;
  
  public void Run() => _service.DoSomething();
}

class Service
{
  private readonly Dependency _dependency;
  
  public Service(Dependency dependency) => _dependency = dependency;
  
  public void DoSomething() { }
}

class Dependency { }

По мере увеличения количества новых классов или с каждой новой зависимостью, сложность композиции объектов возрастает все быстрее и быстрее:

new Generator(
  new Logger<Generator>(),
  new ResolversFieldsBuilder(new Logger<ResolversFieldsBuilder>()),
  new ClassBuilder(new Logger<ClassBuilder>()),
  new ClassDiagramBuilder(new Logger<ClassDiagramBuilder>()),
  new MetadataValidator(new Logger<MetadataValidator>()),
  new CompositionBuilder(
    new Logger<CompositionBuilder>(),
    new []
    {
      new UsingDeclarationsBuilder(new Logger<UsingDeclarationsBuilder>()),
      new PrimaryConstructorBuilder(new Logger<PrimaryConstructorBuilder>()),
      new DefaultConstructorBuilder(
      new Logger<DefaultConstructorBuilder>(),
      new ContractsBuilder(new RootDependencyNodeBuilder())),
      new ChildConstructorBuilder(new Logger<ChildConstructorBuilder>(),
      new ContractsBuilder(
        new ConstructDependencyNodeBuilder(
        new Logger<ConstructDependencyNodeBuilder>()))),
      new RootPropertiesBuilder(new Logger<RootPropertiesBuilder>()),
      new ApiMembersBuilder(
        new Logger<ApiMembersBuilder>(),
        new RootDependencyNodeBuilder(
        new ResolversFieldsBuilder(
        new Logger<ResolversFieldsBuilder>()))),
      new DisposeMethodBuilder(new Logger<DisposeMethodBuilder>()),
      new SingletonFieldsBuilder(new Logger<SingletonFieldsBuilder>()),
      new ArgFieldsBuilder(new Logger<ArgFieldsBuilder>()),
      new StaticConstructorBuilder(
        new Logger<StaticConstructorBuilder>(),
        new UnboundTypeConstructor(),
        new ResolversFieldsBuilder(new Logger<ResolversFieldsBuilder>())),
      new ResolverClassesBuilder(new Logger<ResolverClassesBuilder>()),
      new ToStringMethodBuilder(
        new Logger<ToStringMethodBuilder>(),
        new ContractsBuilder(new RootDependencyNodeBuilder()))
    }),
    new FactoryDependencyNodeBuilder(
      new ImplementationVariantsBuilder(
        new Variator<Models.ImplementationVariant>(),
        new ArgFieldsBuilder()),
      ...

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

Эволюция DI контейнеров

Для упрощения управления этим хаосом многие разработчики создают простейшие реализации DI контейнеров на основе словаря:

Dictionary<Type, Func<object>> map;

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

  • Сложно управлять временем жизни объектов

  • Выполнять перехват

  • А так же приходится зависеть от сигнатуры конструкторов при создании объектов

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

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

  • Отражения типов .NET для понимания того как строить граф зависимостей.

  • Этот граф зависимостей и вызов метода Activator.CreateInstance для создания композиции объектов. Один вызов на один объект, например:

var type = Type.GetType("Sample.MyType");
var obj = Activator.CreateInstance(type);

Библиотеки неплохо справляются со своей ролью, но имеют ряд существенных недостатков. Главный из которых — это низкая производительность создания объектов вызовом метода Activator.CreateInstance. B все это у них занимает примерно тысячи наносекунд.

С версии .NET framework 1.1 появилось пространство имен System.Reflection.Emit и интерфейс ILGenerator, который может создавать код динамически, во время выполнения.

var cctorGen = cctor.GetILGenerator();
cctorGen.Emit(OpCodes.Ldc_I4, 1970);
cctorGen.Emit(OpCodes.Ldc_I4_1);
cctorGen.Emit(OpCodes.Ldc_I4_1);
cctorGen.Emit(OpCodes.Newobj, dateTimeCtor);

Это позволяет избавиться от медленного вызова метода Activator.CreateInstance для каждого создаваемого объекта, а также делает возможным создавать объекты сразу целыми композициями - со всеми их зависимостями, что ускоряет DI контейнеры на 2 порядка, т.е. занимает уже не тысячи, а десятки наносекунд. И это ощутимый прирост.

Библиотек, использующих данный подход, было не много, особенно в первое время, из-за сложности использования System.Reflection.Emit в совокупности с непростыми задачами, которые решаются DI контейнерами. И стоит учесть, что так же как и в предыдущем подходе, обращение к отражению типов .NET все еще существенно снижают производительность.

С выходом C# версии 3, появились Lambda выражения и это упростило процесс генерации кода во время выполнения.

var ctor = typeof(T).GetConstructors().First();
var newExpression = Expression.New(ctor);
Expression.Lambda<Func<T>>(newExpression).Compile();

Используя этот механизм, библиотеки контейнеров можно делать проще и при этом не терять в производительности по сравнению с подходом System.Reflection.Emit. И здесь создание композиции объектов занимает порядка десятка наносекунд и использование отражения типов .NET никуда не делось.

Недостатки DI контейнеров

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

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

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

Бывают и более экзотические случаи, когда генерация кода во время выполнения недоступна, например, в таких сценарии как заранее скомпилированные ahead-of-time приложения.

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

Генераторы кода

По счастливому стечению обстоятельству в .NET 5 появились генераторы кода.

Генераторы кода
Генераторы кода
  • Генераторы кода выполняются перед компиляцией.

  • Они анализирует проект, полагаясь на синтаксические деревья, соответствующие семантические модели, настройки проектов и другую информацию.

  • Создают новый код.

  • Добавляют его в компиляцию.

  • Компиляция выполняется уже с учетом кода, добавленного генераторами.

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

  • Определять граф зависимостей через API как в классических библиотеках DI контейнеров.

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

  • Знать о всех проблемах и нестыковках на этапе компиляции, а на этапе выполнения иметь гарантированно надежный код как в чистом DI. В дополнение, на этапе компиляции можно выполнять более сложные проверки, так как нет жестких ограничений на ресурсы.

  • Тратить минимум усилий на поддержку как в классических библиотеках DI контейнеров.

Пройдя весь путь эволюции разных подходов по реализации DI сейчас мы можем сделать рекурсию и, вернувшись к истокам, взять лучшее от и чистого DI и от классических библиотек DI контейнеров. Такой попыткой взять лучшее и избавится от худшего - стала реализация генератора исходного кода Pure.DIhttps://github.com/DevTeam/Pure.DI

Название выбрано не особо оригинальное, зато оно отражает его суть. Важно, что Pure.DI здесь — это НЕ фреймворк или библиотека, а генератор исходного кода. Он генерирует частичный .NET класс для создания композиций объектов в парадигме чистого DI. Чтобы сделать композиции объектов точно такими как их задумали, используется довольно простой, но выразительный, а главное привычный API. Остановимся на нескольких ключевых особенностях Pure.DI

Pure.DI

Генерируемый код не зависит от каких-либо библиотек и фреймворков.

Де-факто этот код представляет собой композицию из вложенных конструкторов.

Pure.DI генерирует код для создания предсказуемых и проверенных композиций объектов.

Фактически выполняется двойная проверка:

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

  2. И вторая проверка происходит на этапе компиляции, когда компилятор проверяет уже сгенерированный код в совокупности с кодом проекта. На этом этапе о проблемах так же сообщается в виде ошибок или предупреждений компилятора.

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

Сгенерированный код обеспечивает высокую производительность.

Он работает так же быстро, как и код написанный вручную, в парадигме чистого DI, включая оптимизацию во время компиляции и во время выполнения. Исключены боксинг и анбоксинг, приведение типов, использование делегатов и вызовы методов в процессе создания композиции объектов. Там, где возможно аллокация выполняется на стеке. Получение композиций объектов занимает наносекунды.

Сгенерированный код работает везде.

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

Поддерживает и современные и старые версии .NET.

Генератор будет работать с версиями .NET, начиная с Full .NET Framework 2.0, выпущенный 27 октября 2005. Начиная с этой версии появились обобщенные типы. Для работы генератора необходимо:

  1. Установить .NET SDK 6.0.4xx или новее

  2. Проект, который ссылается на Pure.DI, нужно переключить на использование C# 8 или новее

Pure.DI прост в использовании.

Его API очень похож на API большинства классических библиотек DI контейнеров. Это важно, так как программистам не нужно изучать какой-то новый и непохожий API.

Pure.DI обеспечивает тонкую настройку обобщенных типов.

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

Pure.DI поддерживает большой набор базовых типов .NET из коробки.

Я сейчас имею ввиду такие типы как массивы, IEnumerable, IList, ISet, Func, ThreadLocal, Lazy, ImmutableArray и др. различные коллекции, Tuple, Span, Comparer-ы и т. д.

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

Кот Шредингера

Далее попытаемся разобраться в деталях использования Pure.DI на нескольких простых примерах. Первый пример называется “Кот Шредингера” — это обычное консольное приложение:

// Абстракция для некого кота, у которого 2 состояния: жив или мертв
public interface ICat
{
    State State { get; }
}

public enum State
{
    Alive,
    Dead
}

// Реализация этой абстракции - кот Шредингера
public 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";
}

// Абстракция для коробки с произвольным содержимым
public interface IBox<out T>
{
    T Content { get; }
}

// Реализация этой абстракции в виде картонной коробки.
// Важно отметить, что наши абстракции и реализации абсолютно ничего не знают о DI.
public class CardboardBox<T> : IBox<T>
{
    public CardboardBox(T content) => Content = content;

    public T Content { get; }

    public override string ToString() => $"[{Content}]";
}

// Класс программы, которая запрашивает внедрения через конструктор 
// абстрактной коробки с содержимым в виде абстрактного кота.
// При выполнении метода Run описание коробки выводится в stdOut.
public class Program
{
    public static void Main() => new Composition().Root.Run();

    private readonly IBox<ICat> _box;

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

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

// Эти строки нужны чтобы связать абстракции с их реализациями 
// и построить композицию объектов.
// На самом деле эти строки используются только на этапе компиляции.
// На этапе выполнения они ничего не делают.
// Поэтому их можно расположить в любой части кода,
// которая не будет выполняться, но это не обязательно.
internal partial class Composition
{
    private static void Setup() =>
        DI.Setup(nameof(Composition))
            .Bind<Random>().As(Singleton).To<Random>()
            .Bind<State>().To(ctx =>
            {
                ctx.Inject<Random>(out var random);
                return (State)random.Next(2);
            })
            .Bind<ICat>().To<ShroedingersCat>()
            .Bind<IBox<TT>>().To<CardboardBox<TT>>()
            .Root<Program>("Root");
}

Привязки

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

.Bind<ICat>().To<ShroedingersCat>()
.Bind<IBox<TT>>().To<CardboardBox<TT>>()

Здесь всего 2 строки, но обычно их на много больше. Это справедливо, так как краеугольным камнем реализации технологии DI является принцип программирования на основе абстракций, а не на основе конкретных классов. Благодаря ему можно заменять одну конкретную реализацию другой. При этом каждая реализация может соответствовать произвольному числу абстракций. И если даже привязка не определена, с внедрением не будет проблем, но, очевидно при условии, если потребитель запрашивает внедрение НЕ абстрактного типа.

Обобщенные типы

Вместо открытых обобщенных типов, как в классических библиотеках DI контейнеров, используются “маркерные” типы в качестве параметров обобщенных типов, как в строке:

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

Такие “маркерные” типы, позволяют точнее определять граф зависимостей. Рассмотрим гипотетический сценарии когда без “маркерных” типов будет сложновато. Даже в этом этом простом сценарии не возможно точно определить привязку абстракции к ее реализации, используя открытые обобщенные типы:

.Bind(typeof(IMap<,>)).To(typeof(Map<,>))

Можно попытаться сопоставить их по порядку или по названию, полученному из отражения типов .NET. Но это не надежно, так как соответствие порядка и имен не гарантируется. Например, есть некий интерфейс с двумя аргументами типа “ключ” и “значение”. Но в его реализации перепутана последовательность аргументов типа: сначала идет значение, а потом ключ и названия не совпадают:

class Map<TV, TK>: IMap<TKey, TValue> { }

В тоже время маркерные типы TT1 и TT2 с этим справляются легко. Они определяют точное соответствие аргументов типа в интерфейсе и его реализации:

.Bind<IMap<TT1, TT2>>().To<Map<TT2, TT1>>()

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

Макректыне типы — это обычные типы .NET, отмеченные специальным атрибутом, например:

[GenericTypeArgument]
internal abstract class TT1 { }

[GenericTypeArgument]
internal abstract class TT2 { }

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

[GenericTypeArgument]    
internal struct TTS { }

[GenericTypeArgument]
internal interface TTDisposable: IDisposable { }

[GenericTypeArgument]
internal interface TTEnumerator<out T>: IEnumerator<T> { }

Ручное внедрение

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

.Bind<State>().To(ctx =>
{
  ctx.Inject<Random>(out var random);
  return (State)random.Next(2);
})

Теперь пожалуйста обратите внимание на строку кода #3. При ручном создании объекта его зависимости можно получить вызовом метода Inject. Но при генерации кода эти вызовы будут заменены созданием объектов соответствующих типов на месте методов Inject, конечно, включая все его необходимые зависимости. Так Pure.DI заботится о производительности.

Корень композиции

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

.Root<Program>("Root");

Эта строка указывает создать корень композиции с названием Root типа Program.

Частичный класс

Еще требуется определить имя генерируемого частичного класса. Поэтому здесь строка кода

DI.Setup(nameof(Composition))

указывает генератору создать частичный класс Composition. Полагаясь на наши рекомендации, Pure.DI сгенерирует частичный класс под названием Composition, который, в нашем примере, будет иметь один корень композиции или, другими словами, одно свойство с названием Root типа Program.

partial class Composition
{
  private readonly System.IDisposable[] _disposableSingletons;
  private System.Random _random;
    
  public Composition() =>
    _disposableSingletons = new System.IDisposable[0];
    
  public Program Root
  {
    get
    {
      var stateFunc = new Func<State>(() => {
        if (object.ReferenceEquals(_random, null))
        {
          lock (_disposableSingletons)
          {
            if (object.ReferenceEquals(_random, null))
            {
              _random = new Random();
            }
          }
        }      
        
        return (State)random.Next(2);        
      });
      
      return new Program(
        new CardboardBox<ICat>(
          new ShroedingersCat(
            new Lazy<State>(stateFunc))));      
    }
  }  
  
  public T Resolve<T>() { ... }
  
  public T Resolve<T>(object? tag) { ... }
  
  public object Resolve(Type type) { ... }
  
  public object Resolve(Type type, object? tag) { ... }  
}

Помимо корня композиции будут добавлены методы Resolve. Их можно использовать для получения корней композиции как в классических DI контейнерах. Так Pure.DI пытается быть похожим на классические DI контейнеры чтобы сохранить опыт их использования.

Любое значимое изменение в настройках DI, в сигнатурах конструкторов или методов автоматически приведет к повторному анализу графа зависимостей и ре-генерации кода. Это механизм прекрасно работает в IDE как Rider или Visual Studio или при построении проектов из командной строки. Генерируемый код не хранится в репозиториях кода, а автоматически создается перед компиляцией.

Композиция

Как было отмечено ранее, корень композиции — это обычное свойство класса. При обращении к этому свойству будет построена композиция объектов, здесь это будет в 3-ей строке кода:

public class Program
{
    public static void Main() => new Composition().Root.Run(); // #3

    private readonly IBox<ICat> _box;

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

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

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

Методы Resolve

Методы Resolve похожи на обращения к корням композиции. Например, в 3-ей строке кода обращение к свойству Root можно заменить вызовом метода Resolve:

public class Program
{
    public static void Main() => new Composition().Resolve<Program>().Run(); // #3

    ...
}

Корни композиции — это обычные свойства. Их использование эффективно и не вызывает исключений. И поэтому рекомендуется использовать именно их. В отличии от них, методы Resolve имеют ряд недостатков:

  • Они предоставляют доступ к неограниченному набору зависимостей.

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

  • Приводят к снижению производительности, так как ищут корень композиции по его типу.

Первый и второй недостатки — это выбор разработчика, который предпочитает использовать Pure.DI как классический DI контейнер. Что касается последнего, то Pure.DI пытается минимизировать снижение производительности за счет некоторых уловок. Реализация типизированного метода Resolve состоит всего из одной строки кода #3:

partial class Composition
{
  public T Resolve<T>() =>
    Resolver<T>.Value.Resolve(this); // #3

  static Composition()
  {
    Resolver<Program>.Value = new ProgramResolver(); // #8
  }
  
  private class Resolver<T>: IResolver<Composition, T>
  {
    public static IResolver<Composition, T> Value = new Resolver<T>(); // #13
    
    public T Resolve(Composition composite) =>
      throw new InvalidOperationException(
        $"Cannot resolve composition root of type {typeof(T)}.");    
  }
  
  private class ProgramResolver: IResolver<Composition, Program>
  {
    public Program Resolve(Composition composition) => composition.Root; // #22
  }  
}

В этой строке код обращается к статическому полю Value, обобщенного класса по типу корня и вызывает метод Resolve для получения композиции. Это происходит очень быстро. Для того что бы эта уловка сработала, статическое поле Value должно быть заранее проинициализировано объектом класса, сгенерированным для каждого корня, как в строке #8. Этот объект просто возвращает корень композиции, как в строке кода #22. По умолчанию поле Value, в строке #13, проинициализировано объектом, который бросает исключение InvalidOperationException, о том, что корень композиции не может быть получен, так как не был определен.

Фактически, вся эта конструкция заменяет словарь соответствия типа корня к его композиции, используя обобщенные типы .NET, чтобы все работало максимально эффективно. Как результат, производительность метода Resolve совсем немного уступает прямому обращению к свойству корня композиции.

Не типизированный вариант метода Resolve работает немного медленнее его типизированного аналога, так как для поиска корня композиции используется словарь по типу. Можно было бы использовать обычный Dictionary из базовой библиотеки типов .NET, так как он универсален и работает быстро. Но от его функционала требуется только возможность поиска значения по типу и хотелось бы минимизировать ресурсы. Поэтому для поиска корня используется фиксированный массив корзин.

partial class Composition
{
  private readonly static int _bucketSize;
  private readonly static Pair<Type, IResolver<Composition, object>>[] _buckets;
    
  public object Resolve(Type type)
  {
    var index = (int)(_bucketSize * 
      ((uint)RuntimeHelpers.GetHashCode(type) % NumberOfRoots));
    
    var finish = index + _bucketSize;
    do {
      ref var pair = ref _buckets[index];
      if (object.ReferenceEquals(pair.Key, type))
      {
        return pair.Value.Resolve(this);
      }
    } while (++index < finish);
    
    throw new InvalidOperationException(
      $"Cannot resolve composition root of type {type}.");
  }  
}

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

Жизненные циклы

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

.Bind<Random>().As(Singleton).To<Random>()

указывает использовать время жизни Singleton, т.е. определяет создавать лишь один объект типа Random для всех корней композиции. При построении композиции объектов, создание экземпляря типа Random выглядит примерно так:

if (Object.ReferenceEquals(_random, null))
{
  lock (_lockObject)
  {
    if (Object.ReferenceEquals(_random, null))
    {
      _random = new System.Random();
    }
  }
}

В некоторых источниках советуют, как можно чаще использовать объекты с жизненным циклом Singleton, но необходимо учитывать следующие детали:

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

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

  • Контроль потокобезопасности должен быть автоматически расширен на все зависимости, которые использует Singleton, так как их состояние так же теперь общее.

  • Логика для контроля потокобезопасности может быть затратной по ресурсам, приводить к ошибкам, взаимоблокировкам, и сложна в тестировании.

  • Singleton может сохранять ссылки на зависимости дольше их ожидаемого времени жизни, это особенно значимо для объектов, которые удерживают “не возобновляемые” ресурсы, например, такие как Handler операционной системы.

  • Иногда требуется дополнительная логика по утилизации Singleton.

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

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

  • Будут лишние расходы памяти, которых можно было бы избежать.

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

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

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

Перехват

Поговорим немного о перехвате, так как это одна из непосредственных задач, которые решает DI. Перехват — это способность перехватывать вызовы между объектами, для того чтобы обогатить или изменить их поведение, но без необходимости менять их код. Обязательным условием перехвата является слабое связывание. Т.е. если программирование ведется на основе абстракций, основную реализацию можно преобразовать или улучшить, “упаковав” ее в другие реализации той же самой абстракции. По своей сути перехват является применением шаблона проектирования “Декоратор”. Этот шаблон обеспечивает гибкую альтернативу наследованию за счет динамического “прикрепления” к объекту дополнительной ответственности. Декоратор “упаковывает” одну реализацию абстракции в другую реализацию той же самой абстракции наподобие матрешки:

Перехват
Перехват

Использование перехвата дает возможность добавить такую сквозную функциональность как:

  • Логирование

  • Журналирование действий

  • Мониторинг производительности

  • Обеспечение безопасности

  • Кэширование

  • Обработку ошибок

  • Обеспечение устойчивости к сбоям и др.

Рассмотрим пример:

interface IService { string GetMessage(); }

class Service : IService 
{
    public string GetMessage() => "Hello World"; // #5
}

class GreetingService : IService
{
    private readonly IService _baseService;

    public GreetingService([Tag("base")] IService baseService) => // #12
        _baseService = baseService;

    public string GetMessage() => $"{_baseService.GetMessage()} !!!"; //#15
}

DI.Setup("Composition")
  .Bind<IService>("base").To<Service>() // #19
  .Bind<IService>().To<GreetingService>()
  .Root<IService>("Root");

Имеем две реализации интерфейса IService

  • Первая реализация Service в методе GetMessage, в строке #5 возвращает “Hello World”. 

  • Вторая реализация GreetingService - является декоратором, который “упаковывает” в себя поведение базовой реализации, “обогащая” это поведение добавлением восклицательных знаков к концу сообщения в строке кода #15.

Теги

Чтобы выполнить упаковку базовой реализации в декоратор:

  • В строке кода #12 декоратор запрашивает внедрения абстракции IService по тегу “base”

  • А базовая реализация отмечается этим же тегом в строке кода #19 при настройке композиции

Тег с названием “base”, тут выбран произвольно и по смыслу. В качестве тегов можно использовать любые константы, типы и значения перечислимых типов. Теги — это простой и гибкий механизм, который позволяет оперировать разными реализациями одних и тех же абстракций. Этот подход удобен в самых различных сценариях и конечно же не только в сценарии “Декоратор”.

Hints - OnDependencyInjection

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

// OnDependencyInjection = On
// OnDependencyInjectionContractTypeNameRegularExpression = IService
DI.Setup("Composition")
  .Bind<IService>().To<Service>().Root<IService>("Root");
  1. Первая "подсказка" - в строке #1 определяет, что нужно создавать частичный метод оповещения о внедрении зависимостей.

  2. Вторая "подсказка" - в строке #2 содержит регулярное выражение, для того чтобы сузить список типов, для которой будет вызван метод оповещения для минимизации его влияния на производительность.

Для создания динамических прокси объектов можно использовать, например, библиотеку Castle Dynamic Proxy, как здесь. В частичном классе нужно дополнительно реализовать частичный метод OnDependencyInjection:

partial class Composition: IInterceptor
{
    private static readonly ProxyGenerator ProxyGenerator = new();

    private partial T OnDependencyInjection<T>(
        in T value,
        object? tag,
        Lifetime lifetime)
    {
        if (typeof(T).IsValueType)
        {
            return value;
        }

        return (T)ProxyGenerator.CreateInterfaceProxyWithTargetInterface( // #15
            typeof(T),
            value,
            this);
    }

    public void Intercept(IInvocation invocation)
    {
        invocation.Proceed();
        if (invocation.Method.Name == nameof(IService.GetMessage)
            && invocation.ReturnValue is string message)
        {
            invocation.ReturnValue = $"{message} !!!"; // #27
        }
    }
}

Это метод в строке #15 возвращает прокси объект для перехвата вызовов к реальному объекту. Для упрощения примера, частичный класс сам будет перехватчиком, поэтому он дополнительно реализует интерфейс IInterceptor. Сам перехват выполняется в методе Intercept этого интерфейса и, так же как в примере с декоратором, “обогащает” логику базовой реализации добавлением восклицательных знаков в строке кода #27.

Динамические прокси позволяют легко распространить сквозную функциональность, о которой мы говорили ранее (логирование, кэширование и т.д.), на большое количество типов.

Hints - ToString

Разработчики, начинающие применять технологию DI, часто жалуются, что они перестают видеть структуру приложения, поскольку сложно понять, как оно строится. Чтобы облегчить себе жизнь, можно добавить подсказку ToString прямо перед вызовом метода Setup в строке #1 , указав генератору создавать метод ToString, например:

// ToString = On
DI.Setup("Composition")
  .Bind<IService>("base").To<Service>()
  .Bind<IService>().To<GreetingService>()
  .Root<IService>("Root");

Сгенерированный метод ToString, будет выглядеть примерно так:

public override string ToString() =>      
  "classDiagram\n" +
    "  class Composition {\n" +
      "    +IService Root\n" +
      "    + T ResolveᐸTᐳ()\n" +
      "    + T ResolveᐸTᐳ(object? tag)\n" +
      "    + object Resolve(Type type)\n" +
      "    + object Resolve(Type type, object? tag)\n" +
    "  }\n" +
    "  GreetingService --|> IService : \n" +
    "  class GreetingService {\n" +
      "    +GreetingService(IService baseService)\n" +
    "  }\n" +
    "  Service --|> IService : \"base\" \n" +
    "  class Service {\n" +
      "    +Service()\n" +
    "  }\n" +
    "  class IService {\n" +
      "    <<abstract>>\n" +
    "  }\n" +
    "  GreetingService *--  Service : \"base\"  IService\n" +
    "  Composition ..> GreetingService : IService Root";
  

Он возвращает строку в формате схем Mermaid. Эту строку легко можно представить в виде диаграммы классов. Например, диаграмма классов для примера с “упаковкой” базовой реализации в декоратор:

Диаграмма классов Mermaid
Диаграмма классов Mermaid

Эта диаграмма может быть полезна чтобы разобраться с графом зависимостей и понять как строится композиция объектов.

Hints - ThreadSafe

Другая полезная подсказка ThreadSafe позволяет определить нужно ли генерировать код создания композиций объектов потокобезопасным. Когда мы точно знаем, что создание композиций происходит в одном потоке, а это рекомендуемый сценарий, можно выключить этот флаг, как в строке #4:

internal partial class Composition
{
    private static void Setup() =>
        // ThreadSafe = Off
        DI.Setup(nameof(Composition))
            .Bind<Random>().As(Singleton).To<Random>()
            .Bind<State>().To(ctx =>
            {
                ctx.Inject<Random>(out var random);
                return (State)random.Next(2);
            })
            .Bind<ICat>().To<ShroedingersCat>()
            .Bind<IBox<TT>>().To<CardboardBox<TT>>()
            .Root<Program>("Root");
}

И получить ускорение и экономию ресурсов. Обратите внимание, что одиночка "Random" теперь создается без использования примитивов синхронизации и с одной проверкой:

partial class Composition
{
  public Program Root
  {
    get
    {
      var stateFunc = new Func<State>(() => {        
        if (object.ReferenceEquals(_random, null))
        {
          _random = new Random();
        }        
        
        return (State)random.Next(2);        
      });
      
      return new Program(
        new CardboardBox<ICat>(
          new ShroedingersCat(
            new Lazy<State>(stateFunc, true))));      
    }
  }  
}

Помимо рассмотренных выше подсказок, существует множество других. Например:

  • OnNewInstance определяет нужен ли метод оповещения о создании новых объектов внутри композиции. Этот метод можно использовать, например, для логирования создания объектов или даже для их подмены.

  • Resolve включает или отключает создание методов Resolve, и полезна если не будет применяться анти паттерн Service Locator. При отключении создания методов Resolve можно сэкономить незначительно количество статической памяти, так как данные для поиска корней по их типу будут не нужны.

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

  • OnNewRoot позволяет собрать всю информацию о корнях композиции и может быть полезным, например, при создании коллекции сервисов библиотеки Microsoft Dependency Injection.

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

Производительность

В Pure.DI большое значение уделяется минимизации потребления ресурсов. Поэтому не лишним будет взглянуть на результаты сравнения производительности создания композиций объектов:

 

20 transient + 1 singleton

21 transient

Обычный код

3.786 ns

3.866 ns

Корень композиции

4.313 ns

3.664 ns

T Resolve<T>()

5.427 ns

5.021 ns

object Resolve(Type)

8.801 ns

8.258 ns

DryIoc 5.4.1

22.165 ns

20.525 ns

Simple Injector 5.4.1

26.368 ns

25.713 ns

Microsoft DI 7.0.0

27.638 ns

24.907 ns

Light Inject 6.6.4

40.876 ns

11.880 ns

Autofac 7.0.1

7,092.382 ns

11,836.198 ns

Важно отметить, что между кодом, написанного вручную, и сгенерированным Pure.DI отличий нет, а разницу в производительности можно списать на погрешность измерений.

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

Если объявить аргументы вызовом метода Arg, они автоматически появятся как аргументы в конструкторе частичного класса. Эти аргументы по своей сути схожи с Singleton, но они передаются снаружи через конструктор частичного класса.

Если сделать тоже самое но методом RootArg, то корень композиции, который использует аргументы, превратится в метод с этими аргументами. Они по своей сути схожи с PerResolve объектами, но передаваемыми снаружи.

Если в композициях используется хотя бы один утилизируемый Singleton, то частичный класс автоматически становится утилизируемым.

На основе частичного класса можно создать дочерний или даже дерево. Для этого есть конструктор, который принимает родительский.

Есть возможность создания одиночек внутри “сессий”.

При построении композиций объектов упор сделан на производительность и минимальное потребление ресурсов. Поэтому в определенных сценариях аллокация элементов происходит на стеке.

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

В репозитории есть множество примеров для различных сценариев. Все они содержат краткое описание, рекомендации, диаграмму классов и листинг сгенерированного кода. А если какой-то сценарий отсутствует или вызывает вопросы, смело создавайте тикет. Планирую выпустить еще несколько статей по использованию генератора Pure.DI в Web, WPF, MAUI и др. видах приложений и в интересных сценариях.

Спасибо за потраченное время, надеюсь было интересно.

Репозиторий проекта: https://github.com/DevTeam/Pure.DI

NuGet пакет: https://www.nuget.org/packages/Pure.DI

Быстро поробовать:

git clone https://github.com/DevTeam/Pure.DI.Example.git
cd ./Pure.DI.Example
dotnet run

Комментарии (14)


  1. MonkAlex
    04.10.2023 12:23

    На дотнексте смотрел, хорошо что появилась текстовая версия =)

    Есть вопросы.

    1. В начале классно рассказываете про то, что текущие DI контейнеры это привычный инструмент и он скрывает от разработчиков тот факт, что можно без контейнеров. Но в итоге приходите к генератору, который что-то сгенерирует и какая то магия обеспечит создание всех нужных зависимостей. Магия генератора с моей точки зрения выглядит более неявным решением проблем. Итого, я вижу только решение проблемы быстрого запуска, ничего более. Так ли это или я что-то упустил?

    2. Можно ли показать, как будет выглядеть пример с использованием ServiceCollection из Microsoft.Extensions.DependencyInjection?

    3. "хинты" смотрятся как ещё большая магия в довесок к первому пункту. Круто что они есть, они сняли часть моих вопросов, но с точки зрения C# разработчика я скорее предпочту накладные на запуск приложения, чем магию. Нет ли идей\желания сделать магию более привычной для разработчиков? Мне это напоминает Cake и Nuke (инструменты для билда), где первый на магии, а второй вполне привычный шарповый код.


    1. NikolayPyanikov Автор
      04.10.2023 12:23

      Большое спасибо за интерес и на dotnext и тут))

      1. Я показал пример, когда 100500 вложенных конструкторов. Это то, что получится в сложном приложении, когда делать чистый DI руками. Идея проста - автоматизировать создания этого кода (с конструкторами) и использовать привычный API классических контейнеров. Вам можно не думать, как это все делает генератор. В любой момент вы можете посмотреть сгенерированный код – это легко сделать в любой IDE. В какой-то момент вы даже можете его просто скопировать в .cs файл и отказаться от генератора)) Для «декоратора» из статьи этот код на странице https://github.com/DevTeam/Pure.DI/blob/master/readme/decorator.md

      public IService Root
      {
        get
        {
          var transientM09D25di129 = new Service();
          var transientM09D25di128 = new GreetingService(transientM09D25di129);
          return transientM09D25di128;
        }
      }

      Вы ВСЕГДА можете посмотреть сгенерированный код сами и он прост. Никакой магии. Хотя мне пишу разработчики на Unity и спрашивают, что за магия происходит))

      2.

      https://github.com/DevTeam/Pure.DI/blob/master/readme/service-collection.md

      https://github.com/DevTeam/Pure.DI/blob/master/readme/service-provider.md

      https://github.com/DevTeam/Pure.DI/blob/master/readme/WebAPI.md

      Важно, что Microsoft DI выполняет анализ зависимостей и создание композиции на этапе выполнения. Pure.DI все делает на этапе компиляции. Нужно поменять способ мышления. И думать со стороны чистого DI, а генератор это только помощник.

      3.

      На dotnext мне закинули мысль добавить плагины. Я думаю, эта идея реализуемы используя roslyn API. Нужно определиться с сценариями использования. Если у вас есть мысли буду рад обсудить.

      Была у меня еще идея использовать ИИ при создании кода для построения композиций. Покопаю в эту сторону :)


    1. NikolayPyanikov Автор
      04.10.2023 12:23

      Кстати подход с хинтами не нов)) Аннотации ReSharper работаю схоже. По мимо коментариев вы можете использовать обычный API. Вот тут используются 2 способа:

      // OnDependencyInjection = On
      DI.Setup("Composition")
          .Hint(OnDependencyInjectionContractTypeNameRegularExpression, nameof(IDependency))
          .Bind<IDependency>().To<Dependency>()
          .Bind<IService>().Tags().To<Service>().Root<IService>("Root");


  1. wlbm_onizuka
    04.10.2023 12:23

    Интересно, но не все понравилось
    1) Текстовые хинты? А если нужно под флагом компиляции установить разные опции, например для дебага и релиза, что делать с хинтами? Почему не сделать объект с опциями как в JsonSerializer например. Или еще один fluent метод. Строгая типизация лучше утиной.
    2) Декораторы с таким же косяком как у майкрософта, с тэгами, заражают код бизнес-логики зависимостью от атрибута и лишней ответственностью. Есть решения получше. например отдельный метод RegisterDecorator (SimpleInjector) или Decorate (Zenject/Extenject). И код остается чистым, и декораторами можно жонглировать как захочется, меняя порядок регистрации.
    3) "internal partial class Composition" - неужели нельзя спрятать эту излишнюю церемонию куда-то в кишки? смотрится неаккуратно

    p.s: неплохо аккуратно добавить ссылку на проект в начале статьи:
    "В статье пойдет речь о библиотеке ..."
    А так когда возникает интерес пройтись в гит, ссылку сложно найти


    1. NikolayPyanikov Автор
      04.10.2023 12:23

      Спасибо за интерес!

      1) Помимо тестовых хинтов можно использовать вызовы API, я уже приводил этот пример в комментариях:

      // OnDependencyInjection = On
      DI.Setup("Composition")
          .Hint(OnDependencyInjectionContractTypeNameRegularExpression, nameof(IDependency))
          .Bind<IDependency>().To<Dependency>()
          .Bind<IService>().Tags().To<Service>().Root<IService>("Root");

      2) Декораторы это в основном пример использования тегов - частный случай. Теги позволяют просто и наглядно управлять разными реализациями одних и тех же абстракций. Если программировать на основе абстракций, то эта фича должна быть одной из самых востребованных. То, что это «заражает» код бизнес логики DI атрибутами – совершенно согласен. Но пока не придумал другого механизма как работать с такими сценариями. Что может частично нивелировать проблему - это использования кастомных атрибутов. То есть можно создать и использовать свои атрибуты и не зависеть от чужих.

      3) Не очень понял мысль об "internal partial class Composition". Можно сократить до "partial class Composition". Можно натсраивать композицию вне частичного класса, тогда "partial class Composition" останется только в генерируемом коде.

      Сссылку на проект добавлю, спасибо


  1. nervoushammer
    04.10.2023 12:23

    Код библиотеки связанный с потокобезопасностью содержит очевидные ошибки и bad practices.

    1. Не надо делать lock на объекты отличные от Object хранимых в readonly приватных полях. Вот из статьи:

    private readonly System.IDisposable[] _disposableSingletons;
    
    ...
    
    lock (_disposableSingletons)
    

    Пример как правильно:

    private SomeSharedState _someSharedState = ...
    private readonly object _lockSomeSharedState = new();
    
    ...
    
    lock (_lockSomeSharedState)
    { 
      // делаем что-то с _someSharedState
    }
    1. Использование анти-паттерна double-checked locking:

        if (object.ReferenceEquals(_random, null))
        {
          lock (_disposableSingletons)
          {
            if (object.ReferenceEquals(_random, null))
            {
              _random = new Random();
            }
          }
        }      
        
        return (State)random.Next(2);      
    

    DCL (в общем случае) вызывает data race.

    Доступ к разделяемому изменяемому состоянию потокобезопасен только тогда, когда он проходит через какой-то механизм синхронизации.

    Любой доступ. И любое чтение, и любая запись. В DCL чтение состояния в первой проверке "голое".

    Также непонятно, а что такое random ? В коде источнике для кодогенерации это локальная out var с результатом разрешения зависимости. А здесь ?

    Беглый взгляд на GitHub проекта вызывает вопросы и о корректности работы с IDisposable.

    Например, бросилось в глаза "глотание" исключений при вызовах Dispose().

    Также не видно поддержки IAsyncDisposable.

    Вообще же статические DI-фреймворки на кодогенерации это не новость. Dagger смотрели ? Он на\для Java, но практически наверняка вы сможете почерпнуть в его коде очень и очень много полезного.


    1. NikolayPyanikov Автор
      04.10.2023 12:23

      Код библиотеки связанный с потокобезопасностью содержит очевидные ошибки и bad practices.

      Хотелось бы отметить что Pure.DI это не библиотека, а генератор. Код это часть проекта.

      1)

      Не надо делать lock на объекты отличные от Object хранимых в readonly приватных полях.

      Не совсем согласен с вашей идеей. Lock нельзя делать на объекты типов значений. Не рекомендуется делать lock на типы, статические данные. Так же не рекомендуется делать lock на публичные данные, на this, на сроки … другими словами на те объекты внешнее использование в lock которых мы не можем контролировать. _disposableSingletons_ — это нестатическое приватное поле, использование которого в lock полностью под контролем. Это поле используется для экономии ресурсов, так как удовлетворяет всем требованиями по использованию в lock.

      Сейчас есть недостаток в том, что обращение к разным ресурсам контролируются одним lock. Это экономия. Если возникнут проблемы в реальных сценариях можно будет поменять.

      2) Ни когда не слышал о "анти-паттерне double-checked locking". На мой взгляд это общепринятый механизм, опять же быстрый и надежный. Если у вас есть идея чем заменить, буду рад это сделать.

      Также непонятно, а что такое random ? В коде источнике для кодогенерации это локальная out var с результатом разрешения зависимости. А здесь ?

      В генерируемом коде это имя выглядит "не очень" и не имеет особой ценности. Я заменил его в статье что было понятнее.

      Беглый взгляд на GitHub проекта вызывает вопросы и о корректности работы с IDisposable.

      Например, бросилось в глаза "глотание" исключений при вызовах Dispose().

      По традиции метод Dispose должен быть идемпотентным и не должен бросать исключений. Иначе в конструкциях как:

      using var abc = new ...
      using (var abc = new ...)

      нужно будет еще заботится об исключениях. Это будет выглядеть как минимум странно.

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

      public void Dispose()
      {
        lock (_disposableSingletonsM09D25di)
        {
          while (_disposeIndexM09D25di > 0)
          {
            try
            {
              _disposableSingletonsM09D25di[--_disposeIndexM09D25di].Dispose();
            }
            catch
            {
              // ignored
            }
          }
          
          _singletonM09D25di21 = null;
        }
      }
        

      Его задача максимально корректно освободить все ресурсы, если даже какой-то "нерадивый" объект кинет исключение. И привести генерируемы класс композиции в определенное состояние. Если есть идеи как сделать это лучше поделитесь пожалуйста.

      Также не видно поддержки IAsyncDisposable.

      Думаю нужно добавить.

      Вообще же статические DI-фреймворки на кодогенерации это не новость. Dagger смотрели ? Он на\для Java, но практически наверняка вы сможете почерпнуть в его коде очень и очень много полезного.

      Да мне нравится JVM и я смотрел много разных и там. Dagger хорош.


      1. mayorovp
        04.10.2023 12:23

        Ни когда не слышал о "анти-паттерне double-checked locking". На мой взгляд это общеприеятый механизм, опять же быстрый и надежный. Если у вас есть идея чем заменить, буду рад это сделать.

        Теперь слышали. Первое чтение надо делать volatile, без этого никак. А вообще, существует же LazyInitializer, зачем выдумывать что-то ещё?


        Его задача максимально корректно освободить все ресурсы, если даже какой-то "нерадивый" объект кинет исключение. И привести генерируемы класс композиции в определенное состояние. Если есть идеи как сделать это лучше поделитесь пожалуйста.

        Логи!!! Или хотя бы Debug.Fail. Исключение нужно хоть как-то обработать, иначе тот объект так и останется "нерадивым".


        1. NikolayPyanikov Автор
          04.10.2023 12:23

          > Первое чтение надо делать volatile, без этого никак.

          Использование volatile, memory barrier ухудшает производительность. Риск реордеринга инструкций в ia64, попытаюсь решить только для этой платформы.

          LazyInitialize - не вариант так как будет медленным вместе с Func.

          Логи!!! Или хотя бы Debug.Fail. Исключение нужно хоть как-то обработать, иначе тот объект так и останется "нерадивым".

          Спасибо, подумаю добавить хинт для генерации метода обработки исключений


        1. nervoushammer
          04.10.2023 12:23

          Первое чтение надо делать volatile, без этого никак.

          Этого недостаточно. Ещё и запись обязательно должна выполняться после всех операций инициализации сотоварищи. Проще всего всё делать через временную локальную переменную. И только потом присваивать её значение уже volatile полю.


          1. NikolayPyanikov Автор
            04.10.2023 12:23

            Да, если есть что инициализировать, то используется промежуточная временная переменная. Если вызывается только конструктор, то она не нужна.


            1. nervoushammer
              04.10.2023 12:23

              Да, если есть что инициализировать, то используется промежуточная временная переменная.

              Без volatile это не имеет никакого значения - внутренности lock-блока могут быть оптимизированы как угодно.


          1. a-tk
            04.10.2023 12:23

            Строго говоря надо не про volatile говорить, а про барьеры и порядок чтения/записи.

            Модель памяти дотнета сильно проще чем в C++, и практически вся она выражается в слове volatile.

            Можно сделать ещё Volatile.Read/Volatile.Write делать.


            1. nervoushammer
              04.10.2023 12:23

              Строго говоря надо не про volatile говорить, а про барьеры и порядок чтения/записи.

              Надо бы, но люди что такое DCL даже не в курсе.

              Модель памяти дотнета сильно проще чем в C++, и практически вся она выражается в слове volatile.

              Вы преувеличиваете. Ещё есть Interlocked со всеми плюшками, и туда (относительно) недавно даже MemoryBarrierProcessWide завезли.