Мульти- та монорепозиторії в SOA. Організація коду

Мене звати Євгеній Сафонов. Я Senior Software Developer у компанії Terrasoft, де моя робота зосереджена довкола систем з мікросервісною архітектурою, а загалом програмуванням займаюся вже більше 10 років. Ця стаття направлена на загальний огляд двох підходів організації коду у репозиторіях, з їх перевагами та недоліками.

Ціль статті — звернути вашу увагу на важливі фактори та взаємозв’язки при виборі підходу. Тому, хоча один з підходів став виграшним для нашої команди, це не означає, що він є єдиноправильним. Весь описаний досвід відноситься до репозиторіїв з C# / .NET Core кодом, але загалом це все можна легко проеціювати на будь-які мови програмування.

Як ми обираємо спосіб організації коду

Питання «як зберігати код?» на початку розвитку продукту, зазвичай, вирішується просто — ми робимо моноліт, а це означає, що й увесь код буде в одному репозиторії. Далі продукт починає рости та розвиватися, а разом з ним — і команда розробки, що з часом перетворюється на повноцінний R&D, де за окремі частини продукту відповідають вже різні команди.

Цілком логічно, що продукт починає розділятися на окремі сервіси. І ось тут постає питання — продовжувати обслуговувати код у вигляді монорепозиторію, чи розділяти його за сервісами на незалежні репозиторії зі своїм циклом розробки/випуску/підтримки? Якщо розділяти, то за якими принципами і правилами?

Давайте подивимося на можливості, що дають обидва підходи.

Монорепозиторії

  • Легка підтримка спільних сторонніх компонентів і бібліотек. Нові фічі, оновлення безпеки, виправлення помилок — у всіх цих ситуаціях потрібно переконатися, що всі ваші сервіси коректно працюють з новою версією. Монорепозиторій дозволяє зробити це в одному місці, швидко та без страждань.
  • Дотримання єдиного стека, мови, стилю коду. Просте повторне використання коду без зайвих nuget-пакетів.
  • Наскрізне тестування. Ви легко можете запустити всю систему локально на вашій машині і побачити, що відбувається в усіх сервісах після ваших змін. Потім побачити, що саме зламалося (як завжди не там, де ви вносили зміни) і знову розгорнути нову версію.
  • Швидкий рефакторинг. Змінюючи спільний базовий код, за допомогою інструментів рефакторингу вашої улюбленої IDE ви швидко адаптовуєте увесь залежний код у всіх куточках системи.
  • Раптові критичні випуски релізу. Коли потрібен реліз з критичними виправленнями, то саме монорепозиторій може дати всю гнучкість і швидкість у перевірці змін через вищезазначені характеристики й без ризиків зламати API.

Мультирепозиторії

  • Свобода писати код незалежно від інших сервісів/компонент, використання різних стеків, фреймворків, мов програмування.
  • Швидкість внесення змін у код під час виправлення помилок, оновлення, тестування та розгортання. Оскільки зміни потрібно протестувати лише в одному сховищі, розгортання коду відбувається швидше та надійніше.
  • Окремі репозиторії зменшують ймовірність поширення помилок та вузьких місць між сервісами.
  • Чітке дотримання принципу єдиної відповідальності за сервіс.
  • Швидкі та маленькі пайплайни CI/CD в межах одного сервісу.

Якби я був на місці читача, що вперше вивчає це питання, то був би злегка спантеличений — обидва підходи мають чудові характеристики, але все ще не зрозуміло який з них обрати. І ось тут я радив би загадати про упередження виживання: не варто концентруватися лише на перемогах та позитивних сторонах, подивіться, де переваги кожного з підходів стають їх недоліками, а часом і просто катастрофічними перешкодами на шляху розвитку продукту.

Монорепозиторії

  • Конфлікти з принципом єдиної відповідальності, якщо з монорепозиторієм працює багато команд. Код може бути неймовірно базовим і настільки спільним, що виявиться, що ніхто конкретний за нього не відповідає, а зміни вносять всі.
  • Мерж-конфлікти за наявності багатьох команд дуже ймовірні .
  • Саме відкриття IDE зі збільшенням кодової бази стає окремим викликом. Час локального проходження модульних тестів значно збільшується і дозволяє робити перерву на каву і неквапливу світську бесіду з колегами.
  • Ітерації випуску релізу можуть затягуватися. Час проходження пайплайну CI/CD не дозволяє отримати швидкий результат тестування..

Мультирепозиторії

  • «Зоопарк» технологій в сервісах, що утворюють єдину систему в одному домені. Це знизить крос-функціональність команд та ізолює кожну у їх власному середовищі.
  • Набагато більше часу витрачається для перевірки та випуску релізу при внесенні змін в спільні базові компоненти. Потрібен час на синхронізацію змін, особливо якщо за сервіси відповідають різні команди. В особливо критичний, стресовий момент, можливо, знадобиться не одна ітерація «зміни-тестування-випуск» в різних сервісах, щоб зібрати всю систему з необхідними виправленнями.
  • Структура пайплайнів CI/CD може ускладнюватися зі зростанням кількості сервісів-репозиторіїв.
  • Більше ризиків API breaking changes, а відповідно і збільшення кількості сміття у вигляді obsolete API.

Отже, тепер можна оцінити не тільки переваги, які ми отримаємо, але й обрати, чим саме ми готові пожертвувати, прийнявши один з підходів. І найголовніше тут — зрозуміти, що жертвувати чимось доведеться обов’язково. Ми знаємо, що класи об’єднується в компоненти, а компоненти у бібліотеки і це все підпорядковується різноманітним архітектурним принципам. Чому б не спробувати застосувати ці ж принципи до об’єднання сервісів у репозиторії? Для цього ми звернемося до книги «Чиста архітектура» за авторством Роберта Мартіна, де в главі «Зв’язність компонентів» описуються три принципи:

  • REP (Reuse/Release Equivalence Principle) або Принцип еквівалентності повторного використання і випусків — одиниця повторного використання є одиницею випуску. Будь-який спільний код повинен мати свій процес випуску і історію релізів.
  • CCP (Common Closure Principle) або Принцип узгодженої зміни — в один компонент повинні включатися класи, що змінюються з одних причин і в один час (просто замініть слова «класи» і «компоненти» на «сервіси» і «репозиторії»). Якщо ми маємо справу з сервісами однієї системи, то, можливо, потрібно їх поселити в одному репозиторії?
  • CRP (Common Reuse Principle) або Принцип спільного повторного використання — не змушуйте користувача залежати від того, що йому не потрібно. Якщо один з ваших сервісів/компонентів потрібен сусідній дружній команді — то чи правильно змушувати їх жити в вашому монорепозиторії?

І ось, як зрозуміло з визначення цих принципів, неможливо дотримуватися усіх трьох принципів одночасно. Можливо, для вас важливо швидко вносити зміни в будь-яку частину системи й у вас є значна кількість ресурсів для обслуговування великих пайплайнів? Чудово! Оберіть моно репозиторій. У вас величезна кількість сервісів, за які відповідають різні команди з різним темпом розробки та поставкою фіч, а також неймовірна команда DevOps, що може зв’язати все це докупи — тоді вам підійдуть незалежні репозиторії.

Нижче наведено описану в книзі Роберта Мартіна діаграму протиріч цих трьох принципів. Ребра цього трикутника описують ціну порушення принципу на протилежній вершині.

Від теорії до практики

Проте, насправді, не варто занадто фокусуватися лише на одному, адже завжди можна побудувати свою комбінацію підходів для різних ситуацій. І це я поясню на власному досвіді. Наша команда відповідає за розробку підсистеми, що складається з десятка сервісів та кількох спільних компонентів. З самого початку ми обрали шлях мульти-репозиторіїв, де кожен сервіс в окремому репозиторії; спільні компоненти організовані у вигляді nuget-пакетів і також живуть в своїх репозиторіях. Звісно, кожен репозиторій має свій пайплайн CI\CD, а для наскрізного тестування і випуску релізу існує окремий пайплайн, що зв’язує всі сервіси в один пакет релізу. Відразу спойлер: через два роки використання такого підходу ми перейшли (частково) на монорепозиторій. Чому?

Як виглядав типовий цикл розробки:

  • Вносимо зміни у один з сервісів, далі commit/push, рев’ю коду та випуск нової версії сервісу.
  • Після цього запускається пайплайн наскрізного тестування, щоб перевірити сумісність з останніми релізними версіями інших сервісів.
  • Якщо внесені зміни спричиняли помилки чи некоректну роботу в сусідніх сервісах (забули змінити контракт web API, несумісні зміни в об’єкті, що передається в брокер черг, тощо...), то доводилося повторити весь той же шлях, але вже з новим репозиторієм.
  • Якщо ж зміни стосувалися не конкретного сервісу, а спільного компоненту, то шлях ще складніший — написати код, пройти рев’ю, випустити реліз nuget-пакету.
  • Оновити версію пакету в усіх репозиторіях, можливо, внести зміни, щоб підтримати новий API (але тут виявлялося, що саме в цьому сервісі пакет не оновлювався останні 20 версій і для поточного оновлення необхідно підтримати всі попередні зміни), також, завести рев’ю і випустити реліз кожного сервісу.

Тут програміст вже втомлений і знервований, а попереду ще запуск наскрізних тестів, де не факт, що все зростеться. Якщо на цьому етапі знаходяться помилки, то ми повертаємося на самий початок і починаємо шукати причину і, якщо помилка в базовому пакеті, то повторюємо весь балет з оновленням версій і тестуванням в кожному окремому сервісі.

Окремим видом головного болю був debug сервісу в нутрощах компонентів з nuget-пакету — ти бачиш декомпільований код, але змінити його і швидко випробувати не можеш, бо він в іншому репозиторії, і для перевірки гіпотези складного виправлення треба випустити хоча б тестову версію пакету.

При такій організації коду ми мали релізи приблизно раз чи два на спринт. Звісно, у нас також була потенційна можливість випуску та оновлення кожного сервісу окремо, але на практиці ми цього ні разу не робили, так як зміни все одно потребують наскрізного тестування, а нові фічі зазвичай мають стосунок до кількох сервісів одночасно. На команду з 4 розробників одного продукту у нас було 6 репозиторіїв з сервісами: 2 — зі спільними компонентами, 1 — з наскрізними тестами, кілька test kit і безліч Jenkins пайплайнів всіх сортів і забарвлень, а також проекти в Sonar Qube, внутрішній реєстр nuget і постійні конфлікти версій. Не те щоб це був необхідний запас для розвитку продукту, але якщо вже почав дотримуватися архітектурних принципів, то складно зупинитися.

Ось саме тут ми і зрозуміли, що обрали неправильний акцент на принципах REP та CRP (тут рекомендую ще раз подивитися на діаграму) —- жодної вигоди це нам не приносить, а швидкість випуску нових фіч страждає, так як доводиться робити зміни по різних репозиторіях.

Як ми перейшли до моно-репозиторію?

По-перше, ми хотіли зробити релізи частішими, а також позбутися незручності використання nuget-пакетів. По-друге, позбутися людського фактору при постійному перемиканні контексту — в якому зараз репозиторії ти працюєш і для чого вносиш зміни. І взагалі ми почали мріяти про чарівну кнопку, що збирає реліз за один раз з усіма можливими тестами. Тому, оцінивши всі наявні болі і недоліки мульти-репозиторіїв, ми вирішили організувати більшу частину коду у вигляді моно-репозиторію. В цей репозиторій ми включили:

  • усі сервіси нашої системи (підтримка принципу CCP) — тепер після кожної зміни відразу видно вплив на всі залежні частини коду;
  • спільні компоненти, що використовуються лише нашою системою. А ось тут ми свідомо порушуємо принцип REP — але оскільки одиниця спільного використання живе лише в рамках монорепи, то й ускладнення повторного використання нам не страшні.

З іншого боку ми вирішили залишити в своїх окремих репозиторіях такі речі, як:

  • найбільш базові спільні компоненти, що використовувалися й іншими командами (згадуємо про принцип CRP). До того ж, ці компоненти дуже абстрактні, що дає можливість не змінювати їх досить тривалий час, втручатися в код мінімально і лише розширяти їх в компонентах-нащадках;
  • сервіси з test kit, що не є продуктивними, але допомагають в наскрізному тестуванні — це утилітарні інструменти, що не потрібно змінювати часто, тож вони можуть не займати місце в монорепі.

Також значних змін зазнали і пайплайни CI/CD — тепер ми маємо один пайплайн, ту саму магічну кнопку релізу, що дозволяє на окремій гілці репозиторію проганяти лише модульні тести та перевірку quality gate на кожен коміт, а за бажанням виконати також наскрізні тести з усіма актуальними змінами без необхідності виконувати merge в master-гілку. А при запуску на master-гілці — випустити реліз при проходженні всіх тестів. Звісно такий пайплайн виконується довше — близько пів години з повним набором тестів, проти 2 хвилин на пайплайн сервісу в окремому репозиторії. Але це все одно швидше, ніж перемикатися між різними репозиторіями, оновлюючи версії пакетів та аналізуючи тести окремо на кожному пайплайні. На це раніше витрачали від кількох годин до цілого робочого дня.

Так що ж обрати

Наразі ми маємо можливість тестувати фічі набагато швидше, безболісно проводити рефакторинг та випускати релізи кілька разів на день. Але це ні в якому разі не означає, що вибір моно-репозиторію — єдино правильний вибір. Взагалі задавати питання який підхід кращий не вірно, адже кожен з них направлений на вирішення своєї задачі.

В нашому випадку монорепа виграла тому, що:

  • ми маємо деяку кількість сервісів, за які відповідає лише одна команда;
  • кодова база відносно невелика, щоб комфортно почуватися в IDE;
  • а максимальний час проходження пайплайнів все одно менший ніж всі ручні маніпуляції пов’язані з окремими репозиторіями.

Найкраща рекомендація для вибору правильного підходу — оцінити не лише позитивні сторони, а й потенційні недоліки. Не варто дивитися лише на тих, хто вижив. Вивчіть і структуруйте свій негативний досвід, оцініть те, чим вам доведеться пожертвувати. Звертайтеся до основних архітектурних принципів та практик — це теж неабияк допоможе зробити ваш правильний вибір. І пам’ятайте: срібна куля врятувала лише Ван Хелсінга, а в реальному житті потрібно шукати баланс потреб, знань і можливостей.

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось19
До обраногоВ обраному10
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Гарна статья, дякую. Но зрозуміють її не всі. Або потрібен такий самий досвід пройти, а бо прочести літературу яка з цим пов’язана. Наприклад:
Сэм Ньюмен — Создание микросервисов
ну і Роберт Мартін — Чистая архитектура, якого тут вже згадали

Ще раз дякую.

Дякую, цікаво було почитати, хоча у мене інший стек мов програмування та технологій.

Між іншим, ось тут ускладнений мовний зворот:

По-друге, позбутися людського фактору при постійному перемиканні контексту — в якому зараз репозиторії ти працюєш і для чого вносиш зміни

Мабуть краще написати так:

По-друге, позбутися людського фактору при постійному перемиканні контексту, для якого завжди потрібно знати в якому зараз репозиторії ти працюєш і для чого вносиш зміни

Для TypeScript моно-репозиторіїв я використовую lerna у парі із yarn-workspaces. TypeScript-компілятор має спеціальний режим для збірки таких репозиторіїв:

tsc -b

Плюси в тому, що ви можете посилатись на окремі модулі в межах монорепозиторія, навіть якщо кожен із цих модулів має свій, ще неопублікований npm-пакет. Також скорочується час компіляції, та час для запуску CI/CD.

Дякую :)
От якраз цікаво, що для typescript є інструменти для полегшення роботи з монорепозиторіями, а для c# немає. Але наразі нам нічого особливого і не знадобилося — все залежить від того як вибудувати плин роботи і від розміру репозиторію.

От якраз цікаво, що для typescript є інструменти для полегшення роботи з монорепозиторіями, а для c# немає

Dotnet sdk пакует так по-умолчанию пакеты как это описано выше — proj ref при упаковке конвертнется в package ref автоматом.

Згоден, погарячкував :)

Хоча знову ж таки, для c#, для чого в монорепозиторії мати nuget пакет? Якщо він публікується з окремими версіями і ним користується ще хтось, то йому явно місце в окремому репозиторії. Якщо ж він потрібен лише сервісам в монорепозиторії , то сенсу робити проект пакетом немає.

Підписатись на коментарі