Внедрение зависимостей .NET

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

Зависимость — это любой объект, от которого зависит другой объект. Рассмотрим следующий класс MessageWriter с методом Write, от которого зависят другие классы:

public class MessageWriter
{
    public void Write(string message)
    {
        Console.WriteLine($"MessageWriter.Write(message: \"{message}\")");
    }
}

Класс может создать экземпляр класса MessageWriter, чтобы использовать его метод Write. В следующем примере класс MessageWriter выступает зависимостью класса Worker:

public class Worker : BackgroundService
{
    private readonly MessageWriter _messageWriter = new();

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            _messageWriter.Write($"Worker running at: {DateTimeOffset.Now}");
            await Task.Delay(1_000, stoppingToken);
        }
    }
}

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

  • Чтобы заменить MessageWriter другой реализацией, класс Worker необходимо изменить.
  • Если у MessageWriter есть зависимости, их конфигурацию должен выполнять класс Worker. В больших проектах, когда от MessageWriter зависят многие классы, код конфигурации растягивается по всему приложению.
  • Такая реализация плохо подходит для модульных тестов. В приложении нужно использовать имитацию или заглушку в виде класса MessageWriter, что при таком подходе невозможно.

Внедрение зависимостей устраняет эти проблемы следующим образом:

  • Используется интерфейс или базовый класс для абстрагирования реализации зависимостей.
  • Зависимость регистрируется в контейнере служб. .NET предоставляет встроенный контейнер служб IServiceProvider. Службы обычно регистрируются при запуске и добавляются в IServiceCollectionприложение. После добавления всех служб выполните BuildServiceProvider для создания контейнера службы.
  • Служба внедряется в конструктор класса там, где он используется. Платформа берет на себя создание экземпляра зависимости и его удаление, когда он больше не нужен.

В качестве примера рассмотрим интерфейс IMessageWriter, который определяет метод Write:

namespace DependencyInjection.Example;

public interface IMessageWriter
{
    void Write(string message);
}

Этот интерфейс реализуется конкретным типом, MessageWriter.

namespace DependencyInjection.Example;

public class MessageWriter : IMessageWriter
{
    public void Write(string message)
    {
        Console.WriteLine($"MessageWriter.Write(message: \"{message}\")");
    }
}

Этот пример кода регистрирует службу IMessageWriter с конкретным типом MessageWriter. Метод AddSingleton регистрирует службу с одним временем существования, временем существования приложения. Подробнее о времени существования служб мы поговорим далее в этой статье.

using DependencyInjection.Example;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

builder.Services.AddHostedService<Worker>();
builder.Services.AddSingleton<IMessageWriter, MessageWriter>();

using IHost host = builder.Build();

host.Run();

В приведенном выше коде пример приложения:

  • Создает экземпляр построителя ведущих приложений.

  • Настраивает службы путем регистрации:

    • Размещенная Worker служба. Дополнительные сведения см. в разделе "Рабочие службы" в .NET.
    • Интерфейс IMessageWriter как одноэлементная служба с соответствующей реализацией MessageWriter класса.
  • Создает узел и запускает его.

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

namespace DependencyInjection.Example;

public sealed class Worker(IMessageWriter messageWriter) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            messageWriter.Write($"Worker running at: {DateTimeOffset.Now}");
            await Task.Delay(1_000, stoppingToken);
        }
    }
}

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

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

Реализацию интерфейса IMessageWriter можно улучшить с помощью встроенного API ведения журнала:

namespace DependencyInjection.Example;

public class LoggingMessageWriter(
    ILogger<LoggingMessageWriter> logger) : IMessageWriter
{
    public void Write(string message) =>
        logger.LogInformation("Info: {Msg}", message);
}

Обновленный метод AddSingleton регистрирует новую реализацию IMessageWriter:

builder.Services.AddSingleton<IMessageWriter, LoggingMessageWriter>();

Тип HostApplicationBuilder (builder) является частью Microsoft.Extensions.Hosting пакета NuGet.

LoggingMessageWriter зависит от ILogger<TCategoryName>, который запрашивается в конструкторе. ILogger<TCategoryName> — это предоставленная платформой служба.

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

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

В терминологии внедрения зависимостей — служба:

  • Обычно является объектом, предоставляющим службу для других объектов, например службу IMessageWriter.
  • Не относится к веб-службе, хотя служба может использовать веб-службу.

Платформа предоставляет эффективную систему ведения журнала. Реализации IMessageWriter, приведенные в предыдущем примере были написаны для демонстрации базового внедрения зависимостей, а не для реализации ведения журнала. Большинству приложений не нужно писать средства ведения журнала. Следующий код демонстрирует использование ведения журнала по умолчанию, которое требует Worker регистрации только в качестве размещенной службы AddHostedService:

public class Worker : BackgroundService
{
    private readonly ILogger<Worker> _logger;

    public Worker(ILogger<Worker> logger) =>
        _logger = logger;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            _logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
            await Task.Delay(1_000, stoppingToken);
        }
    }
}

Используя предыдущий код, не требуется обновлять Program.cs, так как ведение журнала предоставляется платформой.

Несколько правил обнаружения конструктора

Если тип определяет более одного конструктора, поставщик служб включает логику для определения используемого конструктора. Выбирается тот конструктор, который имеет больше всего параметров, в которых типы могут разрешаться с внедрением зависимостей. Рассмотрим следующий пример службы на C#:

public class ExampleService
{
    public ExampleService()
    {
    }

    public ExampleService(ILogger<ExampleService> logger)
    {
        // omitted for brevity
    }

    public ExampleService(FooService fooService, BarService barService)
    {
        // omitted for brevity
    }
}

В приведенном выше коде предположим, что ведение журнала было добавлено и может быть разрешено от поставщика служб, но типы FooService и BarService не разрешаются. Конструктор с параметром ILogger<ExampleService> используется для разрешения экземпляра ExampleService. Хотя доступен конструктор, который определяет больше параметров, типы FooService и BarService не могут быть разрешены с внедрением зависимостей.

Если при обнаружении конструкторов возникает неоднозначность, создается исключение. Рассмотрим следующий пример службы на C#:

public class ExampleService
{
    public ExampleService()
    {
    }

    public ExampleService(ILogger<ExampleService> logger)
    {
        // omitted for brevity
    }

    public ExampleService(IOptions<ExampleOptions> options)
    {
        // omitted for brevity
    }
}

Предупреждение

Код ExampleService с неоднозначными параметрами типов, которые могут разрешаться с внедрением зависимостей, выдаст исключение. Не делайте этого— оно предназначено для отображения того, что означает "неоднозначные типы, разрешаемые DI".

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

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

public class ExampleService
{
    public ExampleService()
    {
    }

    public ExampleService(
        ILogger<ExampleService> logger,
        IOptions<ExampleOptions> options)
    {
        // omitted for brevity
    }
}

Регистрация групп служб с помощью методов расширения

Расширения Microsoft используют конвенцию для регистрации группы связанных служб. Соглашение заключается в использовании одного метода расширения Add{GROUP_NAME} для регистрации всех служб, необходимых компоненту платформы. Например, метод расширения AddOptions регистрирует все службы, необходимые для работы с параметрами.

Платформенные службы

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

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

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

Тип службы Время существования
Microsoft.Extensions.DependencyInjection.IServiceScopeFactory Отдельная
IHostApplicationLifetime Отдельная
Microsoft.Extensions.Logging.ILogger<TCategoryName> Отдельная
Microsoft.Extensions.Logging.ILoggerFactory Отдельная
Microsoft.Extensions.ObjectPool.ObjectPoolProvider Отдельная
Microsoft.Extensions.Options.IConfigureOptions<TOptions> Временный
Microsoft.Extensions.Options.IOptions<TOptions> Отдельная
System.Diagnostics.DiagnosticListener Отдельная
System.Diagnostics.DiagnosticSource Отдельная

Время существования служб

Службы можно зарегистрировать с одним из следующих вариантов времени существования:

Они описываются в следующих разделах. Для каждой зарегистрированной службы выбирайте подходящее время существования.

Временный

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

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

Ограниченные

Для веб-приложений время существования, привязанное к области, означает, что службы создаются один раз для каждого запроса (подключения) клиента. Регистрируйте службы с заданной областью с помощью AddScoped.

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

При использовании Entity Framework Core метод расширения AddDbContext по умолчанию регистрирует типы DbContext с заданной областью времени существования.

Примечание.

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

  • Разрешение одноэлементной службы из службы с заданной областью или временной службы.
  • Разрешение службы с заданной областью из другой службы с заданной областью или временной службы.

По умолчанию в среде разработки разрешение службы из другой службы с более длинным временем существования вызывает исключение. Дополнительные сведения см. в разделе Проверка области.

Отдельная

Одноэлементные службы времени существования создаются в следующих случаях.

  • При первом запросе.
  • Разработчиком при предоставлении экземпляра реализации непосредственно в контейнер. Этот подход требуется достаточно редко.

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

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

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

Методы регистрации службы

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

Способ Автоматически
объект
удаление
Несколько
реализации
Передача аргументов
Add{LIFETIME}<{SERVICE}, {IMPLEMENTATION}>()

Пример:

services.AddSingleton<IMyDep, MyDep>();
Да Да Нет
Add{LIFETIME}<{SERVICE}>(sp => new {IMPLEMENTATION})

Примеры:

services.AddSingleton<IMyDep>(sp => new MyDep());
services.AddSingleton<IMyDep>(sp => new MyDep(99));
Да Да Да
Add{LIFETIME}<{IMPLEMENTATION}>()

Пример:

services.AddSingleton<MyDep>();
Да No No
AddSingleton<{SERVICE}>(new {IMPLEMENTATION})

Примеры:

services.AddSingleton<IMyDep>(new MyDep());
services.AddSingleton<IMyDep>(new MyDep(99));
No Да Да
AddSingleton(new {IMPLEMENTATION})

Примеры:

services.AddSingleton(new MyDep());
services.AddSingleton(new MyDep(99));
No No Да

Дополнительные сведения об удалении типа см. в разделе Удаление служб.

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

Любой из указанных выше методов регистрации службы можно использовать для регистрации нескольких экземпляров службы одного типа службы. В следующем примере метод AddSingleton вызывается дважды с типом службы IMessageWriter. Второй вызов AddSingleton переопределяет предыдущий, если он разрешается как IMessageWriter, и добавляет к предыдущему, если несколько служб разрешаются через IEnumerable<IMessageWriter>. Службы отображаются в том порядке, в котором они были зарегистрированы при разрешении через IEnumerable<{SERVICE}>.

using ConsoleDI.IEnumerableExample;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

builder.Services.AddSingleton<IMessageWriter, ConsoleMessageWriter>();
builder.Services.AddSingleton<IMessageWriter, LoggingMessageWriter>();
builder.Services.AddSingleton<ExampleService>();

using IHost host = builder.Build();

_ = host.Services.GetService<ExampleService>();

await host.RunAsync();

Предыдущий пример исходного кода регистрирует две реализации IMessageWriter.

using System.Diagnostics;

namespace ConsoleDI.IEnumerableExample;

public sealed class ExampleService
{
    public ExampleService(
        IMessageWriter messageWriter,
        IEnumerable<IMessageWriter> messageWriters)
    {
        Trace.Assert(messageWriter is LoggingMessageWriter);

        var dependencyArray = messageWriters.ToArray();
        Trace.Assert(dependencyArray[0] is ConsoleMessageWriter);
        Trace.Assert(dependencyArray[1] is LoggingMessageWriter);
    }
}

Компонент ExampleService определяет два параметра конструктора: одиночный IMessageWriter и IEnumerable<IMessageWriter>. Одиночный IMessageWriter здесь является последней зарегистрированной реализацией, а IEnumerable<IMessageWriter> представляет все зарегистрированные реализации.

Платформа также предоставляет методы расширения TryAdd{LIFETIME}, которые регистрируют службу только в том случае, если реализация еще не зарегистрирована.

В следующем примере вызов AddSingleton регистрирует ConsoleMessageWriter как реализацию для IMessageWriter. Вызов TryAddSingleton ничего не делает, поскольку у IMessageWriter уже есть зарегистрированная реализация:

services.AddSingleton<IMessageWriter, ConsoleMessageWriter>();
services.TryAddSingleton<IMessageWriter, LoggingMessageWriter>();

Параметр TryAddSingleton не применяется, так как он уже был добавлен, поэтому выполнение "try" завершится ошибкой. В ExampleService будут следующие утверждения:

public class ExampleService
{
    public ExampleService(
        IMessageWriter messageWriter,
        IEnumerable<IMessageWriter> messageWriters)
    {
        Trace.Assert(messageWriter is ConsoleMessageWriter);
        Trace.Assert(messageWriters.Single() is ConsoleMessageWriter);
    }
}

Дополнительные сведения см. в разделе:

Методы TryAddEnumerable(ServiceDescriptor) регистрируют службу только в том случае, если еще не существует реализации того же типа. Несколько служб разрешается через IEnumerable<{SERVICE}>. При регистрации служб добавляйте экземпляр в том случае, если экземпляр такого типа еще не был добавлен. Авторы библиотек используют TryAddEnumerable, чтобы избежать регистрации нескольких копий реализации в контейнере.

В следующем примере первый вызов TryAddEnumerable регистрирует MessageWriter как реализацию для IMessageWriter1. Второй вызов регистрирует MessageWriter для IMessageWriter2. Третий вызов ничего не делает, поскольку у IMessageWriter1 уже есть зарегистрированная реализация MessageWriter:

public interface IMessageWriter1 { }
public interface IMessageWriter2 { }

public class MessageWriter : IMessageWriter1, IMessageWriter2
{
}

services.TryAddEnumerable(ServiceDescriptor.Singleton<IMessageWriter1, MessageWriter>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IMessageWriter2, MessageWriter>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IMessageWriter1, MessageWriter>());

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

IServiceCollection является коллекцией объектов ServiceDescriptor. В следующем примере показано, как зарегистрировать службу, создав и добавив ServiceDescriptor:

string secretKey = Configuration["SecretKey"];
var descriptor = new ServiceDescriptor(
    typeof(IMessageWriter),
    _ => new DefaultMessageWriter(secretKey),
    ServiceLifetime.Transient);

services.Add(descriptor);

Встроенные методы Add{LIFETIME} используют аналогичный подход. Например, см. исходный код для AddScoped.

Поведение внедрения через конструктор

Службы можно разрешать с помощью:

  • IServiceProvider
  • ActivatorUtilities:
    • Создает объекты, которые не зарегистрированы в контейнере.
    • Используется в сочетании с некоторыми возможностями платформы.

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

Когда разрешение служб выполняется через IServiceProvider или ActivatorUtilities, для внедрения через конструктор требуется открытый конструктор.

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

Проверка области

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

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

Корневой поставщик службы создается при вызове BuildServiceProvider. Время существования корневого поставщика службы соответствует времени существования приложения — поставщик запускается с приложением и удаляется, когда приложение завершает работу.

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

Сценарии применения области

Интерфейс IServiceScopeFactory всегда регистрируется как отдельный (singleton), но IServiceProvider зависит от времени существования содержащего класса. Например, если при разрешении служб из области какая-то из служб принимает интерфейс IServiceProvider, это будет экземпляр с заданной областью.

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

namespace WorkerScope.Example;

public sealed class Worker(
    ILogger<Worker> logger,
    IServiceScopeFactory serviceScopeFactory)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            using (IServiceScope scope = serviceScopeFactory.CreateScope())
            {
                try
                {
                    logger.LogInformation(
                        "Starting scoped work, provider hash: {hash}.",
                        scope.ServiceProvider.GetHashCode());

                    var store = scope.ServiceProvider.GetRequiredService<IObjectStore>();
                    var next = await store.GetNextAsync();
                    logger.LogInformation("{next}", next);

                    var processor = scope.ServiceProvider.GetRequiredService<IObjectProcessor>();
                    await processor.ProcessAsync(next);
                    logger.LogInformation("Processing {name}.", next.Name);

                    var relay = scope.ServiceProvider.GetRequiredService<IObjectRelay>();
                    await relay.RelayAsync(next);
                    logger.LogInformation("Processed results have been relayed.");

                    var marked = await store.MarkAsync(next);
                    logger.LogInformation("Marked as processed: {next}", marked);
                }
                finally
                {
                    logger.LogInformation(
                        "Finished scoped work, provider hash: {hash}.{nl}",
                        scope.ServiceProvider.GetHashCode(), Environment.NewLine);
                }
            }
        }
    }
}

В приведенном выше коде во время выполнения приложения фоновая служба:

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

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

Ключи служб

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

Например, рассмотрим ситуацию, когда у вас есть различные реализации интерфейса IMessageWriter: MemoryMessageWriter и QueueMessageWriter.

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

services.AddKeyedSingleton<IMessageWriter, MemoryMessageWriter>("memory");
services.AddKeyedSingleton<IMessageWriter, QueueMessageWriter>("queue");

Он key не ограничен string, он может быть любым, что object вы хотите, если тип правильно реализует Equals.

В конструкторе класса, который используется IMessageWriter, добавьте FromKeyedServicesAttribute ключ службы для разрешения:

public class ExampleService
{
    public ExampleService(
        [FromKeyedServices("queue")] IMessageWriter writer)
    {
        // Omitted for brevity...
    }
}

См. также