В първата част на този материал Илия Погудин, който е back-end разработчик в Artesia, вече сподели голяма част от детайлите около решението на екипа да премине към по-уеднаквена домейн архитектура.
„Преди преминаването имахме над 30 различни услуги. Те бяха изградени на прост принцип – всяка задача, която може логически да се обособи, си имаше отделен сървис“, сподели по-рано Илия. Този преход е сложен, но възможен за Artesia. Ето какво още разказа за него Илия Погудин.
В предишната статия споменах накратко за прехода на Artesia от множество малки сървиси към по-големи. Сега е време да се задълбочим в детайлите на този процес. Основната движеща сила зад обединяването беше нарастващата сложност на поддръжката на кодовата база. Нашият екип, макар и малък, се сблъскваше с факта, че дори и най-простата задача изискваше промени в множество разпръснати услуги. Това доведе до излишна механична работа, умора и, като следствие, забавяне на разработката.
Нашата цел беше да намалим броя на услугите, да оптимизираме тяхната поддръжка и бъдещо развитие, като същевременно намалим разходите за труд. Беше изключително важно преходът да се осъществи плавно, без критични грешки и прекъсване на текущата разработка. За по-голяма яснота ще разгледаме пример с търговско приложение.
Стремихме се търговската услуга да може да взаимодейства директно с доставчика, като общият код на адаптера беше изнесен в отделна библиотека (SDK). Този SDK от своя страна можеше да включва и SDK на самия доставчик, но вече обвит с нашите функции, реализиращи нашата бизнес логика.
Преди да започнем прехода, определихме няколко ключови проблема, които трябваше да решим, за да осигурим успешното обединяване на услугите. Ето основните от тях:
- Комуникация между услугите. Как да осигурим плавно взаимодействие между обединените услуги, за да избегнем сривове в системата? Трябваше да „преплетем“ съществуващите взаимодействия, така че нищо да не се повреди.
- Внасяне на промени в кода и плавно превключване на бизнес процесите. Как да внедрим промени в кода, без да спираме разработката на нови функции или изпълнението на текущи задачи? Беше важно да осигурим „плавно превключване“ на бизнес процесите.
- Миграция на данни. Как безопасно да прехвърлим данни между услуги (или бази данни), за да избегнем загуба или дублиране на критични данни?
- Тестване и осигуряване на обратна съвместимост. Как да се уверим, че в процеса на сливане нищо не е повредено и че услугите продължават да отговарят на очакванията? Как да гарантираме обратна съвместимост на промените?
- Комуникация в екипа и смяна на отговорностите. Как да организираме ефективна комуникация в екипа и да разпределим отговорностите за новите, обединени услуги?
- Мониторинг и сигнализация. Как да запазим прозрачността на работата на системата след обединяването и да избегнем „загуба на видимост“ над важни събития?
- Производителност и мащабируемост. Как да избегнем забавяне на системата поради увеличено натоварване след обединяването на услугите?
- Провал! Как да няма такъв.
- Резултати.
И така, опитваме се да направим нещата добре и да оцелеем.
5. Комуникация в екипа и смяна на отговорностите.
Както обикновено, нещата не се развиха по план, така че и аз няма да го следвам. Въпреки че тaзи точка е пета в списъка, трябва да е първа.
Как може да работи процес, ако няма съгласуваност в екипа? Точно така, затова започнахме с него.
- Не бързаме. Във всеки sprint взимаме нещо малко и с нормален приоритет след основните функционалности за бизнеса.
- Стараем се работата да е разделена на малки, самостоятелни итерации. Първо, всички промени се събират в ума, което помага при debug и тестване. Второ – самото тестване. Няма нужда от огромни инструкции, всичко може да се обясни на QA за минути. Трето – лесно може да се върне назад, ако нещо се обърка. Буквално помниш какво и къде си променил/добавил.
- Следващата промяна се прави след като предишната е внедрена и работи.
- Не правим промени, които нарушават функционалността. Всичко трябва да има обратна съвместимост. Дори в миграциите на база данни спряхме да правим секции за връщане. Тази логика започнахме да използваме в целия проект. Тук е просто – ако DDL миграцията не нарушава текущата схема, няма нужда от връщане назад, а кодът вече е тестван на dev сървър. Ако след това разбереш, че си направил нещо грешно в миграцията, просто го поправяш в следващата.
- Външните договорености (с front-end) не се променят в рамките на тази задача. Разбира се, имаше места, които искахме да оптимизираме по пътя, но това е капан, в който лесно можеш да попаднеш и да затънеш. По-добре си направи задача в backlog-a и опиши оптимизацията. А се занимавай с нея в отделен процес.
- Ако екипът е голям или са няколко, процесът може да се поеме от отделна група, но тя задължително трябва да прави съобщения при всяко внедряване на промени. Екипът трябва да се състои от back-end разработчици, QA, DevOps.
- Баланс. Трябва да свършим всичко навреме, преди внуците ни да започнат училище. 🙂
1. Комуникация между услугите.
Как да преплетем съществуващите взаимодействия, така че нищо да не се счупи?
Е, както казва нашият Android разработчик Юрий: „Прави всичко както трябва и нищо няма да се счупи“. Съветът всъщност е страхотен, решихме да го следваме.
В началото имахме следната схема на взаимодействие между услугите:
Тази схема, разбира се, е малко условна, но показва общия подход. Имахме 4 услуги и 2 типа комуникация между тях. Всяка услуга имаше собствена база данни, но всяка строго притежаваше само своята, така че базите данни няма да разглеждаме. Търговската услуга и Услуга 2 имаха силна зависимост една от друга и постоянно си взаимодействаха чрез gRPC. Това означаваше, че всяка промяна в едната изискваше промени и в другата.
Решихме да обединим тези услуги. Също така решихме да се отървем от услугата dataProvider. Основната ѝ задача беше да посредничи между нашата система и доставчика. От една страна, тя можеше да работи с API на доставчика с цялата му специфика, а от друга – с нашето API. Това беше реализация на шаблона „Разделяне на отговорностите“ или структурния шаблон „Адаптер“. Удобен шаблон, но създаването на отделна услуга за него се оказа непрактично – всяка промяна по веригата можеше да изисква промени и в адаптера.
Не можехме просто да я изтрием, нито да я заменим с пакет в търговската услуга, тъй като доставчика беше достъпен и от друга услуга, условно наречена „Аналитка“. Решението беше да създадем модулна библиотека (SDK) и да поставим целия необходим код там. Оставаше само да споделим credential-и за връзка с доставчика между нужните услуги.
В крайна сметка получихме следната схема:

Следващият етап беше обединяването на услугите „Търговска“ и „Услуга 2“. „Услуга 2“ се използваше и от други услуги, но по-рядко, така че беше достатъчно да прехвърлим gRPC хендлъри и контракти в търговската услуга, поддържайки необходимата логика. След като всички зависими услуги обновиха своите извиквания, „Услуга 2“ беше изключена.
2. Внасяне на промени в кода и плавно превключване на бизнес процесите.
Как да не спрем потока от нови функционалности и да не прекъснем текущите задачи?
Бизнесът определено няма да го разбере и прости. Първото, което идва на ум – балансиране, детайлно тестване на QA среда и малки итерации.
3. Миграция на данни.
Как да прехвърлим данни между услуги (или база данни), без да загубим/дублираме критични данни?
Загуба на данни, проблеми в приложението или спиране на разработката на нови функционалности – бизнесът няма да е доволен. Решението се оказа доста просто, но изискващо внимание – дублиране на данни в двете услуги, които обединявахме.
Действахме по следния начин:
Дублиране на данни. По време на прехода данните се записваха едновременно в старата и новата услуга. Това предотвратяваше загуба на данни и поддържаше актуалността на информацията и на двете места.
Синхронизация. Внимателно следяхме данните в двете услуги да съответстват една на друга и да се актуализират едновременно. Това беше критично важно, за да избегнем несъответствия.
Постепенно превключване. След като се убедихме, че данните са синхронизирани и се актуализират коректно, започнахме постепенно да прехвърляме работата с данни към основната услуга. Част от процесите в услугата-източник бяха изключени.
Проверка и изключване. След като се убедихме, че всичко работи както е планирано, направихме резервно копие на данните, които вече не се използваха в услугата-източник, и спряхме тяхното попълване. В бъдеще тези данни можеха да бъдат изтрити.
4. Тестване и осигуряване на обратна съвместимост.
Как да се уверим, че нищо не е повредено в процеса на сливане и услугите продължават да отговарят на очакванията?
Тестването – ключов етап от всеки процес на разработка, особено когато става дума за толкова значителни промени като сливане на услуги. Нашата задача беше не просто да проверим, че „някак си всичко работи“, а да сме сигурни, че нищо не е повредено и че всички услуги продължават да изпълняват функциите си коректно.
Подход към тестването
Следвахме тези принципи:
Тясно сътрудничество с тестерите. На всеки етап работихме в тясно сътрудничество с QA екипа. Нито един нов етап не мина без щателно тестване от тяхна страна.
Тестване на различни нива. Провеждахме тестване на различни нива – от unit тестове до интеграционни и end-to-end тестове. Това ни позволяваше да откриваме грешки на ранен етап и да сме сигурни в качеството на промените.
Регресионно тестване. След всяка промяна провеждахме регресионно тестване, за да се уверим, че новите промени не са повлияли на вече съществуващата функционалност.
Тестване след пускане в продукционна среда. Дори след внедряване на промените в продукция продължавахме да наблюдаваме и тестваме, за да откриваме и отстраняваме възможни проблеми бързо.
Автоматизация на тестването. Активно използвахме автоматизирани тестове, за да ускорим процеса на тестване и да го направим по-надежден. Това също ни позволи да провеждаме регресионно тестване по-често и по-бързо.
Обратна съвместимост. Стремихме се всички промени да са обратно съвместими. Това означаваше, че старите версии на услугите трябваше да продължат да работят с новите версии на данните и обратно. Разбирахме, че невинаги е възможно всичко да е напълно обратно съвместимо, но това беше целта ни.
Explore more
6. Мониторинг и алармиране
Как да не изгубим прозрачността след обединението и да не останем „в тъмното“?
След обединението на сървисите беше от критично значение да запазим видимостта и контрола върху случващото се. „Загубата на зрение“ върху важни събития можеше да доведе до сериозни проблеми и прекъсвания в работата на системата. Затова трябваше да осигурим надежден мониторинг и система за алармиране.
Подход към мониторинга
- Централизиран мониторинг. Внедрихме система за централизирано наблюдение, която ни позволяваше да следим състоянието на всички обединени сървиси в реално време. Това ни даваше цялостна картина на случващото се и възможност за бързо откриване на проблеми.
- Метрики и дашборди. Определихме ключовите метрики, които трябваше да следим (например време за отговор, брой заявки, натоварване на процесора, използване на памет) и създадохме визуални табла. Те ни позволяваха да оценяваме състоянието на системата и да следим промените във времето.
- Логване. Обърнахме специално внимание на качественото логване. Всички важни събития, грешки и изключения се записваха в логовете, което ни даваше възможност да анализираме проблемите и да отстраняваме бъгове.
- Алармиране. Настроихме система за известяване, която автоматично ни уведомяваше за критични събития и проблеми. Това ни позволяваше да реагираме своевременно и да предотвратим ескалации.
- Използване на специализирани инструменти. За мониторинг и алармиране използвахме специализирани инструменти като Prometheus, Grafana, ELK Stack, които предоставяха широки възможности за събиране, анализ и визуализация на данни.
Ролята на DevOps
Настройването на мониторинг и алармиране често е задача, която попада върху раменете на DevOps инженерите. В нашия случай DevOps изигра ключова роля в осигуряването на надежден мониторинг. Той ни помогна да изберем подходящите инструменти, да ги конфигурираме и интегрираме със системата ни.
7. Производителност и мащабируемост
Как да избегнем ситуация, в която по-малките монолити започват да се „задъхват“ от нарастващото натоварване?
Наистина, обединяването на няколко услуги в по-големи „монолити“ може да доведе до увеличаване на натоварването върху системата. Затова беше важно още от самото начало да обмислим добре въпросите, свързани с производителността и възможността за мащабиране, за да избегнем забавяния и да осигурим стабилна работа.
Подходи за осигуряване на производителност и мащабируемост
Репликация на услуги. Един от основните подходи, които използвахме, беше репликацията на услугите. Разгръщахме по няколко копия на всяка обединена услуга, за да разпределим натоварването между тях. Това ни позволяваше да обработваме по-голям брой заявки и да повишим надеждността на системата. Това обаче наложи и някои доработки в самите услуги.
Оптимизация на кода. Проведохме оптимизация на кода с цел да намалим времето за отговор на услугите и да редуцираме използването на ресурси. Това включваше оптимизация на заявките към базата данни и кеширане на данни.
Балансиране на натоварването. Използвахме балансьор на натоварването за равномерно разпределение на заявките между репликите на услугите. Това ни помогна да избегнем претоварване на отделни сървъри и осигури стабилна работа на системата.
Мониторинг на производителността. Постоянно наблюдавахме показатели за производителност (време за отговор, натоварване на процесора, използване на памет) и анализирахме критичните места. Това ни позволяваше бързо да откриваме и отстраняваме проблеми, свързани с производителността.
8. Моята грешка.
„Всичко започна просто – тук има доста сложен процес за получаване на отговор от доставчика, ще ми трябват няколко дни и всичко ще е наред.
Историята на моята „грешка“ започна съвсем безобидно. Взех задачата да пренеса процеса за получаване на отговор от доставчика след изпращане на заявка. Преди това отговора идваше през socket и трябваше да се синхронизира получаването на данни, съпоставянето им с изпратените, за да се направят правилните записи в базата данни и да се изпълнят нужните тригери. Тригерите също трябваше да се пренесат.
Оцених задачата на „няколко дни“ и започнах работа. Работата беше свършена в срок, но при дебъгването се откриха „слаби места“. Това, което изглеждаше просто, се превърна в две седмици ад.
Какво се обърка?
- Твърде голям обхват. Взех прекалено голямо парче от процеса. Трябваше да разбия задачата на по-малки, управляеми части.
- Опит за „доразвиване на момента“. По време на работа реших да „доразвия“ някои аспекти на процеса. В крайна сметка това ме обърка още повече и усложни debug-ването. В един момент вече не можеше да се отхвърлят тези доработки, беше късно да се започне отначало.
- Недостатъчно планиране на debug-ването. Подцених сложността на debug-ването и не му обърнах достатъчно внимание още при планирането.
- Самоувереност, разбира се. 🙂
Уроци, които научих:
- Разбивай големите задачи. Винаги разделяй големите задачи на по-малки, ясни части, които могат да бъдат тествани.
- Не усложнявай. Не се опитвай да „подобряваш“ или „доразвиваш“ нещо допълнително, ако то не е част от основната задача. Това може да доведе до неочаквани проблеми.
- Планирай debug-ването. То е важна част от работата и трябва да се планира предварително.
- Бъди реалист. Оценявай сроковете и рисковете реалистично. По-добре е да надцениш сложността, отколкото да я подцениш.
В резултат на свършената работа получихме по-проста и ясна архитектура. Броят на услугите значително намаля, което опрости поддръжката и разработката. В същото време с обединяването на услугите се увеличи и отговорността на разработчиците за качеството на кода и спазването на вътрешната структура на модулите и нивата. Това е необходимо, за да не се превърне кодът в „бъркотия“ и да остане поддържан.
9. Резултати.
Не е изключено с по-нататъшен растеж на проекта да възникне необходимост от отделяне на някои части от обединените услуги в отделни, независими услуги. Но това ще бъде следващият етап от развитието на нашата система.
Основни резултати
- Опростяване на архитектурата. Намаляване на броя услуги и улесняване на взаимодействието между тях.
- Оптимизация на поддръжката и разработката. Намаляване на усилията за поддръжка и разработка чрез по-малък брой услуги.
- Повишаване на отговорността на разработчиците. Засилване на изискванията към качеството на кода и спазването на архитектурните принципи.
Готовност за по-нататъшно развитие. Създаване на основа за по-нататъшно мащабиране и развитие на системата.