Пример использования генератора кода Pure.DI

Эта статья о том, что появилось нового в генераторе исходного кода Pure.DI с момента выхода предыдущей статьи Pure.DI v2.1. Помимо исправления некоторых ошибок, основной акцент был сделан на упрощении использования API для настройки генерации кода. Появилась возможность определить корни композиции обобщенных типов. Добавились накопители, что решило вопрос утилизации объектов со временем жизни отличным от Lifetime.Singleton и Lifetime.Scoped. Удалось улучшить производительность методов Resolve() и корней композиции.

Упрощение API

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

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

interface ICat
{
    State State { get; }
}

enum State
{
    Alive,
    Dead
}

interface IConsumer
{
    void Run();
}

и реализаций:

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

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

class ShroedingersCat(Lazy<State> superposition) : ICat
{
    public State State => superposition.Value;

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

class Consumer(IBox<ICat> boxWithCat): IConsumer
{
    public void Run() => Console.WriteLine(boxWithCat);
}

цепочка привязок может выглядеть так:

DI.Setup(nameof(Composition))
    .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>>()
    .Bind<IConsumer>().To<Consumer>()
    .Root<IConsumer>("Consumer");

Пример использования сгенерированной композиции:

var consumer = new Composition().Consumer;
consumer.Run();

Как показала практика, в большинстве случаев можно определить типы абстракций в привязках автоматически. Поэтому был добавлен метод API Bind() без параметров типа для определения абстракций в привязке. И теперь код выше можно упростить следующим образом:

DI.Setup(nameof(Composition))
    .Bind().To(ctx =>
    {
        ctx.Inject<Random>(out var random);
        return (State)random.Next(2);
    })
    .Bind().To<ShroedingersCat>()
    .Bind().To<CardboardBox<TT>>()
    .Bind().To<Consumer>()
    .Root<IConsumer>("Consumer");

Новый метод Bind() выполняет связывание:

  • с самим типом реализации

  • и если он НЕ является абстрактным типом или структурой

    • со всеми абстрактными типами, которые он непосредственно реализует

    • исключения составляют специальные типы

Например:

interface IB;

class B: IB;

interface IA;

class A: B, IA, IDisposable, IList<int>;

Для class A привязка Bind().To<A>() будет эквивалентна привязке Bind<IA, A>().To<A>(). Типы IDisposable, IList<T> не попали в привязку, так как они специальные из списка ниже. B не попал так, как он не абстрактный. IB не попал, так как он не реализуется непосредственно классом A.

A

сам тип реализации

IA

непосредственно реализует

IDisposable

специальный тип

IList<T>

специальный тип

B

не абстрактный

IB

не реализуется непосредственно классом A

Список специальных типов, которые не участвуют в привязке методом Bind() без параметров типа
  • System.Object

  • System.Enum

  • System.MulticastDelegate

  • System.Delegate

  • System.Collections.IEnumerable

  • System.Collections.Generic.IEnumerable<T>

  • System.Collections.Generic.IList<T>

  • System.Collections.Generic.ICollection<T>

  • System.Collections.IEnumerator

  • System.Collections.Generic.IEnumerator<T>

  • System.Collections.Generic.IIReadOnlyList<T>

  • System.Collections.Generic.IReadOnlyCollection<T>

  • System.IDisposable

  • System.IAsyncResult

  • System.AsyncCallback

Эти правила помогают уменьшить вероятность коллизии между привязками, когда несколько привязок сопоставляют одну абстракцию нескольким реализациям. Коллизии приведут к предупреждению компилятора о том, что привязка для абстракции была переопределена. Если метод Bind() все же создает коллизии, рекомендуется использовать другой метод Bind<.>() с параметрами типа, чтобы указать типы в привязке самостоятельно.

Еще пример.

В общем случае рекомендуется определять один корень композиции на всё приложение, как в примере выше, с корнем композиции Consumer. Но иногда необходимо иметь несколько корней. Для упрощения определения корней композиции был добавлен "гибридный" метод API RootBind<T>(string name). Он позволяет определить привязку и одновременно корень композиции. Например, код выше можно переписать так:

DI.Setup(nameof(Composition))
    .Bind().To(ctx =>
    {
        ctx.Inject<Random>(out var random);
        return (State)random.Next(2);
    })
    .Bind().To<ShroedingersCat>()
    .Bind().To<CardboardBox<TT>>()
    .RootBind<IConsumer>("Consumer").To<Consumer>();

Еще пример.

При регистрации нескольких привязок для одной абстракции, они могу быть внедрены как некое перечисление IEnumerable<T>, T[] и т.д., если для привязок были указаны уникальные теги. Добавим еще один вид коробки class BlackBox<T> и изменим код потребителя class Consumer, чтобы получить объекты всех возможных типов, соответствующих абстракции IBox<ICat>:

class BlackBox<T>(T content) : IBox<T>
{
    public T Content { get; } = content;

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

class Consumer(IEnumerable<IBox<ICat>> boxes): IConsumer
{
    public void Run()
    {
        foreach (var box in boxes)
        {
            Console.WriteLine(box);
        }
    }
}

Изменим настройки привязок, обеспечив их уникальность тегом "Black" в предпоследней строке кода:

DI.Setup(nameof(Composition))
    .Bind().To(ctx =>
    {
        ctx.Inject<Random>(out var random);
        return (State)random.Next(2);
    })
    .Bind().To<ShroedingersCat>()
    .Bind().To<CardboardBox<TT>>()
    .Bind("Black").To<BlackBox<TT>>()
    .RootBind<IConsumer>("Consumer").To<Consumer>();

Если не обеспечить уникальность привязки, то компилятор выдаст предупреждение, а при композиции объектов будет принята во внимание только последняя зарегистрированная привязка для BlackBox<T>. Но так как тег "Black" сделал привязку уникальной, результат выполнения выглядит примерно так:

[Alive cat]
<Dead cat>

Чтобы в похожем сценарии не придумывать каждый раз новый уникальный тег самому, можно указать специальный тег Tag.Unique, как в предпоследней строке кода примера ниже:

  DI.Setup(nameof(Composition))
    .Bind().To(ctx =>
    {
        ctx.Inject<Random>(out var random);
        return (State)random.Next(2);
    })
    .Bind().To<ShroedingersCat>()
    .Bind().To<CardboardBox<TT>>()
    .Bind(Tag.Unique).To<BlackBox<TT>>()
    .RootBind<IConsumer>("Consumer").To<Consumer>();

Часто возникает необходимость определить тег для привязки, соответствующий типу реализации. Для этого можно просто воспользоваться специальным тегом Tag.Type, как в строке кода для привязки котов. Для демонстрации был добавлен еще один тип котов class BlackCat.

Весь код будет выглядеть так
using Pure.DI;

var consumer = new Composition().Consumer;
consumer.Run();

DI.Setup(nameof(Composition))
    .Bind().To(ctx =>
    {
        ctx.Inject<Random>(out var random);
        return (State)random.Next(2);
    })
    .Bind(Tag.Type).To<ShroedingersCat>()
    .Bind(Tag.Type).To<BlackCat>()
    .Bind().To<CardboardBox<TT>>()
    .Bind(Tag.Unique).To<BlackBox<TT>>()
    .RootBind<IConsumer>("Consumer").To<Consumer>();

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

interface ICat
{
    State State { get; }
}

enum State
{
    Alive,
    Dead
}

interface IConsumer
{
    void Run();
}

class CardboardBox<T>([Tag(typeof(ShroedingersCat))] T content) : IBox<T>
{
    public T Content { get; } = content;

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

class BlackBox<T>([Tag(typeof(BlackCat))] T content) : IBox<T>
{
    public T Content { get; } = content;

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

class ShroedingersCat(Lazy<State> superposition) : ICat
{
    public State State => superposition.Value;

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

class BlackCat : ICat
{
    public State State => State.Alive;

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

class Consumer(IEnumerable<IBox<ICat>> boxes): IConsumer
{
    public void Run()
    {
        foreach (var box in boxes)
        {
            Console.WriteLine(box);
        }
    }
}

Благодаря использованию тегов, можно быть уверенным, что ShroedingersCat будет внедрен в CardboardBox, а BlackCat в BlackBox. Результат выполнения будет примерно таким:

[Dead cat]
<Alive black cat>

Корни обобщенных типов

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

interface IConsumer<in TContext>
{
    void Run(TContext ctx);
}

class Consumer<TContext>(IEnumerable<IBox<ICat>> boxes)
    : IConsumer<TContext>
{
    public void Run(TContext ctx)
    {
        foreach (var box in boxes)
        {
            Console.WriteLine($"{ctx} {box}");
        }
    }
}

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

DI.Setup(nameof(Composition))
    .Bind().To(ctx =>
    {
        ctx.Inject<Random>(out var random);
        return (State)random.Next(2);
    })
    .Bind(Tag.Type).To<ShroedingersCat>()
    .Bind(Tag.Type).To<BlackCat>()
    .Bind().To<CardboardBox<TT>>()
    .Bind(Tag.Unique).To<BlackBox<TT>>()
    .RootBind<IConsumer<TT>>("Consumer").To<Consumer<TT>>();

Изменения были в последней строке кода. Пример использования на данном этапе может выглядеть так:

var consumer = new Composition().Consumer<string>();
consumer.Run("ctx");

Корень композиции IConsumer Consumer { get; } трансформировался в метод с параметром типа IConsumer<T> Consumer<T>().

Еще пример.

Иногда хочется ограничить параметры типа. Для примера, укажем, что параметр типа может быть только типом значения. Обратите внимание на строки кода с ограничением на параметр типа where TContext: struct

interface IConsumer<in TContext>
    where TContext: struct
{
    void Run(TContext ctx);
}

class Consumer<TContext>(IEnumerable<IBox<ICat>> boxes)
    : IConsumer<TContext>
    where TContext: struct
{
    public void Run(TContext ctx)
    {
        foreach (var box in boxes)
        {
            Console.WriteLine($"{ctx} {box}");
        }
    }
}

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

DI.Setup(nameof(Composition))
    .Bind().To(ctx =>
    {
        ctx.Inject<Random>(out var random);
        return (State)random.Next(2);
    })
    .Bind(Tag.Type).To<ShroedingersCat>()
    .Bind(Tag.Type).To<BlackCat>()
    .Bind().To<CardboardBox<TT>>()
    .Bind(Tag.Unique).To<BlackBox<TT>>()
    .RootBind<IConsumer<TTS>>("Consumer").To<Consumer<TTS>>();

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

В Pure.DI маркеры типы определены так
[GenericTypeArgument]    
abstract class TT { }  

[GenericTypeArgument]
struct TTS { }

[GenericTypeArgument]
interface TTDisposable: IDisposable { }

[GenericTypeArgument]
interface TTComparable: IComparable { }

// и т.д.

Если попробовать скомпилировать код на данном этапе:

var consumer = new Composition().Consumer<string>();
consumer.Run("ctx");

то компилятор выдаст ошибку:

[CS0453] The type 'string' must be a non-nullable value type
in order to use it as parameter 'T' in the generic type
or method 'Composition.Consumer<T>()'

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

var consumer = new Composition().Consumer<int>();
consumer.Run(77);

// Результат:
// 77 [Alive cat]
// 77 <Alive black cat>

Еще пример.

Накопители

Ранее была возможность автоматически утилизировать объекты только со временем жизни Lifetime.Singleton или Lifetime.Scoped. Сейчас можно утилизировать и остальные. Предположим, что class ShroedingersCat содержит какой-то "дорогой" ресурс и реализует интерфейс IDisposable:

class ShroedingersCat(Lazy<State> superposition) : ICat, IDisposable
{
    public State State => superposition.Value;

    public override string ToString() => $"{State} cat";
    
    public void Dispose() =>
        Console.WriteLine($"{nameof(ShroedingersCat)} was disposed");
}

Изменим настройку привязок:

DI.Setup(nameof(Composition))
    .Bind().To(ctx =>
    {
        ctx.Inject<Random>(out var random);
        return (State)random.Next(2);
    })
    .Bind(Tag.Type).To<ShroedingersCat>()
    .Bind(Tag.Type).To<BlackCat>()
    .Bind().To<CardboardBox<TT>>()
    .Bind(Tag.Unique).To<BlackBox<TT>>()
    .Bind().To<Consumer<TTS>>()
    .Root<Owned<IConsumer<TTS>>>("Consumer");

В последней строке кода выше определен корень композиции типа Owned<IConsumer<TTS>>. Объект этого типа имеет неизменяемое поле Value, которое содержит экземпляр корня композиции и метод Dispose() для утилизации всех утилизируемых объектов в созданной композиции объектов. Пример использования:

using var consumer = new Composition().Consumer<int>();
consumer.Value.Run(77);

// Результат:
// 77 [Dead cat]
// 77 <Alive black cat>
// ShroedingersCat was disposed

Owned<T> из API Pure.DI - это накопитель утилизируемых объектов, реализующих интерфейс IDisposable и имеющих время жизни отличное от Lifetime.Singleton или Lifetime.Scoped.

Еще пример, и пример при ленивом получении объекта.

Есть простой способ определить свои накопители для любых абстракций. Создадим тип накопителя котов:

class CatsAccumulator: List<ICat>;

Накопитель должен:

  • быть не абстрактным типом

  • иметь доступный конструктор без аргументов

  • иметь метод Add(arg) с единственным параметром, который позволяет принять накапливаемый объект

Если какое-то из условий не выполняется, компилятор выдаст ошибку. Синхронизацию потоков в методе Add(arg)выполнять не требуется, генератор кода берет это на себя.

Зарегистрируем в качестве накопителя class CatsAccumulator, используя метод Accumulate<T, TAccumulator>(). Его сигнатура выглядит так:

IConfiguration Accumulate<T, TAccumulator>(params Lifetime[] lifetimes)
  where TAccumulator: new();

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

Обратите внимание на четвертую с конца строку, её позиция в настройке выбрана произвольно и не имеет значения:

DI.Setup(nameof(Composition))
    .Bind().To(ctx =>
    {
        ctx.Inject<Random>(out var random);
        return (State)random.Next(2);
    })
    .Bind(Tag.Type).To<ShroedingersCat>()
    .Bind(Tag.Type).To<BlackCat>()
    .Bind().To<CardboardBox<TT>>()
    .Accumulate<ICat, CatsAccumulator>()
    .Bind(Tag.Unique).To<BlackBox<TT>>()
    .Bind().To<Consumer<TTS>>()        
    .Root<Owned<IConsumer<TTS>>>("Consumer");

Далее использовать накопитель можно в качестве зависимости в любом типе. Например, внедрим его в class Consumer<TContext>:

class Consumer<TContext>(
    IEnumerable<IBox<ICat>> boxes,
    CatsAccumulator catsAccumulator)
    : IConsumer<TContext>
    where TContext: struct
{
    public void Run(TContext ctx)
    {
        foreach (var box in boxes)
        {
            Console.WriteLine($"{ctx} {box}");
        }
        
        Console.WriteLine("Cats:");
        foreach (var cat in catsAccumulator)
        {
            Console.WriteLine($"{cat}");
        }
    }
}

Еще пример.

В последних строках кода метода Run() в примере выше выводится список всех котов, созданных для текущей композиции объектов.

Код после всех изменений и результат его выполнения выглядит так
using Pure.DI;

using var consumer = new Composition().Consumer<int>();
consumer.Value.Run(77);

DI.Setup(nameof(Composition))
    .Bind().To(ctx =>
    {
        ctx.Inject<Random>(out var random);
        return (State)random.Next(2);
    })
    .Bind(Tag.Type).To<ShroedingersCat>()
    .Bind(Tag.Type).To<BlackCat>()
    .Bind().To<CardboardBox<TT>>()
    .Accumulate<ICat, CatsAccumulator>()
    .Bind(Tag.Unique).To<BlackBox<TT>>()
    .Bind().To<Consumer<TTS>>()
    .Root<Owned<IConsumer<TTS>>>("Consumer");

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

interface ICat
{
    State State { get; }
}

enum State
{
    Alive,
    Dead
}

interface IConsumer<in TContext>
    where TContext: struct
{
    void Run(TContext ctx);
}

class CardboardBox<T>([Tag(typeof(ShroedingersCat))] T content) : IBox<T>
{
    public T Content { get; } = content;

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

class BlackBox<T>([Tag(typeof(BlackCat))] T content) : IBox<T>
{
    public T Content { get; } = content;

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

class ShroedingersCat(Lazy<State> superposition) : ICat, IDisposable
{
    public State State => superposition.Value;

    public override string ToString() => $"{State} cat";
    
    public void Dispose() =>
        Console.WriteLine($"{nameof(ShroedingersCat)} was disposed");
}

class BlackCat : ICat
{
    public State State => State.Alive;

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

class Consumer<TContext>(
    IEnumerable<IBox<ICat>> boxes,
    CatsAccumulator catsAccumulator)
    : IConsumer<TContext>
    where TContext: struct
{
    public void Run(TContext ctx)
    {
        foreach (var box in boxes)
        {
            Console.WriteLine($"{ctx} {box}");
        }
        
        Console.WriteLine("Cats:");
        foreach (var cat in catsAccumulator)
        {
            Console.WriteLine($"{cat}");
        }
    }
}

class CatsAccumulator: List<ICat>;

// Результат:
// 77 [Dead cat]
// 77 <Alive black cat>
// Cats:
// Dead cat
// Alive black cat
// ShroedingersCat was disposed

В заключении этой темы приведу пример как зарегистрирован накопитель утилизируемых объектов Owned<T> из API Pure.DI:

Accumulate<IDisposable, Owned>(
  Lifetime.Transient, Lifetime.PerResolve, Lifetime.PerBlock)
.Bind<Owned<TT>>()
    .As(Lifetime.PerBlock)
    .To(ctx => {
        ctx.Inject<Owned>(out var owned);
        ctx.Inject<TT>(ctx.Tag, out var value);
        return new Owned<TT>(value, owned);
    })

Диаграмму классов можно найти по этой ссылке. Поиграться с проектом примера можно тут: https://github.com/DevTeam/Pure.DI.Example.2

Улучшение производительности

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

Было

Стало

T Root { get; }

4.965 ns

3.741 ns

Resolve<T>()

5.894 ns

4.879 ns

Resolve(Type type)

7.301 ns

5.377 ns

Тесты проводились на создании композиции из 70 объектов. Корень композиции до микро-оптимизации выглядел так:

public CompositionRoot Root
{
  get
  {
    return new CompositionRoot(
      new Service1(
        new Service2(
          new Service3(new Service4(), new Service4()),
          new Service3(new Service4(), new Service4()),
          new Service3(new Service4(), new Service4()),
          new Service3(new Service4(), new Service4()),
          new Service3(new Service4(), new Service4()))),
      new Service2(
        new Service3(new Service4(), new Service4()),
        new Service3(new Service4(), new Service4()),
        new Service3(new Service4(), new Service4()),
        new Service3(new Service4(), new Service4()),
        new Service3(new Service4(), new Service4())),
      new Service2(
        new Service3(new Service4(), new Service4()),
        new Service3(new Service4(), new Service4()),
        new Service3(new Service4(), new Service4()),
        new Service3(new Service4(), new Service4()),
        new Service3(new Service4(), new Service4())),
      new Service2(
        new Service3(new Service4(), new Service4()),
        new Service3(new Service4(), new Service4()),
        new Service3(new Service4(), new Service4()),
        new Service3(new Service4(), new Service4()),
        new Service3(new Service4(), new Service4())),
      new Service3(new Service4(), new Service4()),
      new Service4(),
      new Service4());
  }  
}

Был добавлен атрибут MethodImpl :

public CompositionRoot Root
{
  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  get
  {
    return ...
  }  
}

Метод T Resolve<T>(T) как раньше, так и сейчас получает корень композиции, применяя трюк с обращением к свойству обобщенного типа, созданного для каждого корня композиции. Единственное изменение - для метода был добавлен атрибут MethodImpl :

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public T Resolve<T>() =>
  Resolver<T>.Value.Resolve(this);

static Composition() =>
  Resolver<CompositionRoot>.Value = new Resolver_CompositionRoot();  

private sealed class Resolver<T>: IResolver<Composition, T>
{
  public static IResolver<Transient, T> Value = new Resolver<T>();
  
  public T Resolve(Composition composite) =>  
    throw new global::System.InvalidOperationException(
      $"Cannot resolve composition root of type {typeof(T)}.");
}

private sealed class Resolver_CompositionRoot
  : IResolver<Composition, CompositionRoot>
{
  public CompositionRoot Resolve(Composition composition) =>
    composition.Root;
}

Метод object Rsolve(Type type) до микро-оптимизаций выглядел так:

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

Этот метод был разбит на 2 части. Первая - короткая и предполагает инлайниг. Она рассчитывает на удачу, когда у типа нет коллизий по хэш коду:

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public object Resolve(Type type)
{
  var index = (int)(_bucketSize * ((uint)RuntimeHelpers.GetHashCode(type) % SIZE);
  ref var pair = ref _buckets[index];
  return pair.Key == type ? pair.Value.Resolve(this) : Resolve(type, index);
}

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

[MethodImpl(MethodImplOptions.NoInlining)]
private object Resolve(Type type, int index)
{
  var finish = index + _bucketSize;
  while (++index < finish)
  {
    ref var pair = ref _buckets[index];
    if (pair.Key == type)
    {
      return pair.Value.Resolve(this);
    }
  }
  
  throw new InvalidOperationException(
    $"Cannot resolve composition root of type {type}.");
}

В результате метод object Resolve(Type type) получил наибольший прирост производительности.

Сравнения производительности и потребления памяти можно найти здесь.

Спасибо за интерес и что дочитали до конца!