ASP.NET Core: Реализация шаблонов проектирования

В этой статье мы поговорим о шаблонах проектирования «Единица работы» и «Репозиторий» в контексте тестового веб-приложения на ASP.NET Core (с использованием встроенного DI), которое мы с вами вместе и разработаем. В результате мы получим две реализации взаимодействия с хранилищем: настоящую, на основе базы данных SQLite, и фейковую, для быстрого тестирования, на основе перечисления в памяти. Переключение между этими двумя реализациями будет выполняться изменением одной строчки кода.

82a1a77bc63f4ab08c075cefd0ae489d.jpg

Подготовка


Традиционно, если вы еще не работали с ASP.NET Core, то здесь есть ссылки на все, что для этого понадобится.

Запускаем Visual Studio, создаем новое веб-приложение:

a59b5428bb684583bcdb9bb1d19988be.png

05a4cf7a4dac48e8baf6856b4abb9103.png

Веб-приложение готово. При желании его можно запустить.

Приступаем


Модели


Начнем с моделей. Вынесем их классы в отдельный проект — библиотеку классов AspNetCoreStorage.Data.Models:

e2e06b04aa564985b7f93a9428494e0c.png

Добавим класс нашей единственной модели Item:

public class Item
{
  public int Id { get; set; }
  public string Name { get; set; }
}

Для нашего примера этого хватит.

Абстракции взаимодействия с хранилищем


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

Для обеспечения возможности простого переключения между различными реализациями взаимодействия с хранилищем, наше веб-приложение не должно использовать какую-то конкретную реализацию напрямую. Вместо этого все взаимодействие с хранилищем должно производиться через слой абстракций. Опишем его в библиотеке классов AspNetCoreStorage.Data.Abstractions (создадим соответствующий проект).

Для начала добавим интерфейс IStorageContext без каких-либо свойств или методов:

public interface IStorageContext
{
}

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

Далее, добавим интерфейс IStorage. Он содержит два метода — GetRepository и Save:

public interface IStorage
{
  T GetRepository() where T : IRepository;
  void Save();
}

Этот интерфейс описывает реализацию шаблона проектирования Единица работы. Объект класса, реализующего этот интерфейс, будет единственной точкой доступа к хранилищу и должен существовать в единственном экземпляре в рамках одного запроса к веб-приложению. За создание этого объекта у нас будет отвечать встроенный в ASP.NET Core DI.

Метод GetRepository будет находить и возвращать репозиторий соответствующего типа (для соответствующей модели), а метод Save — фиксировать изменения, произведенные всеми репозиториями.

Наконец, добавим интерфейс IRepository с единственным методом SetStorageContext:

public interface IRepository
{
  void SetStorageContext(IStorageContext storageContext);
}

Очевидно, что этот интерфейс описывает классы репозиториев. В момент запроса репозитория объект класса, реализующего интерфейс IStorage, будет передавать единый контекст хранилища в возвращаемый репозиторий с помощью метода SetStorageContext, чтобы все обращения к репозиторию производились в рамках этого единого контекста, как мы говорили выше.

На этом общие интерфейсы описаны. Теперь добавим интерфейс репозитория нашей единственной модели Item — IItemRepository. Этот интерфейс содержит лишь один метод — All:

public interface IItemRepository : IRepository
{
  IEnumerable All();
}

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

Конкретные реализации взаимодействия с хранилищем: перечисление в памяти


Как мы уже договорились выше, у нас будет две реализации взаимодействия с хранилищем: на основе базы данных SQLite и на основе перечисления в памяти. Начнем со второй, так как она проще. Опишем ее в библиотеке классов AspNetCoreStorage.Data.Mock (создадим соответствующий проект).

Нам понадобится реализовать 3 интерфейса из нашего слоя абстракций: IStorageContext, IStorage и IItemRepository (т. к. IItemRepository расширяет IRepository).

Реализация интерфейса IStorageContext в случае с перечислением в памяти не будет содержать никакого кода, это просто пустой класс, поэтому перейдем сразу к IStorage. Класс небольшой, поэтому приведем его здесь целиком:

public class Storage : IStorage
{
  public StorageContext StorageContext { get; private set; }

  public Storage()
  {
    this.StorageContext = new StorageContext();
  }

  public T GetRepository() where T : IRepository
  {
    foreach (Type type in this.GetType().GetTypeInfo().Assembly.GetTypes())
    {
      if (typeof(T).GetTypeInfo().IsAssignableFrom(type) && type.GetTypeInfo().IsClass)
      {
        T repository = (T)Activator.CreateInstance(type);

        repository.SetStorageContext(this.StorageContext);
        return repository;
      }
    }

    return default(T);
  }

  public void Save()
  {
    // Do nothing
  }
}

Как видим, класс содержит свойство StorageContext, которое инициализируется в конструкторе. Метод GetRepository перебирает все типы текущей сборки в поисках реализации заданного параметром T интерфейса репозитория. В случае, если подходящий тип обнаружен, создается соответствующий объект репозитория, вызывается его метод SetStorageContext и затем этот объект возвращается. Метод Save не делает ничего. (На самом деле, мы могли бы вообще не использовать StorageContext в этой реализации, передавая null в SetStorageContext, но оставим его для единообразия.)

Теперь посмотрим на реализацию интерфейса IItemRepository:

public class ItemRepository : IItemRepository
{
  public readonly IList items;

  public ItemRepository()
  {
    this.items = new List();
    this.items.Add(new Item() { Id = 1, Name = "Mock item 1" });
    this.items.Add(new Item() { Id = 2, Name = "Mock item 2" });
    this.items.Add(new Item() { Id = 3, Name = "Mock item 3" });
  }

  public void SetStorageContext(IStorageContext storageContext)
  {
    // Do nothing
  }

  public IEnumerable All()
  {
    return this.items.OrderBy(i => i.Name);
  }
}

Все очень просто. Метод All возвращает набор элементов из переменной items, которая инициализируется в конструкторе. Метод SetStorageContext не делает ничего, так как никакого контекста в этом случае нам не нужно.

Конкретные реализации взаимодействия с хранилищем: база данных SQLite


Теперь реализуем те же самые интерфейсы, но уже для работы с базой данных SQLite. На этот раз реализация IStorageContext потребует написания некоторого кода:
public class StorageContext : DbContext, IStorageContext
{
  private string connectionString;

  public StorageContext(string connectionString)
  {
    this.connectionString = connectionString;
  }

  protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
  {
    base.OnConfiguring(optionsBuilder);
    optionsBuilder.UseSqlite(this.connectionString);
  }

  protected override void OnModelCreating(ModelBuilder modelBuilder)
  {
    base.OnModelCreating(modelBuilder);
    modelBuilder.Entity(etb =>
      {
        etb.HasKey(e => e.Id);
        etb.Property(e => e.Id);
        etb.ForSqliteToTable("Items");
      }
    );
  } 
}

Как видим, кроме реализации интерфейса IStorageContext этот класс еще и наследует DbContext, представляющий контекст базы данных в Entity Framework Core, чьи методы OnConfiguring и OnModelCreating он и переопределяет (не будем на них останавливаться). Также обратите внимание на переменную connectionString.

Реализация интерфейса IStorage идентична приведенной выше, за исключением того, что в конструктор класса StorageContext необходимо передать строку подключения (конечно, в реальном приложении указывать строку подключения таким образом неправильно, ее следовало бы взять из параметров конфигурации):

this.StorageContext = new StorageContext("Data Source=..\\..\\..\\db.sqlite");

А также, метод Save должен теперь вызывать метод SaveChanges контекста хранилища, унаследованный от DbContext:

public void Save()
{
  this.StorageContext.SaveChanges();
}

Реализация интерфейса IItemRepository выглядит теперь таким образом:
public class ItemRepository : IItemRepository
{
  private StorageContext storageContext;
  private DbSet dbSet;

  public void SetStorageContext(IStorageContext storageContext)
  {
    this.storageContext = storageContext as StorageContext;
    this.dbSet = this.storageContext.Set();
  }

  public IEnumerable All()
  {
    return this.dbSet.OrderBy(i => i.Name);
  }
}

Метод SetStorageContext принимает объект класса, реализующего интерфейс IStorageContext, и приводит его к StorageContext (то есть к конкретной реализации, о которой этот репозиторий осведомлен, так как сам является ее частью), затем с помощью метода Set инициализирует переменную dbSet, которая представляет таблицу в базе данных SQLite. Метод All на этот раз возвращает реальные данные из таблицы базы данных, используя переменную dbSet.

Конечно, если бы у нас было более одного репозитория, было бы логично вынести общую реализацию в какой-нибудь RepositoryBase, где параметр T описывал бы тип модели, параметризировал dbSet и передавался затем в метод Set контекста хранилища.

Взаимодействие веб-приложения с хранилищем


Теперь мы готовы немного модифицировать наше веб-приложение, чтобы заставить его выводить список объектов нашего класса Item на главной странице.

Для начала, добавим ссылки на обе конкретные реализации взаимодействия с хранилищем в раздел dependencies файла project.json основного проекта веб-приложения. В итоге получится как-то так:

"dependencies": {
  "AspNetCoreStorage.Data.Mock": "1.0.0",
  "AspNetCoreStorage.Data.Sqlite": "1.0.0",
  "Microsoft.AspNetCore.Diagnostics": "1.0.0",
  "Microsoft.AspNetCore.Mvc": "1.0.1",
  "Microsoft.AspNetCore.Server.IISIntegration": "1.0.0",
  "Microsoft.AspNetCore.Server.Kestrel": "1.0.1",
  "Microsoft.Extensions.Logging.Console": "1.0.0",
  "Microsoft.NETCore.App": {
    "version": "1.0.1",
    "type": "platform"
  }
}

Теперь перейдем к методу ConfigureServices класса Startup и добавим туда регистрацию сервиса IStorage для двух разных реализаций (одну из них закомментируем, обратите внимание, что реализации регистрируются с помощью метода AddScoped, что означает, что временем жизни объекта является один запрос):
public void ConfigureServices(IServiceCollection services)
{
  services.AddMvc();
  // Uncomment to use mock storage
  services.AddScoped(typeof(IStorage), typeof(AspNetCoreStorage.Data.Mock.Storage));
  // Uncomment to use SQLite storage
  //services.AddScoped(typeof(IStorage), typeof(AspNetCoreStorage.Data.Sqlite.Storage));
}

Теперь перейдем к контроллеру HomeController:
public class HomeController : Controller
{
  private IStorage storage;

  public HomeController(IStorage storage)
  {
    this.storage = storage;
  }

  public ActionResult Index()
  {
    return this.View(this.storage.GetRepository().All());
  }
}

Мы добавили переменную storage типа IStorage и инициализируем ее в конструкторе. Встроенный в ASP.NET Core DI сам передаст зарегистрированную реализацию интерфейса IStorage в конструктор контроллера во время его создания.

Далее, в методе Index мы получаем доступный репозиторий, реализующий интерфейс IItemRepository (напоминаем, все получаемые таким образом репозитории будут иметь единый контекст хранилища благодаря применению шаблона проектирования Единица работы) и передаем в представление набор объектов класса Item, получив их с помощью метода All репозитория.

Теперь выведем полученный список объектов в представлении. Для этого укажем перечисление объектов класса Item в качестве модели вида для представления, а затем в цикле выведем значения свойства Name каждого из объектов:

@model IEnumerable

Items from the storage:

    @foreach (var item in this.Model) {
  • @item.Name
  • }

Если сейчас запустить наше веб-приложение мы должны получить следующий результат:

9515bddcac024bf69aefe5a883b37482.png

Если же мы поменяем регистрацию реализации интерфейса IStorage на другую, то и результат изменится:

deff839633bd42128758d6002b666323.png

Как видим, все работает!

Заключение


Встроенный в ASP.NET Core механизм внедрения зависимостей (DI) очень упрощает реализацию подобных нашей задач и делает ее более близкой, простой и понятной новичкам. Что касается непосредственно Единицы работы и Репозитория — для типичных веб-приложений это наиболее удачное решение взаимодействия с данными, упрощающее командную разработку и тестирование.

Тестовый проект выложен на GitHub.

Об авторе


9a392d5cd33e4dab80410b6c40ba95b5.jpg
Дмитрий Сикорский — владелец и руководитель компании-разработчика программного обеспечения «Юбрейнианс», а также, совладелец киевской службы доставки пиццы «Пиццариум».

Последние статьи по ASP.NET Core


1. Создание внешнего интерфейса веб-службы для приложения.
2. В ногу со временем: Используем JWT в ASP.NET Core.
3. ASP.NET Core на Nano Server.

Комментарии (0)

© Habrahabr.ru