ASP.NET Core: Приклад реалізації шаблонів проектування Одиниця роботи і Репозиторій

У цій статті ми поговоримо про шаблони проектування «Одиниця роботи» і «Сховище» в контексті тестового веб-додатки на 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>() де 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>() де 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.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.