Архитектурен преглед
Накратко: Архитектурният преглед чете истинските зависимости от кода, намира стрелките в грешна посока и циклите и превръща правилата във 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Програмата прави от списъка граф и търси две неща. Стрелка нагоре: по-долен слой вика по-горен. Цикъл: модул, който през други модули стига обратно до себе си. Цикълът значи, че двата модула не могат да се сменят, тестват или пуснат поотделно. Циклите се намират с обход в дълбочина с цветове.
Виж го
Обявеното срещу истинското
const layer = (module) => LAYERS.indexOf(module.split("/")[0]);
// по-долен слой вика по-горен: стрелка нагореfunction violations(deps) { return deps .filter(([from, to]) => layer(from) > layer(to)) .map(([from, to]) => from + " -> " + to);}
function findCycles(deps) { const next = new Map(); for (const [from, to] of deps) { if (!next.has(from)) next.set(from, []); if (!next.has(to)) next.set(to, []); next.get(from).push(to); } const mark = new Map(); const path = []; const found = []; const visit = (at) => { mark.set(at, "grey"); // сив: на текущия път path.push(at); for (const to of next.get(at)) { if (!mark.has(to)) visit(to); else if (mark.get(to) === "grey") { const loop = path.slice(path.indexOf(to)); found.push([...loop, to].join(" -> ")); } } path.pop(); mark.set(at, "black"); // черен: обходен }; for (const [from] of deps) if (!mark.has(from)) visit(from); return found;}Стъпка по стъпка, с думи
- Диаграмата от 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).
const LAYERS = ["web", "service", "domain", "data"];const layer = (module) => LAYERS.indexOf(module.split("/")[0]);
// по-долен слой вика по-горен: стрелка нагореfunction violations(deps) { return deps .filter(([from, to]) => layer(from) > layer(to)) .map(([from, to]) => from + " -> " + to);}Циклите търси обход в дълбочина. Сив модул е на текущия път, черен е обходен изцяло. Стрелка към сив модул се връща назад по пътя: това е цикъл, и той е частта от пътя от този модул досега.
function findCycles(deps) { const next = new Map(); for (const [from, to] of deps) { if (!next.has(from)) next.set(from, []); if (!next.has(to)) next.set(to, []); next.get(from).push(to); } const mark = new Map(); const path = []; const found = []; const visit = (at) => { mark.set(at, "grey"); // сив: на текущия път path.push(at); for (const to of next.get(at)) { if (!mark.has(to)) visit(to); else if (mark.get(to) === "grey") { const loop = path.slice(path.indexOf(to)); found.push([...loop, to].join(" -> ")); } } path.pop(); mark.set(at, "black"); // черен: обходен }; for (const [from] of deps) if (!mark.has(from)) visit(from); return found;}Map пази реда на добавяне, затова циклите излизат в реда на списъка със зависимости. visit е стрелкова функция и вижда mark, path и found отвън.Целият преглед върху списъка от import-ите:
const deps = parse(IMPORTS);console.log("Зависимости: " + deps.length);show("Нарушени слоеве", violations(deps));show("Цикли", findCycles(deps));Зависимости: 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.
deps.filter(([from, to]) => layer(from) layer(to))>: модулът, от който тръгва стрелката, е в по-долен слой (по-голям индекс) от модула, към който сочи. Стрелка в същия слой (service/orders -> service/payments) не е нарушение на слоевете, но може да е част от цикъл.
Пробвай
Намери грешката
Колега е „опростил“ visit. Сега прегледът печата:
Зависимости: 6Нарушени слоеве: domain/pricing -> web/shopЦикли: нямаИзбери реда с грешката.
Модулът става черен още при влизане, вместо сив. Тогава никой модул не е сив, проверката за стрелка назад никога не е вярна и циклите изчезват. Най-лошото е, че изходът изглежда спокоен: „няма“. Проверка, която мълчи, трябва да се тества и с вход, в който знаеш, че има цикъл.
Идеята
Fitness function: прегледът при всеки комит
Прегледът е снимка. Утре някой добавя нов import и снимката остарява. Fitness function е автоматична проверка на архитектурна характеристика: пуска се при всеки комит, като тест, и пада, когато правилото се наруши. Името идва от книгата „Building Evolutionary Architectures“ (Ford, Parsons, Kua).
Наследената система вече има нарушение. Ако проверката пада заради него, тя ще пада всеки ден и екипът ще я изключи. Затова старият дълг се записва в baseline: проверката пада само за ново нарушение, а оправеното трябва да излезе от baseline-а, за да не се върне незабелязано. Така дългът само намалява.
// пуска се при всеки комит: старият дълг е в baselinefunction fitness(deps, baseline) { const now = violations(deps); return [ ...now.filter((v) => !baseline.includes(v)).map((v) => "✗ нова зависимост нагоре: " + v), ...baseline.filter((old) => !now.includes(old)).map((old) => "✓ оправено, махни от baseline: " + old), ];}
function check(commit, deps, baseline) { const report = fitness(deps, baseline); const fails = report.some((r) => r.startsWith("✗")); console.log(commit + ": " + (fails ? "ПАДА" : "ОК")); for (const r of report) console.log(" " + r);}Комит 1 добавя domain/pricing -> service/payments: цената иска таксата на плащането. Комит 2 мести formatPrice в домейна и маха старото нарушение:
const deps = parse(IMPORTS);const baseline = ["domain/pricing -> web/shop"];
const first = [...deps, ["domain/pricing", "service/payments"]];check("Комит 1", first, baseline);
const second = deps.filter(([from, to]) => from + " -> " + to !== "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;
- какво правиш и кой отговаря.
Резултатът е вероятност × последствие. Подреждаш по него, а при равенство по последствието: рядкото, но тежко събитие е по-напред от честото и леко.
class Risk { constructor(name, probability, impact, response = "") { Object.assign(this, { name, probability, impact, response }); }
get score() { return this.probability * this.impact; }
get level() { return this.score >= 12 ? "висок" : this.score >= 6 ? "среден" : "нисък"; }}// по резултат, а при равен резултат по-голямото последствие е първоfunction ranked(risks) { return [...risks].sort((a, b) => b.score - a.score || b.impact - a.impact);}Рисковете от прегледа на магазина:
const risks = [ 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 (const r of ranked(risks)) console.log(`${String(r.score).padStart(2)} ${r.level.padEnd(6)} ${r.name} → ${r.response}`);15 висок плащането е без timeout и бави поръчките → намали: timeout и прекъсвач12 висок само един човек знае склада от 2009 → намали: втори човек и описание12 висок цикъл orders ⇄ payments → намали: събитие вместо обратно извикване10 среден възстановяването от backup не е пробвано → намали: пробно възстановяване всеки месец 8 среден домейнът вика web → приеми: оправя се със следващата промяна на цените 4 нисък PDF библиотеката за фактури не се поддържа → приемиОтговорите са четири:
- избегни: махаш причината, например не пускаш функцията;
- намали: вероятността или последствието, например timeout и прекъсвач от „Когато съседът е бавен“;
- прехвърли: на някой друг, например застраховка или договор с доставчика;
- приеми: съзнателно и записано, с дата за нов поглед.
Числата от 1 до 5 са преценка, не измерване. 12 не е „два пъти по-лошо“ от 6. Те служат да подредиш и да започне разговорът, а не да смяташ с тях.
b.score - a.score || b.impact - a.impact: ако разликата в резултата е 0, || минава към последствието.Пробвай
Предвиди изхода
Четири риска от друг преглед, подредени със същия ranked. Какво печата програмата?
const risks = [ new Risk("доставчикът на имейли сменя API-то", 3, 2), new Risk("сертификатът на сайта се подновява на ръка", 3, 3), new Risk("складът пада на Black Friday", 2, 5), new Risk("месечните отчети се бавят", 5, 1),];for (const r of ranked(risks)) console.log(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 ландшафт и капабилитита · Твоята първа архитектурна задача
- Задачи по темата: Архитектът в работа