Навіщо нам DI, або в чому недолік сучасних архітектур на Front-end
Привіт! Я Дмитро Ханджанов, Front-end розробник команди MOJAM. Наш продукт — це платформа для кіберспортсменів і гравців у CS2, яка налічує понад 4.7 мільйони користувачів зі 168 країн світу, де на перше місце ставиться інтерактивність, реактивність і відмовостійкість системи.
Працюючи з такими масштабами, ми постійно стикаємося з викликами в архітектурі проєкту. Одна з найбільш недооцінених проблем у frontend-розробці — це коли структура коду зводиться до просто правильно названих директорій, а не до продуманої логіки. У більшості випадків це лише формальність, яка не допомагає підтримувати проєкт у порядку.
Ця стаття — це крик душі про те, як можна реструктуризувати проєкт так, щоб архітектура не просто існувала на папері, а дійсно працювала. Розглянемо, як організувати код таким чином, щоб шари застосунку були очевидними та спонукали (чи навіть змушували) розробників дотримуватися правильної структури. Окрім цього, торкнемося теми ініціалізації застосунку: чому вона важлива і які проблеми виникають при її ігноруванні.
Як виглядає типовий Front-end-проєкт
Уявімо звичайний проєкт, який був розроблений на умовному React’і й має наступну структуру:
- assets(зображення, шрифти тощо) - app(основна логіка додатку) --- components --- pages --- layouts - helpers (глобальні хелпери) - utils (глобальні хелпери, але трішки інші) - layers (умовні шари проєкту) --- store_middleware (middleware для стору, може бути saga або effects або кастом) --- store (стор) --- BLoC (функції, які містять бізнес-логіку) --- request (функції, які формують запити на сервер) - constants (конфігураційні й інші константи)
Як бачимо, в цій структурі є деякі шари, які відокремлені від UI-частини проєкту і, скоріше за все, містять якусь бізнес-логіку. Якщо звернутись до файлу .readme, то, можливо, там навіть можна буде знайти приблизно такий малюнок:
Тобто маємо якусь структуру, з задекларованими правилами взаємодії різних шарів. Можемо навіть сказати, що присутній який-неякий флоу даних. Та якщо зазирнути в файли цих «шарів», і в місця їх використання, можемо побачити, що більшість з них є глобальними:
// component.tsx import { getProducts } from "~/layers/store_middleware/product.ts" const result = getProducts() // layers/store_middleware/product.tsx import { userStore } from "~/layers/store/user.ts" import { loadProductsBloc } from "~/layers/BLoC/product-bloc.ts" export function loadProducts() { const userId = userStore.user.id const products = loadProductsBloc(userId) return products } // layers/BLoC/product-bloc.ts import { isUserIdValid } from "~/utils/user.ts" import productAPI from "~/layers/request/product.ts" export function loadProductsBloc(userId: number){ if(!isUserIdValid(userId)){ throw new Error('User ID isn\'t valid. Pls refresh the page and try again') } return productAPI.loadProducts(userId) } // layers/request/product.ts import { domain } from "~/constants/config.ts" export function loadProducts(userId: number){ return axios.post(`${domain}/getProducts`, { userID }) }
З першого погляду все виглядає дуже лаконічно, охайно і функціонально. Але ті, хто вже мав справу з подібними системами, можуть назвати ряд недоліків:
Глобальний експорт
Перше й основне: всі функції експортуються глобально. На перший погляд, це не проблема, але що завадить мені, як розробнику, використати функцію loadProducts
з файлу layers/request/product.ts
прямо в компоненті, оминаючи middleware
та BLoC
шари? Нічого, крім конвенцій не попереджає некоректній організації коду. Це може здаватись невеликою проблемою, але повірте, конвенції, за якими потрібно слідкувати, завжди втрачають свою силу з часом.
Варто один раз прогледіти таку помилку на рев’ю, і це вже стане прецедентом, який дозволить використовувати будь-які функції будь-де, якщо це зручно.
Звісно, це дуже гнучко, але це породжує хаос, дубляжі в коді, баги, і вносить непередбачуваність при спробах оцінити черговий спринт або епік. Тобто це відкочує проєкт до того стану, яким він був до формування «шарів».
Обов’язковість документації або knowledge holder’а
Скажімо, ПМ втомився від штатних ледарів-розробників і вирішив звільнити всіх, а проєкт передати на аутсорс. І ось вже нова команда збирається підтримувати продукт. Якщо всі функції експортуються глобально, звідки інший програміст без пояснень зрозуміє логіку формування цих «шарів» і їх використання?
Тобто так, з контексту назви файлів і папок має бути зрозуміло ± в яку сторону копати, але це не завжди працює. Повірте, я не раз був дуже здивований, коли переходив на інший проєкт і дізнавався, що звичні для мене назви абстракцій там мають зовсім інший сенс.
Тестування
Якщо доведеться тестувати якусь функцію з шарів, доведеться мокати всі глобально імпортовані константи й функції з інших шарів, які були використані. І мокати доведеться також глобально. В глобальному моку, з суто технічного погляду, не багато різниці, окрім не дуже зручного синтаксису. Втім, якщо в файлі присутні тести з багатьох функцій чи методах класу, глобальні моки будуть спрацьовувати на них всіх, що може як негативно впливати на перформанс й правдивість тестів, як в конкретному файлі, так і в інших файлах, якщо прогледіли почистити ці глобальні моки. Що може негативно впливати на перформанс і правдивість тестів як в конкретному файлі, так і в інших файлах, якщо прогледіли почистити ці глобальні моки.
Порядок ініціалізації
Припустимо, у вас в тих хелперах\утілсах ініціалізуються якісь плагіни типу Google Analytics, Launch Darkly і йому подібних. До того ж потрібно ініціалізувати застосунок, завантажити дані за необхідності й т.д. Мало того, ініціалізація одних компонентів системи\плагінів може залежати від інших, і дуже часто в системах з глобальним експортом компонентів ця ініціалізація розмазана тонким шаром по різних куточках системи, що значно ускладнює її підтримку.
Колізії в іменуванні функцій
Часто в різних компонентах системи прослідковується бажання\необхідність мати однаково названі функції. Для прикладу, якщо в нас є екшн якоїсь middleware
, який завантажує продукти, ми маємо його назвати loadProducts
. Втім, це саме стосується і якогось файлу з API-методами, в якому є функція, що завантажує продукти.
І якщо ці дві функції імпортуються глобально, виникає деякий конфлікт іменування функцій. Цей приклад хоч і наочний, втім не рідкісний. Я впевнений, кожен з вас може назвати з десяток місць у своєму проєкті, де функції дублюють свою назву, і контролювати ви це можете лише через імпорти.
DI як спосіб контролю залежностей і ініціалізації системи
Для початку пропоную ознайомитись з рішенням, потім його обговорити, і наприкінці — вдосконалити. Тож для того, щоб завести DI на самому простому і примітивному рівні, нам знадобиться лише умовний index.ts
файл, в якому буде міститись вся логіка ініціалізації, і який ми будемо запускати при старті застосунка.
Для реалізації такого способу необхідно або через старий добрий патерн «модуль», або через класи організувати можливість інкапсуляції даних в компонентах (модулів, шарів) системи. Це потрібно для того, щоб кожен окремий компонент «шару» чи «модулю» міг отримати необхідні залежності й зберігати їх, доки працює застосунок. Я у своєму прикладі буду використовувати класи:
// initialization/index.ts import { domain } from "~/constants/config.ts" export function initializeApp(store) { // ---------- init gtag ---------- // if(process.env.gtag){ gtag('config', 'TAG_ID', {<additional_config_params>}) } else { console.error('Can\'t init gtag coz key isn\'t provided') } // ---------- lounch darkley ---------- // const context: LDClient.LDContext = { kind: 'user', key: 'user-id-123abc', name: 'Sandy', email: '[email protected]' }; const client = LDClient.initialize('copy-your-client-side-id-here-here', context) client.on('initialized', function () { console.log('LaunchDarkly is successfully authenticated to your account'); }) // ---------- request layer ---------- // const requestProductModule = new RequestProduct(domain) const requestUserModule = new RequestUser(domain) // ---------- BLoC layer ---------- // const blocProductModule = new BlocProduct({ requestProduct: requestProductModule, requestUser: requestUserModule }) const blocUserModule = new BlocUser({ requestUser: requestUserModule }) // ---------- store middleware ---------- // const productStoreMiddleware = new ProductStoreMiddleware(store, { blocProduct: blocProductModule, blockUser: blocUserModule }) const userStoreMiddleware = new ProductStoreMiddleware(store, { blockUser: blocUserModule }) // this will be acessable from the ui components return { productStoreMiddleware, userStoreMiddleware } }
Як бачимо, кожен інстанс зберігає в собі передані йому інстанси. І зберігає він їх весь час доки існує, а в нашому випадку існувати він буде завжди, допоки відкрита вкладка браузера. Для завершення думки передаймо утворений об’єкт в контекст React’у, щоб той став доступним всім компонентам в застосунку (припустимо, це базовий компонент... app.tsx).
import { initializeApp } from "/initialization.index" import store from '~/reducers/store' const businessLogicModules = initializeApp(store) const BLL = createContext(businessLogicModules)
Тепер ми можемо в будь-якій частині застосунку звертатись до нашого business logic layer
та викликати необхідну нам операцію:
// some /pages/products.tsx import { useAppSelector } from 'app/hooks' function Products(){ const products = useAppSelector(state => state.products.value) const bll = useContext(BLL); bll.products.load() }
Що ж... Коли імплементація зрозуміла, можемо перейти до покращень, яких досягли шляхом цієї досить простої імплементації впровадження залежностей:
Зрозумілий і наочний флоу ініціалізації
Ми отримали повний контроль над ініціалізацією проєкту, що дало змогу гарно його структурувати. У випадку, якщо у вас багато модулів чи компонентів, ніхто не забороняє рознести ініціалізацію на декілька файлів.
Мало того, такий контроль має вторинні, неочевидні плюси. До прикладу, якщо у вас використовується SSR, ви можете закешити окремі модулі\компоненти й не збирати їх щоразу, а лише діставати з кешу. Що, хоч і не на багато, але зменшить TTC вашого застосунка.
Легкість тестування
Тепер, коли всі компоненти системи передаються в конструктор один одному, ми можемо мокати їх локально, в рамках кожного окремого тесту, і замоканими передавати як залежності компонентам, що тестуємо. Що, якщо не розв’язує всі проблеми глобальних імпортів, то як мінімум зводить їх до нуля.
Внутрішнє API бізнес-логіки
Тепер, коли в нас є bll context
, ми можемо звернутись до нього й отримати список всіх доменів і методів, які ті підтримують. Як документація, що постійно сама оновлюється. Наявність такого списку значно спрощує пошук місця, де може лежати необхідна функція, а також змушує розробників інтегрувати свої нові функції в наявне рішення, і туди, куди потрібно.
Бо в цій системі потрібно проробити більше роботи, щоб зробити неправильно.
Низька зв’язність проєкту
Всім відомий принцип GRASP Low Coupling Hight Cohesion
в нашому випадку задовольняється максимально, адже кожен модуль незалежний настільки, наскільки це можливо. Але він залишається достатньо гнучким, щоб його можна було доповнювати в міру потреби з ростом запитів бізнесу. Пропоную порівняти, що було (насправді, а не на папері), і що стало:
Я не конспіролог, але, гадаю, можна зробити висновок, що відсутність упорядкування компонентів на рівні коду, це щось від лукавого...
Мінуси такого підходу
Першим і основним мінусом є ускладнення системи. Потрібно розуміти, що ми не лише даємо інструменти розробнику, а й накладаємо на нього деякі обмеження. Якщо раніше умовний розробник міг просто в папочці «helpers» створити новий файл і винести туди якусь функцію, то тепер йому потрібно розуміти, з яких прошарків складається застосунок, і які в них зони відповідальності. На практиці воно набагато легше, ніж звучить в даному абзаці, але факт ускладнення системи беззаперечний. Хоча і підвищення складності буде контрольоване.
Другим мінусом є складність ін’єкції класів з одного прошарку. Потрібно розуміти, що завжди буде спокуса заін’єктити умовний UserController
в ProductController
, щоб, для прикладу, авторизувати користувача, коли він додає щось в кошик. Але цього потрібно максимально уникати.
По-перше, така необхідність сигналізує про косяк в архітектурі або логіці. Ви або неправильно розшарували застосунок, або недостатньо... Або ж намагаєтесь в рамках одного сценарію зробити взагалі все.
По-друге, такі залежності легко можуть призвести до нескінченного імпорту. Якщо ви пишете на TS, то скоріше за все сама мова вам підкаже, що у вас є цикл в імпортах. Втім, якщо ви робите ін’єкцію двох класів в межах одного файлу, і це ок у вашому застосунку, тоді вам потрібно буде значно ускладнити механізм ін’єкції, щоб запобігати випадкам нескінченної рекурсії в створенні класів.
Оптимізація роботи з DI
Чи є в нашій реалізації недоліки? Так — додаткова мануальна робота при створенні нових компонентів системи, або передачі нових зв’язків уже наявним. Потрібно знати, що десь там у файлі, що ініціалізує, є сценарій, в який треба інтегрувати нову залежність. Це на практиці й в 10 разів не так душно, як звучить. Насправді мова йде про написання
Для вирішення цього прикрого непорозуміння ми можемо використати декоратори TypeScript, щоб автоматично:
- Відстежувати наявні в компоненті системи, без необхідності їх додаткової реєстрації.
- Ділити їх на scop’и, для запобігання використання того, що не треба там, де не треба.
- Інжектити залежності в конструктор.
Для кращого розуміння, як це може працювати, пропоную розглянути невеличкий приклад. Втім зауважу, що наведений нижче являється саме прикладом, а не готовим рішенням. Ми його розберемо для того, щоб зрозуміти, як даний механізм працює з середини, а на проєкт я рекомендую тягнути лише готові й відтестовані рішення.
Автоматизація реєстрації компонентів в системі
Тож, уявімо, що ми створюємо новий клас і хочемо, щоб він автоматично підтягувався в конструктори інших класів, а не реєструвати його десь в індексному файлі руками. Для цього ми можемо створити декоратор. Скажімо, @InjectedClass
, який буде автоматично додавати метадані класу в репозиторій для подальшого відтворення.
Для прикладу:
@InjectedClass({name: 'Logger'}) class Logger {}
Щоб була змога передавати інформацію в декоратори не тільки класів, а й методів і властивостей — потрібно в налаштуваннях TS додати властивість experimentalDecorators
.
Сам декоратор буде приблизно таким (дуже спрощений варіант):
export function InjectedClass(config: InjectedClassConfig): Function { return (constructor) => { const metadata = { constructor } MetaDataContainer.set(config.name, metadata) } }
Як бачимо, нічого екстраординарного. Нам просто необхідно внести метадані класу в загальний контейнер, з якого потім можна буде їх брати, і використовувати для створення інстансу.
Важливо розуміти, що створювати інстанс в цьому декораторі не потрібно, цим буде займатись зовсім інша сутність і за своїми, унікальними, правилами. Отже, можна буде організувати щось типу lazy-loading, а не створювати всі інстанси в системі відразу, як ми це робили в початковому варіанті.
Серед важливого в конфігурації — зберегти конструктор, адже саме його ми будемо запускати для створення інстансу. Втім, як можна бачити, є й велике поле для розширення конфігу. Для прикладу, можна додати параметр scope
, який буде відповідати за обмеження області використання даного класу, або singleton
, що дозволить створити лише одну копію інстансу цього класу і перевикористовувати її для ін’єкції в усі пов’язані компоненти.
Автоматизація ін’єкції залежностей
Перед тим, як розбирати створення об’єктів, закінчімо з декораторами й інтерфейсами, а саме з частиною автоматизації самого процесу ін’єкції. Загалом ми можемо автоматично відстежувати, які залежності необхідно заін’єктити. В цьому випадку нам при створенні компонентів системи більше нічого зайвого робити не потрібно. Можемо просто передавати в конструктор класу будь-які залежності, а потім автоматично їх створювати через метадані:
@InjectedClass({name: 'SomeClass'}) class SomeClass { public constructor( userMiddleware: UserMiddleware, productMiddleware: ProductMiddleware ){} }
В цьому випадку, коли ми в фабриці (про це трішки згодом) будемо створювати інстанси, нам доведеться використати маленький лайфхак:
class Factory {
// constructor - param we saved into InjectedClass decorator public function create(constructorFunction: Function) { const paramTypes: unknown[] = Reflect.getMetadata('design:paramtypes', constructor); const params = []; if (paramTypes.length !== 0) { const requiredInstances = paramTypes.map((paramType) => { const instConstuctor = MetaDataContainer.get(paramTypes.name) return this.create(instConstuctor) }) params.push(...requiredInstances) } constructorFunction(...params) } } function create(){}
Щоб зрозуміти, які параметри необхідно засетити, ми скористались не дуже популярним інструментом Reflect, який дав змогу отримати доступ до властивостей переданих в конструктор параметрів (бере їх з внутрішньої властивості paramtypes
).
Втім, працює це ще й через увімкнені прапорці experimentalDecorators
та emitDecoratorMetadata
. Якщо цікаво що буде в paramTypes
, то в нашому випадку — масив конструкторів класів, з яких можна створити об’єкти класу:
const userMiddleware = new paramTypes[0]()
Такий метод ін’єкції найчастіше використовують, коли потрібно налаштувати зв’язки між різними шарами застосунку, або всередині одного шару. Як приклад, можна назвати Angular або NestJS. Так, там не прямо саме такий підхід, і в провайдері потрібно вказувати класи, що будуть ін’єктитись, але концептуально досить гарно описує сценарій, коли він буде найбільш виграшним.
Створення й отримання інстансів класу
Важливо розуміти, що ми не можемо просто взяти й створити інстанс класу, який потребує залежностей через DI. Тож нам в будь-якому разі потрібен буде контейнер, з якого ми будемо отримувати необхідні нам інстанси. Сам контейнер буде чимось нагадувати патерн Multiton
. Цьому самому контейнеру можна довірити й створення цих самих інстансів:
class DIContainer { private instances: Map<string, Record<string, any>> = new Map() private factory: DIInstanceFactory public construnctor() { this.factory = new DIInstanceFactory(this) } public function getInstance(name: string) { const existedInstance = this.instances.get(name) if(existedInstance !== undefined){ return existedInstance } const instance = this.factory.create(name) this.instances.set(name, instance) return instance } }
Використання цього контейнеру буде доволі простим, розгляньмо його в контексті нашого спрощеного прикладу:
import { domain } from "~/constants/config.ts" export function initializeApp(store) { // ---------- init gtag ---------- // if(process.env.gtag){ gtag('config', 'TAG_ID', {<additional_config_params>}) } else { console.error('Can\'t init gtag coz key isn\'t provided') } // ---------- lounch darkley ---------- // const context: LDClient.LDContext = { kind: 'user', key: 'user-id-123abc', name: 'Sandy', email: '[email protected]' }; const client = LDClient.initialize('copy-your-client-side-id-here-here', context) client.on('initialized', function () { console.log('LaunchDarkly is successfully authenticated to your account'); }) const middlewareContainer = new DIContainer() // this will be acessable from the ui components return { middlewareContainer } }
Як бачимо, використання значно спростилось. Більше ніякої ручної праці, тільки створили контейнер і передали його в застосунок зручним для нас способом. Всі інші інстанси будуть створюватись при їх запиті автоматично.
Втім, окремо потрібно розібрати фабрику DIInstanceFactory
, яка породжує ті самі інстанси. Насправді фабрику можна було і не створювати, її можна замінити приватним методом create
в класі DIContainer
, втім створення об’єкта такої складності — досить важка задача, яку краще винести в окремий клас, хоча б щоб мати простір для організації такого алгоритму.
Частково код такої фабрики ми розбирали в попередньому пункті, але лістинг повного алгоритму методу create
я додавати не буду, бо його скоріш за все скопіював би з уже готових рішень і вийшло б простирадло довжиною в життя. Але для форми зазначу ряд ключових моментів, які критично необхідно буде проробити, якщо таки вирішите створювати це самостійно:
- Потрібно з контейнера
MetaDataContainer
отримувати по ключу даних і на їх основі створювати інстанс класу. - В створюваний інстанс класу можуть передаватись залежності, які ще не створювались, тож потрібно буде зайнятись і їх створенням й ін’єкцією.
- Створювати залежності потрібно буде рекурсивно, бо в залежностей можуть бути свої залежності й т.д.
- Залежності можуть бути вже створені, тож потрібно постійно перевіряти їх наявність як в
DIContainer
, так і вMetaDataContainer
, якщо не зберігаєте все відразу вDIContainer
. - Необхідно відразу закласти можливість працювати зі скоупами, бо рано чи пізно така необхідність виникне, тож окрім всього вище перерахованого потрібно буде ще й менеджити інстанси по скоупам.
Список вище не повний, але він точно має всіх переконати в складності цього алгоритму, тому ще раз наполегливо рекомендую використовувати готові рішення. До них і переходимо.
Готові рішення
На щастя, нам не потрібно імплементувати подібні рішення самостійно, адже є ціла плеяда вже готових інструментів на будь-який смак. Для прикладу: Typed Inject, InversifyJS, TypeDI, TSyringe і це ще далеко не повний список. Всі легко гугляться в інтернеті, і якщо подивитись на синтаксис, то можна зрозуміти, що всі вони плюс-мінус про одне й те саме, майже з точністю до крапки.
Коротко про них:
- Мій фаворит це TypeDi через безмежну простоту реалізації та підтримку всіх основних фіч, серед яких, до речі, і вміння працювати з зацикленими залежностями.
- Також можу порадити TSyringe через те, що за ними стоять Microsoft, а отже ресурсів на цю бібліотеку, скоріше за все, не шкодували. У функціоналі ні чим не поступається TypeDI.
- Менш гнучкий, але добре пророблений всередині InversifyJS також вартий уваги, але, як на мене, він більше обмежений в гнучкості використання, ніж перші два варіанти. І потребує додаткових зусиль для організації роботи з ним.
- І, певно, єдиний зі списку, котрого я не можу радити для використання — це Typed Indect, а все через окремий синтаксис, який продиктований цією бібліотекою. Це призведе до міксування полів з даними з цими технічними полями. І до того ж він також доволі обмежений у варіативності використання. Тож використовувати його можна лише якщо ви, з якихось причин, не можете використовувати декоратори на проєкті.
Переваги й недоліки даної автоматизації
Як і будь-яке рішення в нашій сфері, подібну автоматизацію пхати взагалі скрізь — не варто. Її потрібно використовувати там, де її переваги будуть приносити більше користі, ніж недоліки шкоди. Далі буде виключно моя особиста думка, якщо у вас виникне ще декілька пунктів — прошу, напишіть про них в коментарях.
З переваг
- Зручність в користуванні. Системою буде значно приємніше і простіше користуватись, якщо при ін’єкції кожної нової залежності не доведеться йти ще в якийсь файл і там її руками додавати.
- Відсутність простирадла зі створення залежностей в файлі, що ініціює.
- Більш явне розділення на скоупи, що дозволить ясніше розуміти структуру застосунка.
З недоліків
- Необхідність задати в конфігурації TS дві нові властивості:
emitDecoratorMetadata
таexperimentalDecorators
, інакше в декоратори не можна буде передавати додаткові параметри, що значно знизить їх ефективність. - Ускладнене розуміння, як працює система через «магію», яку створюють ці декоратори. Якщо раніше ми керували створенням кожного окремого інстансу, то тепер це відбувається без нашої явної участі.
Висновок
В давній статті ми розібрали 2 проблеми: організацію ініціалізації елементів застосунка та ізоляцію окремих елементів системи один від одного. На щастя, є рішення, яким можна зменшити або усунути ці проблеми на проєкті. Сподіваюсь, що якщо навіть імплементація даних рішень виглядає для вас наразі невиправдано затратною, вам таки сподобались ідеї, які стоять за цими рішеннями, й ви зможете їх адаптувати під потреби свого проєкту.
Фронт-енд фреймворки (окрім, мабуть, angular) взагалі не переймаються організацією коду за межами компонентів (максимум можуть мати у своїй екосистемі flux-based бібліотеки). Це, звісно, дозволяє писати швидко, але зі збільшенням складності проєкту потрібно шукати й складніші та продуманіші рішення. І якщо ваш тімлід не погоджується переписувати весь проєкт на Angular, але всі в команді розуміють, що потрібно якось організовувати той код — тоді запропоноване рішення може стати саме тим, що дозволить отримати бажаний результат за відносно невеликий час і ресурси.
Дуже вірю, що з часом подібні рішення будуть частіше зустрічатись на проєктах. Адже імплементація подібних рішень відкриває дорогу до ще більшого асортименту інструментів, які можуть зробити ваш застосунок більш відмовостійким, стабільним та підтримуваним.
27 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів