Архитектурен преглед
Накратко: Архитектурният преглед чете истинските зависимости от кода, намира стрелките в грешна посока и циклите и превръща правилата във fitness function, която ги пази при всеки commit.
Проблем
Диаграмата казва „слоеве“
Наследяваш онлайн магазина. В README има диаграма: четири слоя, стрелките само надолу.
web web/shopservice service/orders service/paymentsdomain domain/pricingdata data/orders
web → service → domain → dataПървата ти задача е малка: цената в страницата да е с два знака след запетаята. Сменяш formatPrice в web/shop и падат тестовете на плащанията. Оказва се, че domain/pricing ползва formatPrice от web/shop, а поръчките и плащанията се викат взаимно.
Диаграмата не е лъгала нарочно. Тя е била вярна преди две години, а после никой не я е проверявал.
Идеята
Истината е в import-ите
Архитектурният преглед отговаря на няколко въпроса за система, която вече работи:
- Какви са истинските граници? Кой модул кого вика, според кода.
- Спазват ли се правилата? Слоевете от „Слоеве“, посоката на зависимостите от „Hexagonal“, границите на модулите от „Монолит, модулен монолит, услуги“.
- Издържат ли изискванията? Персентилите и наличността от „Нефункционални изисквания“, мерени на живата система, а не обещани.
- Какво може да се обърка? Рисковете, подредени.
- Кои решения никой не е записал? За тях се пишат ADR със задна дата, за да е ясно защо системата е такава.
Тук започваме от първите две. Всеки import е стрелка от един модул към друг. Инструмент ги изважда от кода като списък, по една зависимост на ред:
web/shop -> service/ordersservice/orders -> domain/pricingservice/orders -> service/paymentsservice/payments -> service/ordersservice/orders -> data/ordersdomain/pricing -> web/shopПрограмата прави от списъка граф и търси две неща. Стрелка нагоре: по-долен слой вика по-горен. Цикъл: модул, който през други модули стига обратно до себе си. Цикълът значи, че двата модула не могат да се сменят, тестват или пуснат поотделно. Циклите се намират с обход в дълбочина с цветове.
Виж го
Обявеното срещу истинското
static int layer(String module) { return LAYERS.indexOf(module.split("/")[0]);}
// по-долен слой вика по-горен: стрелка нагореstatic List<Dep> violations(List<Dep> deps) { List<Dep> found = new ArrayList<>(); for (Dep d : deps) if (layer(d.from()) > layer(d.to())) found.add(d); return found;}
void visit(String at) { mark.put(at, Mark.GREY); // сив: на текущия път path.add(at); for (String to : next.get(at)) { if (mark.get(to) == null) visit(to); else if (mark.get(to) == Mark.GREY) cycle(to); } path.removeLast(); mark.put(at, Mark.BLACK); // черен: обходен}
void cycle(String to) { int start = path.indexOf(to); var loop = path.subList(start, path.size()); found.add(String.join(" -> ", loop) + " -> " + to);}Стъпка по стъпка, с думи
- Диаграмата от README: четири слоя, стрелките само надолу. Всеки модул стои в своя слой.
- Инструментът чете import-ите: 6 истински зависимости. Повечето сочат надолу или в същия слой.
- domain/pricing вика web/shop: слой 2 вика слой 0. Стрелката сочи нагоре.
- Обходът в дълбочина тръгва от web/shop. Всеки модул по текущия път е сив.
- domain/pricing сочи назад към сивия web/shop: цикъл, web/shop → service/orders → domain/pricing → web/shop.
- Обходът продължава: service/payments сочи назад към сивия service/orders. Втори цикъл.
- Всички модули са обходени (черни). Обявено: слоеве, само надолу. Истинско: 1 стрелка нагоре и 2 цикъла.
Правилото на слоевете е едно сравнение: индексът на слоя расте надолу, затова стрелка нагоре е layer(from) > layer(to).
static final List<String> LAYERS = List.of("web", "service", "domain", "data");
static int layer(String module) { return LAYERS.indexOf(module.split("/")[0]);}
// по-долен слой вика по-горен: стрелка нагореstatic List<Dep> violations(List<Dep> deps) { List<Dep> found = new ArrayList<>(); for (Dep d : deps) if (layer(d.from()) > layer(d.to())) found.add(d); return found;}Циклите търси обход в дълбочина. Сив модул е на текущия път, черен е обходен изцяло. Стрелка към сив модул се връща назад по пътя: това е цикъл, и той е частта от пътя от този модул досега.
record Dep(String from, String to) { @Override public String toString() { return from + " -> " + to; }}enum Mark { GREY, BLACK }
class Cycles { final Map<String, List<String>> next = new HashMap<>(); final Map<String, Mark> mark = new HashMap<>(); final List<String> path = new ArrayList<>(); final List<String> found = new ArrayList<>();
Cycles(List<Dep> deps) { for (Dep d : deps) { next.computeIfAbsent(d.from(), k -> new ArrayList<>()).add(d.to()); next.computeIfAbsent(d.to(), k -> new ArrayList<>()); } for (Dep d : deps) if (!mark.containsKey(d.from())) visit(d.from()); }
void visit(String at) { mark.put(at, Mark.GREY); // сив: на текущия път path.add(at); for (String to : next.get(at)) { if (mark.get(to) == null) visit(to); else if (mark.get(to) == Mark.GREY) cycle(to); } path.removeLast(); mark.put(at, Mark.BLACK); // черен: обходен }
void cycle(String to) { int start = path.indexOf(to); var loop = path.subList(start, path.size()); found.add(String.join(" -> ", loop) + " -> " + to); }}path.removeLast() е от Java 21. mark.get(to) == null значи „още не е посетен“, защото в речника няма запис.Целият преглед върху списъка от import-ите:
List<Dep> deps = parse(IMPORTS);System.out.println("Зависимости: " + deps.size());show("Нарушени слоеве", violations(deps));show("Цикли", new Cycles(deps).found);Зависимости: 6Нарушени слоеве: domain/pricing -> web/shopЦикли: web/shop -> service/orders -> domain/pricing -> web/shop service/orders -> service/payments -> service/ordersЕдното нарушение и двата цикъла не са три отделни проблема. domain/pricing -> web/shop е и нарушението, и стрелката, която затваря първия цикъл: оправиш ли го, изчезват и двете.
Пробвай
Попълни
Кое сравнение хваща стрелката нагоре? Слоят web е с индекс 0, а data с индекс 3.
for (Dep d : deps)
if (layer(d.from()) layer(d.to())) found.add(d);>: модулът, от който тръгва стрелката, е в по-долен слой (по-голям индекс) от модула, към който сочи. Стрелка в същия слой (service/orders -> service/payments) не е нарушение на слоевете, но може да е част от цикъл.
Пробвай
Намери грешката
Колега е „опростил“ visit. Сега прегледът печата:
Зависимости: 6Нарушени слоеве: domain/pricing -> web/shopЦикли: нямаИзбери реда с грешката.
Модулът става черен още при влизане, вместо сив. Тогава никой модул не е сив, проверката за стрелка назад никога не е вярна и циклите изчезват. Най-лошото е, че изходът изглежда спокоен: „няма“. Проверка, която мълчи, трябва да се тества и с вход, в който знаеш, че има цикъл.
Идеята
Fitness function: прегледът при всеки комит
Прегледът е снимка. Утре някой добавя нов import и снимката остарява. Fitness function е автоматична проверка на архитектурна характеристика: пуска се при всеки комит, като тест, и пада, когато правилото се наруши. Името идва от книгата „Building Evolutionary Architectures“ (Ford, Parsons, Kua).
Наследената система вече има нарушение. Ако проверката пада заради него, тя ще пада всеки ден и екипът ще я изключи. Затова старият дълг се записва в baseline: проверката пада само за ново нарушение, а оправеното трябва да излезе от baseline-а, за да не се върне незабелязано. Така дългът само намалява.
// пуска се при всеки комит: старият дълг е в baselinestatic List<String> fitness(List<Dep> deps, List<String> baseline) { List<String> report = new ArrayList<>(); List<String> now = new ArrayList<>(); for (Dep d : violations(deps)) { now.add(d.toString()); if (!baseline.contains(d.toString())) report.add("✗ нова зависимост нагоре: " + d); } for (String old : baseline) if (!now.contains(old)) report.add("✓ оправено, махни от baseline: " + old); return report;}
static void check(String commit, List<Dep> deps, List<String> baseline) { List<String> report = fitness(deps, baseline); boolean fails = report.stream().anyMatch(r -> r.startsWith("✗")); System.out.println(commit + ": " + (fails ? "ПАДА" : "ОК")); for (String r : report) System.out.println(" " + r);}Комит 1 добавя domain/pricing -> service/payments: цената иска таксата на плащането. Комит 2 мести formatPrice в домейна и маха старото нарушение:
List<Dep> deps = parse(IMPORTS);List<String> baseline = List.of("domain/pricing -> web/shop");
List<Dep> first = new ArrayList<>(deps);first.add(new Dep("domain/pricing", "service/payments"));check("Комит 1", first, baseline);
List<Dep> second = new ArrayList<>(deps);second.removeIf(d -> d.toString().equals("domain/pricing -> web/shop"));check("Комит 2", second, baseline);Комит 1: ПАДА ✗ нова зависимост нагоре: domain/pricing -> service/paymentsКомит 2: ОК ✓ оправено, махни от baseline: domain/pricing -> web/shopFitness function не е само за зависимостите. Същата идея пази и други характеристики:
- p95 на плащането в товарния тест е под бюджета от „Нефункционални изисквания“;
- модулът на търговците вика само публичния API на поръчките, не вътрешностите им;
- JavaScript пакетът на страницата е под 200 KB (примерна граница);
- всеки ADR има раздел „Последици“.
Как точно се пуска проверката в pipeline-а (CI) е тема на ZeroToOps, курса по DevOps. Тук е частта, която пише програмистът: самата проверка.
Идеята
Регистър на рисковете
Прегледът намира повече неща, отколкото има време да се оправят. Регистърът на рисковете ги подрежда. За всеки риск записваш:
- какво може да стане;
- колко е вероятно, от 1 до 5;
- колко ще боли, ако стане (последствие), от 1 до 5;
- какво правиш и кой отговаря.
Резултатът е вероятност × последствие. Подреждаш по него, а при равенство по последствието: рядкото, но тежко събитие е по-напред от честото и леко.
record Risk(String name, int probability, int impact, String response) { int score() { return probability * impact; }
String level() { return score() >= 12 ? "висок" : score() >= 6 ? "среден" : "нисък"; }}// по резултат, а при равен резултат по-голямото последствие е първоstatic List<Risk> ranked(List<Risk> risks) { List<Risk> sorted = new ArrayList<>(risks); sorted.sort(Comparator.comparingInt(Risk::score) .thenComparingInt(Risk::impact) .reversed()); return sorted;}Рисковете от прегледа на магазина:
List<Risk> risks = List.of( new Risk("цикъл orders ⇄ payments", 4, 3, "намали: събитие вместо обратно извикване"), new Risk("плащането е без timeout и бави поръчките", 3, 5, "намали: timeout и прекъсвач"), new Risk("домейнът вика web", 4, 2, "приеми: оправя се със следващата промяна на цените"), new Risk("възстановяването от backup не е пробвано", 2, 5, "намали: пробно възстановяване всеки месец"), new Risk("само един човек знае склада от 2009", 3, 4, "намали: втори човек и описание"), new Risk("PDF библиотеката за фактури не се поддържа", 2, 2, "приеми"));for (Risk r : ranked(risks)) System.out.printf("%2d %-6s %s → %s%n", r.score(), r.level(), r.name(), r.response());15 висок плащането е без timeout и бави поръчките → намали: timeout и прекъсвач12 висок само един човек знае склада от 2009 → намали: втори човек и описание12 висок цикъл orders ⇄ payments → намали: събитие вместо обратно извикване10 среден възстановяването от backup не е пробвано → намали: пробно възстановяване всеки месец 8 среден домейнът вика web → приеми: оправя се със следващата промяна на цените 4 нисък PDF библиотеката за фактури не се поддържа → приемиОтговорите са четири:
- избегни: махаш причината, например не пускаш функцията;
- намали: вероятността или последствието, например timeout и прекъсвач от „Когато съседът е бавен“;
- прехвърли: на някой друг, например застраховка или договор с доставчика;
- приеми: съзнателно и записано, с дата за нов поглед.
Числата от 1 до 5 са преценка, не измерване. 12 не е „два пъти по-лошо“ от 6. Те служат да подредиш и да започне разговорът, а не да смяташ с тях.
thenComparingInt(Risk::impact).reversed() обръща цялата наредба: и резултата, и последствието отиват от голямото към малкото.Пробвай
Предвиди изхода
Четири риска от друг преглед, подредени със същия ranked. Какво печата програмата?
List<Risk> risks = List.of( new Risk("доставчикът на имейли сменя API-то", 3, 2, ""), new Risk("сертификатът на сайта се подновява на ръка", 3, 3, ""), new Risk("складът пада на Black Friday", 2, 5, ""), new Risk("месечните отчети се бавят", 5, 1, ""));for (Risk r : ranked(risks)) System.out.println(r.score() + " " + r.name());Резултатите са 6, 9, 10 и 5. Подредени отгоре надолу: складът на Black Friday (2 × 5 = 10), сертификатът (3 × 3 = 9), имейлите (3 × 2 = 6), отчетите (5 × 1 = 5). Отчетите са с най-голяма вероятност, но най-малко боли, ако закъснеят.
Идеята
Кога прегледът вреди
Прегледът е инструмент, а не ритуал. Вреди, когато:
- е изпит за екипа. Прегледът, на който хората, писали кода, не са поканени, намира виновни, а не рискове. Те знаят защо нещата са такива.
- fitness function пада от шум. Крехка проверка (товарен тест на споделена машина) пада без причина и бива изключена. Едно стабилно правило пази повече от десет крехки.
- baseline-ът само расте. Тогава е списък с извинения. Гледай броя му във времето.
- регистърът стои в папка. Риск без собственик и без дата за нов поглед е забравен риск.
- import-ите са цялата истина. Не са. Обща таблица в базата, извикване по HTTP и опашка свързват модули, без import. Модулите, които четат таблиците на другия („Монолит, модулен монолит, услуги“), не се виждат в графа на import-ите.
По-точно, за любопитните
Инструментите. Никой не пише проверката на зависимостите от нулата. Java има ArchUnit (правилата са JUnit тестове) и jdeps в JDK-то; C# има ArchUnitNET и NetArchTest; Python има import-linter; JavaScript има dependency-cruiser и madge (за циклите). Всички вадят графа от кода и проверяват правила като тези от урока, включително baseline: в ArchUnit се казва FreezingArchRule.
Колко цикъла има? Обходът от урока намира по една стрелка назад за всеки цикъл, който срещне. Това не е броят на всички цикли: ако след domain/pricing -> service/payments пуснеш прегледа, пътят до плащанията минава през цената и малкият цикъл „поръчки ⇄ плащания“ не се брои отделно. Инструментите търсят силно свързани компоненти (алгоритъмът на Tarjan): групите модули, в които всеки стига до всеки. Въпросът „кои модули са в цикъл“ е по-полезен от „колко цикъла“.
Методи за преглед. ATAM (Architecture Tradeoff Analysis Method, от SEI) е формален преглед със сценарии на качеството и заинтересованите страни. C4 моделът е начин да рисуваш архитектурата на четири нива на подробност, така че диаграмата да отговаря на въпроса на читателя.
Упражнение
И циклите в проверката
Реши задачата в редактора си.
Добави към fitness function второ правило: броят цикли не бива да расте над baseline-а за циклите (днес 2). Напиши checkAll(deps, baseline, cycleBaseline), който печата всички находки и накрая дали пада. Пусни го с комит, който добавя data/orders -> service/orders: данните викат услугата, за да пратят имейл след запис.
✗ нова зависимост нагоре: data/orders -> service/orders✗ цикли: 3, baseline: 2ПАДАОще задачи
- Кой е в цикъл. Вместо броя цикли пази множеството модули, които са в някой цикъл. Кое правило е по-трудно да се заобиколи?
- Модулите в слоя. Добави правило:
service/paymentsне бива да викаservice/ordersдиректно, а само чрез събитие (урокът „Събития и опашки“). Как ще го запишеш в baseline-а, докато цикълът не е оправен? - Регистърът. Добави на всеки риск собственик и дата за нов поглед. Отпечатай рисковете с резултат 12 и повече, които нямат собственик.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Събития и опашки · ADR: записани архитектурни решения · Нефункционални изисквания, които се мерят · Когато съседът е бавен: timeout, retry, circuit breaker
- Продължава в: Enterprise ландшафт и капабилитита · Твоята първа архитектурна задача
- Задачи по темата: Архитектът в работа