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

Архитектурен преглед

Накратко: Архитектурният преглед чете истинските зависимости от кода, намира стрелките в грешна посока и циклите и превръща правилата във fitness function, която ги пази при всеки commit.

Проблем

Диаграмата казва „слоеве“

Наследяваш онлайн магазина. В README има диаграма: четири слоя, стрелките само надолу.

README.md
web web/shop
service service/orders service/payments
domain domain/pricing
data data/orders
web → service → domain → data

Първата ти задача е малка: цената в страницата да е с два знака след запетаята. Сменяш formatPrice в web/shop и падат тестовете на плащанията. Оказва се, че domain/pricing ползва formatPrice от web/shop, а поръчките и плащанията се викат взаимно.

Диаграмата не е лъгала нарочно. Тя е била вярна преди две години, а после никой не я е проверявал.

Идеята

Истината е в import-ите

Архитектурният преглед отговаря на няколко въпроса за система, която вече работи:

  1. Какви са истинските граници? Кой модул кого вика, според кода.
  2. Спазват ли се правилата? Слоевете от „Слоеве“, посоката на зависимостите от „Hexagonal“, границите на модулите от „Монолит, модулен монолит, услуги“.
  3. Издържат ли изискванията? Персентилите и наличността от „Нефункционални изисквания“, мерени на живата система, а не обещани.
  4. Какво може да се обърка? Рисковете, подредени.
  5. Кои решения никой не е записал? За тях се пишат ADR със задна дата, за да е ясно защо системата е такава.

Тук започваме от първите две. Всеки import е стрелка от един модул към друг. Инструмент ги изважда от кода като списък, по една зависимост на ред:

imports.txt
web/shop -> service/orders
service/orders -> domain/pricing
service/orders -> service/payments
service/payments -> service/orders
service/orders -> data/orders
domain/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);
}
Стъпка по стъпка, с думи
  1. Диаграмата от README: четири слоя, стрелките само надолу. Всеки модул стои в своя слой.
  2. Инструментът чете import-ите: 6 истински зависимости. Повечето сочат надолу или в същия слой.
  3. domain/pricing вика web/shop: слой 2 вика слой 0. Стрелката сочи нагоре.
  4. Обходът в дълбочина тръгва от web/shop. Всеки модул по текущия път е сив.
  5. domain/pricing сочи назад към сивия web/shop: цикъл, web/shop → service/orders → domain/pricing → web/shop.
  6. Обходът продължава: service/payments сочи назад към сивия service/orders. Втори цикъл.
  7. Всички модули са обходени (черни). Обявено: слоеве, само надолу. Истинско: 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);

Пробвай

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

Колега е „опростил“ visit. Сега прегледът печата:

Изход
Зависимости: 6
Нарушени слоеве:
domain/pricing -> web/shop
Цикли:
няма

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

Идеята

Fitness function: прегледът при всеки комит

Прегледът е снимка. Утре някой добавя нов import и снимката остарява. Fitness function е автоматична проверка на архитектурна характеристика: пуска се при всеки комит, като тест, и пада, когато правилото се наруши. Името идва от книгата „Building Evolutionary Architectures“ (Ford, Parsons, Kua).

Наследената система вече има нарушение. Ако проверката пада заради него, тя ще пада всеки ден и екипът ще я изключи. Затова старият дълг се записва в baseline: проверката пада само за ново нарушение, а оправеното трябва да излезе от baseline-а, за да не се върне незабелязано. Така дългът само намалява.

// пуска се при всеки комит: старият дълг е в baseline
static 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/shop

Fitness 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());
Отговор

Идеята

Кога прегледът вреди

Прегледът е инструмент, а не ритуал. Вреди, когато:

  • е изпит за екипа. Прегледът, на който хората, писали кода, не са поканени, намира виновни, а не рискове. Те знаят защо нещата са такива.
  • 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 и повече, които нямат собственик.

Тест

Провери се

1. Откъде прегледът взима истинските зависимости между модулите?
2. Обходът стига до модул, който е сив. Какво значи това?
3. Защо fitness function за наследена система ползва baseline?
4. Два риска са с резултат 12: вероятност 4 × последствие 3 и вероятност 3 × последствие 4. Кой е по-напред в регистъра от урока?
5. Кое от тези е fitness function?

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