Июнь 2016

Объем 31 Номер 6

Главное в .NET - Встраивание зависимостей с помощью .NET Core

Марк Михейлис | Июнь 2016

Исходный код можно скачать по ссылке GitHub.com/IntelliTect/Articles.

Марк МихейлисВ двух прошлых статьях, «Logging with .NET Core» (msdn.com/magazine/mt694089) и «Configuration in .NET Core» (msdn.com/magazine/mt632279), я продемонстрировал, как функциональность .NET Core можно задействовать из проекта ASP.NET Core (project.json) и из более распространенного проекта .NET 4.6 C# (*.csproj). Другими словами, преимущества новой инфраструктуры могут использовать не только те, кто пишут проекты ASP.NET Core. В этой статье я продолжу исследование .NET Core, уделяя основное внимание средствам встраивания зависимостей (dependency injection, DI) в .NET Core и тому, как они поддерживают шаблон инверсии управления (inversion of control, IoC). Как и раньше, использование функциональности .NET Core возможно и из «традиционных» CSPROJ-файлов, и из нового типа проектов — project.json. Для примера кода я буду использовать на этот раз XUnit из проекта project.json.

Для чего требуется встраивание зависимостей?

В .NET создание экземпляра объекта осуществляется тривиальным вызовом конструктора через оператор new (то есть указываете new MyService или другой тип объекта, экземпляр которого вы хотите создать). К сожалению, такой вызов формирует жестко связанное соединение (встроенную ссылку [hardcoded reference]) кода клиента или приложения с созданным объектом наряду со ссылкой на его сборку или NuGet-пакет. Для стандартных .NET-типов это не проблема. Но для типов, предлагающих «сервис» вроде протоколирования, конфигурирования, оплаты, уведомления или даже DI, зависимость может оказаться нежелательной, если вам нужно переключать реализации используемого вами сервиса. Например, в одном сценарии клиент может использовать NLog для протоколирования, а в другом — выбрать Log4Net или Serilog. В клиенте, использующем NLog, вы предпочтете не засорять свой проект Serilog, поэтому ссылка на оба сервиса протоколирования была бы нежелательна.

Для решения проблемы с «зашивкой» ссылки на реализацию сервиса DI предоставляет уровень абстракции, чтобы вместо создания экземпляра сервиса напрямую с помощью оператора new клиент (или приложение) запрашивал экземпляр у набора сервиса или фабрики. Более того, вместо запроса конкретного типа у набора сервиса (что создает жестко связанную ссылку) вы запрашиваете интерфейс (например, ILoggerFactory), ожидая, что провайдер сервиса (в данном случае NLog, Log4Net или Serilog) реализует этот интерфейс.

В итоге, пока клиент будет напрямую ссылаться на абстрактную сборку (Logging.Abstractions), определяющую интерфейс сервиса, никаких ссылок на конкретную реализацию не потребуется.

Мы называем шаблон отсоединения реального экземпляра, возвращаемого клиенту, инверсией управления (Inversion of Control). Такое название связано с тем, что в этом случае не клиент определяет, экземпляр чего создается, как при явном вызове конструктора оператором new, а DI определяет, что будет возвращено. DI регистрирует связь между типом, запрошенным клиентом (обычно это интерфейс), и типом, который был возвращен. Более того, DI, как правило, определяет срок жизни возвращенного типа, точнее, будет ли это один экземпляр, общий между всеми запросами этого типа, новый экземпляр для каждого запроса или нечто среднее.

Особенно часто потребность в DI возникает в модульных тестах. Рассмотрим сервис корзины покупателя, который в свою очередь зависит от сервиса оплаты. Вообразите, что вы пишете сервис корзины покупателя, который использует сервис оплаты, и пытаетесь проверить модульным тестом сервис корзины покупателя без реального вызова сервиса оплаты. В этом случае вам нужно вызывать имитацию сервиса оплаты. Чтобы добиться этого с помощью DI, ваш код должен запрашивать экземпляр интерфейса сервиса оплаты от инфраструктуры DI вместо вызова, скажем, new PaymentService. Тогда все, что нужно для модульного теста, — «сконфигурировать» инфраструктуру DI на возврат имитации сервиса оплаты.

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

Предоставление экземпляра «сервиса» вместо прямого создания его экземпляра клиентом является фундаментальным принципом DI. И, по сути, некоторые инфраструктуры DI обеспечивают отделение хоста от ссылки на реализацию, поддерживая механизм связывания, который основан на конфигурации и отражении, вместо использования связывания на этапе компиляции. Такое отделение обеспечивается шаблоном Service Locator.

Microsoft.Extensions.DependencyInjection в .NET Core

Чтобы задействовать инфраструктуру DI в .NET Core, вам нужна лишь ссылка на NuGet-пакет Microsoft.Extnesions.DependencyInjection.Abstractions. Это обеспечивает доступ к интерфейсу IServiceCollection, предоставляющему System.IServiceProvider, из которого вы можете вызвать GetService<TService>. Параметр-тип, TService, идентифицирует тип сервиса, который нужно получить (обычно это интерфейс). Поэтому код приложения получает экземпляр так:

ILoggingFactory loggingFactor = serviceProvider.GetService<ILoggingFactory>();

Существуют эквивалентные необобщенные методы GetService, которые принимают Type в качестве параметра (а не обобщенный параметр). Обобщенные методы поддерживают прямое присваивание переменной конкретного типа, тогда как необобщенные версии требуют явного приведения к конкретному типу, поскольку их тип возврата — Object. Более того, существуют обобщенные ограничения для добавления типа сервиса, чтобы можно было полностью избегать приведения при использовании параметра-типа.

Если в наборе сервиса не зарегистрирован никакой тип на момент вызова GetService, он вернет null. Это полезно в сочетании с оператором передачи null (null propagation operator) для добавления дополнительных поведений в приложение. Похожий метод GetRequiredService генерирует исключение, когда тип сервиса не зарегистрирован.

Как видите, код тривиален. Но как получить экземпляр провайдера сервиса, в котором нужно вызвать GetService. Решение простое: сначала нужно инициализировать конструктор по умолчанию в ServiceCollection, затем зарегистрировать тип, который будет предоставляться сервисом. Пример показан на рис. 1, где вы можете предполагать, что каждый класс (Host, Application и PaymentService) реализован в отдельной сборке. Более того, хотя сборке Host известно, какие средства протоколирования следует использовать, в Application или PaymentService нет никакой ссылки на какое-либо средство протоколирования. Аналогично в сборке Host нет ссылки на сборку PaymentServices. Интерфейсы также реализованы в отдельных сборках «Abstractions». Например, интерфейс ILogger определен в сборке Microsoft.Extensions.Logging.Abstractions.

Рис. 1. Регистрация и запрос объекта от инфраструктуры встраивания зависимостей

public class Host
{
  public static void Main()
  {
    IServiceCollection serviceCollection = new ServiceCollection();
    ConfigureServices(serviceCollection);
    Application application = new Application(serviceCollection);
    // Запуск
    // ...
  }
  static private void ConfigureServices(IServiceCollection serviceCollection)
  {
    ILoggerFactory loggerFactory = new Logging.LoggerFactory();
    serviceCollection.AddInstance<ILoggerFactory>(loggerFactory);
  }
}
public class Application
{
  public IServiceProvider Services { get; set; }
  public ILogger Logger { get; set; }
    public Application(IServiceCollection serviceCollection)
  {
    ConfigureServices(serviceCollection);
    Services = serviceCollection.BuildServiceProvider();
    Logger = Services.GetRequiredService<ILoggerFactory>()
            .CreateLogger<Application>();
    Logger.LogInformation("Application created successfully.");
  }
  public void MakePayment(PaymentDetails paymentDetails)
  {
    Logger.LogInformation(
      $"Begin making a payment { paymentDetails }");
    IPaymentService paymentService =
      Services.GetRequiredService<IPaymentService>();
    // ...
  }
  private void ConfigureServices(IServiceCollection serviceCollection)
  {
    serviceCollection.AddSingleton<IPaymentService, PaymentService>();
  }
}
public class PaymentService: IPaymentService
{
  public ILogger Logger { get; }
  public PaymentService(ILoggerFactory loggerFactory)
  {
    Logger = loggerFactory?.CreateLogger<PaymentService>();
    if(Logger == null)
    {
      throw new ArgumentNullException(nameof(loggerFactory));
    }
    Logger.LogInformation("PaymentService created");
  }
}

С концептуальной точки зрения, тип ServiceCollection можно рассматривать как пару «имя-значение», где имя — тип объекта (обычно интерфейс), который впоследствии вы хотите получать, а значение — это либо тип, реализующий интерфейс, либо алгоритм (делегат) для получения этого типа. Следовательно, вызов AddInstance в методе Host.ConfigureServices на рис. 1 регистрирует, что любой запрос типа ILoggerFactory возвращает тот же экземпляр LoggerFactory, созданный в методе ConfigureServices. В итоге и Application, и PaymentService способны получать ILoggerFactory, не зная (или даже не имея ссылки на сборку/NuGet-пакет), какие средства протоколирования реализованы и сконфигурированы. Аналогично Application предоставляет метод MakePayment, ничего не зная, какой сервис оплаты используется.

Заметьте, что ServiceCollection не предоставляет метод GetService или GetRequiredService напрямую. Вместо этого они доступны из IServiceProvider, возвращаемого методом ServiceCollection.BuildServiceProvider. Более того, от провайдера доступны лишь те сервисы, которые добавляются до вызова BuildServiceProvider.

Microsoft.Framework.DependencyInjection.Abstractions также включает вспомогательный статический класс ActivatorUtilities, содержащий несколько полезных методов для работы с параметрами конструктора, не зарегистрированными в IServiceProvider, с пользовательским делегатом ObjectFactory или в ситуациях, где нужно создавать экземпляр по умолчанию, когда вызов GetService возвращает null (см. bit.ly/1WIt4Ka#ActivatorUtilities).

Срок жизни сервиса

На рис. 1 я вызываю метод расширения IServiceCollection AddInstance<TService>(TService implementationInstance). Экземпляр — это один из четырех вариантов срока жизни TService, доступных в .NET Core DI. Он указывает: вызов GetService не только вернет объект типа TService, но и что им будет специфический implementationInstance, зарегистрированный с помощью AddInstance. Иначе говоря, регистрация с помощью AddInstance сохраняет экземпляр специфического implementationInstance, чтобы он мог возвращаться при каждом вызове GetService (или GetRequiredService) с параметром-типом TService метода AddInstance.

Метод расширения IServiceCollection AddSingleton<TService>, напротив, не имеет параметра для экземпляра и вместо этого полагается на TService, имеющий возможность создания экземпляра через конструктор. Хотя конструктор по умолчанию работает, Microsoft.Extensions.DependencyInjection также поддерживает конструкторы, отличные от такового по умолчанию, параметры которых тоже регистрируются. Например, вы можете вызвать:

IPaymentService paymentService = Services.GetRequiredService<IPaymentService>()

и DI позаботится о получении конкретного экземпляра ILoggingFactory и его использовании при создании экземпляра класса PaymentService, который требует ILoggingFactory в своем конструкторе.

Если в типе TService такие средства недоступны, вы можете использовать перегруженную версию метода расширения AddSingleton, принимающую делегат типа Func<IServiceProvider, TService> implementationFactory — метод фабрики для создания экземпляра TService. Предоставляете вы метод фабрики или нет, реализация IServiceCollection гарантирует, что будет создавать только один экземпляр типа TService, а значит, он будет singleton-экземпляром. После первого вызова GetService, инициирующего создание экземпляра TService, тот же экземпляр всегда будет возвращаться в течение срока жизни IServiceCollection.

IServiceCollection также включает методы расширения AddTransient(Type serviceType, Type implementationType) и AddTransient(Type serviceType, Func<IServiceProvider, TService> implementationFactory). Они аналогичны AddSingleton с тем исключением, что возвращают новый экземпляр при каждом вызове, гарантируя, что вы всегда будете получать новый экземпляр типа TService.

Наконец, имеется несколько методов расширения типа AddScoped. Эти методы возвращают один и тот же экземпляр внутри данного контекста и создают новый экземпляр всякий раз, когда контекст (известный как область видимости) изменяется. Поведение ASP.NET Core концептуально соответствует сроку жизни на основе области видимости (scoped lifetime). По сути, новый экземпляр создается для каждого экземпляра HttpContext, и при каждом вызове GetService внутри одного и того же HttpContext возвращается идентичный экземпляр TService.

Итак, существует четыре варианта сроков жизни для объектов, возвращаемых от реализации набора сервиса: Instance, Singleton, Transient и Scoped. Последние три определены в перечислении ServiceLifetime (bit.ly/1SFtcaG). Но Instance отсутствует, поскольку это особый случай Scoped, где контекст не меняется.

Ранее я упоминал, что ServiceCollection концептуально похож на пару «имя-значение» с типом TService, предназначенным для поиска. Собственно реализация типа ServiceCollection осуществляется в классе ServiceDescriptor (см. bit.ly/1SFoDgu). Этот класс предоставляет контейнер для информации, нужной, чтобы создать экземпляр TService, а именно ServiceType (TService), делегат ImplementationType или ImplementationFactory наряду с ServiceLifetime. В дополнение к конструкторам ServiceDescriptor существует группа статических методов фабрики в ServiceDescriptor, которые помогают в создании экземпляра самого ServiceDescriptor.

Независимо от того, с каким сроком жизни вы регистрируете свой TService, сам TService должен быть ссылочным типом, а не значимым. Всякий раз, когда вы используете параметр-тип для TService (вместо передачи Type как параметра), компилятор будет проверять его с использованием ограничения обобщенного класса. Однако одна вещь, которая не проверяется, — использование TService типа object. Вы наверняка захотите этого избежать, как и в случае любых других неуникальных интерфейсов (возможно, вроде IComparable). Причина в том, что, если вы регистрируете что-то как тип object, то какой бы TService вы ни указали в вызове GetService, всегда будет возвращаться object, зарегистрированный как тип TService.

Встраивание зависимостей для реализации DI

ASP.NET использует DI до такой степени, что фактически вы можете встраивать зависимости в саму инфраструктуру DI. Другими словами, вы не ограничены использованием реализации ServiceCollection механизма DI, находящегося в Microsoft.Extensions.DependencyInjection. Пока у вас есть классы, которые реализуют IServiceCollection (определен в Microsoft.Extensions.DependencyInjection.Abstractions; см. bit.ly/1SKdm1z) или IServiceProvider (определен в пространстве имен System инфраструктуры библиотеки .NET Core), вы можете заменять инфраструктуру DI собственной или использовать одну из других общепринятых инфраструктур DI, включая Ninject (ninject.org с огромной благодарностью @IanfDavis за его работу по сопровождению этой инфраструктуры в течение ряда лет) и Autofac (autofac.org).

Заключение

Как и в случае .NET Core Logging/Configuration, механизм .NET Core DI предоставляет сравнительно простую реализацию своей функциональности. Хотя вы вряд ли найдете более продвинутую функциональность DI в некоторых других инфраструктурах, версия в .NET Core является облегченной и обеспечивает отличный способ приступить к работе с DI. Более того, его реализация в .NET Core может быть заменена на более зрелую. Таким образом, вы можете рассматривать инфраструктуру .NET Core DI как оболочку, через которую можно подключать другие инфраструктуры DI, если такая необходимость возникнет в будущем. Благодаря этому вам не требуется определять свою оболочку DI, но можно применять оболочку из .NET Core как стандартную, для которой любой клиент/приложение может подключить собственную реализацию.

Стоит отметить, что ASP.NET Core повсеместно использует DI. Это несомненно отличная практика, если она вам нужна, и особенно она важна, когда пытаешься заменить в библиотеке реализации имитаций для своих модульных тестов. Недостаток в том, что вместо простого вызова конструктора оператором new приходится погружаться в сложности регистрации DI и вызовов GetService. Мне очень хотелось бы знать, можно ли в языке C# упростить все это, но, исходя из текущего проекта C# 7.0, вряд ли это случится в ближайшее время.


Марк Михейлис (Mark Michaelis) — учредитель IntelliTect, где является главным техническим архитектором и тренером. Почти два десятилетия был Microsoft MVP и региональным директором Microsoft с 2007 года. Работал в нескольких группах рецензирования проектов программного обеспечения Microsoft, в том числе C#, Microsoft Azure, SharePoint и Visual Studio ALM. Выступает на конференциях разработчиков, автор множества книг, последняя из которых — «Essential C# 6.0 (5th Edition)» (itl.tc/EssentialCSharp). С ним можно связаться в Facebook (facebook.com/Mark.Michaelis), через его блог (IntelliTect.com/Mark), в Twitter (@markmichaelis) или по электронной почте mark@IntelliTect.com.

Выражаю благодарность за рецензирование статьи экспертам IntelliTect Келли Адамс (Kelly Adams), Кевину Босту (Kevin Bost), Йену Дэвису (Ian Davis) и Филу Споукасу (Phil Spokas).


Discuss this article in the MSDN Magazine forum