MediatR за межами Vertical Slice Architecture. Чому ви можете використовувати його неправильно
Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.
Мене звати Юрій Івон, я співпрацюю з компанією EPAM як Senior Solution Architect. Хотів би поділитися своїми міркуваннями щодо доцільності використання бібліотеки MediatR в .NET проєктах.
Основною ціллю Архітектури вертикальних зрізів (Vertical Slice Architecture), яка запропонована Джимі Богардом, є спрощення підтримки через організацію коду навколо елементів функціоналу (features) замість рівнів (layers). Я б виразив загальні рекомендації цього підходу у двох реченнях:
- Тримайте всі елементи коду, які належать до однієї бізнес-операції, якомога ближче один до одного.
- Тримайте всі бізнес-операції ізольованими на рівні коду одна від одної, наскільки це можливо.
Наступна діаграма ілюструє різницю між традиційним багаторівневим підходом і архітектурою вертикальних зрізів:
Батагорівнева архітектура
Архітектура вертикальних зрізів
Дотримуючись цієї ідеї, можна побачити такі наслідки для практики програмування:
- Абстракції не створюються заздалегідь. Наприклад, на другій діаграмі немає репозиторіїв.
- Жоден код не вважається спільним заздалегідь. Спільне використання додається через рефакторинг тільки тоді, коли воно дійсно потрібне. Деякі доменні об’єкти використовуються тільки в межах однієї операції, обробник замовлень не використовує код, який був написаний для читання продуктів із бази даних у межах іншої операції.
Кожній бізнес-операції відповідає окремий клас обробника, що нагадує шаблон проєктування Command. Схожа абстракція може бути додана для спрощення реалізації та підтримки обробників. І це одна з причин, чому бібліотека MediatR була створена — вона надає абстракцію для обробників операцій та гнучкий пайплайн, через який запити йдуть до обробників. Це означає що MediatR дуже добре вписується в архітектуру вертикальних зрізів і не повинен викликати ніяких питань в цьому контексті.
Але така архітектура — не «срібна куля» і водночас з розв’язанням деяких проблем з підтримкою коду, вона вразлива до деяких інших, таких як дублювання та жорстка зв’язаність бізнес-логіки з доступом до даних. Як підкреслив сам Джимі Богард, ваша команда повинна дуже добре розуміти типові проблеми в коді (code smells) і рефакторинг для того, щоб досягти успіху.
MediatR в інших архітектурах доданків
Я бачу, що MediatR стає все більш популярним в .NET проєктах, які не слідують архітектурі вертикальних зрізів. Нещодавно я стикнувся з проєктом, де відносно невеликий WEB API сервіс використовує MediatR для виклику бізнес-логіки з контролерів, в той час, як решта коду побудована на традиційному багаторівневому підході. Такий варіант викликає питання і сумніви, якими я хочу поділитись.
Ось що ви можете отримати при використанні MediatR в багаторівневій архітектурі:
Щоб зрозуміти, чи дає цей підхід якісь переваги, підсумуймо, як прихильники MediatR обґрунтовують його використання:
- Сприяє слабкій зв’язаності коду.
- Допомагає слідувати принципу одного обов’язку (Single Responsibility Principle).
- Розв’язує проблему надмірної кількості залежностей в конструкторі.
- Спрощує навігацію в коді через розбиття великих сервісів на невеликі класи, які легко можна знайти в Solution Explorer.
- Дає можливість реалізовувати наскрізний код (cross-cutting code).
Сприяє слабкій зв’язаності коду?
Так, але в чому покращення в порівнянні з традиційним підходом, коли контролери залежать від інтерфейсів сервісів?
Технічно, MediatR дійсно робить код дуже слабко зв’язаним: клієнт нічого не знає про сервіс, окрім класу з параметрами обробника. Чи можна це розглядати як перевагу? На мою думку — ні. Такий варіант ізоляції все ж не захищає розробника від низькорівневих деталей у контракті: деякі типи запитів та параметри все одно можуть бути дуже специфічними для реалізації і ви отримаєте логічну зв’язаність, не дивлячись на використання шаблону, який наче спрямований на запобігання цьому.
Це одна з типових проблем в сучасній розробці програмного забезпечення — люди надто багато уваги приділяють конкретним шаблонам та технологіям, забуваючи про фундаментальні принципи проєктування, що стоять за ними. Жодний шаблон автоматично не зробить ваш код легшим у підтримці, тому треба завжди дотримуватися базових принципів і слідкувати за якістю коду незалежно від того, який шаблон використовуєте.
І MediatR, і традиційний підхід дотримуються принципу інверсії залежностей, тому сприяють слабкій зв’язаності коду.
Допомагає дотримуватися принципу одного обов’язку (Single Responsibility Principle)?
З традиційним підходом у вас може з’явитися великий сервіс з багатьма обов’язками й сотнями методів, що буде важко підтримувати. З MediatR, розробники змушені інкапсулювати кожну бізнес-операцію в окремому класі, таким чином зменшуючи ймовірність появи «god object» майже до нуля. Звучить перспективно, чи не так?
Почнемо з більш детального прикладу використання MediatR у веб сервісі.
Тут можна чітко побачити, що MediatR дає більш гранулярне розділення обов’язків між класами бізнес-логіки. Однак, ця діаграма дуже спрощена, тому треба розглянути ще деякі речі.
Перше питання — як реалізовувати спільну логіку для декількох обробників. Всі операції читання можуть використовувати одні й ті самі базові запити до бази даних, операції створення та оновлення можуть щось записувати в загальні таблиці після того, як основні зміни вже зроблені. Досить часто така загальна логіка є деталлю реалізації сервісу і повинна бути ізольована, тому, зазвичай, вона знаходиться в приватних методах того самого сервісу. Додавання допоміжного класу з публічними методами, методу розширення або окремого сервісу для такої загальної логіки не є варіантом, тому що буде порушена ідея інкапсуляції: деталі потрібні тільки одному сервісу будуть доступні іншим частинам коду. Таким чином, єдине що залишається — базові класи обробників, які реалізують загальні методи.
Якщо потрібні методи, загальні для всіх операцій, то може бути ще один рівень наслідування, який дає базовий клас для GetOrderHandlerBase та PersistOrderHandlerBase. Це вже виглядає складно, але згодом може ще ускладнитись, адже у вас може з’явитися декілька базових класів, і буде неочевидно, від якого саме наслідувати нові операції.
Ви можете спитати «А в чому різниця з архітектурою вертикальних зрізів? Нам все одно треба буде мати справу з загальною логікою й там». Але різниця є: у традиційному підході розробники намагаються повторно використати й узагальнити якомога більше, у той час, як вертикальні зрізи зміщують фокус із повторного використання. Ймовірність опинитися в ситуації з переускладненим загальним кодом набагато менша, якщо ви слідуєте ідеології вертикальних зрізів. Більше рекомендацій про те, як боротись із дублюванням коду в рамках такої архітектури можна знайти в статті Джимі Богарда.
MediatR допомагає дотримуватися принципу одного обов’язку, але разом з погонею за максимальним узагальненням, він може зробити структуру класів значно складнішою, ніж була б без нього.
Вирішує проблему надмірної кількості залежностей в конструкторі?
Розглянемо приклад коду з такою проблемою:
public OrdersService(ILogger logger, IOrdersRepository ordersRepository, INotificationService notificationService, IProductsRepository productsRepository, IAuditService auditService, ISecurityService securityService, ILegacyOrdersService legacyOrdersService, IPriceCalculationService procesCalculationService, …
Якщо ви розіб’єте сервіс на декілька класів, де кожен буде відповідати за свою операцію, скоріш за все кожен з них буде мати менше параметрів в конструкторі. Але подивімося краще на першопричину, ніж на її наслідки.
Якщо в конструкторі багато залежностей, скоріш за все цей сервіс має багато обов’язків. Популярне практичне правило каже, що краще не мати більше ніж п’яти залежностей. Звісно, можуть бути винятки, але ця метрика є хорошим і простим індикатором того, коли треба розглянути винос деяких обов’язків в окремий сервіс. Тож чому б не розглядати це насамперед, а не покладатися на шаблон, який ніби сам по собі розв’яже проблему? Як я вже сказав, жоден шаблон автоматично не полегшить подальшу підтримку вашого коду.
MediatR ніби розв’язує проблему надмірної кількості залежностей в конструкторі, але якщо розробники не дотримуються базових принципів ООП, то це просто відкладає проблему у часі.
Спрощує навігацію в коді?
Я чув від деяких розробників, що класи з багатьма методами важкі для навігації в коді, тому краще мати більш гранулярні класи для того, щоб можна було легко знайти потрібні частинки коду в дереві проєкту. Це може здатися розумним, але не всі розробники з цим погодяться. Якщо ви боїтесь класів з багатьма методами, то використовуйте команду «Collapse to definitions» (Ctrl+M, O), яка згорне тіла всіх функцій, і ви зможете розгорнути тільки те, що потрібно. Я б сказав, що краще боятися змішування обов’язків у межах одного класу, ніж боятися просто кількості методів.
Водночас є декілька помітних недоліків:
- Складніше знайти обробник для запиту при читанні коду. Зазвичай, коли вам потрібно знайти реалізацію методу, що викликається, ви натискаєте «Go to implementation» — це просто й очевидно. З MediatR це вже не працює, і у вас є дві альтернативи:
- Відкрити діалог пошуку (Ctrl + T) і вставити ім’я класу запиту + «Handler». Або ввести перші великі літери з усіх слів, що входять в назву класу.
- Викликати команду «Find references» на класі запиту, і якщо у вас небагато коду, що посилається на нього — знайти відповідний обробник в результатах пошуку.
- Більше зусиль на створення нового методу. Для додання нового методу в сервіс з традиційним підходом, вам потрібні тільки дві прості зміни — додати сігнатуру методу в інтерфейс та додати його реалізацію в сервіс. З MediatR вам потрібно створити два нових класи — запит та обробник. Це забирає більше часу та більше дратує, особливо, коли ваші обробники мають декілька залежностей.
Чи спрощує навігацію в коді поділ одного сервісу на декілька класів — це суб’єктивне питання. Я вважаю, що краще мати всі згуртовані (cohesive) частини в одному файлі, водночас деякі розробники думають навпаки. Ви можете звикнути до таких переходів від типу запиту до відповідного обробника, але будьте готові, що нові члени вашої команди будуть трохи не раді таким практикам на початку. Створення нових методів в контролерах може викликати незручності, але зазвичай немає потреби реалізовувати сотні API методів за короткий проміжок часу.
Загалом, я не можу впевнено сказати, що MediatR спрощує навігацію в коді чи якісь інші тривіальні дії розробників.
Дає можливість реалізовувати наскрізний код?
Так, дає. Але перед тим як робити висновок, чи це може бути аргументом для використання MediatR, давайте трохи поговоримо про альтернативи.
Якщо ви пишете звичайний REST сервіс з використанням ASP .NET Core, то у вас вже є можливість реалізовувати наскрізний код за допомогою middleware або фільтрів. Ви можете заперечити, що це засоби транспортного рівня, а вам потрібно мати наскрізну логіку незалежно від зовнішнього інтерфейсу, тому що ви плануєте використовувати той самий код сервісів через різні протоколи та канали комунікації, можливо навіть плануєте зробити окрему бібліотеку з бізнес-логікою.
В сучасному світі мікросервісів повторне використання бізнес-логіки декількома сервісами через спільну бібліотеку не вважається хорошою практикою. Крім того, є тренд на перенесення наскрізної логіки за межі коду сервісів, щоб розробників ніщо не відривало при роботі над бізнес-логікою. Всі ці API gateways та service meshes переважно про це. Таким чином, навіть якщо вам дійсно потрібно виставити той самий набір операцій через декілька каналів, швидше за все це буде зроблено у вигляді декількох правил на API gateway або додаткових мікросервісів, які адаптують інші протоколи до REST API вашого сервісу.
Якщо такий «передовий» підхід не відповідає вашим умовам і все ще є потреба повторно використовувати сервіс через декілька каналів зв’язку, ви можете розв’язати проблему за допомогою аспектно-орієнтованого програмування та відповідних бібліотек. Якщо швидкість виконання критична, має сенс брати compile-time AOP фреймворки такі як AspectInjector. Щоб мати більше гнучкості під час виконання — Castle Core, Castle.Core.AsyncInterceptor або їх аналоги. Будь-який із них забезпечить більшу гнучкість в реалізації наскрізного коду, ніж MediatR, і не вплине на структуру вашого сервісу та його внутрішні класи.
Мотивація вибору MediatR винятково здатністю реалізувати наскрізний код є помилкою — це все одно, що закручувати гайку плоскогубцями, маючи в ящику інструментів гайковий ключ лише тому, що плоскогубці «можуть це зробити». Те, що MediatR надає такі можливості, є радше приємним бонусом, ніж основною причиною його вибору.
Висновки
Якщо ви зацікавлені в архітектурі вертикальних зрізів і вважаєте, що ваша команда достатньо зріла, щоб працювати над постійним рефакторингом — спробуйте. MediatR є природним вибором у цьому випадку та не викликає зайвих питань.
Однак, якщо ви збираєтеся додати MediatR до багаторівневої програми — подумайте двічі, оскільки цей шаблон сам по собі має кілька недоліків:
- Не дає жодних переваг в контексті слабкої зв’язаності у порівнянні з традиційним підходом.
- Допомагає дотримуватися принципу одного обов’язку, але може ускладнити структуру класів.
- Не виглядає, що він полегшує розробникам тривіальну роботу з кодом, таку як навігація в коді або створення нових операцій. Якщо якесь покращення і є, воно дуже суб’єктивне.
- Його використання не може бути обґрунтоване тільки потребою реалізовувати наскрізний код.
Коли ми розглядаємо MediatR в контексті архітектури вертикальних зрізів, деякі з цих проблем стають менш вираженими, тому що не дивлячись на описані раніше ускладнення, цей архітектурний підхід може полегшити подальшу підтримку. Це досягається завдяки організації коду навколо елементів функціонала так, що зміна в одному ніяк не може вплинути на інші, і вам не треба стрибати між багатьма різними місцями в коді, щоб реалізувати або змінити окрему функцію.
Коротко кажучи, я б не використовував MediatR в багаторівневій архітектурі. Найбільша проблема, яку я тут бачу, полягає в тому, що інструмент, створений для підтримки однієї ідеології, використовується в іншій без повного розуміння, чому він взагалі створений.
Це переклад моєї статті з medium.com.
10 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів