Навіщо нам DI, або в чому недолік сучасних архітектур на Front-end

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до 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 разів не так душно, як звучить. Насправді мова йде про написання 2-х додаткових рядків коду. Втім, це все ж породжує деяку незручність.

Для вирішення цього прикрого непорозуміння ми можемо використати декоратори 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, але всі в команді розуміють, що потрібно якось організовувати той код — тоді запропоноване рішення може стати саме тим, що дозволить отримати бажаний результат за відносно невеликий час і ресурси.

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

👍ПодобаєтьсяСподобалось15
До обраногоВ обраному4
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
Наш продукт — це платформа для кіберспортсменів і гравців у CS2, яка налічує понад 4.7 мільйони користувачів зі 168 країн світу

цікавлюсь кіберспортом з 2005, ніколи не чув за таку платформу, як і інші кіберспортсмени, стрімери, аналітики та організатори теж не чули

XD
то є трішки очевидно, чому ця приписка тут)))
я ж раджу увагу приділити самій статті)

— helpers (глобальні хелпери)
— utils (глобальні хелпери, але трішки інші)

Ось це на початку сподобалось, дуже життєво :)

Коли дочитував, і лишалось кілька розділів, хотів лишити коментар, типу: «А в кінці получився Ангуляр». Та автор сказав це у висновках, але іншими словами.

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

Вам не здається що коли проблема робиться прецендентом це взагалі не ок.

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

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

P.S.
Часто пофіксити в коді 9-ної давнини — це дуже дорого. З великим імпактом на проект, що потребуватиме додаткових QA ресурсів... то ж не завжди є така можливість, одразу щось виправити)

Недолік не в архітектурі а в її відсутності

Влучно підмічено)
Втім просту «структуру директорій» багато хто називає «архітектурою»)

А що заважає створити наприклад в проекті директорію contracts або factories де будуть проксі модулі з реекспортами та ініціалізаціями? І використовувати в проекті імпорти через них замість ін’єкцій. Можна навіть лінтером перевіряти щоб в застосунку всі імпорти були через ці проксі. Тоді функцію контейнера буде виконувати сама інфраструктура es модулів з автоматичним кешуванням без магічних бібліотек? І буде слабкий каплінг, бо завжди можна переписати фабрику наприклад під інший логер. Я розумію для чого в java та інших подібних мовах ін’єкції. Бо там файли це класи і контейнер виконує функцію фабрик. Проте навіщо це в js коли є більш гнучка та зручна система модулів? Можливо я помиляюсь і не бачу кілер фіч?

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

Щодо Вашого рішення з модулями. Не скажу що зрозумів його на 100%, втім якщо ви можете контролювати використання експорту в різних прошарках — тобі це рішення може для Вас спрацювати (до прикладу, якщо Ви можете заборонити використання файлів транспортного прошарку в ui компонентах) Рішень багато, кожен обирає собі те що йому підходить)

Втім є декілька тонкощів в ідеї з імпортами на які я б звернув увагу:
— ідея зберігати всі константи в одній дерикторії є не дуже файною. Краще їх зберігати поряд с місцем використання, або в модулі який відповідає за відповідний домен. Просто виносити всі типи, константи, фабрики в окремі файли, це те саме що зараз виносити всі css, html и js файли в різни дерикторії, а потім намагатись імпортувати їх в відповідні компоненти)
— якщо весь контроль за імпортами буде триматись на єдиному лінтері — є ризики що його будуть обходити. Бо це легко. І з зростанням кількості таких обходів є ризик що правило вимкнуть зовсім. Такі ризики є і з DI, але DI, як на мене, більш наявно описує взаємозв’язок між прошарками.

P.S.
Я б дуже хотів побачити лістинг проекту з імплементацією Вашої ідеї )

Ну в react чи vue3 пхати ооп не виглядає як на мене доречно. Особливо react який пропагандує фп. А ось ангуляру данне рішення непотрібне.

Так, в одну директорію фабрики класти може бути незручно у великому проекті, проте це умовний приклад. Ви можете мати не одну директорію а декілька, чи взагалі іменувати ці файли наприклад cache.factory.js який буде лежати поряд з файлом memory-cache.js

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

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

Ну якщо буде час можливо зроблю proof of concept моєї ідеї. Але не можу обіцяти)

Доречі до цих думок мене спонукнудо знайомство з zend фреймворком. Там контейнера саме і працює з подібними фабриками. Але той підхід адаптований для php і тому там є di контейнер. В js на маю думку він міг би бути зайвим.

Дозвольте спарирувати:

1. Імхо: організація бізнес-логіки застосунку повинна бути фреймворк-агностік. Якщо ваша бізнес-логіка знаходиться вся в компонентах та\або підв’язана під інструменти фреймворку, є велики різики з супроводженням проекту, не кажучи вже про неконтрольований ріст складності проекту з часом.
2. DI на ін’єкції не конкретної реалізації, а інтерфейсу, мабуть, не існує в природі. Або ж прошу жбурнути в мене посиланням на подібну реалізацію)
Одне діло якщо це агрегація классів, тоді так, ми можемо використовувати інтерфейси для отримання всіх переваг поліморфізму (саме так працює паттерн «стратегія»), втім коли ми ін’єктимо залежність в конкретний класс — ми очікуємо конкретну реалізацію.
3. Ангуляр з коробки пропонує дуже простеньку структуру яка складається з компонентів і сервісів, а все інше треба допилювати окремо. Тому як все буде організовано — лише Вам вирішувати)
4. Я катигорично не погоджуюсь що архітектура це набір правил на папері. Архітектура повинна бути закладена в коді. Якщо є прошарки — потрібно забезпечити неможливість їх некорректного застосування. Якщо є модулі — потрібно змусити створювати для них фасад і окремо його реєструвати. Архітектура додатку повинна бути максимально самодостатньою, без необхідності вичитувати мануали на конфлюєнсі. Імхо)

Ухх... коли згадали за zend я аж прослизився)
згадалось прям

1. Ну це залежить від об’єму бізнес логіки. Доволі часто більша частина логіки осідає на беку, а не на фронті.

2. Можливо ми один одного не зрозуміли і думаємо про те саме? І чого це коли ми ін’єктуемо залежність у конкретний клас ми очікуємо конкретну реалізацію? Ми навпаки для зменшення coupling очікуємо абстрактну реалізацію яка відповідає інтерфейсу. Клас клієнт не повинен знати який саме логер був переданий конструктор. Бо на проді релік, а на локалці console.log.

3. Ну тут ні з чим сперечатись) я лиш проте що в ангулярі всі приклади di завжди на рівні ін’єкцій конкретних класів, а не абстракцій.

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

А я кожен день цей zend бачу))))

1. Згоден. Втім ООП в своєму розвитку пішло на дві голови далі функціонального підходу (не кажучи вже про процедурний підхід). Може це і смаковщина, втім на мій погляд функціональний підхі дуже розробника обмежує в побудові хоч скільки небуть масштабних рішень... я б мабуть його використовував лише для MVP... і то не всякого)

2. Тому що DI як інструмент предназначений для імпорту конкретної реалізації. Відразу з декількох причин:
— Якщо ми знаходимось в классі, умовно ProductController і ми хочемо реалізувати метод SaveProduct, тоді нам потрібно в конструктор класса проін’єктити залежність ProductMapper з транспортного шару. І ми не можемо ін’єктити умовний IMapper, тому що ми маємо бути впевнені що використовуємо. Для цього DI створюється в першу чергу. Втім якщо хочется посилатись на абстракцію — можна ін’єктити не за типом, а за ключем, тоді тип можна вказати будь-який. Для прикладу constructor(@Prop(’logger’) logger: ILogger). Але логгер доведетсья сетити в DIContainer самостійно.
— Ми можемо приймати одразу перелік необхідних классів з однієї абстракції. Для прикладу ми можемо приймати ProductMapper і UserMapper в одному ProductController. Якщо їх по-замовчуванню позначати як IMapper тоді явно виникне конфлікт...
— По-замовчуванню DI намагається по типу розрулити що саме ін’єктити. В статті є приклад, який показує цей процесс. Тобто якщо скормити DI не конкретний тип, а абстракцію — він просто не буде знати який тип створювати для контейнера. Як я писав вище, для таких випадків можна юзати ключі)

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

Трішки заздрю)
в PHP фреймворки хоч якусь структуру проекта задають. На клієнті це просто жах. Тупо mvvm паттерн + якесь flux-based solution і все. А потім кажуть що фронтенд не надійний((

1. Ну слід зауважити що Олександр Соловйов з casta кожен раз згадує ООП з наголосом наскільки ж він його не любить і який він доволі ний шо переписав усе на clojure. Проте згоден, що це смаковщтга. Але ж мені особисто на реакті який повністю на фп бачити виклики класів незвично і викликає диссонанс) навіть якщо це AbortController

2. Ось тут погодитись не можу. Так, на DI можна робити ін’єкції конкретних класів але в такому випадку ви втрачаєте low coupling. І я навіть сенсу використовувати DI тут не бачу. Кожен java, .net, php девелопер знає що треба будувати застосунок зв’язуючись на інтерфейси. Для цього в symfony, spring, asp.net в користуються байндінги. Ми створюємо SaveProductInterface. Потім в конфігурації апи зв’язуємо цей інтерфейс з реалізацією наприклад SaveProductMySql на рівні конфігурацій. Це по-перше дозволяє нам для тестів писати stubs. Тобто в тестах підставити свою реалізацію SaveProductStub замість того, щоб мокати магією типу jest.mock. а по-друге ми можемо наприклад перейти на нову реалізацію SaveProduct лишивши стару на певний час. Ну або як з логером який я наводив вижче: мати NewRelicLogger для проду та ConsoleLogger на локалці. А весь клієнтський код спирається на LoggerInterface.

Доречі на тому ж ангулярі це можна реалізувати. Якщо не помиляюсь приблизно ось так:
constructor(@inject(TYPES.Logger) private logger: ILogger)

Що виглядає не дуже елегантно через те, що інтерфейсів в рантаймі не існує.

В php це виглядало бось так:
__construct(private LoggerInterface $lоgger)
Ну і байндінги у yml файлі конфігурації. Один для проду інший для локальної розробки.

Ps. Коли немає інтерфейсів писати про low coupling недоречно, бо там його нема)))

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

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

Так що з фронтом не все так і погано)

2. Ага, Ви продублювали мою строку з конструктором )
Замість типу передали як значення для створення інстанса — ключ. Тобто при створенні інстанса залежнсоті ви будете руками присвоювати відповідність цього інстанса і ключа. Що б робити це автоматично — потрібно посилатись на конкретну реалізацію)
І так як в 99% в додатку немає сильно розгалудженої системи залежностей, тому автоматичне створення оних є виправданим)

4. У випадку з DI це буде хоча б болісно, а на ревью палевно. Я кажу не про униможлпивнення, а про зменшення ризиків)

2. В тому то й суть. Коли ви посилаєтесь на конкретну реалізацію ви створюєте high coupling і ні про який low coupling речі ніякої не йде. З таким успіхом можна просто архітектуру на сінглтонах побудувати і не мати з цим проблем бо основна проблема сінглтонів як раз у тому, що неможливо адекватно підмінити реалізацію в майбутньому без повного переписування модулів. Тобто гнучкість angular з DI на класах абсолютно еквівалента гнучкості react з прямим використанням модулів. Яка різниця отримувати через імпорти чи через аргументи конструктора? Ви всеодно зав’язані на реалізації хардкодно.

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

Варто звернути увагу на InversifyJS, який хоч і складніший на початку, але дає більше контролю над життєвим циклом залежностей. Це особливо корисно в складних проєктах. Окрім того, він ще має більшу популярність за інші два npm-compare.com/...​inversify,tsyringe,typedi
Я розумію, що це складно може бути для команди, яка ніколи цього не робила, але якщо DI вам зайде, то з часом вам захочеться більше.

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

Хмм.. хотів би побачити реалізацію подібного)
Якщо це пет проект — жбурніть в мене посиланням, будь-ласка )

не можу, це мій робочий проект. А що саме цікавить? Якщо про відключення модулів — насправді, це тільки у планах, я мігрую проект з Xamarin Forms -> React Native. І до цього ще дуже далеко, мінімум 6 місяців. Але архітектура під це робиться, або, як мінімум, планується.

Усі фіча модулі у yarn workspaces, але там поки що не все ідеально через «фічу на вчора». У мене багато зав’язано на кастомні транформери під конкретний конфіг (saas для дашборда, мобільної апки, та окремо є ще можливість зробити конкретно під замовника, якщо хоче, saas ще не готовий, тільки у розробці) — тому головний упор на автоматизацію трансформації нативних проектів та застосування кастомних конфігурацій, які початково базуються на деревовидних json файлах.

Цікавить яким чином з конфігу будуть вимикатись модулі. Тобто я ± розумію принцип відключення, але... для прикладу є модуль умовно «продукт», якщо його вимкнути тоді зникнуть сторінки «продукт», «каталог» і інші. Весь обслуговуючий фунціонал буде не потрібен, тому це також ок. Але як бути з модулями які містили модуль «продукт» як залежність?
Потрібно або і їх автоматично відключати, що певно не дуже хочется, або розділити інтерфейс кожного модуля по принципу NullObject що б додаток все ще міг до нього звертатись, але не отримував реальних данних. Виглядає як дуже не проста задача по менеджменту залежностей і підтримці працездатності проекту при їх відключенні...
Хоча можу припустити що під якийсь специфічний домен таке рішення буде значно легше реалізовувати. Для прикладу якщо всі модулі легко можуть буди незалежні один від одного.

То ж, підсумовуючи:
— яким чином будете забезпечувати безпечне відключення\підключення модулів?
— чи є Ваше рішення універсальним, тобто таким яке можна перевикористати на більшості проектів?

технічно запланована реалізація двох варіантів керування модуль-фічами, це фізична відсутність модуля (модуль може мати у собі кілька фіч), або керування конфігурацією, яка прилітає з сервера конкретному користувачу.
Модуль може містити декілька фіч. Щодо фізичної відсутності модуля — то він просто «вимикається» з проекту видаленням референсу на нього, а увесь проксі код, який підключає його у проект, видаляється (автоматично, враховуючи трансформації). Наприклад, якщо у нас є модуль, який відповідає за відображення карт у мобільному додатку, а його варіант інтеграції у додаток складається з наступних опцій:
— експорт menu item
— експорт сторінок для навігації
— інтеграція deep linking (aka push notifications)
— інші дрібні речі. Наприклад, є фіча emergency contacts, технічно її не потрібно вимикати фізично, бо дуже мало коду, але вона інтегрована ще у налаштування профілю та потрібно заповнити дані (і провалідувати) під час онбоардінгу нового співробітника. Також, якщо цей працівник видалить ці дані, десь за 8 годин спрацює тріггер, який буде вимагати повернути дані для продовження можливості роботи (це все опціонально, налаштовується у дашборді, для офісних працівників вимоги простіше ніж для філд воркерів). Так от у цьому випадку буде ще потрібен додатковий рівень абстракції (але глобальний) для того аби мати можливість підключити з модуля цю фічу у профіль користувача з іншого модуля, який може відключитись. Але за потреби це буде імплементовано, якщо виникне така технічна або бізнес необхідність.

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

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

Аналогічно і з багаторівневою залежністю інших модулів від нижчих модулів — тут можна погратись з абстракціями, але у мене таких потреб не виникало, на даний момент. Загалом, я поки що про це не думаю, бо головна проблема була з xamarin forms, що було багато різних кліентів з різним набором фіч для кожного, і розмір додатку на андроіді доходив до 100+ мегабайт фінального пакету зі стору, багато часу доводилось оптимізувати все підряд.
З react native поки що запланований feature toggle на клієнті на базі статичної та дінамічної конфігурації з бекенду. Але код вже пишеться умовно «модульно» з урахуванням потенційних змін у майбутньому, плюс з такими речами простіше працювати, мерджити, перевіряти PR, писати тести тощо.

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

Дуже дякую за розгорнуту відповідь!)

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

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

Ще раз дякую за відповідь))

Що це за залежність стору від компонентів?

Хмм

В голові були думки про потік данних, коли малював діаграму... але Ви праві, потрібно буде ту стрілочку розвернути трішки XD

Дякую за статтю! Не вистачає в кінці прикладу з використання готових рішеннь. Перечитайте секцію

Тестування

там текст продубльований

Прибрав дублювання, дякую що помітили)

Хмм.. можливо на вихідних додам в кінці таких прикладів, дякую за пораду)

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