Логістика, локальність та поділ

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

  • Ви нарешті справді зрозумієте, що таке MVC, low coupling, SRP.
  • Ви зрозумієте як писати простіший та зручніший код.

Опис проблеми

Якщо хтось ще не зрозумів, програмування дуже тісно пов’язане з логістикою, а ефективність із локальністю. Усе, що використовується разом, має знаходитися поруч. У світі ІТ принцип(?) локальності ще називають high cohesion, або високою згуртованістню.

В ідеальному випадку код, що опрацювує певний запит (і тільки він – low coupling) має знаходитися у одній єдиній функції, а не бути розкиданим по купі модулів. Бо модулі містять у собі багато зайвого та поєднуються у відповідній функції чи ще одному модулі, який відповідає за обробку запиту.

З іншого боку, ми маємо безліч принципів та підходів, що (начебто) стосуються поділу коду на модулі: MVC, Layered Architecture, Onion Architecture, Hexagonal Architecture і так далі. Виникає питання: навіщо вони?

Приклад з MVC

Мало хто може відповісти, навіщо нам MVC підхід. Люди малюють MVC як нашарування, як трикутник і навіть як купу стрілочок між усіма цима M, V та C. В Ruby on Rails модель – це взагалі не уся модель предметної області, а клас сутностей. Чим далі термін віддалається від першоджерела – тим більше він втрачає сенс, стає розмитим, незрозумілим та спекулятивним.

Так от, патерн MVC – це про відокремлення моделі від її презентації. Є модель, є презентація моделі і є Anti Corruption Layer, який у випадку MVC називається контролером. Проте, ще жодного разу на співбесідах я не чув про ACL, коли запитував про MVC. Втім, без розуміння, що контролер – це ACL, патерн MVC неможливо правильно використовувати.

В т.з. ООП мовах програмування зазвичай створюють окремі класи для кожної сутності, контролеру та відображення. Додавати у сутності методи, як #to_json чи #to_html чи #to_s, вважаться порушенням MVC. Але з чого ми взяли, що розділення на M, V та C – це взагалі про класи та їх екземпляри? Ми вже знаємо, що Model – це не про окремі класи та екземпляри, а про т.з. “бізнес-логіку“, яка може бути представлена абсолютно різними компонентами/модулями та зв’язками і відносинами між ними. То чому ж більшість розробників вирішили, що #to_s, #to_json, #to_html – це порушення MVC/SRP/layered architecture чи навіть hexagonal architecture?

Згідно принципу локальності (high cohesion), мати у певній сутності окремі методи для її відображення чи мати для абстрактного типу даних функцію для відображення значень цього типу абсолютно нормальне рішення. Відображення (як код відображення певної сутності, а не як цілий шар застосунку) абсолютно повністю залежить від того, що воно відображає. Якщо змінюється те, що відображається, має змінитися і його відображення. Що використовується разом – має бути разом. Що змінюється разом – має бути разом. Таким чином, коли ми виносимо відображення значень в окремі класи, partials, templates, і таке інше – ми порушуємо SRP та high cohesion. Крім того, ми ускладнюємо код, оскільки кількість компонентів/модулів росте, а функціональність – ні.

Приклад з Hexagonal architecture

Hexagonal… Hexagonal… як мало сенсу у цьому слові. Ports & Adapters має набагато більше сенсу. Якщо зовсім коротко, то усі ці Layered, Onion, Hexagonal – херня, яку вигадують розробники, які зрозуміли, що як розробники заробити значно більше середньої по ринку зарплатні вони не зможуть, та й працювати не дуже хочеться. Треба створювати активи – контент, який працює за тебе, але це вже тема для іншої публікації.

Чогось нового у архітектурі ports & adapters немає! Ports – це інтерфейси, адже необхідно залежати від абстрактних інтерфейсів, а не конкретних реалізацій. Adapters – це Anti Corruption Layer. Одразу варто зазначити, що Porta & Adapters має сенс лише у випадку використання 3rd-parties, legacy, або жахливої у більшості випадків microservices “архітектури”, бо навіщо ще можуть бути необхідні адаптери?

Також варто зазначити, що називати Ports & Adapters архітектурою – це те саме, що називати архітектурою MVC, або як називати архітектую буферні зони / airlocks у будинках. Ports & Adapters – це просто загально відомий патерн, який Алістер замалював за допомогою шестикутника.

Багато архітекторів чи техлідів вірять, що їм варто лише задекларувати у Confluence “Ми використовуємо Clean / Layered / Onion / Hexagonal arghitecture” – і половина справи зроблена. Це працює не так. Подібне свідчить про нерозуміння того, що таке архітектура. Жодна з перерахованих вище “архітектур” не відповідає на питання про нарізку реальності. Чому? Тому що все дуже сильно залежить від предметної області, специфіки та стратегії бізнеса і таке інше.

Є умовно чотири рівні прийняття рішень:

  • Операційний
  • Тактичний
  • Стратегічний
  • Філософський/онтологічний

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

Strategy without tactics is the slowest route to victory. Tactics without strategy is the noise before defeat. -- Sun Tzu

Насправді усі ці підходи до “архітектури” відповідають лише на питання про інтеграцію з кодом, над яким команда розробників не має влади і з яким ми не бажаємо мати т.з. vendor-lock’у.

Три абстрактні шари

Існує уява про те, що код універсально можна поділити на три шари:

  • Презентаційний шар
  • Бізнесовий шар / Шар моделі предметної області
  • Шар даних

У Hexagonal Architecture, презентаційний шар відповідає Driving Side, шар моделі – це той самий шестикутник, який через порти й адаптери інтегрується з двома іншими, а шар даних – це Driven side. Одразу виникає декілька питань щодо такого поділу.

1. Хіба шар даних не залежить від шару моделі?

На чому взагалі заснована схема даних, як не на моделі? Хіба в нас невідомо звідки беруться якісь дані і на рівні моделі ми намагаємось їх якось використати? Ні! Модель формує схему даних і схема даних є невід’ємною частиною моделі. Ми не можемо змінити схему даних, не змінюючи моделі. Як не можемо змінити моделі, не змінюючи схеми даних.

Які взагалі тут можуть бути порти і адаптери, коли модель і дані єдині? Це можна пояснити лише одним чином. Під рівнем даних необхідно розуміти лише доступ до них, тобто взаємодію з тим чи іншим СУБД, або DAL.

Аби не провалюватися зненацька у деталі та не забруднювати код предметної області тим, що до нього не має відношення, нам необхідно визначити інтерфейс та заховати у його реалізації усі специфічні для обраного СУБД деталі. Якщо нам необхідний деякий Account за певним ID, то інтерфейс має виглядати просто як сигнатура: ID -> Account.

Але, насправді, якщо ми щось хочемо зробити з Account, то не модуль моделі має робити запит на отримання облікового запиту за ID, а обліковий запис має бути наданий відповідному модулю (функції, конструктору об’єкта, методу, процедурі, функтору, …) як аргумент. Ідеальний інтерфейс – це інтерфейс, якого немає, але функція якого виконується! У такому випадку DAL знаходиться поза моделю і не є її деталлю.

2. Хіба презентація не залежить від даних, які необхідно відобразити?

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

Презентація не тільки користується моделлю, а й повністю залежить від неї. У такому випадку ми, звісно, можемо створити Anti Corruption Layer між презентацією та моделлю, який у MVC називається контролером, але дивіться вище частину про MVC.

З іншого боку, модель не існує у вакуумі, окремо від користувачів, наче якась ідеальна Платонівська сутність. Модель має реагувати на запити користувачів і запити користувачів первинні. Якщо модель протистоїть запитам користувачів – це погана модель.

Для користувача UI/UX – це і є застосунок. Якщо UI/UX сильно відрізняється від моделі – це породжує додаткову складнісь та неефективність за рахунок необхідності трансляції між двома моделями. Ми самі кажемо, що інтерфейс – це головне, а все, що за ним – то деталі реалізації, які менш важливі. Але чомусь забуваємо про це, коли підіймаємося на рівень вище.

UI/UX – це інтерфейс, через який зовнішній світ може працювати з нашим додатком. Його стабільність і простота значно важливіші за те, що знаходиться за ним. Якщо розглядати не web/GUI додатки, то інтерфейсом буде API, протокол, stdlib.h і таке інше.

Ще один важливий момент – це те, що UI/UX є дуже важливою feature застосунку, яка часто має чи не найголовнішу роль у прийнятті користувачем рішення про придбання того чи іншого застосунку. Модель, яка знаходиться під UI/UX – нікого, крім розробників, не цікавить.

3. Що взагалі таке ця модель?

Моделі предметної області – це найскладніший для розуміння “шар“, якому насправді зовсім нічого не присвячено в усіх згаданих вище підходах до архітектури, які, насправді є незначними надбудовами над патерном Anti Corruption Layer. Досить багато про модель говориться у Domain Driven Design/Development, але все воно належить тактичному рівню та більше про використання тих чи інших патернів.

Пояснення що таке модель на стільки абстрактне, що не має жодного сенсу. Якщо подивитися на діаграми з прикладами, то побачимо, що модель зображується як композиція деяких компонентів. Функціонери DDD пропонують описувати модель предметної області як взаємодію таких компонентів, як сутності (Entities), багаті значення (ValueObjects), сервіси (Services) та агрегати (Aggregates). Насправді модель не має нічого спільного з цими патернами і розробникам зовсім необов’язково їх використовувати.

Знаєте Ви про DDD та Domain Model, чи не знаєте – Ви все одно будете працювати з моделлю, яка буде композицією якихось компонентів. Філософствування про модель предметної області не змінює нічого.

Однією з найголовніших ідей в DDD є ідея про bounded context. Ще нещодавно я вважав, що ця ідея корисна, але прийшов до того, що звернення до неї нам не дає жодних переваг. Як і з Domain Model, немає жодного чіткого пояснення, що таке bounded context (обмежений контекст) та чим він обмежується. Зазвичай, архітектори просто покладаються на власний смак і малюють bounded context’и як забажають.

До виявлення обмежених контекстів можна підійти статистично (не треба!). Для цього достатно відмалювати усі компоненти на одній діаграмі та візуально визначити кластери. Цей підхід дуже трудоємний, а діаграми, які Ви отримаєте, будуть занадто складними.

Netflix – взірець мікросервісної архітектури, насправді має розподілений моноліт.
DDD з bounded context’ами не працює навіть серед дуже багатих компаній з начебто топовими інженерами.

У ідеальному випадку обмежений контекст – це одна єдина функція, яка отримує усе необхідне і виконує певну бізнес-функцію над отриманими аргументами. Отже, нам не треба сутностей, агрегатів, сервісів, value-objectic’ів й інших ускладнень. Необхідно лише під кожен бізнесовий use-case створювати окрему функцію, а значення, з якими вона працює, тримати максимально близько одне до одного. Логічна згуртованість має відповідати фізичній.

Редукціонізм vs Холізм

Поділяй та володарюй. – Гай Юлій Цезар (?)

Ціле більше за суму власних складових. – Арістотель

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

Коли ми нарізаємо ціле на компоненти – модулі/мікросервіси, то схиляємося до редукціонізму. Коли отримуємо в результаті розподілений моноліт, то стикаємося з холізмом кожного із компонентів. Ми не можемо порізати ціле на шматки та продовжити працювати з цілим. Зернятко, розрізане навпіл – це вже не зернятко, а дві його половини. Воно не проросте, але його можна змолотити на борошно.

Абсолютний редукціонізм – це розтин трупа. Повністю ізольовані одне від одного модулі не є одним цілим. Вони – вирізані з мерця органи, які не працюють, але які можна вивчати.

Живі штуки – холони. В наших тілах є окремі органи, але хвороби серця впливають на нирки і навпаки. Ми маємо спеціалізовані органи, але усі вони все одно пов’язані між собою, а помах крилець метелика в одній точці Земного геоіду може викликати ураган у зовсім іншій.

Все у світі – холон, але ми не здатні мислити про все одночасно. Редукціонізм – це єдиний спосіб про щось мислити (мова складається зі слів, що йдуть одне за одним, а не з усіх слів одночасно), але в такий спосіб ми мислимо не про щось, а лише про одну його сторону, про його тінь на стіні печери в якій сидимо.

Модулі мають якось спілкуватися та виставляти вимоги одне до одного. Ми все одно маємо деяку сплутаність (coupling), якої ніяк не можна позбавитися, принаймні згідно другому закону термодинаміки. Можливо, саме тому принцип називається low coupling, а не no coupling.

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

Чи можна хоч щось подіяти?

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

Низхідний (Top-down) підхід

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

Це зовсім не відміняє висхідного (bottom-up) підходу, але висхідний має бути первинним, а для того, аби потоваришувати обидва підходи – слід використовувати Anti Corruption Layer, який дозволяє використовувати вже існуючі компоненти через той інтерфейс, який має сенс саме у кожному конкретному випадку і який не засмічує код користувача чимось специфічним для тієї чи іншої деталі.

Також top-down підхід – це єдиний спосіб домогтись відповідності принципу інверсії залежностей (Dependency Inversion Principle). У будь-якому іншому випадку домогтися цього неможливо, чи, принаймні, дуже складно.

Принцип локальності

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

Розділення зацікавленостей/вимог (Segregation of concerns)

В ідеальному випадку кожна вимога має бути реалізована в окремому модулі. Коли ми змішуємо вимоги – ми порушуємо принцип єдиної відповідальності (Single Responsibility Principle). Як тоді діяти у випадку crosscutting concerns, якщо ми хочемо розділити вимоги, але й мати високу ефективність? – Для цього необхідно виконувати ін’єкції коду та використовувати метапрограмування.

Код, який ми пишемо та код, який виконується CPU – це два різні коди. Ми маємо створювати проєкт/план майбутньої побудови та надавати будівельні матеріали – модулі по вимогам, а саму побудову мають виконувати компілятор та система побудови.

Мінімалізм

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

Ідеї генерувати легко, але більшість із них – погані. Ми маємо фокусуватися на основній функціональності і відкидати майже усе “було б непогано …”, або “клієнт Х хоче …”. Краща лопата – це більш ефективна лопата, а не лопата з вбудованим bluetooth.

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *