Repository
Накратко: Repository скрива базата данни зад интерфейс с методи като „намери“ и „запиши“, затова бизнес правилата не знаят за SQL, а хранилището се сменя, без услугата да разбере.
Проблем
Заявките, пръснати по страницата и касата
Магазинът дава 10% отстъпка на клиент с три поръчки и повече. Страницата на клиента показва дали има отстъпка, а касата я смята при плащане. Всяка пише своя заявка към базата и свое правило:
// Страницата на клиентаfunction showDiscount(db, customer) { const { count } = db.prepare("SELECT COUNT(*) AS count FROM orders WHERE customer = ?").get(customer); const text = count > 3 ? "отстъпка 10%" : "без отстъпка"; console.log(`${customer}: ${text}`);}
// Касатаfunction checkout(db, customer, total) { const rows = db.prepare("SELECT id FROM orders WHERE customer = ?").all(customer); return rows.length >= 3 ? total * 0.9 : total;}
const db = new DatabaseSync(":memory:");setup(db);showDiscount(db, "Мария");console.log(`Мария плаща ${checkout(db, "Мария", 50).toFixed(2)} €`);db.close();Мария: без отстъпкаМария плаща 45.00 €Мария има три поръчки. Страницата ѝ казва „без отстъпка“, защото там правилото е count > 3. Касата ѝ дава отстъпката, защото там е count >= 3. Две места, две версии на едно правило, и никой не забелязва, докато клиент не се оплаче.
Има и втори проблем. Правилото е залепено за заявката. За да провериш дали >= 3 е вярно, ти трябва база с таблица и редове в нея. Таблицата orders и редовете ѝ ги пълни функцията setup, както в уроците за базата в програмата. Тук е скрита, за да не пречи.
Идеята
Repository: данните зад интерфейс
Заявките отиват на едно място: в хранилище. То има няколко метода с имена от езика на магазина, а не на базата: findById, findByCustomer, save. Който пита за поръчки, вика тях и не знае дали отзад има SQL, файл или речник в паметта.
Това е шаблонът Repository (хранилище). Около него кодът се подрежда на слоеве, и всеки слой говори само със слоя под себе си:
- контролерът (страницата, касата) получава заявката и показва отговора;
- услугата държи правилото: „три поръчки и повече дават 10%“;
- хранилището дава и пази данни. В него няма правила;
- данните: паметта, SQLite или друга база.
Услугата получава хранилището отвън, в конструктора. Това е Dependency Injection от урока Singleton и Dependency Injection. Затова под нея може да стои всяко хранилище, което има същите методи.
На JavaScript интерфейсът е по-скоро уговорка, отколкото тип: услугата ще приеме всеки обект, който имаfindByCustomer. В JavaScript interface няма, затова уговорката е коментар над класовете. Шаблонът не изчезва: разделянето на слоеве и правилото на едно място остават същите.Виж го
Договорът и хранилището в паметта
Поръчката и договорът на хранилището: трите метода, без нито ред SQL. В JavaScript поръчката е обикновен обект { id, customer, total }, а договорът е коментар.
// Всяко хранилище на поръчки има тези три метода:// findById(id), findByCustomer(customer), save(order)Първото хранилище пази поръчките в паметта, в речник от номера към поръчката. save със същия номер заменя старата поръчка.
class InMemoryOrderRepository { #orders = new Map();
findById(id) { return this.#orders.get(id) ?? null; }
findByCustomer(customer) { return [...this.#orders.values()].filter((order) => order.customer === customer); }
save(order) { this.#orders.set(order.id, order); }}Една функция пълни всяко хранилище с поръчките на магазина. Тя знае само договора, затова работи и с паметта, и с базата:
function fill(orders) { orders.save({ id: 1, customer: "Мария", total: 80 }); orders.save({ id: 2, customer: "Иван", total: 25.5 }); orders.save({ id: 3, customer: "Мария", total: 40 }); orders.save({ id: 4, customer: "Мария", total: 12.9 });}Виж го
Услугата не знае откъде идват данните
Правилото е в услугата, а контролерът само показва отговора. Никой от двамата не пише SQL:
class LoyaltyService { constructor(orders) { this.orders = orders; }
discountFor(customer) { return this.orders.findByCustomer(customer).length >= 3 ? 10 : 0; }}
class OrderController { constructor(loyalty) { this.loyalty = loyalty; }
show(customer) { const discount = this.loyalty.discountFor(customer); const text = discount > 0 ? `отстъпка ${discount}%` : "без отстъпка"; console.log(`${customer}: ${text}`); }}Една и съща услуга и един и същ контролер, първо върху паметта, после върху SQLite:
const memory = new InMemoryOrderRepository();fill(memory);new OrderController(new LoyaltyService(memory)).show("Мария");
const db = new DatabaseSync(":memory:");const database = new SqliteOrderRepository(db);fill(database);new OrderController(new LoyaltyService(database)).show("Мария");db.close();Мария: отстъпка 10%Мария: отстъпка 10%class LoyaltyService { constructor(orders) { this.orders = orders; }
discountFor(customer) { return this.orders.findByCustomer(customer).length >= 3 ? 10 : 0; }}
class OrderController { constructor(loyalty) { this.loyalty = loyalty; }
show(customer) { const discount = this.loyalty.discountFor(customer); const text = discount > 0 ? `отстъпка ${discount}%` : "без отстъпка"; console.log(`${customer}: ${text}`); }}
const memory = new InMemoryOrderRepository();fill(memory);new OrderController(new LoyaltyService(memory)).show("Мария");
const db = new DatabaseSync(":memory:");const database = new SqliteOrderRepository(db);fill(database);new OrderController(new LoyaltyService(database)).show("Мария");db.close();Стъпка по стъпка, с думи
- Хранилище в паметта, напълнено с поръчките. Услугата го получава, без да знае какво е.
- Контролерът получава заявката за Мария. Той не пита базата: вика услугата.
- Услугата иска поръчките на клиента и ги иска от хранилището.
- findByCustomer на хранилището. Отговаря хранилището в паметта: обхожда речника си.
- Три поръчки на Мария идват нагоре. Правилото е тук и само тук: 3 ≥ 3, значи 10%.
- Контролерът само показва отговора: „Мария: отстъпка 10%“.
- Смяната: на мястото на паметта идва SQLite. Методите са същите, затова услугата и контролерът не се пипат.
- Същото извикване. Различен е само обектът под услугата.
- findByCustomer сега праща SELECT … WHERE customer = ? към таблицата orders.
- Пак три реда, пак 10%. Услугата не разбра, че източникът е друг.
- Същият ред, този път от база.
- Контролер → услуга → хранилище → данни. Смени се само най-долният слой, а правилото остана на едно място.
Смени се само най-долният слой. Услугата и контролерът са същите класове, без нито един променен ред, а Мария вече получава отстъпката и на страницата, и на касата, защото правилото е едно.
Виж го
Истинската база зад същия интерфейс
Ето и хранилището в SQLite. Всеки метод е една заявка с параметри (?), никога текст, долепен в SQL. Защо това е важно, има в урока SQL injection и параметрите.
class SqliteOrderRepository { constructor(db) { this.db = db; db.exec("CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, customer TEXT NOT NULL, total REAL NOT NULL)"); }
findById(id) { return this.db.prepare("SELECT id, customer, total FROM orders WHERE id = ?").get(id) ?? null; }
findByCustomer(customer) { return this.db.prepare("SELECT id, customer, total FROM orders WHERE customer = ? ORDER BY id").all(customer); }
save(order) { this.db .prepare("INSERT INTO orders (id, customer, total) VALUES (?, ?, ?) ON CONFLICT (id) DO UPDATE SET customer = excluded.customer, total = excluded.total") .run(order.id, order.customer, order.total); }}node:sqlite връща редовете като обекти с имената на колоните, затова поръчката от базата изглежда като поръчката от паметта.Това е мястото, където живее SQL на поръчките. Тук може да оправиш бавна заявка, да добавиш индекс или да смениш SQLite с PostgreSQL, а услугата не разбира. Ако хранилището стане ORM, внимавай за заявка на всеки ред: урокът ORM и проблемът N+1 показва как изглежда.
Виж го
Правилото без база
Щом услугата приема всяко хранилище, правилото се проверява с хранилището в паметта: без файл, без сървър, за милисекунди.
const orders = new InMemoryOrderRepository();const loyalty = new LoyaltyService(orders);orders.save({ id: 1, customer: "Мария", total: 80 });orders.save({ id: 2, customer: "Мария", total: 40 });console.log(`2 поръчки: ${loyalty.discountFor("Мария")}%`);orders.save({ id: 3, customer: "Мария", total: 12.9 });console.log(`3 поръчки: ${loyalty.discountFor("Мария")}%`);2 поръчки: 0%3 поръчки: 10%Две поръчки не стигат, третата дава 10%. Това е точно границата, на която страницата и касата се разминаха. Така изглежда и тестът на правилото: пълниш хранилището в паметта и проверяваш какво казва услугата.
Пробвай
Предвиди изхода
Поръчка номер 1 се записва два пъти, с различна сума. Какво печата програмата?
const orders = new InMemoryOrderRepository();orders.save({ id: 1, customer: "Мария", total: 80 });orders.save({ id: 1, customer: "Мария", total: 50 });console.log(orders.findByCustomer("Мария").length);console.log(`${orders.findById(1).total.toFixed(2)} €`);Пробвай
Намери грешката
Някой решава, че търсенето по клиент е бавно, и пише хранилище, което пази поръчките направо по клиент:
class FastOrderRepository { #byCustomer = new Map();
findById(id) { for (const orders of this.#byCustomer.values()) { const found = orders.find((order) => order.id === id); if (found) { return found; } } return null; }
findByCustomer(customer) { return this.#byCustomer.get(customer) ?? []; }
save(order) { for (const [customer, orders] of this.#byCustomer) { this.#byCustomer.set(customer, orders.filter((o) => o.id !== order.id)); } if (!this.#byCustomer.has(order.customer)) { this.#byCustomer.set(order.customer, []); } this.#byCustomer.get(order.customer).push(order); }}В услугата се появява нов метод: „ще има ли отстъпка, ако купи и тази?“. После страницата пита колко поръчки има Иван:
class LoyaltyService { constructor(orders) { this.orders = orders; }
discountFor(customer) { return this.orders.findByCustomer(customer).length >= 3 ? 10 : 0; }
discountIfBuys(customer, cart) { const orders = this.orders.findByCustomer(customer); orders.push(cart); return orders.length >= 3 ? 10 : 0; }}const orders = new FastOrderRepository();fill(orders);const loyalty = new LoyaltyService(orders);console.log(`Ако Иван купи още една: ${loyalty.discountIfBuys("Иван", { id: 5, customer: "Иван", total: 30 })}%`);console.log(`Поръчки на Иван: ${orders.findByCustomer("Иван").length}`);Ако Иван купи още една: 0%Поръчки на Иван: 2Иван има една поръчка, а хранилището казва две. Никой не е викал save. На кой ред от хранилището е грешката?
Избери реда с грешката.
Хранилището връща своя списък, не копие. Услугата добавя кошницата в него, и тя тихо става поръчка на Иван. Хранилището трябва да връща копие: [...]. Тогава викащият може да прави каквото иска със списъка, а данните се променят само през save. С копието изходът е „Поръчки на Иван: 1“. Хранилището в паметта от урока прави нов списък при всяко търсене, затова няма тази грешка.
Идеята
Кога не ти трябва
Repository добавя интерфейс и поне един клас. Не си струва, когато:
- програмата е малък скрипт с една заявка. Слоевете ще са по-дълги от самата работа;
- ползваш ORM, който вече дава хранилище (
модела в Prisma). Твой интерфейс, който само препраща към него ред по ред, е още един файл без нищо ново; - хранилището расте с метод за всяка страница:
findByCustomerAndStatusOrderByTotalи още двайсет такива. Тогава заявките пак са пръснати, само че с по-дълги имена.
Струва си, когато правилата трябва да се проверяват без база, когато източникът може да се смени (база, файл, друга услуга) или когато едни и същи заявки се ползват от много места.
Упражнение
Изтриване от хранилището
Реши задачата в редактора си.
Изтриване. Добави delete(id) към договора и към двете хранилища. В SQLite това е DELETE FROM orders WHERE id = ?. Покажи отстъпката на Мария, изтрий поръчка 4 и я покажи пак, веднъж с паметта и веднъж с базата.
Мария: отстъпка 10%Мария: без отстъпкаМария: отстъпка 10%Мария: без отстъпкаОще задачи
- Правило по сума. Магазинът сменя правилото: 10% при похарчени 150 € и повече. Кой клас пипаш? Провери с хранилището в паметта, без база.
- Трето хранилище. Напиши хранилище, което пази поръчките във файл, ред по ред. Услугата и контролерът трябва да останат без промяна.
- Копие при търсене. Поправи хранилището от „Намери грешката“ и добави проверка: след
discountIfBuysброят на поръчките на Иван е пак 1.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Данни, switch и разглобяване · Шаблони за дизайн · Strategy · Singleton и Dependency Injection
- Продължава в: Магазинът: кой шаблон къде · Слоеве · Hexagonal и clean architecture · Монолит, модулен монолит, услуги
- Задачи по темата: Шаблони за дизайн