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("Мария");Стъпка по стъпка, с думи
- Хранилище в паметта, напълнено с поръчките. Услугата го получава, без да знае какво е.
- Контролерът получава заявката за Мария. Той не пита базата: вика услугата.
- Услугата иска поръчките на клиента и ги иска от хранилището.
- FindByCustomer на IOrderRepository. Отговаря хранилището в паметта: обхожда речника си.
- Три поръчки на Мария идват нагоре. Правилото е тук и само тук: 3 ≥ 3, значи 10%.
- Контролерът само показва отговора: „Мария: отстъпка 10%“.
- Смяната: на мястото на паметта идва SQLite. Интерфейсът е същият IOrderRepository, затова услугата и контролерът не се пипат.
- Същото извикване. Различен е само обектът под услугата.
- FindByCustomer сега праща SELECT … WHERE customer = ? към таблицата orders.
- Пак три реда, пак 10%. Услугата не разбра, че източникът е друг.
- Същият ред, този път от база.
- Контролер → услуга → хранилище → данни. Смени се само най-долният слой, а правилото остана на едно място.
Смени се само най-долният слой. Услугата и контролерът са същите класове, без нито един променен ред, а Мария вече получава отстъпката и на страницата, и на касата, защото правилото е едно.
Виж го
Истинската база зад същия интерфейс
Ето и хранилището в 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. На кой ред от хранилището е грешката?
Избери реда с грешката.
Хранилището връща своя списък, не копие. Услугата добавя кошницата в него, и тя тихо става поръчка на Иван. Хранилището трябва да връща копие: new List<Order>(...). Тогава викащият може да прави каквото иска със списъка, а данните се променят само през Save. С копието изходът е „Поръчки на Иван: 1“. Хранилището в паметта от урока прави нов списък при всяко търсене, затова няма тази грешка.
Друг начин в C#: интерфейсът да връща IReadOnlyList<Order>. Тогава Add не се компилира, и грешката се хваща, преди програмата да тръгне.
Идеята
Кога не ти трябва
Repository добавя интерфейс и поне един клас. Не си струва, когато:
- програмата е малък скрипт с една заявка. Слоевете ще са по-дълги от самата работа;
- ползваш ORM, който вече дава хранилище (
DbContext в Entity Framework). Твой интерфейс, който само препраща към него ред по ред, е още един файл без нищо ново; - хранилището расте с метод за всяка страница:
FindByCustomerAndStatusOrderByTotalи още двайсет такива. Тогава заявките пак са пръснати, само че с по-дълги имена.
Струва си, когато правилата трябва да се проверяват без база, когато източникът може да се смени (база, файл, друга услуга) или когато едни и същи заявки се ползват от много места.
Упражнение
Изтриване от хранилището
Реши задачата в редактора си.
Изтриване. Добави Delete(id) към договора и към двете хранилища. В SQLite това е DELETE FROM orders WHERE id = ?. Покажи отстъпката на Мария, изтрий поръчка 4 и я покажи пак, веднъж с паметта и веднъж с базата.
Мария: отстъпка 10%Мария: без отстъпкаМария: отстъпка 10%Мария: без отстъпкаОще задачи
- Правило по сума. Магазинът сменя правилото: 10% при похарчени 150 € и повече. Кой клас пипаш? Провери с хранилището в паметта, без база.
- Трето хранилище. Напиши хранилище, което пази поръчките във файл, ред по ред. Услугата и контролерът трябва да останат без промяна.
- Копие при търсене. Поправи хранилището от „Намери грешката“ и добави проверка: след
DiscountIfBuysброят на поръчките на Иван е пак 1.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: record, switch и шаблони · Шаблони за дизайн · Strategy · Singleton и Dependency Injection
- Продължава в: Магазинът: кой шаблон къде · Слоеве · Hexagonal и clean architecture · Монолит, модулен монолит, услуги
- Задачи по темата: Шаблони за дизайн