Поделиться через


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

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

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

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

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

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);
        }
    }
}

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

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

Концепция

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

  • Используется интерфейс или базовый класс для абстрагирования реализации зависимостей.

  • Регистрация зависимостей в контейнере службы.

    .NET предоставляет встроенный контейнер служб IServiceProvider. Службы обычно регистрируются при запуске и добавляются в IServiceCollectionприложение. После добавления всех служб используйте BuildServiceProvider для создания контейнера службы.

  • Внедрение сервиса в конструктор класса, где оно используется.

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

Подсказка

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

Например, предположим, что интерфейс IMessageWriter определяет метод Write. Этот интерфейс реализуется конкретным типом, MessageWriterпоказанным ранее. Следующий пример кода регистрирует IMessageWriter службу с конкретным типом MessageWriter. Метод AddSingleton регистрирует службу с одиночным временем существования, что означает, что она не удаляется, пока приложение не завершит работу.

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

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

using IHost host = builder.Build();

host.Run();

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

// <SnippetIMW>
public interface IMessageWriter
{
    void Write(string message);
}
// </SnippetIMW>

// <SnippetWorker>
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);
        }
    }
}

// </SnippetWorker>

В предыдущем примере кода выделенные строки:

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

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

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

Теперь представьте, что вы хотите заменить MessageWriter на тип, который использует предоставляемую платформой службу ведения журналов. Создайте класс LoggingMessageWriter, который зависит от ILogger<TCategoryName>, запрашивая его в конструкторе.

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

Чтобы переключиться с MessageWriter на LoggingMessageWriter, просто обновите вызов на AddSingleton, чтобы зарегистрировать эту новую IMessageWriter реализацию:

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

Подсказка

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

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

Службы можно вызывать с помощью IServiceProvider (встроенный контейнер служб) или ActivatorUtilities. ActivatorUtilities создает объекты, которые не зарегистрированы в контейнере и используются с некоторыми функциями платформы.

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

При IServiceProvider разрешении ActivatorUtilities служб внедрение конструктора требует общедоступного конструктора.

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

Правила выбора конструктора

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

public class ExampleService
{
    public ExampleService()
    {
    }

    public ExampleService(ILogger<ExampleService> logger)
    {
        // ...
    }

    public ExampleService(ServiceA serviceA, ServiceB serviceB)
    {
        // ...
    }
}

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

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

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

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

public class ExampleService
{
    public ExampleService()
    {
    }

    public ExampleService(ILogger<ExampleService> logger)
    {
        // ...
    }

    public ExampleService(IOptions<ExampleOptions> options)
    {
        // ...
    }
}

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

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

public class ExampleService
{
    public ExampleService()
    {
    }

    public ExampleService(
        ILogger<ExampleService> logger,
        IOptions<ExampleOptions> options)
    {
        // ...
    }
}

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

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

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

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

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

Интерфейс 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 могут использовать преимущества времени существования служб с заданной областью.

Ключи служб

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

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

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

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

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

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

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

Свойство KeyedService.AnyKey

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

var services = new ServiceCollection();

// Register a fallback cache for any key.
services.AddKeyedSingleton<ICache>(KeyedService.AnyKey, (sp, key) =>
{
    // Create a cache instance based on the key.
    return new DefaultCache(key?.ToString() ?? "unknown");
});

// Register a specific cache for the "premium" key.
services.AddKeyedSingleton<ICache>("premium", new PremiumCache());

var provider = services.BuildServiceProvider();

// Requesting with "premium" key returns PremiumCache.
var premiumCache = provider.GetKeyedService<ICache>("premium");
Console.WriteLine($"Premium key: {premiumCache}");

// Requesting with any other key uses the AnyKey fallback.
var basicCache = provider.GetKeyedService<ICache>("basic");
Console.WriteLine($"Basic key: {basicCache}");

var standardCache = provider.GetKeyedService<ICache>("standard");
Console.WriteLine($"Standard key: {standardCache}");

В предыдущем примере:

  • Запрос ICache с ключом "premium" возвращает экземпляр PremiumCache.
  • Запрос ICache с любым другим ключом (например "basic" или "standard") создает новый DefaultCache с помощью резервного AnyKey механизма.

Это важно

Начиная с .NET 10, вызов GetKeyedService() с KeyedService.AnyKey вызывает исключение InvalidOperationException, так как AnyKey предназначено в качестве резервного механизма регистрации, а не в качестве ключа запроса. Дополнительные сведения см. в разделе "Устранение проблем" в GetKeyedService() и GetKeyedServices() с AnyKey.

См. также