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

Магазинът: кой шаблон къде

Накратко: Упражнение по шаблони за дизайн: магазин с плащане, известия и съхранение в един метод се подрежда със Strategy, Observer и Repository, а изходът остава същият.

Проблем

Магазин в един метод

Ето малък онлайн магазин. Методът за поръчка смята сумата по начина на плащане, пази поръчката, праща имейл и пише на склада:

class Shop {
private final List<String> orders = new ArrayList<>();
void placeOrder(String id, String customer, double amount, String method) {
double total;
if (method.equals("карта")) {
total = amount;
} else if (method.equals("наложен платеж")) {
total = amount + 2.50;
} else {
throw new IllegalArgumentException("Непознат начин на плащане: " + method);
}
orders.add(id + ";" + customer + ";" + total);
System.out.printf("Имейл до %s: поръчка %s, %.2f €%n", customer, id, total);
System.out.println("Склад: подготви " + id);
}
int count() {
return orders.size();
}
}
Shop shop = new Shop();
shop.placeOrder("A-1", "ana@example.com", 80, "карта");
shop.placeOrder("A-2", "ivan@example.com", 45, "наложен платеж");
System.out.println("Поръчки: " + shop.count());
Изход
Имейл до ana@example.com: поръчка A-1, 80.00 €
Склад: подготви A-1
Имейл до ivan@example.com: поръчка A-2, 47.50 €
Склад: подготви A-2
Поръчки: 2

Работи. Но през следващия месец идват три промени: банков превод с 2% отстъпка, SMS след всяка поръчка и база вместо списък. И трите са в един и същ метод. Всяка от тях го пипа, а тест за сумата без да тръгне имейл не може да се напише.

В този урок няма нов шаблон. Той е упражнение: ти разпознаваш кой от шаблоните в модула подрежда всяко от тези неща и кой тук не трябва.

Идеята

Симптомът казва шаблона

Шаблон не се избира от списък. Избира се от това, което боли:

Симптом в кода Шаблон
верига от if по вид, която расте с всеки нов вариант Strategy, или Factory, ако веригата избира какво да създаде
след едно събитие кодът сам вика всеки, който трябва да разбере Observer
съхранението е вътре в правилата и тест без база не става Repository и подаване отвън
switch по текста на състоянието във всеки метод State
конструктор с много незадължителни параметри Builder
чужда библиотека с неудобен интерфейс Adapter
пет подсистеми, които трябва да се викат в точен ред Facade
поведение „около“ метод: логване, кеш, добавки към цената Decorator
отмяна на последното действие Command
дърво, в което част и цяло се ползват еднакво Composite

Ако не намираш симптома, най-вероятно шаблон не ти трябва.

Пробвай

Разпознай

Погледни отново магазина от първата карта.

1. Веригата от if по текста на начина на плащане, която расте с всеки нов начин. Кой шаблон я подрежда?
2. След поръчката методът сам праща имейл и пише на склада. Утре идва и SMS. Кой шаблон?
3. Поръчките се пазят в списък вътре в магазина. Утре трябва база, а за теста искаш нещо в паметта. Кой шаблон?
4. Поръчката има три полета. Трябва ли ѝ Builder?

Виж го

Едно възможно подреждане

Три от симптомите са в магазина, затова и шаблоните са три.

Начините на плащане са стратегии. Интерфейсът има един метод, затова всеки начин е ламбда в таблицата Map:
interface PaymentMethod {
double total(double amount);
}
Поръчката е record. Съхранението е зад интерфейс, а за паметта има една имплементация:
record Order(String id, String customer, double total) {}
interface OrderRepository {
void save(Order order);
int count();
}
class InMemoryOrders implements OrderRepository {
private final List<Order> orders = new ArrayList<>();
public void save(Order order) {
orders.add(order);
}
public int count() {
return orders.size();
}
}
interface OrderListener {
void placed(Order order);
}
Магазинът получава хранилището и таблицата с начините отвън. Пази списък с абонати и казва на всеки, когато поръчката е направена:
class Shop {
private final OrderRepository orders;
private final Map<String, PaymentMethod> methods;
private final List<OrderListener> listeners = new ArrayList<>();
Shop(OrderRepository orders, Map<String, PaymentMethod> methods) {
this.orders = orders;
this.methods = methods;
}
void subscribe(OrderListener listener) {
listeners.add(listener);
}
void placeOrder(String id, String customer, double amount, String method) {
PaymentMethod payment = methods.get(method);
if (payment == null) {
throw new IllegalArgumentException("Непознат начин на плащане: " + method);
}
Order order = new Order(id, customer, payment.total(amount));
orders.save(order);
for (OrderListener listener : listeners) {
listener.placed(order);
}
}
int count() {
return orders.count();
}
}

Какво хранилище, кои начини и кои абонати, се решава на едно място, там, където магазинът се сглобява:

Map<String, PaymentMethod> methods = Map.of(
"карта", amount -> amount,
"наложен платеж", amount -> amount + 2.50);
Shop shop = new Shop(new InMemoryOrders(), methods);
shop.subscribe(o -> System.out.printf("Имейл до %s: поръчка %s, %.2f €%n", o.customer(), o.id(), o.total()));
shop.subscribe(o -> System.out.println("Склад: подготви " + o.id()));
shop.placeOrder("A-1", "ana@example.com", 80, "карта");
shop.placeOrder("A-2", "ivan@example.com", 45, "наложен платеж");
System.out.println("Поръчки: " + shop.count());
Изход
Имейл до ana@example.com: поръчка A-1, 80.00 €
Склад: подготви A-1
Имейл до ivan@example.com: поръчка A-2, 47.50 €
Склад: подготви A-2
Поръчки: 2

Изходът е същият, ред по ред. Подреждането не е сменило поведението, а това е първото, което проверяваш. Тук и двете програми се пускат при всяка проверка на сайта и дават един и същ изход.

Виж го

Трите промени сега

Банковият превод е нов ред в таблицата. SMS е нов абонат. Базата ще е нова имплементация на OrderRepository. В класа на магазина не се пипа нито ред:

Map<String, PaymentMethod> methods = Map.of(
"карта", amount -> amount,
"наложен платеж", amount -> amount + 2.50,
"банков превод", amount -> amount * 0.98);
Shop shop = new Shop(new InMemoryOrders(), methods);
shop.subscribe(o -> System.out.println("SMS до " + o.customer() + ": " + o.id()));
shop.placeOrder("A-3", "ana@example.com", 100, "банков превод");
Изход
SMS до ana@example.com: A-3

Сега и тест за сумата е лесен: подаваш хранилище в паметта и не абонираш имейла.

Идеята

Къде да спреш

В модула има повече от десет шаблона, а тук са ползвани три. Останалите не са забравени, просто тук не помагат:

  • Builder: поръчката има три задължителни полета. Конструкторът (record) стига;
  • Singleton: нищо не трябва да е единствено. Хранилището се подава отвън, затова тестът и магазинът могат да имат свое;
  • Factory: начините на плащане са функции в таблица, а таблицата вече е мястото, където се избира;
  • State: поръчката още няма състояния. Когато дойдат „платена“ и „изпратена“, ще дойде и той (виж задачите).

Спираш, когато промяната, която идва, стане лесна. Повече шаблони не значи по-добър код.

Упражнение

Подреди магазина нататък

Реши задачите в редактора си, като тръгнеш от подредения магазин. След всяка стъпка пусни програмата от картата „Едно възможно подреждане“: изходът ѝ трябва да остане същият.

1. Състоянията на поръчката. Поръчката става нова → платена → изпратена и може да е отказана, но не и след като е изпратена. Направи го с State (или с изброим тип и таблица на преходите, ако състоянията нямат свое поведение) и покажи грешката при отказ на изпратена поръчка.

2. Отстъпка над 100 €. Поръчка над 100 € получава 5% отстъпка, каквото и да е плащането. Помисли къде е мястото ѝ: в стратегия, в Decorator около стратегията или в магазина. Защо?

3. Хранилище, което помни. Направи второ хранилище, което записва поръчките във файл (по една на ред), и го подай на магазина вместо това в паметта. Класът на магазина не се пипа.

4. Чужд куриер. Складът вече се вика през библиотека на куриер с метод createShipment(reference, weightGrams). Абонатът за склада става Adapter към нея.

Тест

Провери се

1. Подреди кода с шаблони. Как разбираш, че не си счупил нищо?
2. Кое НЕ е добра причина да добавиш шаблон?
3. След подреждането добавяш банков превод и SMS. Какво пипаш в класа на магазина?

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