Архитектурен преглед
Накратко: Архитектурният преглед чете истинските зависимости от кода, намира стрелките в грешна посока и циклите и превръща правилата във 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Програмата прави от списъка граф и търси две неща. Стрелка нагоре: по-долен слой вика по-горен. Цикъл: модул, който през други модули стига обратно до себе си. Цикълът значи, че двата модула не могат да се сменят, тестват или пуснат поотделно. Циклите се намират с обход в дълбочина с цветове.
Виж го
Обявеното срещу истинското
def layer(module): return LAYERS.index(module.split("/")[0])
# по-долен слой вика по-горен: стрелка нагореdef violations(deps): return [f"{a} -> {b}" for a, b in deps if layer(a) > layer(b)]
def find_cycles(deps): nxt = {} for a, b in deps: nxt.setdefault(a, []).append(b) nxt.setdefault(b, []) mark, path, found = {}, [], []
def visit(at): mark[at] = "grey" # сив: на текущия път path.append(at) for to in nxt[at]: if to not in mark: visit(to) elif mark[to] == "grey": loop = path[path.index(to):] + [to] found.append(" -> ".join(loop)) path.pop() mark[at] = "black" # черен: обходен
for a, _ in deps: if a not in mark: visit(a) 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).
LAYERS = ["web", "service", "domain", "data"]
def layer(module): return LAYERS.index(module.split("/")[0])
# по-долен слой вика по-горен: стрелка нагореdef violations(deps): return [f"{a} -> {b}" for a, b in deps if layer(a) > layer(b)]Циклите търси обход в дълбочина. Сив модул е на текущия път, черен е обходен изцяло. Стрелка към сив модул се връща назад по пътя: това е цикъл, и той е частта от пътя от този модул досега.
def find_cycles(deps): nxt = {} for a, b in deps: nxt.setdefault(a, []).append(b) nxt.setdefault(b, []) mark, path, found = {}, [], []
def visit(at): mark[at] = "grey" # сив: на текущия път path.append(at) for to in nxt[at]: if to not in mark: visit(to) elif mark[to] == "grey": loop = path[path.index(to):] + [to] found.append(" -> ".join(loop)) path.pop() mark[at] = "black" # черен: обходен
for a, _ in deps: if a not in mark: visit(a) return foundvisit е функция във функцията и вижда mark, path и found отвън, без да ги подаваш. Рекурсията в Python спира около 1000 нива: за граф с хиляди модули обходът се пише с цикъл и свой стек.Целият преглед върху списъка от import-ите:
deps = parse(IMPORTS)print(f"Зависимости: {len(deps)}")show("Нарушени слоеве", violations(deps))show("Цикли", find_cycles(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.
[f"{a} -> {b}" for a, b in deps if layer(a) layer(b)]>: модулът, от който тръгва стрелката, е в по-долен слой (по-голям индекс) от модула, към който сочи. Стрелка в същия слой (service/orders -> service/payments) не е нарушение на слоевете, но може да е част от цикъл.
Пробвай
Намери грешката
Колега е „опростил“ visit. Сега прегледът печата:
Зависимости: 6Нарушени слоеве: domain/pricing -> web/shopЦикли: нямаИзбери реда с грешката.
Модулът става черен още при влизане, вместо сив. Тогава никой модул не е сив, проверката за стрелка назад никога не е вярна и циклите изчезват. Най-лошото е, че изходът изглежда спокоен: „няма“. Проверка, която мълчи, трябва да се тества и с вход, в който знаеш, че има цикъл.
Идеята
Fitness function: прегледът при всеки комит
Прегледът е снимка. Утре някой добавя нов import и снимката остарява. Fitness function е автоматична проверка на архитектурна характеристика: пуска се при всеки комит, като тест, и пада, когато правилото се наруши. Името идва от книгата „Building Evolutionary Architectures“ (Ford, Parsons, Kua).
Наследената система вече има нарушение. Ако проверката пада заради него, тя ще пада всеки ден и екипът ще я изключи. Затова старият дълг се записва в baseline: проверката пада само за ново нарушение, а оправеното трябва да излезе от baseline-а, за да не се върне незабелязано. Така дългът само намалява.
# пуска се при всеки комит: старият дълг е в baselinedef fitness(deps, baseline): now = violations(deps) report = [f"✗ нова зависимост нагоре: {v}" for v in now if v not in baseline] report += [f"✓ оправено, махни от baseline: {old}" for old in baseline if old not in now] return report
def check(commit, deps, baseline): report = fitness(deps, baseline) fails = any(r.startswith("✗") for r in report) print(f"{commit}: {'ПАДА' if fails else 'ОК'}") for r in report: print(" " + r)Комит 1 добавя domain/pricing -> service/payments: цената иска таксата на плащането. Комит 2 мести formatPrice в домейна и маха старото нарушение:
deps = parse(IMPORTS)baseline = ["domain/pricing -> web/shop"]
first = deps + [("domain/pricing", "service/payments")]check("Комит 1", first, baseline)
second = [d for d in deps if d != tuple("domain/pricing -> web/shop".split(" -> "))]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;
- какво правиш и кой отговаря.
Резултатът е вероятност × последствие. Подреждаш по него, а при равенство по последствието: рядкото, но тежко събитие е по-напред от честото и леко.
@dataclassclass Risk: name: str probability: int # 1..5 impact: int # 1..5 response: str = ""
@property def score(self): return self.probability * self.impact
@property def level(self): return "висок" if self.score >= 12 else "среден" if self.score >= 6 else "нисък"# по резултат, а при равен резултат по-голямото последствие е първоdef ranked(risks): return sorted(risks, key=lambda r: (r.score, r.impact), reverse=True)Рисковете от прегледа на магазина:
risks = [ Risk("цикъл orders ⇄ payments", 4, 3, "намали: събитие вместо обратно извикване"), Risk("плащането е без timeout и бави поръчките", 3, 5, "намали: timeout и прекъсвач"), Risk("домейнът вика web", 4, 2, "приеми: оправя се със следващата промяна на цените"), Risk("възстановяването от backup не е пробвано", 2, 5, "намали: пробно възстановяване всеки месец"), Risk("само един човек знае склада от 2009", 3, 4, "намали: втори човек и описание"), Risk("PDF библиотеката за фактури не се поддържа", 2, 2, "приеми"),]for r in ranked(risks): print(f"{r.score:2} {r.level:<6} {r.name} → {r.response}")15 висок плащането е без timeout и бави поръчките → намали: timeout и прекъсвач12 висок само един човек знае склада от 2009 → намали: втори човек и описание12 висок цикъл orders ⇄ payments → намали: събитие вместо обратно извикване10 среден възстановяването от backup не е пробвано → намали: пробно възстановяване всеки месец 8 среден домейнът вика web → приеми: оправя се със следващата промяна на цените 4 нисък PDF библиотеката за фактури не се поддържа → приемиОтговорите са четири:
- избегни: махаш причината, например не пускаш функцията;
- намали: вероятността или последствието, например timeout и прекъсвач от „Когато съседът е бавен“;
- прехвърли: на някой друг, например застраховка или договор с доставчика;
- приеми: съзнателно и записано, с дата за нов поглед.
Числата от 1 до 5 са преценка, не измерване. 12 не е „два пъти по-лошо“ от 6. Те служат да подредиш и да започне разговорът, а не да смяташ с тях.
Ключът е кортеж(score, impact): кортежите се сравняват първо по първия елемент. reverse=True обръща и двата.Пробвай
Предвиди изхода
Четири риска от друг преглед, подредени със същия ranked. Какво печата програмата?
risks = [ Risk("доставчикът на имейли сменя API-то", 3, 2), Risk("сертификатът на сайта се подновява на ръка", 3, 3), Risk("складът пада на Black Friday", 2, 5), Risk("месечните отчети се бавят", 5, 1),]for r in ranked(risks): print(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). Напиши check_all(deps, baseline, cycle_baseline), който печата всички находки и накрая дали пада. Пусни го с комит, който добавя data/orders -> service/orders: данните викат услугата, за да пратят имейл след запис.
✗ нова зависимост нагоре: data/orders -> service/orders✗ цикли: 3, baseline: 2ПАДАОще задачи
- Кой е в цикъл. Вместо броя цикли пази множеството модули, които са в някой цикъл. Кое правило е по-трудно да се заобиколи?
- Модулите в слоя. Добави правило:
service/paymentsне бива да викаservice/ordersдиректно, а само чрез събитие (урокът „Събития и опашки“). Как ще го запишеш в baseline-а, докато цикълът не е оправен? - Регистърът. Добави на всеки риск собственик и дата за нов поглед. Отпечатай рисковете с резултат 12 и повече, които нямат собственик.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Събития и опашки · ADR: записани архитектурни решения · Нефункционални изисквания, които се мерят · Когато съседът е бавен: timeout, retry, circuit breaker
- Продължава в: Enterprise ландшафт и капабилитита · Твоята първа архитектурна задача
- Задачи по темата: Архитектът в работа