Pure.DI v2.1

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

Генерация кода налету

Генерация кода налету

Времена жизни PerBlock и Scoped

«Время жизни объектов в Dependency Injection — это как срок годности продуктов в холодильнике: если выбирать их спустя рукава, то объекты могут стать испорченными» © YaGPT 2. В переводе на человеческий — неверно выбранное время жизни объекта может стать причиной излишней траты ресурсов: памяти и процессора или даже ошибок в приложении. Изначально Pure.DI поддерживал следующие времена жизни:

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

  • Singleton — объект создается один раз для всех корней композиции

  • PerResolve — объект создается один раз для каждого из корней композиции

С первыми двумя все относительно просто. Остановимся на последнем в этом списке — PerResolve. Это время жизни позволяет использовать один и тот же объект внутри одного графа объектов — »Singleton на минималках», для одной композиции объектов, например:

interface IDependency;

class Dependency : IDependency;

class Service
{
  public Service(
    IDependency dep1,
    IDependency dep2,
    Lazy<(IDependency dep3, IDependency dep4)> deps)
  {
    Dep1 = dep1;
    Dep2 = dep2;
    Dep3 = deps.Value.dep3;
    Dep4 = deps.Value.dep4;
  }

  public IDependency Dep1 { get; }

  public IDependency Dep2 { get; }

  public IDependency Dep3 { get; }
  
  public IDependency Dep4 { get; }
}

DI.Setup(nameof(Composition))
  .Bind().As(Lifetime.PerResolve).To()
  .Root("Root");

var composition = new Composition();

var service1 = composition.Root;
service1.Dep1.ShouldBe(service1.Dep2);
service1.Dep3.ShouldBe(service1.Dep4);
service1.Dep1.ShouldBe(service1.Dep3);

var service2 = composition.Root;
service2.Dep1.ShouldNotBe(service1.Dep1);

В примере выше, при создании композиции объектов с корнем service1, его зависимости в свойствах Dependency1, Dependency2, Dependency3, Dependency4 будут содержать один и тот же объект. Для композиции с корнем service2 для этих свойств будет создан другой объект. Генерируемый код — чуть сложнее, чем можно было ожидать:

partial class Composition
{
  public IService Root
  {
    var dependency = default(Dependency);
    var funcDeps = new Func<(IDependency dep3, IDependency dep4)>(
    () =>
    {
      if (ReferenceEquals(dependency, null))
      {
        lock (_lock)
        {
          if (ReferenceEquals(dependency, null))
          {
            dependency = new Dependency();
          }
        }
      }

      return (dependency, dependency);
    });
    
    var lazyDeps = new Lazy<(IDependency dep3, IDependency dep4)>(funcDeps, true);  
    
    if (ReferenceEquals(dependency, null))
    {
      lock (_lock)
      {
        if (ReferenceEquals(dependency, null))
        {
          dependency = new Dependency();
        }
      }
    }
    
    return new Service(dependency, dependency, lazyDeps);
  }
}

Этот код учитывает сценарии когда PerResolve зависимости, такие как Dependency, будут создаваться лениво в лямбда функциях, при работе с IEnumerable, Lazy и т.д. Очевидно, что генерируемый код тратит ресурсы на проверку «единственности». В некоторых сценариях такое жесткое требование чрезмерно, и достаточно просто уменьшить количество объектов за счет их повторного использования, но без фанатизма, т.е. без гарантии их «единственности». Время жизни PerBlock - своего рода компромисс между количеством объектов типа внутри композиции и тратами на проверку «единственности», например:

DI.Setup(nameof(Composition))
  // PerResolve -> PerBlock
  .Bind().As(Lifetime.PerBlock).To()
  .Root("Root");

var composition = new Composition();

var service1 = composition.Root;
service1.Dep1.ShouldBe(service1.Dep2);
service1.Dep3.ShouldBe(service1.Dep4);
service1.Dep1.ShouldNotBe(service1.Dep3);

var service2 = composition.Root;
service2.Dep1.ShouldNotBe(service1.Dep1);

Для одного корня композиции с корнем service1 будет создано два объекта типа Dependency:

  • первый — для свойств Dependecy1 и Dependecy2

  • второй — при обращении к deps, для свойств Dependecy3 и Dependecy4

Сгенерированный код выглядит примерно так:

partial class Composition
{
  public Service Root
  {
    var funcDeps = new Func<(IDependency dep3, IDependency dep4)>(
    () =>
    {
        // второй
        var dependency34 = new Dependency();
        return (dependency34, dependency34);
    });
    
    var lazyDeps = new Lazy<(IDependency dep3, IDependency dep4)>(funcDeps, true);

    // первый
    var dependency12 = new Dependency();
    return new Service(dependency12, dependency12, lazyDeps);
  }
}

В созданном коде нет лишних проверок на «единственность» объектов типа Dependency и не нужна синхронизация потоков. Таким образом PerBlock улучшает производительность создания композиции объектов, но потенциально увеличивает количество объектов. Если создание новых объектов и их утилизация выгоднее чем проверка «единственности», то время жизни PerBlock будет полезным в конкретной ситуации.

Другое нововведение в Pure.DI версии 2.1 — это время жизни Scoped. Большинству пользователей библиотеки Microsoft Dependency Injection хорошо известно назначение времени жизни Scoped:

  • обеспечить «единственность» объекта в рамках некоторой сессии (области применения)

  • обеспечить утилизацию объекта при завершении этой сессии

В Pure.DI время жизни Scoped работает похоже, например:

interface IDependency
{
  bool IsDisposed { get; }
}

class Dependency : IDependency, IDisposable
{
  public bool IsDisposed { get; private set; }

  public void Dispose() => IsDisposed = true;
}

interface IService
{
  IDependency Dependency { get; }
}

class Service(IDependency dependency) : IService
{
  public IDependency Dependency => dependency;
}

// Implements a session
class Session(Composition composition) : Composition(composition);

class Program(Func sessionFactory)
{
  public Session CreateSession() => sessionFactory();
}

partial class Composition
{
  private static void Setup() =>
    DI.Setup(nameof(Composition))
      .Bind().As(Scoped).To()
      .Bind().To()
      // Session composition root
      .Root("SessionRoot")
      // Program composition root
      .Root("ProgramRoot");
}

var composition = new Composition();
var program = composition.ProgramRoot;
        
// Creates session #1
var session1 = program.CreateSession();
var dependency1 = session1.SessionRoot.Dependency;
var dependency12 = session1.SessionRoot.Dependency;
        
// Checks the identity of scoped instances in the same session
dependency1.ShouldBe(dependency12);
        
// Creates session #2
var session2 = program.CreateSession();
var dependency2 = session2.SessionRoot.Dependency;
        
// Checks that the scoped instances are not identical in different sessions
dependency1.ShouldNotBe(dependency2);
        
// Disposes of session #1
session1.Dispose();
// Checks that the scoped instance is finalized
dependency1.IsDisposed.ShouldBeTrue();
        
// Disposes of session #2
session2.Dispose();
// Checks that the scoped instance is finalized
dependency2.IsDisposed.ShouldBeTrue();

В примере выше создается две сессии session1 и session2. Каждая сессия внутри себя будет использовать единственный объект типа Dependency. Вместе с утилизацией сессии то же самое произойдет и со всеми её Scoped зависимостями.

Всё, что необходимо для этого:

  • определиться с типами, которые будут использоваться как одиночки внутри сессии — указать им время жизни Scoped, как в строке кода #35

  • выделить некий тип (или несколько) для организации сессии, в примере выше это тип Session в строчке кода #24

  • организовать возможность создания сессий лениво, как в строке кода #26, где для этого используется зависимость типа Func

Сгенерированный код выглядит примерно так:

partial class Composition: IDisposable
{
  private readonly Composition _root;
  private readonly object _lock;
  readonly IDisposable[] _disposables;
  private int _disposeIndex;
  private Dependency _scopedDependency;

  public Composition()
  {
    _root = this;
    _lock = new object();
    _disposables = new IDisposable[1];
  }

  internal Composition(Composition baseComposition)
  {
    _root = baseComposition._root;
    _lock = _root._lock;
    _disposables = new IDisposable[1];
  }

  /// 
  /// Program composition root
  /// 
  public Program ProgramRoot
  {
    get
    {
      var sessionFactory = new Func(() => new Session(this));
      return new Program(sessionFactory);
    }
  }

  /// 
  /// Session composition root
  /// 
  public IService SessionRoot
  {
    get
    {
      if (ReferenceEquals(_scopedDependency, null))
      {
        lock (_lock)
        {
          if (ReferenceEquals(_scopedDependency, null))
          {
            _scopedDependency = new Dependency();
            _disposables[_disposeIndex++] = _scopedDependency;
          }
        }        
      }
      
      return new Service(_scopedDependency);
    }
  }

  public void Dispose()
  {
    lock (_lock)
    {
      while (_disposeIndex > 0)
      {
        var disposableInstance = _disposables[--_disposeIndex];
        try
        {
          disposableInstance.Dispose();
        }
        catch(Exception exception)
        {
          OnDisposeException(disposableInstance, exception);
        }
      }
      
      _scopedDependency = null;
    }
  }
}

Для создания новой сессии используется специальный сгенерированный конструктор композиции в строке кода #16. Утилизация выполняется вызовом метода Dispose () для сессии. Обратите внимание, что сейчас в случае проблем с утилизацией вызывается частичный метод OnDisposeException. Реализовав его тело в частичном классе композиции, можно определить реакцию на исключения, возникающие при утилизации объектов.

Аргументы корня композиции

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

interface IDependency
{
  int Id { get; }
}

class Dependency(int id) : IDependency
{
  public int Id { get; } = id;
}

interface IService
{
  string Name { get; }

  IDependency Dependency { get; }
}

class Service(
  [Tag("name")] string name,
  IDependency dependency) : IService
{
  public string Name { get; } = name;

  public IDependency Dependency { get; } = dependency;
}

DI.Setup(nameof(Composition))
  .Bind().To()
  .Bind().To()
  .Root("Root")
  // Some kind of identifier
  .Arg("id")
  // An argument can be tagged (e.g., tag "name")
  // to be injectable by type and this tag
  .Arg("serviceName", "name");

var composition = new Composition(serviceName: "Abc", id: 123);
        
// service = new Service("Abc", new Dependency(123));
var service = composition.Root;
        
service.Name.ShouldBe("Abc");
service.Dependency.Id.ShouldBe(123);

Аргументы конструктора определяются вызовами метода Arg(argName) как в строке кода #32. По одному вызову для каждого аргумента. В строке кода #37 при создании объекта композиции его конструктор принимает ранее объявленные аргументы serviceName и id. Их значения могут быть внедрены любым объектам в любой композиции объектов. Они хранятся в приватных неизменяемых полях класса:

partial class Composition
{
  private readonly int _id;
  private readonly string _serviceName;

  public Composition(int id, string serviceName)
  {
    _id = id;
    _serviceName = serviceName;
  }

  public IService Root
  {
    get
    {
      return new Service(_serviceName, new Dependency(_id));
    }
  }
}

У некоторых пользователей Pure.DI возникла необходимость определять набор аргументов отдельно для каждого корня композиции — аргументы корня композиции. Для этого в новой версии генератора был добавлен метод RootArg(string argName), например:

interface IDependency
{
  int Id { get; }
  
  public string DependencyName { get; }
}

class Dependency(int id, string dependencyName) : IDependency
{
  public int Id { get; } = id;

  public string DependencyName { get; } = dependencyName;
}

interface IService
{
  string Name { get; }
  
  IDependency Dependency { get; }
}

class Service(
  [Tag("forService")] string name,
  IDependency dependency)
  : IService
{
  public string Name { get; } = name;

  public IDependency Dependency { get; } = dependency;
}

 DI.Setup(nameof(Composition))
  .Bind().To()
  .Bind().To().Root("CreateService")
  // Some argument
  .RootArg("id")
  .RootArg("dependencyName")
  // An argument can be tagged (e.g., tag "forService")
  // to be injectable by type and this tag
  .RootArg("serviceName", "forService");

var composition = new Composition();

var service = composition.CreateService(
  serviceName: "Abc",
  id: 123,
  dependencyName: "dependency 123");

service.Name.ShouldBe("Abc");
service.Dependency.Id.ShouldBe(123);
service.Dependency.DependencyName.ShouldBe("dependency 123");

Обратите внимание, что параметры serviceName и dependencyName имеют одинаковый тип string. Поскольку оба они одинакового типа, для точного определения куда будет внедрен каждый, используются тэги. В реальных задачах это частый случай. В этом примере тег имеет значение «forService» как в строках #40 и #23. Теги позволяют точно определить композицию объектов. Генератор создает примерно такой код:

partial class Composition
{
  public IService CreateService(
    int id,
    string dependencyName,
    string serviceName)
  {
    return new Service(
      serviceName,
      new Dependency(id, dependencyName));
  }
}

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

Новые BCL типы из коробки

В дополнение к таким типам как ICollection, IList, IReadOnlyCollection, IReadOnlyList, ISet, Queue, ImmutableArray, Lazy, и многим другим, добавилась поддержка других типов BCL. Теперь для внедрения без дополнительных усилий готовы:

Набор типов BCL, готовых для внедрения, зависит от версии .NET проекта. Например, тип ValueTask готов для проектов .NET Core 1+, NET 5+, и для .NET Standard 2.1+. Текущий набор подготовленных для внедрения BCL типов и .NET версий определен в этом файле. Без дополнительных усилий можно внедрять множество или даже большинство других НЕ абстрактных типов, как в этом примере. Если же требуется вручную настроить внедрение для типа: изменить время жизни с Transient на другое или использовать особенный конструктор, или выполнить предварительную инициализацию, то можно следовать примеру ниже для типа Lazy:

.Bind>()
  .To(ctx =>
  {
      ctx.Inject>(ctx.Tag, out var factory);
      return new Lazy(factory, true);
  })

Здесь предписывается использовать специальный конструктор, чтобы определить аргумент isThreadSafe значением true для безопасного одновременного использования несколькими потоками. Теперь каждый раз при создании объекта типа Lazy будет использован специальный конструктор.

Комментарии

В новой версии Pure.DI генерируемый код теперь поддерживает комментарии. Для примера:

class Dependency;

class Service(Dependency dependency);

// Specifies to create a partial class "Composition"
DI.Setup("Composition")
  // Specifies to create a property "MyService"
  .Root("MyService");
        
var composition = new Composition();
var service = composition.MyService;

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

/// 
/// 
/// Specifies to create a partial class "Composition"
/// 
/// 
/// Composition roots:
/// /// /// Root /// Description /// /// /// /// MyService /// /// /// Specifies to create a property "MyService" /// /// /// ///
/// /// This shows how to get an instance of type using the composition root : /// /// var composition = new Composition(); /// var instance = composition.MyService; /// /// /// Class diagram
/// This class was created by Pure.DI source code generator. ///
/// partial class Composition { /// /// Specifies to create a property "MyService" /// public Service MyService { get { return new Service(new Dependency()); } } }

В IDE Rider эти комментарии отображаются так:

Комментарий к корню композиции MyService

Комментарий к корню композиции MyService

Комментарий к классу Composition

Комментарий к классу Composition

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

Пример диаграммы классов

Пример диаграммы классов

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

Примеры

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

  • Console

  • UI

  • Web

  • Git repo with examples

Пример AvaloniaПример Blazor WebAssembly

Спасибо что дочитали до конца))

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

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

© Habrahabr.ru