Към съдържанието
wannacode

Repository

Накратко: Repository скрива базата данни зад интерфейс с методи като „намери“ и „запиши“, затова бизнес правилата не знаят за SQL, а хранилището се сменя, без услугата да разбере.

Проблем

Заявките, пръснати по страницата и касата

Магазинът дава 10% отстъпка на клиент с три поръчки и повече. Страницата на клиента показва дали има отстъпка, а касата я смята при плащане. Всяка пише своя заявка към базата и свое правило:

using var conn = new SqliteConnection("Data Source=:memory:");
conn.Open();
Setup(conn);
ShowDiscount(conn, "Мария");
Console.WriteLine($"Мария плаща {Checkout(conn, "Мария", 50.00m):F2} €");
// Страницата на клиента
static void ShowDiscount(SqliteConnection conn, string customer)
{
var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM orders WHERE customer = $customer";
cmd.Parameters.AddWithValue("$customer", customer);
var count = Convert.ToInt32(cmd.ExecuteScalar());
var text = count > 3 ? "отстъпка 10%" : "без отстъпка";
Console.WriteLine($"{customer}: {text}");
}
// Касата
static decimal Checkout(SqliteConnection conn, string customer, decimal total)
{
var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id FROM orders WHERE customer = $customer";
cmd.Parameters.AddWithValue("$customer", customer);
using var reader = cmd.ExecuteReader();
var count = 0;
while (reader.Read())
{
count++;
}
return count >= 3 ? total * 0.9m : total;
}
Изход
Мария: без отстъпка
Мария плаща 45.00 €

Мария има три поръчки. Страницата ѝ казва „без отстъпка“, защото там правилото е count > 3. Касата ѝ дава отстъпката, защото там е count >= 3. Две места, две версии на едно правило, и никой не забелязва, докато клиент не се оплаче.

Има и втори проблем. Правилото е залепено за заявката. За да провериш дали >= 3 е вярно, ти трябва база с таблица и редове в нея. Таблицата orders и редовете ѝ ги пълни функцията Setup, както в уроците за базата в програмата. Тук е скрита, за да не пречи.

Идеята

Repository: данните зад интерфейс

Заявките отиват на едно място: в хранилище. То има няколко метода с имена от езика на магазина, а не на базата: FindById, FindByCustomer, Save. Който пита за поръчки, вика тях и не знае дали отзад има SQL, файл или речник в паметта.

Това е шаблонът Repository (хранилище). Около него кодът се подрежда на слоеве, и всеки слой говори само със слоя под себе си:

  • контролерът (страницата, касата) получава заявката и показва отговора;
  • услугата държи правилото: „три поръчки и повече дават 10%“;
  • хранилището дава и пази данни. В него няма правила;
  • данните: паметта, SQLite или друга база.

Услугата получава хранилището отвън, в конструктора. Това е Dependency Injection от урока Singleton и Dependency Injection. Затова под нея може да стои всяко хранилище, което има същите методи.

Виж го

Договорът и хранилището в паметта

Поръчката и договорът на хранилището: трите метода, без нито ред SQL.

record Order(int Id, string Customer, decimal Total);
interface IOrderRepository
{
Order? FindById(int id);
List<Order> FindByCustomer(string customer);
void Save(Order order);
}

Първото хранилище пази поръчките в паметта, в речник от номера към поръчката. Save със същия номер заменя старата поръчка.

class InMemoryOrderRepository : IOrderRepository
{
private readonly Dictionary<int, Order> orders = new();
public Order? FindById(int id) => orders.GetValueOrDefault(id);
public List<Order> FindByCustomer(string customer)
{
var result = new List<Order>();
foreach (var order in orders.Values)
{
if (order.Customer == customer)
{
result.Add(order);
}
}
return result;
}
public void Save(Order order) => orders[order.Id] = order;
}

Една функция пълни всяко хранилище с поръчките на магазина. Тя знае само договора, затова работи и с паметта, и с базата:

static void Fill(IOrderRepository orders)
{
orders.Save(new Order(1, "Мария", 80.00m));
orders.Save(new Order(2, "Иван", 25.50m));
orders.Save(new Order(3, "Мария", 40.00m));
orders.Save(new Order(4, "Мария", 12.90m));
}

Виж го

Услугата не знае откъде идват данните

Правилото е в услугата, а контролерът само показва отговора. Никой от двамата не пише SQL:

class LoyaltyService
{
private readonly IOrderRepository orders;
public LoyaltyService(IOrderRepository orders)
{
this.orders = orders;
}
public int DiscountFor(string customer) => orders.FindByCustomer(customer).Count >= 3 ? 10 : 0;
}
class OrderController
{
private readonly LoyaltyService loyalty;
public OrderController(LoyaltyService loyalty)
{
this.loyalty = loyalty;
}
public void Show(string customer)
{
var discount = loyalty.DiscountFor(customer);
var text = discount > 0 ? $"отстъпка {discount}%" : "без отстъпка";
Console.WriteLine($"{customer}: {text}");
}
}

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

IOrderRepository memory = new InMemoryOrderRepository();
Fill(memory);
new OrderController(new LoyaltyService(memory)).Show("Мария");
using var conn = new SqliteConnection("Data Source=:memory:");
conn.Open();
IOrderRepository database = new SqliteOrderRepository(conn);
Fill(database);
new OrderController(new LoyaltyService(database)).Show("Мария");
Изход
Мария: отстъпка 10%
Мария: отстъпка 10%

class LoyaltyService
{
private readonly IOrderRepository orders;
public LoyaltyService(IOrderRepository orders)
{
this.orders = orders;
}
public int DiscountFor(string customer) => orders.FindByCustomer(customer).Count >= 3 ? 10 : 0;
}
class OrderController
{
private readonly LoyaltyService loyalty;
public OrderController(LoyaltyService loyalty)
{
this.loyalty = loyalty;
}
public void Show(string customer)
{
var discount = loyalty.DiscountFor(customer);
var text = discount > 0 ? $"отстъпка {discount}%" : "без отстъпка";
Console.WriteLine($"{customer}: {text}");
}
}
IOrderRepository memory = new InMemoryOrderRepository();
Fill(memory);
new OrderController(new LoyaltyService(memory)).Show("Мария");
using var conn = new SqliteConnection("Data Source=:memory:");
conn.Open();
IOrderRepository database = new SqliteOrderRepository(conn);
Fill(database);
new OrderController(new LoyaltyService(database)).Show("Мария");
Стъпка по стъпка, с думи
  1. Хранилище в паметта, напълнено с поръчките. Услугата го получава, без да знае какво е.
  2. Контролерът получава заявката за Мария. Той не пита базата: вика услугата.
  3. Услугата иска поръчките на клиента и ги иска от хранилището.
  4. FindByCustomer на IOrderRepository. Отговаря хранилището в паметта: обхожда речника си.
  5. Три поръчки на Мария идват нагоре. Правилото е тук и само тук: 3 ≥ 3, значи 10%.
  6. Контролерът само показва отговора: „Мария: отстъпка 10%“.
  7. Смяната: на мястото на паметта идва SQLite. Интерфейсът е същият IOrderRepository, затова услугата и контролерът не се пипат.
  8. Същото извикване. Различен е само обектът под услугата.
  9. FindByCustomer сега праща SELECT … WHERE customer = ? към таблицата orders.
  10. Пак три реда, пак 10%. Услугата не разбра, че източникът е друг.
  11. Същият ред, този път от база.
  12. Контролер → услуга → хранилище → данни. Смени се само най-долният слой, а правилото остана на едно място.

Смени се само най-долният слой. Услугата и контролерът са същите класове, без нито един променен ред, а Мария вече получава отстъпката и на страницата, и на касата, защото правилото е едно.

Виж го

Истинската база зад същия интерфейс

Ето и хранилището в SQLite. Всеки метод е една заявка с параметри ($customer), никога текст, долепен в SQL. Защо това е важно, има в урока SQL injection и параметрите.

class SqliteOrderRepository : IOrderRepository
{
private readonly SqliteConnection conn;
public SqliteOrderRepository(SqliteConnection conn)
{
this.conn = conn;
var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, customer TEXT NOT NULL, total REAL NOT NULL)";
cmd.ExecuteNonQuery();
}
public Order? FindById(int id)
{
var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, customer, total FROM orders WHERE id = $id";
cmd.Parameters.AddWithValue("$id", id);
using var reader = cmd.ExecuteReader();
return reader.Read() ? Read(reader) : null;
}
public List<Order> FindByCustomer(string customer)
{
var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, customer, total FROM orders WHERE customer = $customer ORDER BY id";
cmd.Parameters.AddWithValue("$customer", customer);
using var reader = cmd.ExecuteReader();
var result = new List<Order>();
while (reader.Read())
{
result.Add(Read(reader));
}
return result;
}
public void Save(Order order)
{
var cmd = conn.CreateCommand();
cmd.CommandText = "INSERT INTO orders (id, customer, total) VALUES ($id, $customer, $total) ON CONFLICT (id) DO UPDATE SET customer = excluded.customer, total = excluded.total";
cmd.Parameters.AddWithValue("$id", order.Id);
cmd.Parameters.AddWithValue("$customer", order.Customer);
cmd.Parameters.AddWithValue("$total", order.Total);
cmd.ExecuteNonQuery();
}
private static Order Read(SqliteDataReader r) => new(r.GetInt32(0), r.GetString(1), r.GetDecimal(2));
}
IOrderRepository не знае за SqliteConnection. Само това хранилище го знае, а услугата вижда интерфейса.

Това е мястото, където живее SQL на поръчките. Тук може да оправиш бавна заявка, да добавиш индекс или да смениш SQLite с PostgreSQL, а услугата не разбира. Ако хранилището стане ORM, внимавай за заявка на всеки ред: урокът ORM и проблемът N+1 показва как изглежда.

Виж го

Правилото без база

Щом услугата приема всяко хранилище, правилото се проверява с хранилището в паметта: без файл, без сървър, за милисекунди.

var orders = new InMemoryOrderRepository();
var loyalty = new LoyaltyService(orders);
orders.Save(new Order(1, "Мария", 80.00m));
orders.Save(new Order(2, "Мария", 40.00m));
Console.WriteLine($"2 поръчки: {loyalty.DiscountFor("Мария")}%");
orders.Save(new Order(3, "Мария", 12.90m));
Console.WriteLine($"3 поръчки: {loyalty.DiscountFor("Мария")}%");
Изход
2 поръчки: 0%
3 поръчки: 10%

Две поръчки не стигат, третата дава 10%. Това е точно границата, на която страницата и касата се разминаха. Така изглежда и тестът на правилото: пълниш хранилището в паметта и проверяваш какво казва услугата.

Пробвай

Предвиди изхода

Поръчка номер 1 се записва два пъти, с различна сума. Какво печата програмата?

IOrderRepository orders = new InMemoryOrderRepository();
orders.Save(new Order(1, "Мария", 80.00m));
orders.Save(new Order(1, "Мария", 50.00m));
Console.WriteLine(orders.FindByCustomer("Мария").Count);
Console.WriteLine($"{orders.FindById(1)!.Total:F2} €");
Отговор

Пробвай

Намери грешката

Някой решава, че търсенето по клиент е бавно, и пише хранилище, което пази поръчките направо по клиент:

class FastOrderRepository : IOrderRepository
{
private readonly Dictionary<string, List<Order>> byCustomer = new();
public Order? FindById(int id)
{
foreach (var orders in byCustomer.Values)
{
foreach (var order in orders)
{
if (order.Id == id)
{
return order;
}
}
}
return null;
}
public List<Order> FindByCustomer(string customer) => byCustomer.GetValueOrDefault(customer) ?? new List<Order>();
public void Save(Order order)
{
foreach (var orders in byCustomer.Values)
{
orders.RemoveAll(o => o.Id == order.Id);
}
if (!byCustomer.ContainsKey(order.Customer))
{
byCustomer[order.Customer] = new List<Order>();
}
byCustomer[order.Customer].Add(order);
}
}

В услугата се появява нов метод: „ще има ли отстъпка, ако купи и тази?“. После страницата пита колко поръчки има Иван:

class LoyaltyService
{
private readonly IOrderRepository orders;
public LoyaltyService(IOrderRepository orders)
{
this.orders = orders;
}
public int DiscountFor(string customer) => orders.FindByCustomer(customer).Count >= 3 ? 10 : 0;
public int DiscountIfBuys(string customer, Order cart)
{
var list = orders.FindByCustomer(customer);
list.Add(cart);
return list.Count >= 3 ? 10 : 0;
}
}
IOrderRepository orders = new FastOrderRepository();
Fill(orders);
var loyalty = new LoyaltyService(orders);
Console.WriteLine($"Ако Иван купи още една: {loyalty.DiscountIfBuys("Иван", new Order(5, "Иван", 30.00m))}%");
Console.WriteLine($"Поръчки на Иван: {orders.FindByCustomer("Иван").Count}");
Изход
Ако Иван купи още една: 0%
Поръчки на Иван: 2

Иван има една поръчка, а хранилището казва две. Никой не е викал Save. На кой ред от хранилището е грешката?

Избери реда с грешката.

Идеята

Кога не ти трябва

Repository добавя интерфейс и поне един клас. Не си струва, когато:

  • програмата е малък скрипт с една заявка. Слоевете ще са по-дълги от самата работа;
  • ползваш ORM, който вече дава хранилище (DbContext в Entity Framework). Твой интерфейс, който само препраща към него ред по ред, е още един файл без нищо ново;
  • хранилището расте с метод за всяка страница: FindByCustomerAndStatusOrderByTotal и още двайсет такива. Тогава заявките пак са пръснати, само че с по-дълги имена.

Струва си, когато правилата трябва да се проверяват без база, когато източникът може да се смени (база, файл, друга услуга) или когато едни и същи заявки се ползват от много места.

Упражнение

Изтриване от хранилището

Реши задачата в редактора си.

Изтриване. Добави Delete(id) към договора и към двете хранилища. В SQLite това е DELETE FROM orders WHERE id = ?. Покажи отстъпката на Мария, изтрий поръчка 4 и я покажи пак, веднъж с паметта и веднъж с базата.

Изход
Мария: отстъпка 10%
Мария: без отстъпка
Мария: отстъпка 10%
Мария: без отстъпка

Още задачи

  • Правило по сума. Магазинът сменя правилото: 10% при похарчени 150 € и повече. Кой клас пипаш? Провери с хранилището в паметта, без база.
  • Трето хранилище. Напиши хранилище, което пази поръчките във файл, ред по ред. Услугата и контролерът трябва да останат без промяна.
  • Копие при търсене. Поправи хранилището от „Намери грешката“ и добави проверка: след DiscountIfBuys броят на поръчките на Иван е пак 1.

Тест

Провери се

1. Къде живее правилото „3 поръчки и повече дават 10% отстъпка“?
2. Защо правилото може да се провери без база?
3. Хранилището връща списъка, който пази вътре, а викащият добавя в него. Какво става?
4. Проектът ползва ORM, който вече дава хранилище за всяка таблица. Струва ли си собствен интерфейс със същите методи отгоре?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·