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

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

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

Виж го

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

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

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

Идеята

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

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

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