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

Монолит, модулен монолит, услуги

Накратко: Модулният монолит е едно приложение с ясни модули, всеки с публичен API и свои данни; микроуслугите се отделят, когато екипи, мащаб или независим deploy си струват цената.

Проблем

Поръчките в таблицата на склада

Магазинът в града запазва бройки за клиенти, които ще дойдат на място. Складът пази наличността и резервациите, а за чайника KTL-100 наличността е 2. Онлайн поръчките са писани от друг екип, който „за бързина“ чете и пише направо в таблицата на склада:

// Склад: наличността и резервациите на магазина в града
class Warehouse
{
public Dictionary<string, int> Stock { get; } = [];
public Dictionary<string, int> Reserved { get; } = [];
public bool Reserve(string sku, int qty)
{
if (Free(sku) < qty) return false;
Reserved[sku] = Reserved.GetValueOrDefault(sku) + qty;
return true;
}
public int Free(string sku) =>
Stock.GetValueOrDefault(sku) - Reserved.GetValueOrDefault(sku);
}
// Поръчки: бъркат направо в таблицата на склада
class Orders(Warehouse warehouse)
{
public string Place(string sku, int qty)
{
int left = warehouse.Stock[sku];
if (left < qty) return "няма наличност";
warehouse.Stock[sku] = left - qty;
return "приета";
}
}
var warehouse = new Warehouse();
warehouse.Stock["KTL-100"] = 2;
bool held = warehouse.Reserve("KTL-100", 2);
Console.WriteLine("Резервация за магазина: " + (held ? "да" : "не"));
var orders = new Orders(warehouse);
Console.WriteLine("Онлайн поръчка: " + orders.Place("KTL-100", 1));
Console.WriteLine("Свободни в склада: " + warehouse.Free("KTL-100"));
Изход
Резервация за магазина: да
Онлайн поръчка: приета
Свободни в склада: -1

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

Втората болка идва по-късно. Екипът на склада иска да раздели stock по складове (София, Пловдив). Не може: не знае кой още чете и пише в таблицата му, и всяка промяна чупи чужд код.

Идеята

Модули с API и свои данни

Кодът е в една програма, която се пуска наведнъж: монолит. Това само по себе си не е беда. Бедата е, че вътре няма граници.

Модулен монолит е монолит, разделен на модули. Всеки модул:

  • има публичен API: няколко метода, през които другите го викат (receive, reserve, available);
  • има свои данни: таблиците и класовете вътре са негови, и никой друг не чете и не пише в тях;
  • зависи само от модулите, от които има право.

Това е подредбата „по функция“ от урока Слоеве, с правило за границите. В domain-driven design такава граница се казва bounded context: частта от системата, в която думата „наличност“ има едно значение.

Модулите са в един процес и често в една база. Извикването на модул е извикване на метод: бързо, без мрежа, в една транзакция, ако трябва.

Виж го

Складът като модул

// Модулът „склад“: данните са негови, навън е само API-то
class Warehouse
{
private readonly Dictionary<string, int> stock = [];
private readonly Dictionary<string, int> reserved = [];
public void Receive(string sku, int qty) =>
stock[sku] = stock.GetValueOrDefault(sku) + qty;
public bool Reserve(string sku, int qty)
{
if (Available(sku) < qty) return false;
reserved[sku] = reserved.GetValueOrDefault(sku) + qty;
return true;
}
public int Available(string sku) =>
stock.GetValueOrDefault(sku) - reserved.GetValueOrDefault(sku);
}
// Модулът „поръчки“: вика само API-то на склада
class Orders(Warehouse warehouse)
{
public string Place(string sku, int qty)
{
if (!warehouse.Reserve(sku, qty)) return "няма наличност";
return "приета";
}
}
var warehouse = new Warehouse();
warehouse.Receive("KTL-100", 2);
bool held = warehouse.Reserve("KTL-100", 2);
Console.WriteLine("Резервация за магазина: " + (held ? "да" : "не"));
var orders = new Orders(warehouse);
Console.WriteLine("Онлайн поръчка: " + orders.Place("KTL-100", 1));
Console.WriteLine("Свободни в склада: " + warehouse.Available("KTL-100"));
Изход
Резервация за магазина: да
Онлайн поръчка: няма наличност
Свободни в склада: 0

// Склад: наличността и резервациите на магазина в града
class Warehouse
{
public Dictionary<string, int> Stock { get; } = [];
public Dictionary<string, int> Reserved { get; } = [];
public bool Reserve(string sku, int qty)
{
if (Free(sku) < qty) return false;
Reserved[sku] = Reserved.GetValueOrDefault(sku) + qty;
return true;
}
public int Free(string sku) =>
Stock.GetValueOrDefault(sku) - Reserved.GetValueOrDefault(sku);
}
// Поръчки: бъркат направо в таблицата на склада
class Orders(Warehouse warehouse)
{
public string Place(string sku, int qty)
{
int left = warehouse.Stock[sku];
if (left < qty) return "няма наличност";
warehouse.Stock[sku] = left - qty;
return "приета";
}
}
Стъпка по стъпка, с думи
  1. Един процес, една база. Складът пази наличността и резервациите, а таблиците му са отворени за всички.
  2. Магазинът запазва и двата чайника. Онлайн поръчката бърка направо в таблицата, не вижда резервацията и свободните стават -1.
  3. Модулен монолит: данните на склада стават негови. Навън остава само API-то: receive, reserve, available.
  4. Поръчките минават през reserve и складът пази правилото си: „няма наличност“, свободните остават 0. Още един процес, без мрежа.
  5. Складът излиза като отделна услуга: свой процес, свой deploy, свой екип. Извикването става съобщение по мрежата.
  6. Три поръчки, три съобщения: ok, мълчание, no. Извикване, което в монолита не можеше да се провали, сега се проваля.
  7. Модулите пазят границите без мрежа. Услугата добавя независим deploy, а плаща с мрежа, разделени данни и наблюдение.

Поръчката минава през reserve, а складът пази правилото си: двата чайника са запазени, затова онлайн поръчката получава „няма наличност“. Последните стъпки на сцената показват какво става, когато складът излезе като отделна услуга. Кодът им е по-надолу.

В C# private пази полетата, а internal скрива класове от другите проекти. Модул често е отделен проект (.csproj) в същото решение: това, което е internal, не се вижда отвън.

Пробвай

Намери грешката

Отчетът за склада иска да покаже цялата таблица, и някой добавя метод:

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

Виж го

Проверка на границите

Границите не се пазят от добро желание. Ето малка проверка: какво дава всеки модул, кой от кои може да зависи и какво кодът ползва в действителност. В истински проект последният списък се чете от import-ите.

record Use(string From, string To, string Name);
static class Boundaries
{
public static List<string> Check(Dictionary<string, HashSet<string>> api,
Dictionary<string, HashSet<string>> allowed, List<Use> uses)
{
var problems = new List<string>();
foreach (var (from, to, name) in uses)
{
if (!allowed[from].Contains(to))
problems.Add($"✗ {from} → {to}: зависимостта не е позволена");
else if (!api[to].Contains(name))
problems.Add($"✗ {from} → {to}.{name}: не е в API-то");
}
return problems;
}
}
// Какво всеки модул дава навън
var api = new Dictionary<string, HashSet<string>>
{
["orders"] = ["place"],
["warehouse"] = ["receive", "reserve", "available"],
["payments"] = ["charge"],
["invoices"] = ["issue"],
};
// Кой модул от кои може да зависи
var allowed = new Dictionary<string, HashSet<string>>
{
["orders"] = ["warehouse", "payments"],
["warehouse"] = [],
["payments"] = [],
["invoices"] = ["orders"],
};
// Какво ползва кодът в действителност (от import-ите)
List<Use> uses =
[
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(Console.WriteLine);
Console.WriteLine("Нарушения: " + problems.Count);
Изход
✗ orders → warehouse.stock: не е в API-то
✗ invoices → orders.rows: не е в API-то
✗ warehouse → orders: зависимостта не е позволена
Нарушения: 3

Три нарушения: поръчките в таблицата на склада, фактурите в редовете на поръчките и склад, който вика поръчките (а поръчките вече викат склада). Такава проверка се пуска при всяка промяна и пада, щом някой мине през стената. Как става част от CI, има в урока Архитектурен преглед.

Идеята

Кога модулът става услуга

Услуга (microservice) е модул, който се пуска сам: свой процес, свой deploy, своя база. Другите го викат по мрежата. Струва си, когато:

  • екипите пречат един на друг: складът иска да пуска нова версия три пъти на ден, а магазинът веднъж седмично;
  • натоварването е различно: търсенето в каталога иска десет копия, а фактурите едно;
  • отказът трябва да се изолира: ако препоръките паднат, плащането трябва да работи.

И се плаща:

  • мрежа: извикването може да е бавно, да не стигне или да стигне два пъти;
  • данни: всяка услуга има своя база, затова няма JOIN между тях и няма една транзакция. Поръчка и резервация вече не се записват „заедно“. Как се живее с това, има в Събития и опашки, Изгубеното събитие и Saga;
  • наблюдение: една заявка минава през пет процеса, и грешката се търси в пет лога.

Сървърите, контейнерите, Kubernetes и наблюдението като инфраструктура са тема на курса ZeroToOps за DevOps, не на този урок.

Виж го

Складът по мрежата

Складът е отделна услуга. В програмата „мрежата“ е обект със записан сценарий: на всяко съобщение отговаря ok, no или мълчи (timeout). Поръчките виждат само API-то IWarehouse и не знаят, че отзад е мрежа:

interface IWarehouse
{
bool Reserve(string sku, int qty);
}
// Мрежата е записан сценарий: "ok", "no" или "timeout"
class Network(params string[] script)
{
private readonly Queue<string> queue = new(script);
public int Sent { get; private set; }
public string Send(string message)
{
Sent++;
return queue.Dequeue();
}
}
// Складът като отделна услуга: всяко извикване е съобщение
class RemoteWarehouse(Network network) : IWarehouse
{
public bool Reserve(string sku, int qty)
{
var answer = network.Send($"reserve {sku} {qty}");
if (answer == "timeout")
throw new TimeoutException("складът не отговаря");
return answer == "ok";
}
}
// Поръчките не знаят дали складът е модул, или услуга
class Orders(IWarehouse warehouse)
{
public string Place(string sku, int qty)
{
try
{
if (!warehouse.Reserve(sku, qty)) return "няма наличност";
return "приета";
}
catch (TimeoutException e)
{
return "чака: " + e.Message;
}
}
}
var network = new Network("ok", "timeout", "no");
var orders = new Orders(new RemoteWarehouse(network));
for (int i = 1; i <= 3; i++)
Console.WriteLine($"Поръчка {i}: {orders.Place("KTL-100", 1)}");
Console.WriteLine("Съобщения по мрежата: " + 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++)
Console.WriteLine($"Поръчка {i}: {orders.Place("KTL-100", 1)}");
Console.WriteLine("Съобщения по мрежата: " + 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) и печатай всяка таблица, която ползва повече от един модул.

Тест

Провери се

1. Какво прави един модул модул, а не просто папка?
2. Поръчките пишат направо в таблицата stock. Каква е бедата?
3. Кое е добра причина да отделиш склада като услуга?
4. Складът е услуга и не отговаря. Какво от изброеното е нов проблем, който монолитът нямаше?
5. Проверката на границите е позволила orders → warehouse и warehouse → orders. Защо е лошо?

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