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

Repository

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

Проблем

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

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

# Страницата на клиента
def show_discount(conn, customer):
(count,) = conn.execute("SELECT COUNT(*) FROM orders WHERE customer = ?", (customer,)).fetchone()
text = "отстъпка 10%" if count > 3 else "без отстъпка"
print(f"{customer}: {text}")
# Касата
def checkout(conn, customer, total):
rows = conn.execute("SELECT id FROM orders WHERE customer = ?", (customer,)).fetchall()
return total * 0.9 if len(rows) >= 3 else total
conn = sqlite3.connect(":memory:")
setup(conn)
show_discount(conn, "Мария")
print(f"Мария плаща {checkout(conn, 'Мария', 50.00):.2f} €")
conn.close()
Изход
Мария: без отстъпка
Мария плаща 45.00 €

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

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

Идеята

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

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

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

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

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

На Python интерфейсът е по-скоро уговорка, отколкото тип: услугата ще приеме всеки обект, който има find_by_customer. Базовият клас с ABC тук само записва уговорката и казва рано, ако някой метод липсва. Шаблонът не изчезва: разделянето на слоеве и правилото на едно място остават същите.

Виж го

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

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

@dataclass(frozen=True)
class Order:
id: int
customer: str
total: float
class OrderRepository(ABC):
@abstractmethod
def find_by_id(self, order_id): ...
@abstractmethod
def find_by_customer(self, customer): ...
@abstractmethod
def save(self, order): ...

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

class InMemoryOrderRepository(OrderRepository):
def __init__(self):
self.orders = {}
def find_by_id(self, order_id):
return self.orders.get(order_id)
def find_by_customer(self, customer):
return [order for order in self.orders.values() if order.customer == customer]
def save(self, order):
self.orders[order.id] = order

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

def fill(orders):
orders.save(Order(1, "Мария", 80.00))
orders.save(Order(2, "Иван", 25.50))
orders.save(Order(3, "Мария", 40.00))
orders.save(Order(4, "Мария", 12.90))

Виж го

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

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

class LoyaltyService:
def __init__(self, orders):
self.orders = orders
def discount_for(self, customer):
return 10 if len(self.orders.find_by_customer(customer)) >= 3 else 0
class OrderController:
def __init__(self, loyalty):
self.loyalty = loyalty
def show(self, customer):
discount = self.loyalty.discount_for(customer)
text = f"отстъпка {discount}%" if discount > 0 else "без отстъпка"
print(f"{customer}: {text}")

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

memory = InMemoryOrderRepository()
fill(memory)
OrderController(LoyaltyService(memory)).show("Мария")
conn = sqlite3.connect(":memory:")
database = SqliteOrderRepository(conn)
fill(database)
OrderController(LoyaltyService(database)).show("Мария")
conn.close()
Изход
Мария: отстъпка 10%
Мария: отстъпка 10%

class LoyaltyService:
def __init__(self, orders):
self.orders = orders
def discount_for(self, customer):
return 10 if len(self.orders.find_by_customer(customer)) >= 3 else 0
class OrderController:
def __init__(self, loyalty):
self.loyalty = loyalty
def show(self, customer):
discount = self.loyalty.discount_for(customer)
text = f"отстъпка {discount}%" if discount > 0 else "без отстъпка"
print(f"{customer}: {text}")
memory = InMemoryOrderRepository()
fill(memory)
OrderController(LoyaltyService(memory)).show("Мария")
conn = sqlite3.connect(":memory:")
database = SqliteOrderRepository(conn)
fill(database)
OrderController(LoyaltyService(database)).show("Мария")
conn.close()
Стъпка по стъпка, с думи
  1. Хранилище в паметта, напълнено с поръчките. Услугата го получава, без да знае какво е.
  2. Контролерът получава заявката за Мария. Той не пита базата: вика услугата.
  3. Услугата иска поръчките на клиента и ги иска от хранилището.
  4. find_by_customer на OrderRepository. Отговаря хранилището в паметта: обхожда речника си.
  5. Три поръчки на Мария идват нагоре. Правилото е тук и само тук: 3 ≥ 3, значи 10%.
  6. Контролерът само показва отговора: „Мария: отстъпка 10%“.
  7. Смяната: на мястото на паметта идва SQLite. Интерфейсът е същият OrderRepository, затова услугата и контролерът не се пипат.
  8. Същото извикване. Различен е само обектът под услугата.
  9. find_by_customer сега праща SELECT … WHERE customer = ? към таблицата orders.
  10. Пак три реда, пак 10%. Услугата не разбра, че източникът е друг.
  11. Същият ред, този път от база.
  12. Контролер → услуга → хранилище → данни. Смени се само най-долният слой, а правилото остана на едно място.

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

Виж го

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

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

class SqliteOrderRepository(OrderRepository):
def __init__(self, conn):
self.conn = conn
conn.execute("CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, customer TEXT NOT NULL, total REAL NOT NULL)")
def find_by_id(self, order_id):
row = self.conn.execute("SELECT id, customer, total FROM orders WHERE id = ?", (order_id,)).fetchone()
return Order(*row) if row else None
def find_by_customer(self, customer):
rows = self.conn.execute("SELECT id, customer, total FROM orders WHERE customer = ? ORDER BY id", (customer,))
return [Order(*row) for row in rows]
def save(self, order):
self.conn.execute(
"INSERT INTO orders (id, customer, total) VALUES (?, ?, ?) ON CONFLICT (id) DO UPDATE SET customer = excluded.customer, total = excluded.total",
(order.id, order.customer, order.total),
)
Order(*row) прави поръчка от реда: колоните са в същия ред като полетата на Order.

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

Виж го

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

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

orders = InMemoryOrderRepository()
loyalty = LoyaltyService(orders)
orders.save(Order(1, "Мария", 80.00))
orders.save(Order(2, "Мария", 40.00))
print(f"2 поръчки: {loyalty.discount_for('Мария')}%")
orders.save(Order(3, "Мария", 12.90))
print(f"3 поръчки: {loyalty.discount_for('Мария')}%")
Изход
2 поръчки: 0%
3 поръчки: 10%

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

Пробвай

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

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

orders = InMemoryOrderRepository()
orders.save(Order(1, "Мария", 80.00))
orders.save(Order(1, "Мария", 50.00))
print(len(orders.find_by_customer("Мария")))
print(f"{orders.find_by_id(1).total:.2f} €")
Отговор

Пробвай

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

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

class FastOrderRepository(OrderRepository):
def __init__(self):
self.by_customer = {}
def find_by_id(self, order_id):
for orders in self.by_customer.values():
for order in orders:
if order.id == order_id:
return order
return None
def find_by_customer(self, customer):
return self.by_customer.get(customer, [])
def save(self, order):
for orders in self.by_customer.values():
orders[:] = [o for o in orders if o.id != order.id]
self.by_customer.setdefault(order.customer, []).append(order)

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

class LoyaltyService:
def __init__(self, orders):
self.orders = orders
def discount_for(self, customer):
return 10 if len(self.orders.find_by_customer(customer)) >= 3 else 0
def discount_if_buys(self, customer, cart):
orders = self.orders.find_by_customer(customer)
orders.append(cart)
return 10 if len(orders) >= 3 else 0
orders = FastOrderRepository()
fill(orders)
loyalty = LoyaltyService(orders)
print(f"Ако Иван купи още една: {loyalty.discount_if_buys('Иван', Order(5, 'Иван', 30.00))}%")
print(f"Поръчки на Иван: {len(orders.find_by_customer('Иван'))}")
Изход
Ако Иван купи още една: 0%
Поръчки на Иван: 2

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

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

Идеята

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

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

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

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

Упражнение

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

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

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

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

Още задачи

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

Тест

Провери се

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

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