Рекомендации по внедрению зависимостей

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

Проектирование служб для внедрения зависимостей

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

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

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

Удаление служб

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

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

namespace ConsoleDisposable.Example;

public sealed class TransientDisposable : IDisposable
{
    public void Dispose() => Console.WriteLine($"{nameof(TransientDisposable)}.Dispose()");
}

Предыдущий удаляемый объект должен иметь временное существование.

namespace ConsoleDisposable.Example;

public sealed class ScopedDisposable : IDisposable
{
    public void Dispose() => Console.WriteLine($"{nameof(ScopedDisposable)}.Dispose()");
}

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

namespace ConsoleDisposable.Example;

public sealed class SingletonDisposable : IDisposable
{
    public void Dispose() => Console.WriteLine($"{nameof(SingletonDisposable)}.Dispose()");
}

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

using ConsoleDisposable.Example;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

using IHost host = CreateHostBuilder(args).Build();

ExemplifyDisposableScoping(host.Services, "Scope 1");
Console.WriteLine();

ExemplifyDisposableScoping(host.Services, "Scope 2");
Console.WriteLine();

await host.RunAsync();

static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
    .ConfigureServices((_, services) =>
        services.AddTransient<TransientDisposable>()
    .AddScoped<ScopedDisposable>()
    .AddSingleton<SingletonDisposable>());

static void ExemplifyDisposableScoping(IServiceProvider services, string scope)
{
    Console.WriteLine($"{scope}...");

    using IServiceScope serviceScope = services.CreateScope();
    IServiceProvider provider = serviceScope.ServiceProvider;

    _ = provider.GetRequiredService<TransientDisposable>();
    _ = provider.GetRequiredService<ScopedDisposable>();
    _ = provider.GetRequiredService<SingletonDisposable>();
}

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

Scope 1...
ScopedDisposable.Dispose()
TransientDisposable.Dispose()

Scope 2...
ScopedDisposable.Dispose()
TransientDisposable.Dispose()

info: Microsoft.Hosting.Lifetime[0]
      Application started.Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
     Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
     Content root path: .\configuration\console-di-disposable\bin\Debug\net5.0
info: Microsoft.Hosting.Lifetime[0]
     Application is shutting down...
SingletonDisposable.Dispose()

Службы, не созданные контейнером службы

Рассмотрим следующий код.

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton(new ExampleService());

    // ...
}

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

  • Экземпляр ExampleServiceне создается контейнером службы.
  • Платформа не удаляет службы автоматически.
  • За удаление служб отвечает разработчик.

Руководство по применению временных и общих экземпляров IDisposable

Временный экземпляр, ограниченное время существования

Сценарий

Приложению требуется экземпляр IDisposable с ограниченным временем существования для реализации любого из следующих сценариев:

  • Экземпляр разрешается в корневой области (в корневом контейнере).
  • Экземпляр должен быть удален до завершения области.

Решение

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

  • Получить IServiceProvider в своем конструкторе.
  • Используйте ActivatorUtilities.CreateInstance, чтобы создать экземпляр за пределами контейнера, используя контейнер для его зависимостей.

Общий экземпляр, ограниченное время существования

Сценарий

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

Решение

Зарегистрируйте экземпляр с временем существования с заданной областью. Используйте IServiceScopeFactory.CreateScope для создания нового IServiceScope. Используйте IServiceProvider области для получения необходимых служб. Удалите область, если она больше не нужна.

Общие рекомендации для IDisposable

  • Не регистрируйте экземпляры IDisposable с временным временем существования. Вместо этого используйте шаблон фабрики.
  • Не разрешайте экземпляры IDisposable с временным временем существования или временем существования с заданной областью в корневую область. Единственное исключение — это когда приложение создает или повторно создает и удаляет IServiceProvider, но это не является идеальным вариантом.
  • Для получения зависимости IDisposable через DI не требуется, чтобы получатель реализовывал сам интерфейс IDisposable. Получатель зависимости IDisposable не должен вызывать Dispose для этой зависимости.
  • Области должны использоваться для управления временем существования служб. Области не являются иерархическими, и между ними нет специальной связи.

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

Замена стандартного контейнера служб

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

  • Внедрение свойств
  • Внедрение по имени
  • Дочерние контейнеры
  • Настраиваемое управление временем существования
  • Func<T> поддерживает отложенную инициализацию
  • Регистрация на основе соглашения

С приложениями ASP.NET Core можно использовать следующие сторонние контейнеры:

Потокобезопасность

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

Фабричный метод одноэлементной службы, например второй аргумент addSingleton<TService>(IServiceCollection, Func<IServiceProvider,TService>), не должен быть потокобезопасным. Как и конструктор типов (static), он гарантированно будет вызываться только один раз одним потоком.

Рекомендации

  • Разрешение служб на основе async/await и Task не поддерживается. Так как C# не поддерживает асинхронные конструкторы, следует использовать асинхронные методы после асинхронного разрешения службы.
  • Не храните данные и конфигурацию непосредственно в контейнере служб. Например, обычно не следует добавлять корзину пользователя в контейнер служб. Конфигурация должна использовать шаблон параметров. Аналогичным образом, избегайте объектов "хранения данных", которые служат лишь для доступа к другому объекту. Лучше запросить фактический элемент через внедрение зависимостей.
  • Избегайте статического доступа к службам. Например, не используйте везде IApplicationBuilder.ApplicationServices в качестве статического поля или свойства.
  • Обеспечьте высокую скорость и синхронизацию фабрик DI.
  • Старайтесь не использовать шаблон обнаружения служб. Например, не вызывайте GetService для получения экземпляра службы, когда можно использовать внедрение зависимостей.
  • Другой вариант указателя службы, позволяющий избежать этого, — внедрение фабрики, которая разрешает зависимости во время выполнения. Оба метода смешивают стратегии инверсии управления.
  • Избегайте вызовов BuildServiceProvider в ConfigureServices. Вызов BuildServiceProvider обычно происходит, когда разработчику необходимо разрешить службу в ConfigureServices.
  • Контейнер собирает удаляемые временные службы для удаления. Это может привести к утечке памяти, если разрешение выполняется в контейнере верхнего уровня.
  • Включите проверку области, чтобы убедиться, что в приложении нет отдельных объектов, записывающих службы с заданной областью. Дополнительные сведения см. в разделе Проверка области.

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

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

Примеры антишаблонов

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

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

Это примеры антишаблонов. Не копируйте этот код и ни в коем случае не используйте такие действия.

Контейнер собирает удаляемые временные службы

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

static void TransientDisposablesWithoutDispose()
{
    var services = new ServiceCollection();
    services.AddTransient<ExampleDisposable>();
    ServiceProvider serviceProvider = services.BuildServiceProvider();

    for (int i = 0; i < 1000; ++ i)
    {
        _ = serviceProvider.GetRequiredService<ExampleDisposable>();
    }

    // serviceProvider.Dispose();
}

В предыдущем антишаблоне создаются и размещаются на корневом уровне экземпляры 1000 объектов ExampleDisposable. Они не будут удалены, пока существует экземпляр serviceProvider.

Дополнительные сведения об отладке утечек памяти см. в статье Отладка утечки памяти в .NET Core.

Фабрики асинхронного внедрения могут вызвать взаимоблокировки

Термин "фабрики асинхронного внедрения" обозначает методы перегрузки, которые существуют при вызове Add{LIFETIME}. Некоторые перегрузки принимают Func<IServiceProvider, T>, где T обозначает регистрируемую службу, а параметр имеет имя implementationFactory. implementationFactory можно предоставить как лямбда-выражение, локальную функцию или метод. Если фабрика является асинхронной и используется Task<TResult>.Result, происходит взаимоблокировка.

static void DeadLockWithAsyncFactory()
{
    var services = new ServiceCollection();
    services.AddSingleton<Foo>(implementationFactory: provider =>
    {
        Bar bar = GetBarAsync(provider).Result;
        return new Foo(bar);
    });

    services.AddSingleton<Bar>();

    using ServiceProvider serviceProvider = services.BuildServiceProvider();
    _ = serviceProvider.GetRequiredService<Foo>();
}

В приведенном выше коде объект implementationFactory получает лямбда-выражение, в котором тело вызывает Task<TResult>.Result для метода возврата Task<Bar>. Это приводит к взаимоблокировкам. Метод GetBarAsync эмулирует асинхронную работу с использованием Task.Delay, а затем вызывает GetRequiredService<T>(IServiceProvider).

static async Task<Bar> GetBarAsync(IServiceProvider serviceProvider)
{
    // Emulate asynchronous work operation
    await Task.Delay(1000);

    return serviceProvider.GetRequiredService<Bar>();
}

Дополнительные сведения об асинхронной работе см. в статье Асинхронное программирование: важная информация и советы. Дополнительные сведения об отладке взаимоблокировок см. в статье Отладка взаимоблокировки в .NET Core.

Когда при использовании этого антишаблона возникает взаимоблокировка, вы можете изучить два ожидающих потока в окне параллельных стеков Visual Studio. Дополнительные сведения см. в статье о просмотре потоков и задач в окне "Параллельные стеки".

Зависимость с захватом

Термин зависимость с захватом был предложен Марком Симаном (Mark Seeman) для обозначения ситуаций с неправильной настройкой времени существования службы, когда более длительно выполняемая служба "захватывает" службы с более коротким временем существования.

static void CaptiveDependency()
{
    var services = new ServiceCollection();
    services.AddSingleton<Foo>();
    services.AddScoped<Bar>();

    using ServiceProvider serviceProvider = services.BuildServiceProvider();
    // Enable scope validation
    // using ServiceProvider serviceProvider = services.BuildServiceProvider(validateScopes: true);

    _ = serviceProvider.GetRequiredService<Foo>();
}

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

namespace DependencyInjection.AntiPatterns
{
    public class Foo
    {
        public Foo(Bar bar)
        {
        }
    }
}

Объекту Foo требуется объект Bar, и возникает ошибка конфигурации, так как Foo работает с одним экземпляром, а Bar имеет ограниченную область действия. В таком варианте экземпляр Foo будет создан только один раз, и он будет удерживать Bar в течение всего времени существования, что превышает предполагаемое время существования для службы Bar с ограниченной областью действия. Мы рекомендуем проверить области, передав validateScopes: true в BuildServiceProvider(IServiceCollection, Boolean). При проверке областей вы получите сообщение InvalidOperationException, похожее на строку "Не удается использовать службу "Bar" с заданной областью из службы "Foo" с одним экземпляром".

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

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

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

static void ScopedServiceBecomesSingleton()
{
    var services = new ServiceCollection();
    services.AddScoped<Bar>();

    using ServiceProvider serviceProvider = services.BuildServiceProvider(validateScopes: true);
    using (IServiceScope scope = serviceProvider.CreateScope())
    {
        // Correctly scoped resolution
        Bar correct = scope.ServiceProvider.GetRequiredService<Bar>();
    }

    // Not within a scope, becomes a singleton
    Bar avoid = serviceProvider.GetRequiredService<Bar>();
}

В приведенном выше коде Bar извлекается в IServiceScope, что является верным. Антишаблоном здесь будет извлечение Bar вне пределов области, и имя переменной avoid подсказывает нам, какой пример извлечения неправилен.

См. также раздел