Repository
Накратко: Repository скрива базата данни зад интерфейс с методи като „намери“ и „запиши“, затова бизнес правилата не знаят за SQL, а хранилището се сменя, без услугата да разбере.
Проблем
Заявките, пръснати по страницата и касата
Магазинът дава 10% отстъпка на клиент с три поръчки и повече. Страницата на клиента показва дали има отстъпка, а касата я смята при плащане. Всяка пише своя заявка към базата и свое правило:
// Страницата на клиентаstatic void showDiscount(Connection conn, String customer) throws SQLException { try (PreparedStatement st = conn.prepareStatement("SELECT COUNT(*) FROM orders WHERE customer = ?")) { st.setString(1, customer); try (ResultSet rs = st.executeQuery()) { rs.next(); int count = rs.getInt(1); String text = count > 3 ? "отстъпка 10%" : "без отстъпка"; System.out.println(customer + ": " + text); } }}
// Касатаstatic double checkout(Connection conn, String customer, double total) throws SQLException { try (PreparedStatement st = conn.prepareStatement("SELECT id FROM orders WHERE customer = ?")) { st.setString(1, customer); int count = 0; try (ResultSet rs = st.executeQuery()) { while (rs.next()) { count++; } } return count >= 3 ? total * 0.9 : total; }}
try (Connection conn = DriverManager.getConnection("jdbc:sqlite::memory:")) { setup(conn); showDiscount(conn, "Мария"); System.out.printf("Мария плаща %.2f €%n", checkout(conn, "Мария", 50.00));}Мария: без отстъпкаМария плаща 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, double total) {}
interface OrderRepository { Order findById(int id); List<Order> findByCustomer(String customer); void save(Order order);}Първото хранилище пази поръчките в паметта, в речник от номера към поръчката. save със същия номер заменя старата поръчка.
class InMemoryOrderRepository implements OrderRepository { private final Map<Integer, Order> orders = new LinkedHashMap<>();
public Order findById(int id) { return orders.get(id); }
public List<Order> findByCustomer(String customer) { List<Order> result = new ArrayList<>(); for (Order order : orders.values()) { if (order.customer().equals(customer)) { result.add(order); } } return result; }
public void save(Order order) { orders.put(order.id(), order); }}Една функция пълни всяко хранилище с поръчките на магазина. Тя знае само договора, затова работи и с паметта, и с базата:
static void fill(OrderRepository orders) { orders.save(new Order(1, "Мария", 80.00)); orders.save(new Order(2, "Иван", 25.50)); orders.save(new Order(3, "Мария", 40.00)); orders.save(new Order(4, "Мария", 12.90));}Виж го
Услугата не знае откъде идват данните
Правилото е в услугата, а контролерът само показва отговора. Никой от двамата не пише SQL:
class LoyaltyService { private final OrderRepository orders;
LoyaltyService(OrderRepository orders) { this.orders = orders; }
int discountFor(String customer) { return orders.findByCustomer(customer).size() >= 3 ? 10 : 0; }}
class OrderController { private final LoyaltyService loyalty;
OrderController(LoyaltyService loyalty) { this.loyalty = loyalty; }
void show(String customer) { int discount = loyalty.discountFor(customer); String text = discount > 0 ? "отстъпка " + discount + "%" : "без отстъпка"; System.out.println(customer + ": " + text); }}Една и съща услуга и един и същ контролер, първо върху паметта, после върху SQLite:
OrderRepository memory = new InMemoryOrderRepository();fill(memory);new OrderController(new LoyaltyService(memory)).show("Мария");
try (Connection conn = DriverManager.getConnection("jdbc:sqlite::memory:")) { OrderRepository database = new SqliteOrderRepository(conn); fill(database); new OrderController(new LoyaltyService(database)).show("Мария");}Мария: отстъпка 10%Мария: отстъпка 10%class LoyaltyService { private final OrderRepository orders;
LoyaltyService(OrderRepository orders) { this.orders = orders; }
int discountFor(String customer) { return orders.findByCustomer(customer).size() >= 3 ? 10 : 0; }}
class OrderController { private final LoyaltyService loyalty;
OrderController(LoyaltyService loyalty) { this.loyalty = loyalty; }
void show(String customer) { int discount = loyalty.discountFor(customer); String text = discount > 0 ? "отстъпка " + discount + "%" : "без отстъпка"; System.out.println(customer + ": " + text); }}
OrderRepository memory = new InMemoryOrderRepository();fill(memory);new OrderController(new LoyaltyService(memory)).show("Мария");
try (Connection conn = DriverManager.getConnection("jdbc:sqlite::memory:")) { OrderRepository database = new SqliteOrderRepository(conn); fill(database); new OrderController(new LoyaltyService(database)).show("Мария");}Стъпка по стъпка, с думи
- Хранилище в паметта, напълнено с поръчките. Услугата го получава, без да знае какво е.
- Контролерът получава заявката за Мария. Той не пита базата: вика услугата.
- Услугата иска поръчките на клиента и ги иска от хранилището.
- findByCustomer на OrderRepository. Отговаря хранилището в паметта: обхожда речника си.
- Три поръчки на Мария идват нагоре. Правилото е тук и само тук: 3 ≥ 3, значи 10%.
- Контролерът само показва отговора: „Мария: отстъпка 10%“.
- Смяната: на мястото на паметта идва SQLite. Интерфейсът е същият OrderRepository, затова услугата и контролерът не се пипат.
- Същото извикване. Различен е само обектът под услугата.
- findByCustomer сега праща SELECT … WHERE customer = ? към таблицата orders.
- Пак три реда, пак 10%. Услугата не разбра, че източникът е друг.
- Същият ред, този път от база.
- Контролер → услуга → хранилище → данни. Смени се само най-долният слой, а правилото остана на едно място.
Смени се само най-долният слой. Услугата и контролерът са същите класове, без нито един променен ред, а Мария вече получава отстъпката и на страницата, и на касата, защото правилото е едно.
Виж го
Истинската база зад същия интерфейс
Ето и хранилището в SQLite. Всеки метод е една заявка с параметри (?), никога текст, долепен в SQL. Защо това е важно, има в урока SQL injection и параметрите.
class SqliteOrderRepository implements OrderRepository { private final Connection conn;
SqliteOrderRepository(Connection conn) throws SQLException { this.conn = conn; try (Statement st = conn.createStatement()) { st.executeUpdate("CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, customer TEXT NOT NULL, total REAL NOT NULL)"); } }
public Order findById(int id) { try (PreparedStatement st = conn.prepareStatement("SELECT id, customer, total FROM orders WHERE id = ?")) { st.setInt(1, id); try (ResultSet rs = st.executeQuery()) { return rs.next() ? read(rs) : null; } } catch (SQLException e) { throw new IllegalStateException(e); } }
public List<Order> findByCustomer(String customer) { try (PreparedStatement st = conn.prepareStatement("SELECT id, customer, total FROM orders WHERE customer = ? ORDER BY id")) { st.setString(1, customer); List<Order> result = new ArrayList<>(); try (ResultSet rs = st.executeQuery()) { while (rs.next()) { result.add(read(rs)); } } return result; } catch (SQLException e) { throw new IllegalStateException(e); } }
public void save(Order order) { try (PreparedStatement st = conn.prepareStatement( "INSERT INTO orders (id, customer, total) VALUES (?, ?, ?) ON CONFLICT (id) DO UPDATE SET customer = excluded.customer, total = excluded.total")) { st.setInt(1, order.id()); st.setString(2, order.customer()); st.setDouble(3, order.total()); st.executeUpdate(); } catch (SQLException e) { throw new IllegalStateException(e); } }
private static Order read(ResultSet rs) throws SQLException { return new Order(rs.getInt("id"), rs.getString("customer"), rs.getDouble("total")); }}SQLException, затова грешката от базата се обвива в IllegalStateException. Така услугата не зависи от java.sql, а грешката пак стига до този, който може да я покаже.Това е мястото, където живее SQL на поръчките. Тук може да оправиш бавна заявка, да добавиш индекс или да смениш SQLite с PostgreSQL, а услугата не разбира. Ако хранилището стане ORM, внимавай за заявка на всеки ред: урокът ORM и проблемът N+1 показва как изглежда.
Виж го
Правилото без база
Щом услугата приема всяко хранилище, правилото се проверява с хранилището в паметта: без файл, без сървър, за милисекунди.
InMemoryOrderRepository orders = new InMemoryOrderRepository();LoyaltyService loyalty = new LoyaltyService(orders);orders.save(new Order(1, "Мария", 80.00));orders.save(new Order(2, "Мария", 40.00));System.out.println("2 поръчки: " + loyalty.discountFor("Мария") + "%");orders.save(new Order(3, "Мария", 12.90));System.out.println("3 поръчки: " + loyalty.discountFor("Мария") + "%");2 поръчки: 0%3 поръчки: 10%Две поръчки не стигат, третата дава 10%. Това е точно границата, на която страницата и касата се разминаха. Така изглежда и тестът на правилото: пълниш хранилището в паметта и проверяваш какво казва услугата.
Пробвай
Предвиди изхода
Поръчка номер 1 се записва два пъти, с различна сума. Какво печата програмата?
OrderRepository orders = new InMemoryOrderRepository();orders.save(new Order(1, "Мария", 80.00));orders.save(new Order(1, "Мария", 50.00));System.out.println(orders.findByCustomer("Мария").size());System.out.printf("%.2f €%n", orders.findById(1).total());Пробвай
Намери грешката
Някой решава, че търсенето по клиент е бавно, и пише хранилище, което пази поръчките направо по клиент:
class FastOrderRepository implements OrderRepository { private final Map<String, List<Order>> byCustomer = new HashMap<>();
public Order findById(int id) { for (List<Order> orders : byCustomer.values()) { for (Order order : orders) { if (order.id() == id) { return order; } } } return null; }
public List<Order> findByCustomer(String customer) { return byCustomer.getOrDefault(customer, new ArrayList<>()); }
public void save(Order order) { byCustomer.values().forEach(orders -> orders.removeIf(o -> o.id() == order.id())); byCustomer.computeIfAbsent(order.customer(), c -> new ArrayList<>()).add(order); }}В услугата се появява нов метод: „ще има ли отстъпка, ако купи и тази?“. После страницата пита колко поръчки има Иван:
class LoyaltyService { private final OrderRepository orders;
LoyaltyService(OrderRepository orders) { this.orders = orders; }
int discountFor(String customer) { return orders.findByCustomer(customer).size() >= 3 ? 10 : 0; }
int discountIfBuys(String customer, Order cart) { List<Order> orders = this.orders.findByCustomer(customer); orders.add(cart); return orders.size() >= 3 ? 10 : 0; }}OrderRepository orders = new FastOrderRepository();fill(orders);LoyaltyService loyalty = new LoyaltyService(orders);System.out.println("Ако Иван купи още една: " + loyalty.discountIfBuys("Иван", new Order(5, "Иван", 30.00)) + "%");System.out.println("Поръчки на Иван: " + orders.findByCustomer("Иван").size());Ако Иван купи още една: 0%Поръчки на Иван: 2Иван има една поръчка, а хранилището казва две. Никой не е викал save. На кой ред от хранилището е грешката?
Избери реда с грешката.
Хранилището връща своя списък, не копие. Услугата добавя кошницата в него, и тя тихо става поръчка на Иван. Хранилището трябва да връща копие: new ArrayList<>(...). Тогава викащият може да прави каквото иска със списъка, а данните се променят само през save. С копието изходът е „Поръчки на Иван: 1“. Хранилището в паметта от урока прави нов списък при всяко търсене, затова няма тази грешка.
Идеята
Кога не ти трябва
Repository добавя интерфейс и поне един клас. Не си струва, когато:
- програмата е малък скрипт с една заявка. Слоевете ще са по-дълги от самата работа;
- ползваш ORM, който вече дава хранилище (
Spring Data). Твой интерфейс, който само препраща към него ред по ред, е още един файл без нищо ново; - хранилището расте с метод за всяка страница:
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 · Монолит, модулен монолит, услуги
- Задачи по темата: Шаблони за дизайн