Внедрение зависимостей (Dependency Injection)

Steve Smith, Scott Addie

ASP.NET Core поддерживает внедрение зависимостей. Приложения ASP.NET Core используют встроенные сервисы фреймворка, которые внедрены в методы класса Startup, а также сервисы приложения могут быть настроены на DI. Контейнер сервисов по умолчанию предоставляет минимальный набор функций и не заменяет другие контейнеры.

Скачайте пример с GitHub.

Что такое внедрение зависимостей?

Внедрение зависимостей (DI) - это технология, которая используется для того, чтобы по максимуму разделить объекты и зависимости. Вместо прямой установки компонентов или использования статических ссылок, объект, который нужен классу для выполнения некоторых действий, предоставляется этому классу в иной манере. Наиболее часто классы заявляют о зависимостях через конструктор, позволяя им следовать принципу явных зависимостей. Такой подход известен как “внедрение конструктора”.

Если классы используют DI, их компоненты не зависят друг от друга напрямую. Это называется Принципом инверсии зависимостей, который гласит, что “модули высокого уровня не должны зависеть от модулей низкого уровня - и те, и другие должны зависеть от абстракций.” Вместо использования конкретных реализаций, классы запрашивают абстракции (обычно interface), которые предоставляются им после создания. Извлечение зависимостей в интерфейсы и передача реализаций этих интерфейсов в качестве параметров также является примером шаблона проектирования Strategy.

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

ASP.NET Core включает в себя простой встроенный контейнер (представленный интерфейсом IServiceProvider), который по умолчанию поддерживает внедрение конструктора, и ASP.NET предоставляет через DI определенные сервисы. Контейнер ASP.NET работает с типами как с сервисами. Далее в этой статье сервисами мы будем называть типы, которые управляются IoC контейнером ASP.NET Core. Вы можете настраивать встроенные сервисы контейнера с помощью метода ConfigureServices класса Startup.

Примечание

Мартин Фаулер написал статью Контейнеры IoC и паттерн внедрения зависимостей. Есть еще одна отличная статья Внедрение зависимостей, а также книга Марка Симана Внедрение зависимостей в .NET.

Примечание

В этой статье мы рассматриваем, как внедрение зависимостей применяется во всех ASP.NET приложениях. Внедрение зависимостей в MVC рассматривается в статье Внедрение зависимостей и контроллеры <doc:/mvc/controllers/dependency-injection>`.

Использование сервисов фреймворка

Метод ConfigureServices класса Startup определяет сервисы, которые будут использованы в приложении, включая функции таких платформ, как Entity Framework Core и ASP.NET Core MVC. Изначально у IServiceCollection, который передается ConfigureServices, есть всего парочка сервисов. Вот пример того, как добавлять в контейнер дополнительные сервисы с помощью таких методов расширения, как AddDbContext, AddIdentity и AddMvc.

// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
    // Add framework services.
    services.AddDbContext<ApplicationDbContext>(options =>
        options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));

    services.AddIdentity<ApplicationUser, IdentityRole>()
        .AddEntityFrameworkStores<ApplicationDbContext>()
        .AddDefaultTokenProviders();

    services.AddMvc();

    // Add application services.
    services.AddTransient<IEmailSender, AuthMessageSender>();
    services.AddTransient<ISmsSender, AuthMessageSender>();
}

Функции и связующее ПО ASP.NET используют один метод расширения AddService, чтобы регистрировать все сервисы, которые требуются данной функции.

Примечание

Вы можете запрашивать отдельные сервисы с помощью методов Startup. См. Запуск приложения.

Вы можете настроить приложение таким образом, чтобы оно использовало разные функции фреймворка, а также вы можете работать с ConfigureServices, чтобы настроить собственные сервисы.

Регистрация собственных сервисов

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

services.AddTransient<IEmailSender, AuthMessageSender>();
services.AddTransient<ISmsSender, AuthMessageSender>();

Примечание

Каждый вызов services.Add<service> добавляет сервисы. Например, services.AddMvc() добавляет сервис, который нужен MVC.

Метод AddTransient связывает абстрактные типы с конкретными сервисами, экземпляр которых создается отдельно для каждого объекта, который их запрашивает. Это известно как жизненный цикл сервиса. Важно выбрать подходящий жизненный цикл для сервиса, который вы регистрируете. Должен ли новый экземпляр сервиса предоставляться каждому классу, который его запрашивает? Должен ли новый экземпляр использоваться на протяжении всего запроса? Должен ли один экземпляр использоваться во время всего жизненного цикла приложения?

В примере для этой статьи существует один контроллер CharacterController. Его метод Index отображает текущий список персонажей, и инициализирует коллекцию, если такой еще нет. Хотя в этом приложении используется Entity Framework и класс ApplicationDbContext, они не видны в контроллере. Вместо этого механизм доступа к конкретным данным “прячется” за интерфейсом ICharacterRepository, который следует паттерну Repository. Экземпляр ICharacterRepository запрашивается через конструктор и присваивается закрытому полю, которое затем используется для доступа к нужному персонажу.

    public class CharactersController : Controller
    {
        private readonly ICharacterRepository _characterRepository;

        public CharactersController(ICharacterRepository characterRepository)
        {
            _characterRepository = characterRepository;
        }

        // GET: /characters/
        public IActionResult Index()
        {
            PopulateCharactersIfNoneExist();
            var characters = _characterRepository.ListAll();

            return View(characters);
        }
        
        private void PopulateCharactersIfNoneExist()
        {
            if (!_characterRepository.ListAll().Any())
            {
                _characterRepository.Add(new Character("Darth Maul"));
                _characterRepository.Add(new Character("Darth Vader"));
                _characterRepository.Add(new Character("Yoda"));
                _characterRepository.Add(new Character("Mace Windu"));
            }
        }
    }

ICharacterRepository определяет два метода, которые нужны контроллеру для работы с экземплярами Character.

using System.Collections.Generic;
using DependencyInjectionSample.Models;

namespace DependencyInjectionSample.Interfaces
{
    public interface ICharacterRepository
    {
        IEnumerable<Character> ListAll();
        void Add(Character character);
    }
}

Интерфейс же реализуется с помощью конкретного типа CharacterRepository.

Примечание

Способ, которым используется DI с классом CharacterRepository является общей моделью, и вы можете следовать ей для работы со всеми сервисами - не только в “репозиториях” или классах доступа к данным.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
using System.Collections.Generic;
using System.Linq;
using DependencyInjectionSample.Interfaces;

namespace DependencyInjectionSample.Models
{
    public class CharacterRepository : ICharacterRepository
    {
        private readonly ApplicationDbContext _dbContext;

        public CharacterRepository(ApplicationDbContext dbContext)
        {
            _dbContext = dbContext;
        }

        public IEnumerable<Character> ListAll()
        {
            return _dbContext.Characters.AsEnumerable();
        }

        public void Add(Character character)
        {
            _dbContext.Characters.Add(character);
            _dbContext.SaveChanges();
        }
    }
}

Обратите внимание, что CharacterRepository запрашивает в свой конструктор ApplicationDbContext. Часто внедрение зависимостей используется по цепочке, то есть, каждая запрашиваемая зависимость может запрашивать свои собственные зависимости. Контейнер отвечает за использование всех зависимостей в дереве и возвращает полноценный сервис.

Примечание

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

В данном случае ICharacterRepository и ApplicationDbContext должны быть зарегистрированы с помощью контейнера сервисов в ConfigureServices в Startup. ApplicationDbContext настраивается с помощью метода расширения AddDbContext<T>. Далее показана регистрация типа CharacterRepository:

{
    services.AddDbContext<ApplicationDbContext>(options =>
        options.UseInMemoryDatabase()
    );

    // Add framework services.
    services.AddMvc();

    // Register application services.
    services.AddScoped<ICharacterRepository, CharacterRepository>();
    services.AddTransient<IOperationTransient, Operation>();
    services.AddScoped<IOperationScoped, Operation>();
    services.AddSingleton<IOperationSingleton, Operation>();
    services.AddSingleton<IOperationSingletonInstance>(new Operation(Guid.Empty));
    services.AddTransient<OperationService, OperationService>();
}

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

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

Будьте осторожны при использовании сервиса Scoped из Singleton. Если это случится, у сервисов будет некорректное состояние при обработке последующих запросов.

“Жизненный цикл” сервисов и опции регистрации

ASP.NET сервисы могут быть сконфигурированы со следующими жизненными циклами:

Transient
Transient-сервисы создаются каждый раз, когда они запрашиваются. Такой жизненный цикл лучше всего подходит не особо значимым, “легким” сервисам.
Scoped
Scoped-сервисы создаются при каждом запросе.
Singleton
Singleton-сервисы создаются один раз после запроса, а затем каждый последующий запрос использует тот же экземпляр. Если приложению требуется singleton, когда контейнер сервисов управляет жизненным циклом сервиса, то вам стоит реализовать паттерн singleton и управлять жизненным циклом объекта в самом классе.

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

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

using System;

namespace DependencyInjectionSample.Interfaces
{
    public interface IOperation
    {
        Guid OperationId { get; }
    }

    public interface IOperationTransient : IOperation
    {
    }
    public interface IOperationScoped : IOperation
    {
    }
    public interface IOperationSingleton : IOperation
    {
    }
    public interface IOperationSingletonInstance : IOperation
    {
    }
}

Мы также реализуем все эти интерфейсы с помощью класса Operation, который принимает в свой конструктор Guid или создает новый Guid, если такового еще нет.

Далее, в ConfigureServices каждый тип добавляется в конструктор в зависимости от соответствующего жизненнгого цикла:

services.AddScoped<ICharacterRepository, CharacterRepository>();
services.AddTransient<IOperationTransient, Operation>();
services.AddScoped<IOperationScoped, Operation>();
services.AddSingleton<IOperationSingleton, Operation>();
services.AddSingleton<IOperationSingletonInstance>(new Operation(Guid.Empty));
services.AddTransient<OperationService, OperationService>();

Обратите внимание, что IOperationSingletonInstance использует экземпляр с ID Guid.Empty, так что при использовании этого типа он будет пустым. Также мы зарегистрировали OperationService, который зависит от других типов Operation, так что он будет пустым внутри запроса, если сервис получает тот же экземпляр, что и контроллер, либо же получает другой для разного типа операций.

using DependencyInjectionSample.Interfaces;

namespace DependencyInjectionSample.Services
{
    public class OperationService
    {
        public IOperationTransient TransientOperation { get; }
        public IOperationScoped ScopedOperation { get; }
        public IOperationSingleton SingletonOperation { get; }
        public IOperationSingletonInstance SingletonInstanceOperation { get; }

        public OperationService(IOperationTransient transientOperation,
            IOperationScoped scopedOperation,
            IOperationSingleton singletonOperation,
            IOperationSingletonInstance instanceOperation)
        {
            TransientOperation = transientOperation;
            ScopedOperation = scopedOperation;
            SingletonOperation = singletonOperation;
            SingletonInstanceOperation = instanceOperation;
        }
    }
}

Чтобы показать вам жизненные циклы объекта внутри и между разными запросами в приложении, мы включили в пример OperationsController, который запрашивает все виды типа IOperation, а также OperationService. Метод действия Index затем отображает все значения OperationId контроллеров и сервисов.

using DependencyInjectionSample.Interfaces;
using DependencyInjectionSample.Services;
using Microsoft.AspNetCore.Mvc;

namespace DependencyInjectionSample.Controllers
{
    public class OperationsController : Controller
    {
        private readonly OperationService _operationService;
        private readonly IOperationTransient _transientOperation;
        private readonly IOperationScoped _scopedOperation;
        private readonly IOperationSingleton _singletonOperation;
        private readonly IOperationSingletonInstance _singletonInstanceOperation;

        public OperationsController(OperationService operationService,
            IOperationTransient transientOperation,
            IOperationScoped scopedOperation,
            IOperationSingleton singletonOperation,
            IOperationSingletonInstance singletonInstanceOperation)
        {
            _operationService = operationService;
            _transientOperation = transientOperation;
            _scopedOperation = scopedOperation;
            _singletonOperation = singletonOperation;
            _singletonInstanceOperation = singletonInstanceOperation;
        }

        public IActionResult Index()
        {
            // viewbag contains controller-requested services
            ViewBag.Transient = _transientOperation;
            ViewBag.Scoped = _scopedOperation;
            ViewBag.Singleton = _singletonOperation;
            ViewBag.SingletonInstance = _singletonInstanceOperation;
            
            // operation service has its own requested services
            ViewBag.Service = _operationService;
            return View();
        }
    }
}

Теперь мы делаем два отдельных запроса к методу действия контроллера:

../_images/lifetimes_request1.png ../_images/lifetimes_request2.png

Посмотрите, как значения OperationId отличаются при запросе и между запросами.

  • Объекты Transient всегда различаются; новый экземпляр предоставляется каждому контроллеру и каждому сервису.
  • Объекты Scoped всегда одинаковы при одном запросе, но различаются при разных запросах.
  • Объекты Singleton одинаковы для каждого запроса и для каждого объекта.

Сервисы запросов

Сервисы, доступные ASP.NET запросу из HttpContext, входят в RequestServices.

../_images/request-services.png

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

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

Примечание

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

Создание сервисов для внедрения зависимостей

Вы должны избегать вызовов статических методов (http://deviq.com/static-cling/) и создания напрямую экземпляров зависимых классов внутри сервисов. Вы должны хорошенько подумать перед тем, как создать экземпляр определенного типа или запросить его при помощи внедрения зависимостей. Если вы последуете принципу SOLID, ваши классы будут небольшими, хорошо организованными и легко тестируемыми.

А что если в ваших классах слишком много внедренных зависимостей? Обычно это обозначает то, что ваш класс слишком перегружен и нарушает принцип SRP - принцип единственной обязанности. Вы должны изменить данный класс, переместив некоторые его функции в новый класс. Обратите внимание, что классы Controller должны быть сфокусированы на UI, так что бизнес-правила и доступ к данным должны храниться в классах в соответствии с разделением обязанностей.

Что касается доступа к данным, вы можете внедрять в контроллеры DbContext (если вы добавили EF в контейнер сервисов в ConfigureServices). Некоторые разработчики предпочитают использовать интерфейс репозитория, вместо внедрения DbContext напрямую. Использование интерфейса для инкапсулирования логики доступа к данным в одном месте может уменьшить число мест, куда вы должны вносить изменения при наличии изменений в БД.

Замена сервисного контейнера по умолчанию

Встроенный контейнер сервисов обрабатывает базовые нужды фреймворка. Однако разработчики могут легко заменить этот контейнер другим. Обычно метод ConfigureServices возвращает void, но если он был изменен так, чтобы возвращать IServiceProvider, то можно настроить и вернуть другой контейнер. Есть много IOC контейнеров, доступных для .NET. Мы попробуем добавить ссылки на реализации контейнеров DNX. В этом примере используется пакет Autofac.

Во-первых, добавьте соответствующие пакеты контейнеров в свойство dependencies в project.json:

"dependencies" : {
  "Autofac": "4.0.0",
  "Autofac.Extensions.DependencyInjection": "4.0.0"
},

Далее, настройте контейнер в ConfigureServices и верните IServiceProvider:

public IServiceProvider ConfigureServices(IServiceCollection services)
{
  services.AddMvc();
  // add other framework services

  // Add Autofac
  var containerBuilder = new ContainerBuilder();
  containerBuilder.RegisterModule<DefaultModule>();
  containerBuilder.Populate(services);
  var container = containerBuilder.Build();
  return new AutofacServiceProvider(container);
}

Примечание

При использовании стороннего DI контейнера вы должны изменить ConfigureServices, так чтобы он возвращал IServiceProvider вместо void.

Наконец, настройте Autofac в DefaultModule:

public class DefaultModule : Module
{
  protected override void Load(ContainerBuilder builder)
  {
    builder.RegisterType<CharacterRepository>().As<ICharacterRepository>();
  }
}

Теперь во время запуска будет использоваться Autofac для обработки типов и внедрения зависимостей.

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

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

  • DI предназначается для объектов со сложными зависимостями. Контроллеры, сервисы, адаптеры и репозитории являются примерами объектов, которые могут использоваться с DI.
  • Избегайте хранения данных и конфигурации напрямую в DI. Например, корзину пользователя не стоит добавлять в контейнер сервисов. При настройках стоит использовать модель Options. Кроме того, избегайте объектов “data holder”, которые существуют только для того, чтобы вы могли получить доступ к другим объектам. Лучше всего получать доступ к нужным элементам через DI.
  • Избегайте статического доступа к сервисам.
  • Не располагайте сервисы в коде приложения.
  • Избегайте статического доступа к HttpContext.

Примечание

В некоторых ситуациях вы можете не следовать этим рекомендациям в виде исключения.

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

Поделись хорошей новостью с друзьями!
Следи за новостями!