В этой статье мы поговорим о шаблонах проектирования «Единица работы» и «Репозиторий» в контексте тестового веб-приложения на ASP.NET Core (с использованием встроенного DI), которое мы с вами вместе и разработаем. В результате мы получим две реализации взаимодействия с хранилищем: настоящую, на основе базы данных SQLite, и фейковую, для быстрого тестирования, на основе перечисления в памяти. Переключение между этими двумя реализациями будет выполняться изменением одной строчки кода.
Подготовка
Традиционно, если вы еще не работали с ASP.NET Core, то здесь есть ссылки на все, что для этого понадобится.
Запускаем Visual Studio, создаем новое веб-приложение:
Веб-приложение готово. При желании его можно запустить.
Приступаем
Модели
Начнем с моделей. Вынесем их классы в отдельный проект — библиотеку классов AspNetCoreStorage.Data.Models:
Добавим класс нашей единственной модели 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<T>() 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<Item> 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<T>() 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<Item> items;
public ItemRepository()
{
this.items = new List<Item>();
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<Item> 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<Item>(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<Item> dbSet;
public void SetStorageContext(IStorageContext storageContext)
{
this.storageContext = storageContext as StorageContext;
this.dbSet = this.storageContext.Set<Item>();
}
public IEnumerable<Item> 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<IItemRepository>().All());
}
}
Мы добавили переменную storage типа IStorage и инициализируем ее в конструкторе. Встроенный в ASP.NET Core DI сам передаст зарегистрированную реализацию интерфейса IStorage в конструктор контроллера во время его создания.
Далее, в методе Index мы получаем доступный репозиторий, реализующий интерфейс IItemRepository (напоминаем, все получаемые таким образом репозитории будут иметь единый контекст хранилища благодаря применению шаблона проектирования Единица работы) и передаем в представление набор объектов класса Item, получив их с помощью метода All репозитория.
Теперь выведем полученный список объектов в представлении. Для этого укажем перечисление объектов класса Item в качестве модели вида для представления, а затем в цикле выведем значения свойства Name каждого из объектов:
@model IEnumerable<AspNetCoreStorage.Data.Models.Item>
<h1>Items from the storage:</h1>
<ul>
@foreach (var item in this.Model)
{
<li>@item.Name</li>
}
</ul>
Если сейчас запустить наше веб-приложение мы должны получить следующий результат:
Если же мы поменяем регистрацию реализации интерфейса IStorage на другую, то и результат изменится:
Как видим, все работает!
Заключение
Встроенный в ASP.NET Core механизм внедрения зависимостей (DI) очень упрощает реализацию подобных нашей задач и делает ее более близкой, простой и понятной новичкам. Что касается непосредственно Единицы работы и Репозитория — для типичных веб-приложений это наиболее удачное решение взаимодействия с данными, упрощающее командную разработку и тестирование.
Тестовый проект выложен на GitHub.
Об авторе
Дмитрий Сикорский — владелец и руководитель компании-разработчика программного обеспечения «Юбрейнианс», а также, совладелец киевской службы доставки пиццы «Пиццариум».
Последние статьи по ASP.NET Core
1. Создание внешнего интерфейса веб-службы для приложения.
2. В ногу со временем: Используем JWT в ASP.NET Core.
3. ASP.NET Core на Nano Server.
Автор: Microsoft