Твоята първа архитектурна задача
Накратко: Проект по софтуерна архитектура: изисквания с числа, три варианта с цената им, матрица на решението, 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 по-долу са примерни (на доставчиците от договора, на търговците от проба):
const chain = [ { name: "API на магазина", availability: 99.95, p95: 120 }, { name: "база", availability: 99.95, p95: 40 }, { name: "плащане", availability: 99.9, p95: 450 }, { name: "наличност при търговците", availability: 99.0, p95: 400 },];budget("Наличността при търговците е в пътя на плащането:", chain, 99.7, 800);budget("Без нея (копие, обновявано от събития):", chain.slice(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) ✓Решение
const MINUTES_PER_MONTH = 30 * 24 * 60;
function budget(title, chain, target, p95Budget) { let availability = 1; let p95 = 0; for (const hop of chain) { availability *= hop.availability / 100; p95 += hop.p95; // сборът на p95 е горна граница, не точно p95 на веригата } const down = Math.round((1 - availability) * MINUTES_PER_MONTH); const allowed = Math.round((1 - target / 100) * MINUTES_PER_MONTH); console.log(title); const mark = down <= allowed ? "✓" : "✗"; console.log(` наличност ${(availability * 100).toFixed(2)}% → ${down} мин. престой на месец (бюджет ${allowed}) ${mark}`); console.log(` p95 ${p95} ms (бюджет ${p95Budget}) ${p95 <= p95Budget ? "✓" : "✗"}`);}Една система с 99,0% сваля плащането до 98.80%: 517 минути престой на месец срещу бюджет 130. Закъснението също не се събира. Затова наличността при търговците не може да е синхронна стъпка на плащането. Тя става копие при нас, обновявано от събития (урокът „Събития и опашки“), а цената е, че копието може да закъснее. Това решение идва от числата, не от вкуса.
Сборът на p95 е горна граница: p95 на веригата обикновено е по-малък, защото рядко всички стъпки са бавни наведнъж. За бюджета горната граница е безопасната страна.
Упражнение
Стъпка 2: варианти с цената им
Опиши поне два варианта, които наистина биха работили, и цената на всеки: пари, време, хора и какво губиш. Три естествени варианта:
| A: три копия на магазина | B: модулен монолит | C: услуги от първия ден | |
|---|---|---|---|
| идея | по един магазин на държава | държавата е настройка, търговците са модул | каталог, поръчки, търговци, плащане поотделно |
| време до пускане | най-бързо | средно | най-бавно |
| цена на месец | три пъти инфраструктура и поддръжка | една система | много услуги, наблюдение, мрежа |
| какво губиш | копията се разминават и не се сливат обратно | промяна за една държава се пуска за трите | екипът учи разпределени системи, докато пуска |
Сравнението се прави в матрица на решението: критерии с тегла (колко важен е всеки) и оценка от 1 до 5 за всеки вариант. Теглата и оценките са на екипа и собственика:
// време до пускане 3, наличност 3, цена на месец 2, екипът го може 2, обратимост 2const weights = [3, 3, 2, 2, 2];const options = new Map([ ["A: три копия на магазина", [5, 3, 2, 4, 2]], ["B: модулен монолит, държавата е настройка", [3, 4, 4, 4, 4]], ["C: услуги от първия ден", [1, 4, 2, 2, 2]],]);for (const [name, scores] of options) console.log(name + ": " + total(scores, weights));console.log("Избор: " + best(options, weights));Напиши total (сбор от тегло × оценка) и best:
A: три копия на магазина: 40B: модулен монолит, държавата е настройка: 45C: услуги от първия ден: 27Избор: B: модулен монолит, държавата е настройкаРешение
function total(scores, weights) { return weights.reduce((sum, w, i) => sum + w * scores[i], 0);}
function best(options, weights) { let winner = null; for (const [name, scores] of options) if (winner === null || total(scores, weights) > total(options.get(winner), weights)) winner = name; return winner;}Матрицата не взима решението вместо теб. Тя прави видими теглата: ако собственикът каже „времето е всичко“, това е число, с което може да се спори.
Упражнение
Стъпка 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 ✗ Последици: няма нито едно „лошо:“Окончателен: ✓ ОКРешение
const REQUIRED = ["Статус", "Контекст", "Варианти", "Решение", "Последици", "Кога преразглеждаме"];
function sections(adr) { const result = new Map(); let current = null; for (const line of adr.trim().split("\n")) { if (line.startsWith("## ")) { current = []; result.set(line.slice(3), current); } else if (current !== null && line.trim() !== "") { current.push(line); } } return result;}
function checkAdr(adr) { const s = sections(adr); const problems = REQUIRED.filter((name) => !s.has(name)).map((name) => "✗ липсва раздел „" + name + "“"); const count = (s.get("Варианти") ?? []).filter((line) => line.startsWith("- ")).length; if (count < 2) problems.push("✗ Варианти: " + count + ", нужни са поне 2"); if (!(s.get("Последици") ?? []).some((line) => line.startsWith("- лошо:"))) problems.push("✗ Последици: няма нито едно „лошо:“"); if (s.has("Кога преразглеждаме") && !/\d/.test(s.get("Кога преразглеждаме").join(" "))) problems.push("✗ Кога преразглеждаме: няма число, което да се мери"); return problems;}for (const [name, text] of [["Чернова", DRAFT], ["Окончателен", FINAL]]) { const problems = checkAdr(text); console.log(name + ": " + (problems.length === 0 ? "✓ ОК" : problems.length)); for (const p of problems) console.log(" " + p);}Окончателният ADR е в примерното решение най-долу.
Упражнение
Стъпка 4: планът на миграцията
Магазинът работи и в момента. Поръчките трябва да научат за държавата, без спиране и без голямо превключване. Подреди стъпките така, че всяка да е обратима до последната:
1% от гръцкия трафик минава по новия път (флаг)новият код пише country, старият още работидобави колоната country (празна, старият код не я чете)10%, 50%, 100% само при зелени метрики, назад при червениbackfill: старите поръчки получават BG, после сравни брояизключи стария път и махни стария код
Дяловете (1%, 10%, 50%, 100%) не се вдигат по календар, а по метрики. Напиши rollout: за всеки дял чете следващите записани метрики. При зелени (до 5 грешки на 1000 и p95 до 800 ms) минава напред, при червени връща трафика на предишния дял и опитва пак след поправката. Метриките са записан сценарий, за да е изходът един и същ:
const observed = [ new Metrics(2, 610), new Metrics(9, 700), new Metrics(3, 640), new Metrics(4, 690), new Metrics(3, 700),];rollout([1, 10, 50, 100], observed.values());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 ✓Старият път е изключен: точка без връщане.Решение
class Metrics { constructor(errorsPer1000, p95) { this.errorsPer1000 = errorsPer1000; this.p95 = p95; }
healthy() { return this.errorsPer1000 <= 5 && this.p95 <= 800; }}function rollout(shares, observed) { let step = 0; while (step < shares.length) { const m = observed.next().value; const line = `${shares[step]}%: грешки ${m.errorsPer1000}/1000, p95 ${m.p95} ms`; if (m.healthy()) { console.log(line + " ✓"); step++; } else { const back = step === 0 ? 0 : shares[step - 1]; console.log(`${line} ✗ връщане към ${back}%`); } } console.log("Старият път е изключен: точка без връщане.");}Точката без връщане е само една и е последна: махането на стария път. Всичко преди нея се връща с флаг. Колоната се добавя празна (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 и повече) без мярка е отворена дупка и се показва с „✗“, а по-малък без мярка е съзнателно приет.
const risks = [ 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 екипът не познава модула за търговци → работа по двойки първите два месецаРешение
class Risk { constructor(name, probability, impact, measure = "") { Object.assign(this, { name, probability, impact, measure }); }
get score() { return this.probability * this.impact; }}function register(risks) { const sorted = [...risks].sort((a, b) => b.score - a.score || b.impact - a.impact); for (const r of sorted) { const measure = r.measure || (r.score >= 12 ? "✗ няма мярка" : "(приемаме)"); console.log(`${String(r.score).padStart(2)} ${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 (fromModule !== toModule && toPart !== "")api: към чужд модул се ходи само през публичната му част. Вътре в своя модул (orders.internal -> orders.db) всичко е разрешено.
Решение
// чужд модул се вика само през apifunction boundaryViolations(imports) { const found = []; for (const line of imports) { const [source, target] = line.split(" -> "); const [fromModule] = source.split("."); const [toModule, toPart] = target.split("."); if (fromModule !== toModule && toPart !== "api") found.push(`${line}: вика вътрешностите на ${toModule}`); } return found;}const imports = [ "sellers.internal -> orders.api", "sellers.internal -> orders.internal", "orders.internal -> payments.api", "orders.internal -> sellers.internal", "catalog.internal -> sellers.api", "orders.internal -> orders.db",];const found = boundaryViolations(imports);for (const v of found) console.log("✗ " + v);console.log("Нарушения: " + found.length);Пусни я при всеки комит с 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 ландшафт и капабилитита
- Задачи по темата: Архитектът в работа