Вводная

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

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

Во второй (Конфигурация) я расскажу про недостатки старого способа конфигурирования приложения (через app.config/web.config) и про то, как они исправлены в новом подходе.

В третьей (Логирование) посмотрим на стандартный интерфейс логирования и прикрутим к нему привычный NLog.

Далее следует план статей по очереди их публикации. Все статьи доступны в нашем блоге.

План

  1. IoC-контейнер.
  2. Конфигурация.
  3. Логирование.

Текущая статья посвящена первой теме плана — IoC-контейнер.

DI-контейнер

Сейчас пятерка лидеров на нугете по тегу IoC — это AutofacNinject, Simple InjectorStructureMapUnity. У всех 5 есть несколько недостатков:

  1. Они несовместимы друг с другом. Задача замены контейнера, конечно, появляется очень редко, но появляется. В этом случае весь код, использующий контейнер, придется переписывать.
  2. Все, кроме Simple Injector, очень медленные, особенно Ninject. В ходе оптимизации нашего проекта «Официальный информационный портал ЕГЭ» в определённый момент на профилировке основными пожирателями производительности стали Ninject и сериализация.  Сравнить производительность можно здесь или здесь.
  3. Часто вводится много абстракций, которые не нужны конечному пользователю. Это увеличивает гибкость, но опять страдает возможность перехода с одного контейнера на другой (и с точки зрения кода, и с точки зрения знаний разработчика)

Как же с этим поможет Microsoft.Extensions.DependencyInjection?

  1. Он совместим с любым контейнером. Пакет Microsoft.Extensions.DependencyInjection.Abstractions описывает абстракции, которые применяются для разрешения зависимостей. Любой контейнер может имплементировать эти абстракции и использоваться везде одинаково. Для замены достаточно просто поменять имплементацию.
  2. Стандартная реализация Microsoft.Extensions.DependencyInjection — очень быстрая (см. сравнения производительности выше).
  3. Он простой. Всего 2 основных интерфейса — IServiceCollection для создания контейнера и IServiceProvider для резолва зависимостей + обвязка для поддержки Scope.

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

Напишем простое приложение, на котором рассмотрим аспекты работы.

Для начала создаем новое консольное .NET Core приложение

Через Nuget добавляем Microsoft.Extensions.DependencyInjection
Install-Package Microsoft.Extensions.DependencyInjection -Version 1.1.1

И пишем простую программу, на которой будем смотреть, как это работает

Для начала посмотрим, как работают Scopes

internal class Program
{
    private static IServiceProvider _provider;

    private static void Main(string[] args)
    {
        IServiceCollection services = new ServiceCollection();

        services.AddTransient<DisposeMe, DisposeMe>(); // Биндим клас на самого себя
        services.AddTransient<IDisposeMeTo, DisposeMeTo>(); // Биндим интерфейс на класс

        var provider = services.BuildServiceProvider();
        M(provider);
        using (var outerScope = provider.CreateScope())
        {
            M(outerScope.ServiceProvider);
            using (var innerScope = outerScope.ServiceProvider.CreateScope())
            {
                M(innerScope.ServiceProvider);
            }
            M(outerScope.ServiceProvider);
        }
        
        Console.ReadLine();
    }

    public static void M(IServiceProvider sp)
    {
        sp.GetService().DoIt();
    }
}

public class DisposeMe : IDisposable
{
    private static int _increment;
    private readonly IDisposeMeTo _child;
    private readonly int _number;

    public DisposeMe(IDisposeMeTo child)
    {
        _number = ++_increment;
        Console.WriteLine($"Outer {_number} created");
        _child = child;
    }

    public void Dispose()
    {
        Console.WriteLine($"Outer {_number} disposed");
    }

    public void DoIt()
    {
        Console.WriteLine($"Outer {_number} did it");
    }
}

public interface IDisposeMeTo : IDisposable
{
}

public class DisposeMeTo : IDisposeMeTo
{
    private static int _increment;
    private readonly int _number;

    public DisposeMeTo()
    {
        _number = ++_increment;
        Console.WriteLine($"Inner {_number} created");
    }

    public void Dispose()
    {
        Console.WriteLine($"Inner {_number} disposed");
    }
}

Нас сейчас интересуют следующие строчки:

services.AddTransient<DisposeMe, DisposeMe>(); // Биндим клас на самого себя
services.AddTransient<IDisposeMeTo, DisposeMeTo>(); // Биндим интерфейс на класс

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

Мы видим, что на каждый вызов создается по экземпляру каждого сервиса, и сервисы диспозятся в момент диспоза Scope. Еще мы видим, что скоупы поддерживают вложенность и диспозятся раздельно. Стоит отметить, что outerScope.Dispose() не вызовет innerScope.Dispose().

Если мы укажем

services.AddScoped<DisposeMe, DisposeMe>(); // Биндим клас на самого себя
services.AddScoped<IDisposeMeTo, DisposeMeTo>(); // Биндим интерфейс на класс

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

Скоупы используются, например, в .NET Core-приложениях, где создаются на запрос.

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

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

public static class MyLogicExtensions
{
    public static IServiceCollection AddLogicServices(this IServiceCollection services)
    {
        services.AddSingleton<DisposeMe, DisposeMe>();
        services.AddSingleton<IDisposeMeTo, DisposeMeTo>();
        return services;
    }
}

По ощущениям это менее удобно, чем модули Ninject, но похоже.

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

internal class Program
{
    private static void Main(string[] args)
    {
        IServiceCollection services = new ServiceCollection();

        services.Add(new ServiceDescriptor(typeof(IService), p => new ServiceOne(), ServiceLifetime.Singleton));
        services.Add(new ServiceDescriptor(typeof(IService), p => new ServiceTwo(), ServiceLifetime.Singleton));

        var provider = services.BuildServiceProvider();

        var oneService = provider.GetService();
        var collectionServices = provider.GetService<IEnumerable<IService>>().ToList();

        oneService.DoIt();

        foreach (var s in collectionServices)
        {
            s.DoIt();
        }

        Console.ReadLine();
    }
}

public interface IService
{
    void DoIt();
}


internal class ServiceOne : IService
{
    public void DoIt()
    {
        Console.WriteLine("Service one");
    }
}

internal class ServiceTwo : IService
{
    public void DoIt()
    {
        Console.WriteLine("Service two");
    }
}

Выведет

В этом примере можно увидеть еще и сырой биндинг в фабрику. Т.к. IServiceCollection — это просто IList<ServiceDescriptor>, и даже BuildServiceProvider — метод-расширение, то мы просто добавляем экземпляр в коллекцию.

Также доступен биндинг дженериков:

internal class Program
{
    private static void Main(string[] args)
    {
        IServiceCollection services = new ServiceCollection();

        services.AddSingleton(typeof(IGenericService<>), typeof(GenericService<>));

        var provider = services.BuildServiceProvider();
        Console.WriteLine($"string: {provider.GetService<IGenericService<string>>().Create()}");
        Console.WriteLine($"int: {provider.GetService<IGenericService<int>>().Create()}");
        Console.WriteLine($"DateTime: {provider.GetService<IGenericService<DateTime>>().Create()}");
        Console.ReadLine();
    }
}

public interface IGenericService<T>
{
    T Create();
}

public class GenericService<T> : IGenericService<T>
{
    public T Create()
    {
        return default(T);
    }
}

Стоит отметить, что у метода-расширения BuildServiceProvider присутствует параметр validateScopes. Если установить этот параметр в true, то попытка достать сервисы, добавленные в ServiceLifetime.Scoped, в корневом ServiceProvider приведет к исключению:

IServiceCollection services = new ServiceCollection();
services.AddScoped(p => "123");
var provider = services.BuildServiceProvider(true);
var s = provider.GetService(); //здесь InvalidOperationException

В завершение напишем простую реализацию биндинга по атрибутам.
Сначала сделаем базовый класс атрибута-биндера

public abstract class BindAttribute : Attribute
{
    protected ServiceLifetime Lifetime { get; }

    protected BindAttribute(ServiceLifetime lifetime)
    {
        Lifetime = lifetime;
    }

    public abstract IServiceCollection Bind(IServiceCollection serviceCollection, Type definedOn);
}

Сделаем метод-расширение для биндинга всех размеченных типов в сборке

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddAssembly(this IServiceCollection serviceCollection, Type assemblyType)
    {
        if (serviceCollection == null) throw new ArgumentNullException(nameof(serviceCollection));
        if (assemblyType == null) throw new ArgumentNullException(nameof(assemblyType));
        var defines = assemblyType
                .GetTypeInfo()
                .Assembly
                .DefinedTypes
                .Select(x => new
                {
                    Attribute = x.GetCustomAttribute<BindAttribute>(),
                    Type = x.AsType()
                })
                .Where(x => x.Attribute != null);
        foreach (var define in defines)
        {
            define.Attribute.Bind(serviceCollection, define.Type);
        }

        return serviceCollection;
    }
}

Потом напишем реализации для биндинга из размеченного типа на переданный

[AttributeUsage(AttributeTargets.Interface | AttributeTargets.Class)]
public class BindToAttribute : BindAttribute
{
    private readonly Type _type;

    public BindToAttribute(ServiceLifetime lifetime, Type type) : base(lifetime)
    {
        _type = type ?? throw new ArgumentNullException(nameof(type));
    }

    public override IServiceCollection Bind(IServiceCollection serviceCollection, Type definedOn)
    {
        serviceCollection.Add(new ServiceDescriptor(definedOn, _type, Lifetime));
        return serviceCollection;
    }
}

на размеченный из переданных

[AttributeUsage(AttributeTargets.Interface | AttributeTargets.Class)]
public class BindFromAttribute : BindAttribute
{
    public BindFromAttribute(ServiceLifetime lifetime, params Type[] types): base(lifetime)
    {
        _types = types == null || !types.Any()
            ? throw new ArgumentNullException(nameof(types))
            : types;
    }

    private readonly IReadOnlyCollection _types;

    public override IServiceCollection Bind(IServiceCollection serviceCollection, Type definedOn)
    {
        serviceCollection.Add(new ServiceDescriptor(definedOn, definedOn, Lifetime));
        foreach (var type in _types)
        {
            serviceCollection.Add(new ServiceDescriptor(type, p => p.GetRequiredService(definedOn), Lifetime));
        }
        return serviceCollection;
    }
}

Тут замечу, что этот метод не идеален, т.к. приходится сначала биндить тип сам на себя, а потом отдавать именно его. Это требуется, чтобы не плодить несколько объектов одного класса, если они не нужны. Мне кажется, именно такого поведения мы ожидаем, если биндим несколько интерфейсов на класс

на размеченный из размеченного

[AttributeUsage(AttributeTargets.Class)]
public class SelfBindAttribute : BindAttribute
{
    public SelfBindAttribute(ServiceLifetime lifeTime) : base(lifeTime)
    {
    }

    public override IServiceCollection Bind(IServiceCollection serviceCollection, Type definedOn)
    {
        serviceCollection.Add(new ServiceDescriptor(definedOn, definedOn, Lifetime));
        return serviceCollection;
    }
}

Посмотрим как это можно использовать.

[BindTo(ServiceLifetime.Scoped, typeof(GetUserNamesQuery))]
internal interface IGetUserNamesQuery
{
    IEnumerable<string> Ask();
}

internal class GetUserNamesQuery : IGetUserNamesQuery
{
    public IEnumerable<string> Ask()
    {
        yield return "vasya";
        yield return "petya";
        yield return "kolya";
    }
}
internal interface ISetUserNameCommand
{
    void Execute(Guid id, string newName);
}
internal interface IGetUserNameQuery
{
    string Ask(Guid id);
}

[BindFrom(ServiceLifetime.Scoped, typeof(ISetUserNameCommand), typeof(IGetUserNameQuery))]
internal class SetUserNameCommandGetUserNameQuery : ISetUserNameCommand, IGetUserNameQuery
{
    private readonly IdValidator _validator;

    public SetUserNameCommandGetUserNameQuery(IdValidator validator)
    {
        _validator = validator;
    }

    public string Ask(Guid id)
    {
        _validator.EnshureIdExists(id);
        return "1";
    }

    public void Execute(Guid id, string newName)
    {
        _validator.EnshureIdExists(id);
    }

    [SelfBind(ServiceLifetime.Scoped)]
    internal class IdValidator
    {
        public void EnshureIdExists(Guid id)
        {
        }
    }
}

И попробуем вызвать это в программе

private static void Main(string[] args)
{
    IServiceCollection services = new ServiceCollection();
    services.AddAssembly(typeof(Program));
    var provider = services.BuildServiceProvider(false);

    var getNameQuery = provider.GetRequiredService();
    var setNameCommand = provider.GetRequiredService();
    var getNamesQuery = provider.GetRequiredService();
    var validator = provider.GetRequiredService();

    Console.WriteLine(ReferenceEquals(getNameQuery, setNameCommand));

    Console.ReadLine();
}

Резюмируя, хочу отметить, что инструментария Microsoft.Extensions.DependencyInjection должно хватать в большинстве приложений, а там, где требуется какой-то серьезный тюнинг, можно использовать только интерфейсы из Microsoft.Extensions.DependencyInjection.Abstractions. Важно, что .NET Core по умолчанию использует этот механизм, и, следовательно, он будет развиваться, а старым решениям придется подстроиться под него.

Со второй и третьей частями цикла можно ознакомиться по ссылкам:

  1. Конфигурация — вторая часть цикла.
  2. Логирование — третья часть цикла.