Твоята първа архитектурна задача
Накратко: Проект по софтуерна архитектура: изисквания с числа, три варианта с цената им, матрица на решението, ADR, план за миграция и регистър на рисковете за магазин, който расте.
Проблем
Задачата
Собственикът на магазина пише:
От пролетта продаваме и в Гърция и Хърватия. Пускаме и външни търговци: качват свои стоки, ние взимаме комисионна. Black Friday да не падне като миналата година. Колко ще струва и кога?
Това е истинската първа задача на архитекта. Никой не ти казва „направи модулен монолит“. Казват ти желание, а ти трябва да върнеш:
- изисквания с числа, които могат да се мерят;
- поне два варианта с цената им;
- решение, записано в ADR;
- план за миграция на живата система, обратим на всяка стъпка;
- регистър на рисковете;
- проверка, която пази новите граници.
Трите държави са в еврозоната, затова парите остават в евро. Всичко друго се различава: език, ДДС, куриери, закони за връщане.
Идеята
Планът
Всяка стъпка ползва урок от модула:
- Изискванията с персентили и бюджет на наличността, както в „Нефункционални изисквания“.
- Вариантите с компромисите от „Компромиси“ и формите от „Монолит, модулен монолит, услуги“.
- ADR като в „ADR: записани архитектурни решения“.
- Миграцията със strangler fig и expand–contract.
- Рисковете като в „Архитектурен преглед“.
- Fitness function за границите, пак от прегледа.
Всяка стъпка има изход, с който да сравниш своя. Решенията са в „Решение“ под всяка стъпка, а накрая е примерното решение на цялата задача. Опитай първо сам: архитектурата се учи, като решаваш, а не като четеш решения.
Упражнение
Стъпка 1: изискванията с числа
„Да е бързо и да не пада“ не е изискване. Превърни всяко от тези желания в сценарий на качеството: източник, стимул, отговор и мярка с число.
- „Black Friday да не падне.“
- „Плащането винаги да работи.“
- „Търговците да виждат продажбите си веднага.“
- „Нова държава да се пуска лесно.“
Примерни сценарии
| Качество | Източник и стимул | Отговор | Мярка |
|---|---|---|---|
| мащаб | Black Friday, 5 пъти обичайния трафик | страницата на плащането отговаря | p95 до 800 ms, p99 до 2 s |
| наличност | купувач във всяка от трите държави плаща | плащането минава | 99,7% от минутите в месеца, до 130 мин. престой |
| съгласуваност | търговец сменя наличност | купувачите я виждат | до 2 мин.; продадено над наличното се отменя с извинение до 1 час |
| изолация | търговец качва 10 000 продукта наведнъж | магазинът работи както преди | p95 на каталога расте с не повече от 20% |
| разширяемост | четвърта държава | пуска се с настройки | до 2 седмици, без промяна в кода на поръчките |
Числата тук са примерни: в истинската задача идват от собственика („колко струва час престой на Black Friday?“) и от мерене на днешната система.
„Търговците да виждат продажбите си веднага“ стана „до 2 минути“. Това е съзнателен избор на евентуална съгласуваност: „веднага“ би вкарало системите на търговците в пътя на плащането.
Упражнение
Стъпка 1б: бюджетът на веригата
Плащането минава през API-то на магазина, базата и платежния доставчик. Първата идея за търговците е при всяко плащане да питаш системата на търговеца дали стоката я има. Наличностите и p95 по-долу са примерни (на доставчиците от договора, на търговците от проба):
List<Hop> chain = List.of( new Hop("API на магазина", 99.95, 120), new Hop("база", 99.95, 40), new Hop("плащане", 99.9, 450), new Hop("наличност при търговците", 99.0, 400));budget("Наличността при търговците е в пътя на плащането:", chain, 99.7, 800);budget("Без нея (копие, обновявано от събития):", chain.subList(0, 3), 99.7, 800);Напиши budget, който умножава наличността по веригата, събира p95 и сравнява с бюджета: 99.7% (престоят в минути на месец от 30 дни) и p95 до 800 ms. Пусни го с търговците във веригата и без тях:
Наличността при търговците е в пътя на плащането: наличност 98.80% → 517 мин. престой на месец (бюджет 130) ✗ p95 1010 ms (бюджет 800) ✗Без нея (копие, обновявано от събития): наличност 99.80% → 86 мин. престой на месец (бюджет 130) ✓ p95 610 ms (бюджет 800) ✓Решение
record Hop(String name, double availability, int p95) {}static final int MINUTES_PER_MONTH = 30 * 24 * 60;
static void budget(String title, List<Hop> chain, double target, int p95Budget) { double availability = 1; int p95 = 0; for (Hop hop : chain) { availability *= hop.availability() / 100; p95 += hop.p95(); // сборът на p95 е горна граница, не точно p95 на веригата } long down = Math.round((1 - availability) * MINUTES_PER_MONTH); long allowed = Math.round((1 - target / 100) * MINUTES_PER_MONTH); System.out.println(title); System.out.printf(Locale.ROOT, " наличност %.2f%% → %d мин. престой на месец (бюджет %d) %s%n", availability * 100, down, allowed, down <= allowed ? "✓" : "✗"); System.out.printf(" p95 %d ms (бюджет %d) %s%n", p95, p95Budget, p95 <= p95Budget ? "✓" : "✗");}Locale.ROOT в printf: иначе на машина с български език десетичният знак става запетая.Една система с 99,0% сваля плащането до 98.80%: 517 минути престой на месец срещу бюджет 130. Закъснението също не се събира. Затова наличността при търговците не може да е синхронна стъпка на плащането. Тя става копие при нас, обновявано от събития (урокът „Събития и опашки“), а цената е, че копието може да закъснее. Това решение идва от числата, не от вкуса.
Сборът на p95 е горна граница: p95 на веригата обикновено е по-малък, защото рядко всички стъпки са бавни наведнъж. За бюджета горната граница е безопасната страна.
Упражнение
Стъпка 2: варианти с цената им
Опиши поне два варианта, които наистина биха работили, и цената на всеки: пари, време, хора и какво губиш. Три естествени варианта:
| A: три копия на магазина | B: модулен монолит | C: услуги от първия ден | |
|---|---|---|---|
| идея | по един магазин на държава | държавата е настройка, търговците са модул | каталог, поръчки, търговци, плащане поотделно |
| време до пускане | най-бързо | средно | най-бавно |
| цена на месец | три пъти инфраструктура и поддръжка | една система | много услуги, наблюдение, мрежа |
| какво губиш | копията се разминават и не се сливат обратно | промяна за една държава се пуска за трите | екипът учи разпределени системи, докато пуска |
Сравнението се прави в матрица на решението: критерии с тегла (колко важен е всеки) и оценка от 1 до 5 за всеки вариант. Теглата и оценките са на екипа и собственика:
// време до пускане 3, наличност 3, цена на месец 2, екипът го може 2, обратимост 2int[] weights = {3, 3, 2, 2, 2};List<Option> options = List.of( new Option("A: три копия на магазина", new int[] {5, 3, 2, 4, 2}), new Option("B: модулен монолит, държавата е настройка", new int[] {3, 4, 4, 4, 4}), new Option("C: услуги от първия ден", new int[] {1, 4, 2, 2, 2}));for (Option o : options) System.out.println(o.name() + ": " + total(o, weights));System.out.println("Избор: " + best(options, weights).name());Напиши total (сбор от тегло × оценка) и best:
A: три копия на магазина: 40B: модулен монолит, държавата е настройка: 45C: услуги от първия ден: 27Избор: B: модулен монолит, държавата е настройкаРешение
record Option(String name, int[] scores) {}static int total(Option option, int[] weights) { int sum = 0; for (int i = 0; i < weights.length; i++) sum += weights[i] * option.scores()[i]; return sum;}
static Option best(List<Option> options, int[] weights) { Option best = options.get(0); for (Option o : options) if (total(o, weights) > total(best, weights)) best = o; return best;}Матрицата не взима решението вместо теб. Тя прави видими теглата: ако собственикът каже „времето е всичко“, това е число, с което може да се спори.
Упражнение
Стъпка 2б: колко крехко е решението
Оценките са преценка. Провери чувствителността: вдигай теглото на времето до пускане от 3 нагоре и намери първото тегло, при което печели друг вариант:
При тегло 6 на времето печели A: три копия на магазина (55 срещу 54)Изборът издържа, докато времето не стане два пъти по-важно от наличността. Това казваш на собственика: „ако пускането до пролетта е по-важно от всичко друго, копията печелят, но ето цената им“.
Упражнение
Стъпка 3: ADR
Запиши решението в ADR. Проверката от урока за ADR гледа дали разделите ги има. Тук я правиш по-строга, защото ADR без цена е реклама:
- поне два варианта в „Варианти“;
- поне една последица, която започва с „- лошо:“;
- в „Кога преразглеждаме“ има число, което може да се мери.
Ето черновата, която колега ти праща:
# ADR-014: Три държави и търговци в модулния монолит## Статуспредложено## КонтекстМагазинът тръгва в Гърция и Хърватия и пуска външни търговци. Плащането: 99.7% на месец, p95 до 800 ms.## Варианти- B: модулен монолит, държавата е настройка, търговците са модул## РешениеB. Наличността при търговците идва асинхронно и не е в пътя на плащането.## Последици- добро: един код за трите държавиНапиши checkAdr и я пусни върху черновата и върху своя окончателен ADR:
Чернова: 3 ✗ липсва раздел „Кога преразглеждаме“ ✗ Варианти: 1, нужни са поне 2 ✗ Последици: няма нито едно „лошо:“Окончателен: ✓ ОКРешение
static final List<String> REQUIRED = List.of("Статус", "Контекст", "Варианти", "Решение", "Последици", "Кога преразглеждаме");
static Map<String, List<String>> sections(String adr) { Map<String, List<String>> result = new LinkedHashMap<>(); List<String> current = null; for (String line : adr.strip().split("\n")) { if (line.startsWith("## ")) { current = new ArrayList<>(); result.put(line.substring(3), current); } else if (current != null && !line.isBlank()) { current.add(line); } } return result;}
static List<String> checkAdr(String adr) { Map<String, List<String>> s = sections(adr); List<String> problems = new ArrayList<>(); for (String name : REQUIRED) if (!s.containsKey(name)) problems.add("✗ липсва раздел „" + name + "“"); List<String> options = s.getOrDefault("Варианти", List.of()); long count = options.stream().filter(l -> l.startsWith("- ")).count(); if (count < 2) problems.add("✗ Варианти: " + count + ", нужни са поне 2"); List<String> cons = s.getOrDefault("Последици", List.of()); if (cons.stream().noneMatch(l -> l.startsWith("- лошо:"))) problems.add("✗ Последици: няма нито едно „лошо:“"); List<String> revisit = s.get("Кога преразглеждаме"); if (revisit != null && !String.join(" ", revisit).matches(".*\\d.*")) problems.add("✗ Кога преразглеждаме: няма число, което да се мери"); return problems;}for (String[] adr : new String[][] {{"Чернова", DRAFT}, {"Окончателен", FINAL}}) { List<String> problems = checkAdr(adr[1]); System.out.println(adr[0] + ": " + (problems.isEmpty() ? "✓ ОК" : problems.size())); for (String p : problems) System.out.println(" " + p);}Окончателният ADR е в примерното решение най-долу.
Упражнение
Стъпка 4: планът на миграцията
Магазинът работи и в момента. Поръчките трябва да научат за държавата, без спиране и без голямо превключване. Подреди стъпките така, че всяка да е обратима до последната:
1% от гръцкия трафик минава по новия път (флаг)новият код пише country, старият още работидобави колоната country (празна, старият код не я чете)10%, 50%, 100% само при зелени метрики, назад при червениbackfill: старите поръчки получават BG, после сравни брояизключи стария път и махни стария код
Дяловете (1%, 10%, 50%, 100%) не се вдигат по календар, а по метрики. Напиши rollout: за всеки дял чете следващите записани метрики. При зелени (до 5 грешки на 1000 и p95 до 800 ms) минава напред, при червени връща трафика на предишния дял и опитва пак след поправката. Метриките са записан сценарий, за да е изходът един и същ:
List<Metrics> observed = List.of( new Metrics(2, 610), new Metrics(9, 700), new Metrics(3, 640), new Metrics(4, 690), new Metrics(3, 700));rollout(new int[] {1, 10, 50, 100}, observed.iterator());1%: грешки 2/1000, p95 610 ms ✓10%: грешки 9/1000, p95 700 ms ✗ връщане към 1%10%: грешки 3/1000, p95 640 ms ✓50%: грешки 4/1000, p95 690 ms ✓100%: грешки 3/1000, p95 700 ms ✓Старият път е изключен: точка без връщане.Решение
record Metrics(int errorsPer1000, int p95) { boolean healthy() { return errorsPer1000 <= 5 && p95 <= 800; }}static void rollout(int[] shares, Iterator<Metrics> observed) { int step = 0; while (step < shares.length) { Metrics m = observed.next(); String line = shares[step] + "%: грешки " + m.errorsPer1000() + "/1000, p95 " + m.p95() + " ms"; if (m.healthy()) { System.out.println(line + " ✓"); step++; } else { int back = step == 0 ? 0 : shares[step - 1]; System.out.println(line + " ✗ връщане към " + back + "%"); } } System.out.println("Старият път е изключен: точка без връщане.");}Точката без връщане е само една и е последна: махането на стария път. Всичко преди нея се връща с флаг. Колоната се добавя празна (expand), а се маха чак когато никой не я чете (contract).
Пробвай
Намери грешката
Колега пише проверката на метриките. Пускането минава гладко, но 10% тръгва с 9 грешки на 1000:
1%: грешки 2/1000, p95 610 ms ✓10%: грешки 9/1000, p95 700 ms ✓50%: грешки 3/1000, p95 640 ms ✓100%: грешки 4/1000, p95 690 ms ✓Старият път е изключен: точка без връщане.Избери реда с грешката.
Условието е „или“ вместо „и“: стига една от двете метрики да е зелена. 9 грешки на 1000 минават, защото p95 е добро. Условие за безопасност е „и“: напред се върви само когато всичко е зелено.
Упражнение
Стъпка 5: регистърът на рисковете
Ето рисковете, които екипът събра. Напиши register, който ги подрежда (по резултат, при равенство по последствие) и показва мярката. Висок риск (12 и повече) без мярка е отворена дупка и се показва с „✗“, а по-малък без мярка е съзнателно приет.
List<Risk> risks = List.of( new Risk("ДДС и фактурите по държави се объркват", 3, 5, "тест с фактура за всяка държава преди 1%"), new Risk("куриерът в Хърватия няма API за проследяване", 2, 4, ""), new Risk("трафикът на Black Friday в Гърция е 5 пъти по-голям", 3, 4, ""), new Risk("копието на наличността закъснява и продаваме изчерпана стока", 4, 3, "резервация при плащане и отмяна с извинение"), new Risk("екипът не познава модула за търговци", 3, 2, "работа по двойки първите два месеца"));register(risks);15 ДДС и фактурите по държави се объркват → тест с фактура за всяка държава преди 1%12 трафикът на Black Friday в Гърция е 5 пъти по-голям → ✗ няма мярка12 копието на наличността закъснява и продаваме изчерпана стока → резервация при плащане и отмяна с извинение 8 куриерът в Хърватия няма API за проследяване → (приемаме) 6 екипът не познава модула за търговци → работа по двойки първите два месецаРешение
record Risk(String name, int probability, int impact, String measure) { int score() { return probability * impact; }}static void register(List<Risk> risks) { List<Risk> sorted = new ArrayList<>(risks); sorted.sort(Comparator.comparingInt(Risk::score).thenComparingInt(Risk::impact).reversed()); for (Risk r : sorted) { String measure = !r.measure().isEmpty() ? r.measure() : r.score() >= 12 ? "✗ няма мярка" : "(приемаме)"; System.out.printf("%2d %s → %s%n", r.score(), r.name(), measure); }}За Black Friday мярката е товарен тест с 5 пъти трафика преди пускането в Гърция и план кое се изключва първо (препоръките, търсенето по търговци), за да оцелее плащането. Мащабирането е от „Мащабиране“, а изключването е като прекъсвача от „Когато съседът е бавен“.
Упражнение
Стъпка 6: fitness function за новите граници
Модулът на търговците е нов. Ако още първия месец започне да чете вътрешностите на поръчките, след година няма да може да се отдели. Правилото: всеки модул има api (публичното) и internal; чужд модул се вика само през api. Import-ите на модулите:
sellers.internal -> orders.apisellers.internal -> orders.internalorders.internal -> payments.apiorders.internal -> sellers.internalcatalog.internal -> sellers.apiorders.internal -> orders.db✗ sellers.internal -> orders.internal: вика вътрешностите на orders✗ orders.internal -> sellers.internal: вика вътрешностите на sellersНарушения: 2if (otherModule && !to[1].equals(""))api: към чужд модул се ходи само през публичната му част. Вътре в своя модул (orders.internal -> orders.db) всичко е разрешено.
Решение
// чужд модул се вика само през apistatic List<String> boundaryViolations(List<String> imports) { List<String> found = new ArrayList<>(); for (String line : imports) { String[] ends = line.split(" -> "); String[] from = ends[0].split("\\."); String[] to = ends[1].split("\\."); boolean otherModule = !from[0].equals(to[0]); if (otherModule && !to[1].equals("api")) found.add(line + ": вика вътрешностите на " + to[0]); } return found;}List<String> imports = List.of( "sellers.internal -> orders.api", "sellers.internal -> orders.internal", "orders.internal -> payments.api", "orders.internal -> sellers.internal", "catalog.internal -> sellers.api", "orders.internal -> orders.db");List<String> found = boundaryViolations(imports);for (String v : found) System.out.println("✗ " + v);System.out.println("Нарушения: " + found.size());Пусни я при всеки комит с baseline, както в прегледа. Днешните две нарушения се оправят, преди модулът на търговците да излезе.
Виж го
Примерно решение
Сравни своето решение с това. То не е единственото вярно: вярно е всяко решение, чиито числа, варианти и рискове са честни и записани.
Изискванията. Петте сценария от стъпка 1. Ключовото: плащането е 99,7% и p95 до 800 ms, а наличността при търговците е евентуално съгласувана, до 2 минути.
Вариантите. A, B и C с цената им; изборът е B с 45 точки срещу 40 и 27. Решението издържа до тегло 6 на времето.
Архитектурата на B.
- Държавата е настройка: език, ДДС, куриер и правила за връщане идват от таблица по държава, не от
ifв кода. - Търговците са модул в монолита, със свои данни и публичен
api(„Монолит, модулен монолит, услуги“). - Наличността при търговците идва като събития в копие при нас (outbox от тяхна страна или опашка от нашата).
- Продадено над наличното се отменя със saga: резервация, плащане, а при отказ от търговеца връщане на парите.
- Склад 2009 и счетоводството остават зад anti-corruption layer, както досега.
ADR-ът.
# ADR-014: Три държави и търговци в модулния монолит## Статусприето## КонтекстМагазинът тръгва в Гърция и Хърватия и пуска външни търговци. Плащането: 99.7% на месец, p95 до 800 ms.## Варианти- A: три копия на магазина, по едно на държава- B: модулен монолит, държавата е настройка, търговците са модул- C: услуги от първия ден## РешениеB. Наличността при търговците идва асинхронно и не е в пътя на плащането.## Последици- добро: един код за трите държави- лошо: промяна за една държава се пуска и за трите- лошо: копието на наличността може да закъснее с минути## Кога преразглеждамеАко търговците станат над 500 или екипите станат повече от 3.Планът. Колоната country (expand), новият код пише в нея, backfill на старите поръчки като България, после 1%, 10%, 50% и 100% от гръцкия трафик при зелени метрики, накрая махането на стария път (contract). Хърватия тръгва след Гърция по същия план.
Рисковете. Регистърът от стъпка 5, с мярка за Black Friday. Отговаря собственикът на всеки риск, а регистърът се гледа на всеки две седмици до пускането.
Проверките. Границите от стъпка 6 и бюджетът от стъпка 1 като fitness function: товарен тест преди всяко пускане, който пада, ако p95 на плащането е над 800 ms. Как се пускат в pipeline-а е тема на ZeroToOps, курса по DevOps.
Какво не решихме. Отделна услуга за търговците. ADR-ът казва кога да се върнем към този въпрос: над 500 търговци или повече от 3 екипа.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Интеграция със стари и чужди системи · Смяна на жива система (strangler fig, expand–contract) · Архитектурен преглед · Enterprise ландшафт и капабилитита
- Задачи по темата: Архитектът в работа