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

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();
Стъпка по стъпка, с думи
  1. Хранилище в паметта, напълнено с поръчките. Услугата го получава, без да знае какво е.
  2. Контролерът получава заявката за Мария. Той не пита базата: вика услугата.
  3. Услугата иска поръчките на клиента и ги иска от хранилището.
  4. findByCustomer на хранилището. Отговаря хранилището в паметта: обхожда речника си.
  5. Три поръчки на Мария идват нагоре. Правилото е тук и само тук: 3 ≥ 3, значи 10%.
  6. Контролерът само показва отговора: „Мария: отстъпка 10%“.
  7. Смяната: на мястото на паметта идва SQLite. Методите са същите, затова услугата и контролерът не се пипат.
  8. Същото извикване. Различен е само обектът под услугата.
  9. findByCustomer сега праща SELECT … WHERE customer = ? към таблицата orders.
  10. Пак три реда, пак 10%. Услугата не разбра, че източникът е друг.
  11. Същият ред, този път от база.
  12. Контролер → услуга → хранилище → данни. Смени се само най-долният слой, а правилото остана на едно място.

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

Виж го

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

Ето и хранилището в 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. На кой ред от хранилището е грешката?

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

Идеята

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

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

  • програмата е малък скрипт с една заявка. Слоевете ще са по-дълги от самата работа;
  • ползваш ORM, който вече дава хранилище (модела в Prisma). Твой интерфейс, който само препраща към него ред по ред, е още един файл без нищо ново;
  • хранилището расте с метод за всяка страница: 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 за достъпно обучение по програмиране ·