ФЕВРАЛЯ 2016

ТОМ 31 НОМЕР 2

Доступ к данным - Рефакторинг проекта ASP.NET 5 с EF6 и встраивание зависимостей

Джули Лерман | ФЕВРАЛЯ 2016

Исходный код можно скачать по ссылкеmsdn.com/magazine/0216magcode.

Julie LermanВстраивание зависимостей (dependency injection, DI) — это, по сути, свободное связывание (bit.ly/1TZWVtW). Вместо того чтобы «зашивать» классы, от которых вы зависите, в другие классы, вы запрашиваете их откуда-то из другого места, в идеале из конструктора своего класса. Это соответствует принципу явных зависимостей (Explicit Dependencies Principle), более ясно информируя пользователей вашего класса о необходимых ему вспомогательных классах. Это также позволяет делать ваше ПО более гибким в таких сценариях, как альтернативные конфигурации экземпляра объекта класса, и дает реальный выигрыш при написании автоматизированных тестов для подобных классов. В моем мире, заполненном кодом для Entity Framework, типичным примером кодирования без свободного связывания является создание репозитария или контроллера, который напрямую создает экземпляр DbContext. Я проделывала это тысячи раз. По сути, цель этой статьи — применить то, чему я научилась, изучая DI, к коду, который я писала в статье «The EF6, EF7 and ASP.NET 5 Soup» (msdn.com/magazine/dn973011). Например, вот метод, где я напрямую создавала экземпляр DbContext:

public List<Ninja> GetAllNinjas() {
  using (var context=new NinjaContext())
  {
    return context.Ninjas.ToList();
  }
}

Поскольку я использовала это в решении на основе ASP.NET 5, а в ASP.NET 5 встроена развитая поддержка DI, Роуэн Миллер (Rowan Miller) из группы EF предположил, что я могла бы улучшить своей пример, используя преимущества этой поддержки DI. Я была так сконцентрирована на других аспектах задачи, что даже не задумывалась об этом. Поэтому я приступила к рефакторингу того примера, шаг за шагом, пока не добилась его работы так, как было предписано. На самом деле Миллер подсказал мне отличный пример, написанный Павелом Груденем (Paweł Grudzień) в статье «Entity Framework 6 with ASP.NET 5» в своем блоге (bit.ly/1k4Tt4Y), но я намеренно предпочла отвести от него глаза и не пытаться копировать ту статью. Вместо этого я выработала свои идеи, чтобы можно было лучше понять поток управления в коде. В итоге я была рада, увидев, что мое решение хорошо коррелирует с рекомендованной статьей из блога.

Инверсия управления (Inversion of Control, IoC) и контейнеры IoC являются шаблонами, которые всегда казались мне слегка устрашающими. Учтите, что у меня почти 30-летний опыт программирования, поэтому не думаю, что я единственный опытный разработчик, не сумевший перестроить свое мышление под этот шаблон. Мартин Фаулер (Martin Fowler), хорошо известный эксперт в этой области, отмечает, что IoC имеет несколько смысловых наполнений, но тот, который соответствует DI (термин, придуманный им, чтобы прояснить суть этой разновидности IoC), выражает следующее: какая часть приложения управляет созданием конкретных объектов. Без IoC это всегда было трудной задачей.

Когда я работала над учебным курсом Pluralsight «Domain-Driven Design Fundamentals» (bit.ly/PS-DDD) в соавторстве со Стивом Смитом (Steve Smith) (deviq.com), я наконец начала использовать библиотеку StructureMap, которая стала одним из самых популярных IoC-контейнеров среди .NET-разработчиков с момента ее появления в 2005 году. Суть в том, что я немного поздно вступила в игру. Под руководством Смита я смогла понять, как она работает и в чем ее преимущества, но до сих пор я не чувствую полной уверенности в работе с ней. Так что после подсказки от Миллера я решили переработать свой ранний пример под использование контейнера, который упрощает встраивание экземпляров объектов в логику, требующую их применения.

Но сначала принцип DRY

Первая проблема в моем классе, который содержит класс GetAllNinjas, показанный ранее, заключается в том, что я повторяю код using:

using(var context=new NinjaContext)

в других методах того класса, например:

public Ninja GetOneNinja(int id) {
  using (var context=new NinjaContext())
  {
    return context.Ninjas.Find(id);
  }
}

Принцип Don’t Repeat Yourself (DRY) («не повторяйся») помог мне осознать эту потенциальную ловушку. Я перемещу создание экземпляра NinjaContext в конструктор и сделаю переменную вроде _context общей для остальных методов:

NinjaContext _context;
public NinjaRepository() {
  _context = new NinjaContext();
}

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

NinjaContext _context;
public NinjaRepository(NinjaContext context) {
  _context = context;
}

Теперь репозитарий предоставлен самому себе. Мне больше не нужно возиться с ним для создания контекста. Репозитарий не интересует, как конфигурируется контекст при создании или при удалении. Это тоже помогает классу следовать другому принципу ООП — принципу одной обязанности (Single Responsibility Principle), поскольку он больше не отвечает за управление EF-контекстами в дополнение к выдаче запросов к базам данных. Работая над классом репозитария, я могу сосредоточиться на запросах. Кроме того, это упрощает его тестирование, так как мои тесты могут принимать эти решения и не будут спотыкаться о репозитарий, спроектированный на такое использование, которое не соответствует тому, как я, возможно, предпочла бы задействовать его в автоматизированных тестах.

В моем исходном примере есть другая проблема, связанная с тем, что я «зашила» строку подключения в DbContext. В то время я сделала так, потому что это была просто демонстрация и получение строки подключения из выполняемого приложения (приложения ASP.NET 5) в EF6-проекте было делом сложным, а я была сосредоточена на других вещах. Однако, раз уж я взялась за рефакторинг этого проекта, я буду использовать IoC для передачи строки подключения из выполняемого приложения. Как это сделано, см. далее в этой статье

Позволим ASP.NET 5 встраивать NinjaContext

Но куда переместить создание NinjaContext? Контроллер использует репозитарий. Мне определенно не хочется вводить EF в контроллер, чтобы передавать его в новый экземпляр репозитария. Это могло бы привести к путанице, например такой:

public class NinjaController : Controller {
  NinjaRepository _repo;
  public NinjaController() {
    var context = new NinjaContext();
    _repo = new NinjaRepository(context);
  }
  public IActionResult Index() {
    return View(_repo.GetAllNinjas());
  }
}

Хотя я вынуждаю контроллер быть осведомленным о EF, этот код игнорирует проблемы создания экземпляров зависимых объектов, которые я только что решила в репозитарии. Контроллер напрямую создает экземпляр класса репозитария. Я просто хочу, чтобы он использовал этот репозитарий, не заботясь о том, как и когда создавать или удалять его. Так же, как я встраивала экземпляр NinjaContext в репозитарий, мне нужно встроить готовый к использованию экземпляр репозитария в контроллер.

Более внятная версия кода внутри класса контроллера выглядит так:

public class NinjaController : Controller {
  NinjaRepository _repo;
  public NinjaController(NinjaRepository repo) {
    _repo = repo;
  }
  public IActionResult Index() {
    return View(_repo.GetAllNinjas());
  }
}

Управление созданием объектов с помощью IoC-контейнеров

Поскольку я работаю с ASP.NET 5, вместо StructureMap я задействую преимущества встроенной в ASP.NET 5 поддержки DI. Не только многие новые классы ASP.NET рассчитаны на прием встраиваемых объектов, но в самой ASP.NET 5 есть инфраструктура сервисов, позволяющая управлять тем, какие и куда объекты направляются, — IoC-контейнер. Кроме того, она дает возможность указывать область видимости объектов, которые будут создаваться и встраиваться. Работа со встроенной поддержкой — более простой способ для новичков.

Прежде чем использовать поддержку DI в ASP.NET 5 для встраивания NinjaContext и NinjaRepository, давайте рассмотрим, как выглядит встраивание EF7-классов, поскольку в EF7 есть встроенные методы для его подключения к поддержке DI в ASP.NET 5. Класс startup.cs, являющийся частью стандартного проекта ASP.NET 5, имеет метод ConfigureServices. Именно здесь вы сообщаете своему приложению, как вам нужно подключать зависимости, чтобы оно могло создать, а затем встроить должные объекты в те объекты, которым это требуется. Вот этот метод, из которого исключено все, кроме конфигурирования для EF7:

public void ConfigureServices(IServiceCollection services)
{
  services.AddEntityFramework()
          .AddSqlServer()
          .AddDbContext<NinjaContext>(options =>
            options.UseSqlServer(
            Configuration["Data:DefaultConnection:ConnectionString"]));
}

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

Поскольку EntityFramework.MicrosoftSqlServer был указан в файле project.json, проект ссылается на все релевантные сборки EF7. Одна из них, сборка EntityFramework.Core, предоставляет метод расширения AddEntityFramework для IServiceCollection, позволяя мне добавить сервис Entity Framework. EntityFramework.MicrosoftSqlServer DLL предоставляет метод расширения AddSqlServer, который дописывается к AddEntityFramework. Он помещает сервис SqlServer в IoC-контейнере так, что EF будет знать о нем и задействует его, когда будет искать провайдер базы данных.

AddDbContext содержится в базовой части EF. Этот код добавляет указанный экземпляр DbContext (с заданными параметрами) в контейнер, встроенный в ASP.NET 5. Любой класс, который запрашивает DbContext в своем конструкторе (и который конструируется ASP.NET 5), получит сконфигурированный DbContext в процессе создания. Поэтому данный код добавляет NinjaContext как известный тип, экземпляр которого будет создаваться сервисом при необходимости. Кроме того, код указывает, что при конструировании NinjaContext следует использовать строку, найденную в конфигурационном коде (в данном случае она содержится в файле appsettings.json в ASP.NET 5, созданном шаблоном проекта), как конфигурационный параметр SqlServer. Так как ConfigureService выполняется в стартовом коде, когда любой код в приложении ожидает NinjaContext, но никакой его конкретный экземпляр еще не предоставлен, ASP.NET 5 создаст новый объект NinjaContext, используя заданную строку подключения, и передаст его в ConfigureService.

Так что все необходимое встроено в EF7. Увы, ничего этого в EF6 нет. Но теперь, когда вы получили представление о том, как работают эти сервисы, шаблон для добавления EF6 NinjaContext в сервисы приложения должен стать понятным.

Добавление сервисов, не рассчитанных на ASP.NET 5

В дополнение к сервисам, рассчитанным на работу с ASP.NET 5, которые имеют удобные расширения вроде AddEntityFramework и AddMvc, можно добавлять другие зависимости. Интерфейс IServicesCollection содержит исходный метод Add наряду с набором методов, которые определяют срок существования добавляемого сервиса: AddScoped, AddSingleton и AddTransient. В моем решении меня больше интересует AddScoped, так как он определяет область существования запрошенного экземпляра для каждого HTTP-запроса в MVC-приложении, где я хочу использовать свой проект EF6Model. Приложение не будет пытаться использовать такой экземпляр в разных запросах. Это обеспечивает эмуляцию того, чего я изначально добивалась созданием и удалением NinjaContext в рамках каждой операции контроллера, потому что каждая такая операция реагировала на единственный запрос.

Вспомните, что у меня есть два класса, в которые нужно встраивать объекты. Классу NinjaRepository необходим NinjaContext, а классу NinjaController — объект NinjaRepository.

В методе ConfigureServices из файла startup.cs я начинаю с добавления:

services.AddScoped<NinjaRepository>();
services.AddScoped<NinjaContext>();

Теперь мое приложение осведомлено об этих типах и будет создавать их экземпляры при запросе конструктором другого типа.

Когда конструктор контроллера ищет NinjaRepository, который следует передать как параметр:

public NinjaController(NinjaRepository repo) {
    _repo = repo;
  }

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

Помните фокус со строкой подключения в моем DbContext, который я показала ранее? Теперь я могу указать методу AddScoped, чтобы тот сконструировал NinjaContext, используя строку подключения. Я снова помещу ее в файл appsetting.json. Вот как выглядит соответствующая часть этого файла:

"Data": {
    "DefaultConnection": {
      "NinjaConnectionString":
      "Server=(localdb)\\mssqllocaldb;Database=NinjaContext;
      Trusted_Connection=True;MultipleActiveResultSets=true"
    }
  }

Заметьте, что JSON не поддерживает перенос строк, поэтому строку, которая начинается с Server=, нельзя разбить в JSON-файле. Здесь перенос строк используется только для удобства чтения.

Я модифицировала конструктор NinjaContext, чтобы он принимал строку подключения и использовал ее в перегрузке DbContext, который также принимает строку подключения:

public NinjaContext(string connectionString):
    base(connectionString) { }

Теперь я могу сообщить AddScoped, что, встретив NinjaContext, он должен сконструировать его, используя ту перегрузку, и передать в NinjaConnectionString, находящуюся в appsettings.json:

services.AddScoped<NinjaContext>
(serviceProvider=>new NinjaContext
  (Configuration["Data:DefaultConnection:NinjaConnectionString"]));

После этого последнего изменения решение, работа которого была нарушена мной в ходе рефакторинга, теперь вновь полностью функционирует. Стартовая логика настраивает приложение на встраивание репозитария и контекста. Когда приложение переходит к контроллеру по умолчанию (применяющему репозитарий, который использует контекст), необходимые объекты создаются «на лету» и из базы данных извлекается требуемая информация. Мое приложение для ASP.NET 5 использует преимущества ее встроенной поддержки DI для взаимодействия с более старой сборкой, где для создания модели была применена инфраструктура EF6.

Интерфейсы для большей гибкости

Остается одно последнее усовершенствование — применение интерфейсов. Если существует вероятность того, что мне может понадобиться использование другой версии класса NinjaRepository или NinjaContext, я могу повсеместно реализовать интерфейсы. Я не вижу, для чего может потребоваться какая-то вариация NinjaContext, поэтому создам интерфейс только для класса репозитария.

Как показано на рис. 1, NinjaRepository теперь реализует контракт INinjaRepository.

Рис. 1. NinjaRepository использует интерфейс

public interface INinjaRepository
{
  List<Ninja> GetAllNinjas();
}
public class NinjaRepository : INinjaRepository
{
  NinjaContext _context;
  public NinjaRepository(NinjaContext context) {
    _context = context;
  }
  public List<Ninja> GetAllNinjas() {
    return _context.Ninjas.ToList();
  }
}

Контроллер в приложении ASP.NET 5 MVC теперь использует интерфейс INinjaRepository вместо конкретной реализации, NinjaRepository:

public class NinjaController : Controller {
  INinjaRepository _repo;
  public NinjaController(INinjaRepository repo) {
    _repo = repo;
  }
  public IActionResult Index() {
    return View(_repo.GetAllNinjas());
  }
}

Я модифицировала метод AddScoped так, чтобы NinjaRepository сообщал ASP.NET 5 использовать подходящую реализацию (в настоящее время — NinjaRepository) всякий раз, когда требуется этот интерфейс:

services.AddScoped<INinjaRepository, NinjaRepository>();

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

Учитесь на деле, а не копированием кода 

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


Джули Лерман (Julie Lerman) Microsoft MVP, преподаватель и консультант по .NET, живет в Вермонте. Часто выступает на конференциях по всему миру и в группах пользователей по тематике, связанной с доступом к данным и другими технологиями Microsoft .NET. Ведет блог thedatafarm.com/blog и является автором серии книг «Programming Entity Framework» (O’Reilly Media, 2010), в том числе «Code First Edition» (2011) и «DbContext Edition» (2012), также выпущенных издательством O’Reilly Media. Вы можете читать ее заметки в @julielerman и смотреть ее видеокурсы для Pluralsight на juliel.me/PS-Videos.

Выражаю благодарность за рецензирование статьи эксперту Стиву Смиту (Steve Smith) (ardalis.com).