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()Стъпка по стъпка, с думи
- Хранилище в паметта, напълнено с поръчките. Услугата го получава, без да знае какво е.
- Контролерът получава заявката за Мария. Той не пита базата: вика услугата.
- Услугата иска поръчките на клиента и ги иска от хранилището.
- find_by_customer на OrderRepository. Отговаря хранилището в паметта: обхожда речника си.
- Три поръчки на Мария идват нагоре. Правилото е тук и само тук: 3 ≥ 3, значи 10%.
- Контролерът само показва отговора: „Мария: отстъпка 10%“.
- Смяната: на мястото на паметта идва SQLite. Интерфейсът е същият OrderRepository, затова услугата и контролерът не се пипат.
- Същото извикване. Различен е само обектът под услугата.
- find_by_customer сега праща SELECT … WHERE customer = ? към таблицата orders.
- Пак три реда, пак 10%. Услугата не разбра, че източникът е друг.
- Същият ред, този път от база.
- Контролер → услуга → хранилище → данни. Смени се само най-долният слой, а правилото остана на едно място.
Смени се само най-долният слой. Услугата и контролерът са същите класове, без нито един променен ред, а Мария вече получава отстъпката и на страницата, и на касата, защото правилото е едно.
Виж го
Истинската база зад същия интерфейс
Ето и хранилището в 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 0orders = 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. На кой ред от хранилището е грешката?
Избери реда с грешката.
Хранилището връща своя списък, не копие. Услугата добавя кошницата в него, и тя тихо става поръчка на Иван. Хранилището трябва да връща копие: list(...). Тогава викащият може да прави каквото иска със списъка, а данните се променят само през save. С копието изходът е „Поръчки на Иван: 1“. Хранилището в паметта от урока прави нов списък при всяко търсене, затова няма тази грешка.
Идеята
Кога не ти трябва
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.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: dataclass, match и шаблони · Шаблони за дизайн · Strategy · Singleton и Dependency Injection
- Продължава в: Магазинът: кой шаблон къде · Слоеве · Hexagonal и clean architecture · Монолит, модулен монолит, услуги
- Задачи по темата: Шаблони за дизайн