Монолит, модулен монолит, услуги
Накратко: Модулният монолит е едно приложение с ясни модули, всеки с публичен API и свои данни; микроуслугите се отделят, когато екипи, мащаб или независим deploy си струват цената.
Проблем
Поръчките в таблицата на склада
Магазинът в града запазва бройки за клиенти, които ще дойдат на място. Складът пази наличността и резервациите, а за чайника KTL-100 наличността е 2. Онлайн поръчките са писани от друг екип, който „за бързина“ чете и пише направо в таблицата на склада:
// Склад: наличността и резервациите на магазина в градаclass Warehouse { final Map<String, Integer> stock = new HashMap<>(); final Map<String, Integer> reserved = new HashMap<>();
boolean reserve(String sku, int qty) { if (free(sku) < qty) return false; reserved.merge(sku, qty, Integer::sum); return true; }
int free(String sku) { int held = reserved.getOrDefault(sku, 0); return stock.getOrDefault(sku, 0) - held; }}
// Поръчки: бъркат направо в таблицата на складаclass Orders { private final Warehouse warehouse;
Orders(Warehouse warehouse) { this.warehouse = warehouse; }
String place(String sku, int qty) { int left = warehouse.stock.get(sku); if (left < qty) return "няма наличност"; warehouse.stock.put(sku, left - qty); return "приета"; }}var warehouse = new Warehouse();warehouse.stock.put("KTL-100", 2);boolean held = warehouse.reserve("KTL-100", 2);System.out.println("Резервация за магазина: " + (held ? "да" : "не"));var orders = new Orders(warehouse);System.out.println("Онлайн поръчка: " + orders.place("KTL-100", 1));System.out.println("Свободни в склада: " + warehouse.free("KTL-100"));Резервация за магазина: даОнлайн поръчка: приетаСвободни в склада: -1Магазинът е запазил и двата чайника, а онлайн поръчката е приета. Свободните са -1: продадена е бройка, която я няма. Правилото за резервациите е в склада, но поръчките не минават през него.
Втората болка идва по-късно. Екипът на склада иска да раздели stock по складове (София, Пловдив). Не може: не знае кой още чете и пише в таблицата му, и всяка промяна чупи чужд код.
Идеята
Модули с API и свои данни
Кодът е в една програма, която се пуска наведнъж: монолит. Това само по себе си не е беда. Бедата е, че вътре няма граници.
Модулен монолит е монолит, разделен на модули. Всеки модул:
- има публичен API: няколко метода, през които другите го викат (
receive,reserve,available); - има свои данни: таблиците и класовете вътре са негови, и никой друг не чете и не пише в тях;
- зависи само от модулите, от които има право.
Това е подредбата „по функция“ от урока Слоеве, с правило за границите. В domain-driven design такава граница се казва bounded context: частта от системата, в която думата „наличност“ има едно значение.
Модулите са в един процес и често в една база. Извикването на модул е извикване на метод: бързо, без мрежа, в една транзакция, ако трябва.
Виж го
Складът като модул
// Модулът „склад“: данните са негови, навън е само API-тоclass Warehouse { private final Map<String, Integer> stock = new HashMap<>(); private final Map<String, Integer> reserved = new HashMap<>();
void receive(String sku, int qty) { stock.merge(sku, qty, Integer::sum); }
boolean reserve(String sku, int qty) { if (available(sku) < qty) return false; reserved.merge(sku, qty, Integer::sum); return true; }
int available(String sku) { int held = reserved.getOrDefault(sku, 0); return stock.getOrDefault(sku, 0) - held; }}
// Модулът „поръчки“: вика само API-то на складаclass Orders { private final Warehouse warehouse;
Orders(Warehouse warehouse) { this.warehouse = warehouse; }
String place(String sku, int qty) { if (!warehouse.reserve(sku, qty)) return "няма наличност"; return "приета"; }}var warehouse = new Warehouse();warehouse.receive("KTL-100", 2);boolean held = warehouse.reserve("KTL-100", 2);System.out.println("Резервация за магазина: " + (held ? "да" : "не"));var orders = new Orders(warehouse);System.out.println("Онлайн поръчка: " + orders.place("KTL-100", 1));System.out.println("Свободни в склада: " + warehouse.available("KTL-100"));Резервация за магазина: даОнлайн поръчка: няма наличностСвободни в склада: 0// Склад: наличността и резервациите на магазина в градаclass Warehouse { final Map<String, Integer> stock = new HashMap<>(); final Map<String, Integer> reserved = new HashMap<>();
boolean reserve(String sku, int qty) { if (free(sku) < qty) return false; reserved.merge(sku, qty, Integer::sum); return true; }
int free(String sku) { int held = reserved.getOrDefault(sku, 0); return stock.getOrDefault(sku, 0) - held; }}
// Поръчки: бъркат направо в таблицата на складаclass Orders { private final Warehouse warehouse;
Orders(Warehouse warehouse) { this.warehouse = warehouse; }
String place(String sku, int qty) { int left = warehouse.stock.get(sku); if (left < qty) return "няма наличност"; warehouse.stock.put(sku, left - qty); return "приета"; }}// Склад: наличността и резервациите на магазина в градаclass Warehouse { final Map<String, Integer> stock = new HashMap<>(); final Map<String, Integer> reserved = new HashMap<>();
boolean reserve(String sku, int qty) { if (free(sku) < qty) return false; reserved.merge(sku, qty, Integer::sum); return true; }
int free(String sku) { int held = reserved.getOrDefault(sku, 0); return stock.getOrDefault(sku, 0) - held; }}
// Поръчки: бъркат направо в таблицата на складаclass Orders { private final Warehouse warehouse;
Orders(Warehouse warehouse) { this.warehouse = warehouse; }
String place(String sku, int qty) { int left = warehouse.stock.get(sku); if (left < qty) return "няма наличност"; warehouse.stock.put(sku, left - qty); return "приета"; }}// Модулът „склад“: данните са негови, навън е само API-тоclass Warehouse { private final Map<String, Integer> stock = new HashMap<>(); private final Map<String, Integer> reserved = new HashMap<>();
void receive(String sku, int qty) { stock.merge(sku, qty, Integer::sum); }
boolean reserve(String sku, int qty) { if (available(sku) < qty) return false; reserved.merge(sku, qty, Integer::sum); return true; }
int available(String sku) { int held = reserved.getOrDefault(sku, 0); return stock.getOrDefault(sku, 0) - held; }}
// Модулът „поръчки“: вика само API-то на складаclass Orders { private final Warehouse warehouse;
Orders(Warehouse warehouse) { this.warehouse = warehouse; }
String place(String sku, int qty) { if (!warehouse.reserve(sku, qty)) return "няма наличност"; return "приета"; }}// Мрежата е записан сценарий: "ok", "no" или "timeout"class Network { private final Deque<String> script; int sent = 0;
Network(String... script) { this.script = new ArrayDeque<>(List.of(script)); }
String send(String message) { sent++; return script.removeFirst(); }}
// Складът като отделна услуга: всяко извикване е съобщениеclass RemoteWarehouse implements WarehouseApi { private final Network network;
RemoteWarehouse(Network network) { this.network = network; }
public boolean reserve(String sku, int qty) { String answer = network.send("reserve " + sku + " " + qty); if (answer.equals("timeout")) throw new IllegalStateException("складът не отговаря"); return answer.equals("ok"); }}
// Поръчките не знаят дали складът е модул, или услугаclass Orders { private final WarehouseApi warehouse;
Orders(WarehouseApi warehouse) { this.warehouse = warehouse; }
String place(String sku, int qty) { try { if (!warehouse.reserve(sku, qty)) return "няма наличност"; return "приета"; } catch (IllegalStateException e) { return "чака: " + e.getMessage(); } }}Стъпка по стъпка, с думи
- Един процес, една база. Складът пази наличността и резервациите, а таблиците му са отворени за всички.
- Магазинът запазва и двата чайника. Онлайн поръчката бърка направо в таблицата, не вижда резервацията и свободните стават -1.
- Модулен монолит: данните на склада стават негови. Навън остава само API-то: receive, reserve, available.
- Поръчките минават през reserve и складът пази правилото си: „няма наличност“, свободните остават 0. Още един процес, без мрежа.
- Складът излиза като отделна услуга: свой процес, свой deploy, свой екип. Извикването става съобщение по мрежата.
- Три поръчки, три съобщения: ok, мълчание, no. Извикване, което в монолита не можеше да се провали, сега се проваля.
- Модулите пазят границите без мрежа. Услугата добавя независим deploy, а плаща с мрежа, разделени данни и наблюдение.
Поръчката минава през reserve, а складът пази правилото си: двата чайника са запазени, затова онлайн поръчката получава „няма наличност“. Последните стъпки на сцената показват какво става, когато складът излезе като отделна услуга. Кодът им е по-надолу.
private пази полетата, а пакетите и module-info.java (JPMS) пазят цели групи класове: това, което модулът не изнася с exports, не се вижда отвън. Spring Modulith проверява границите между модулите в Spring приложение.Пробвай
Намери грешката
Отчетът за склада иска да покаже цялата таблица, и някой добавя метод:
Избери реда с грешката.
Методът връща самата таблица, не копие. Всеки, който я получи, може да пише в нея (stockTable().put(...)), и границата е пак отворена, само че по-скрито. Модулът трябва да връща копие или само това, което отчетът наистина иска, например списък „продукт и свободни бройки“.
Виж го
Проверка на границите
Границите не се пазят от добро желание. Ето малка проверка: какво дава всеки модул, кой от кои може да зависи и какво кодът ползва в действителност. В истински проект последният списък се чете от import-ите.
record Use(String from, String to, String name) {}
class Boundaries { static List<String> check(Map<String, Set<String>> api, Map<String, Set<String>> allowed, List<Use> uses) { List<String> problems = new ArrayList<>(); for (Use u : uses) { String edge = u.from() + " → " + u.to(); if (!allowed.get(u.from()).contains(u.to())) problems.add("✗ " + edge + ": зависимостта не е позволена"); else if (!api.get(u.to()).contains(u.name())) problems.add("✗ " + edge + "." + u.name() + ": не е в API-то"); } return problems; }}// Какво всеки модул дава навънMap<String, Set<String>> api = Map.of( "orders", Set.of("place"), "warehouse", Set.of("receive", "reserve", "available"), "payments", Set.of("charge"), "invoices", Set.of("issue"));// Кой модул от кои може да зависиMap<String, Set<String>> allowed = Map.of( "orders", Set.of("warehouse", "payments"), "warehouse", Set.of(), "payments", Set.of(), "invoices", Set.of("orders"));// Какво ползва кодът в действителност (от import-ите)var uses = List.of( new Use("orders", "warehouse", "reserve"), new Use("orders", "warehouse", "stock"), new Use("orders", "payments", "charge"), new Use("invoices", "orders", "rows"), new Use("warehouse", "orders", "place"));
var problems = Boundaries.check(api, allowed, uses);problems.forEach(System.out::println);System.out.println("Нарушения: " + problems.size());✗ orders → warehouse.stock: не е в API-то✗ invoices → orders.rows: не е в API-то✗ warehouse → orders: зависимостта не е позволенаНарушения: 3Три нарушения: поръчките в таблицата на склада, фактурите в редовете на поръчките и склад, който вика поръчките (а поръчките вече викат склада). Такава проверка се пуска при всяка промяна и пада, щом някой мине през стената. Как става част от CI, има в урока Архитектурен преглед.
Идеята
Кога модулът става услуга
Услуга (microservice) е модул, който се пуска сам: свой процес, свой deploy, своя база. Другите го викат по мрежата. Струва си, когато:
- екипите пречат един на друг: складът иска да пуска нова версия три пъти на ден, а магазинът веднъж седмично;
- натоварването е различно: търсенето в каталога иска десет копия, а фактурите едно;
- отказът трябва да се изолира: ако препоръките паднат, плащането трябва да работи.
И се плаща:
- мрежа: извикването може да е бавно, да не стигне или да стигне два пъти;
- данни: всяка услуга има своя база, затова няма
JOINмежду тях и няма една транзакция. Поръчка и резервация вече не се записват „заедно“. Как се живее с това, има в Събития и опашки, Изгубеното събитие и Saga; - наблюдение: една заявка минава през пет процеса, и грешката се търси в пет лога.
Сървърите, контейнерите, Kubernetes и наблюдението като инфраструктура са тема на курса ZeroToOps за DevOps, не на този урок.
Виж го
Складът по мрежата
Складът е отделна услуга. В програмата „мрежата“ е обект със записан сценарий: на всяко съобщение отговаря ok, no или мълчи (timeout). Поръчките виждат само API-то WarehouseApi и не знаят, че отзад е мрежа:
interface WarehouseApi { boolean reserve(String sku, int qty);}// Мрежата е записан сценарий: "ok", "no" или "timeout"class Network { private final Deque<String> script; int sent = 0;
Network(String... script) { this.script = new ArrayDeque<>(List.of(script)); }
String send(String message) { sent++; return script.removeFirst(); }}
// Складът като отделна услуга: всяко извикване е съобщениеclass RemoteWarehouse implements WarehouseApi { private final Network network;
RemoteWarehouse(Network network) { this.network = network; }
public boolean reserve(String sku, int qty) { String answer = network.send("reserve " + sku + " " + qty); if (answer.equals("timeout")) throw new IllegalStateException("складът не отговаря"); return answer.equals("ok"); }}
// Поръчките не знаят дали складът е модул, или услугаclass Orders { private final WarehouseApi warehouse;
Orders(WarehouseApi warehouse) { this.warehouse = warehouse; }
String place(String sku, int qty) { try { if (!warehouse.reserve(sku, qty)) return "няма наличност"; return "приета"; } catch (IllegalStateException e) { return "чака: " + e.getMessage(); } }}var network = new Network("ok", "timeout", "no");var orders = new Orders(new RemoteWarehouse(network));for (int i = 1; i <= 3; i++) System.out.println("Поръчка " + i + ": " + orders.place("KTL-100", 1));System.out.println("Съобщения по мрежата: " + network.sent);Поръчка 1: приетаПоръчка 2: чака: складът не отговаряПоръчка 3: няма наличностСъобщения по мрежата: 3Втората поръчка не знае дали бройката е запазена: складът може да е получил съобщението и да е отговорил, а отговорът да се е изгубил. В модула този въпрос не съществуваше.
Пробвай
Предвиди изхода
Мрежата мълчи два пъти, после отговаря ok. Какво печата програмата?
var network = new Network("timeout", "timeout", "ok");var orders = new Orders(new RemoteWarehouse(network));for (int i = 1; i <= 3; i++) System.out.println("Поръчка " + i + ": " + orders.place("KTL-100", 1));System.out.println("Съобщения по мрежата: " + network.sent);Всяка поръчка праща свое съобщение, затова са три. Първите две не получават отговор и чакат, третата е приета. Колко пъти и кога да се опита пак е отделна тема: урокът Когато съседът е бавен.
Идеята
Кога услугите вредят
- Твърде рано. Нов магазин с един екип и десет услуги плаща цялата цена на мрежата, без да има нужда от независим deploy. Границите още не са ясни, а преместването на граница между услуги е много по-скъпо, отколкото между модули.
- Разпределен монолит. Услуги, които трябва да се пускат заедно и се викат една друга при всяка заявка, имат всички недостатъци на монолита и на услугите наведнъж.
- Обща база. Две услуги, които пишат в едни и същи таблици, са проблемът от началото на урока, само че през мрежата.
Затова честият път е първо модулен монолит: ясни модули в един процес и проверка на границите. Модул, който наистина има нужда да е отделен, излиза като услуга. Това е лесно, ако вече има API и свои данни, както складът в сцената.
По-точно, за любопитните
Рамките. Една услуга е обикновено приложение: Spring Boot, ASP.NET Core, FastAPI или Django, Express или Nest. Услугите си говорят по HTTP (REST, gRPC) или през брокер на съобщения (Kafka, RabbitMQ). Отпред често стои API gateway: един вход, който пренасочва заявките към услугите и проверява входа.
Законът на Conway. Системите приличат на организацията, която ги прави: три екипа правят система от три части. Затова границите на услугите често следват екипите.
Проверка на границите. ArchUnit (Java) и ArchUnitNET или NetArchTest (C#) пишат проверката като тест. import-linter (Python) и dependency-cruiser (JavaScript) я пишат като правила в конфигурация. Spring Modulith (Java) и Nx (JavaScript) знаят и за модулите.
Колко голяма е услугата. „Micro“ не е размер в редове. Добрата граница е тази, през която минават малко извиквания, а зад която има данни, които никой друг не пише.
Упражнение
Цикъл между модулите
Реши задачата в редактора си.
Някой е позволил и warehouse → orders. Сега двата модула зависят един от друг. Напиши функция, която получава картата „кой от кои може да зависи“ и намира всяка двойка модули, които зависят един от друг. Всяка двойка се печата веднъж, по азбучен ред.
✗ orders ↔ warehouseНарушения: 1Още задачи
- Цикъл през три модула.
orders → warehouse → invoices → ordersсъщо е цикъл. Намери го с обхождане в дълбочина (урокът DFS има намиране на цикъл). - Таблиците. Добави към проверката кой модул чия таблица чете (
invoicesчетеorders.rows) и печатай всяка таблица, която ползва повече от един модул.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Repository · SOLID без догма · Слоеве · Hexagonal и clean architecture
- Продължава в: Събития и опашки · Transactional outbox · Мащабиране · ADR: записани архитектурни решения
- Задачи по темата: Софтуерни архитектури