Для чого моделі в фронтенді, або куди подіти весь той код з helpers/utils

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

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

Трішки сюрреалістичний приклад, чи не так? Але саме це більшість розробників front-end додатків роблять з даними. Згадайте самі, я впевнений на 99% що в кожного на поточному чи минулому проекті всі дані зберігались в умовному «store», а функції по обробці цих даних були розмазані тонким прошарком по проекту. Але здебільшого в якійсь папочці ’helpers’ \ ’utils’ \ ’logic’ \ ’handlers’ \ ’services’ (необхідне підкреслити).

Це призводить до наступних проблем:

  • Дублювання коду. Через велику кількість глобальних функцій, які експортуються без будь-якого контексту, дуже важко знайти необхідний шматок функціоналу. Через що, найчастіше, навіть якщо функція вже десь реалізована, розробник просто напише її знову. При чому, не рідко в тому ж файлі, просто на декілька сотень рядків нижче.
  • Складність використання через відсутність інкапсуляції. Звучить, звісно, смішно. Типу: «Яка там складність, це ж глобально експортовані функції. Просто бери і використовуй». Але ми говоримо навіть не про десятки, а про сотні, можливо ще більше, глобальних функцій. І це ми ще не беремо до уваги те, що можна імпортувати з фреймворку. Для більш зрозумілого опису проблеми можемо ще раз повернутись до матеріального світу і розглянути робочий стіл якогось майстра з ремонту телевізорів:

    Уявіть звичайний стіл, 1 на 2 метри. І на ньому лежать всі необхідні інструменти: болтики, гвинтики, цвяхи і все інше. Спершу здається, що зручно, адже «що може бути простіше?», втім якщо задуматись, то одразу розумієш, що знайти щось на цьому столі буде вкрай складно. Просто бо коли під рукою є все одразу, це називається безладом. Ти не можеш спиратись на якусь систему для пошуку необхідного інструменту чи розхідників, тобі потрібно тримати в голові, де конкретно на столі ти поклав кожну окрему дрібницю.

    З цього випливає, як наслідок, наступний пункт:

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

Список цей не повний, і його можна продовжувати ще дуже довго. Достатньо хоча б відкрити поняття основних принципів ООП, GRASP патернів, і можно доповнювати кожним включним пунктом.

Тепер, коли ми розібрали проблему в теорії, давайте розглянемо і практичний приклад, для того щоб потім наглядно й показати як працюють моделі. Тож, розглянемо приклад з найбільш розповсюдженого домену: e-commerce.

Скажімо, у вас є продукт (єдиний), який Ви продаєте. Іноді Ви ставите знижку на лімітовану кількість цього продукту щоб заманити покупців. Поки що давайте на цьому зупинимось і розглянемо як у дефолтному застосунку буде організована подібна логіка:

Створимо сутність продукту:

interface Product {
  id: number;
  price: number;
  name: string;
}

Як раніше було вказано в прикладі, логіку обрахунку знижки будемо задавати в хелпері:

function getPriceWithDiscount(price: number, discount: number): number {
  return Number((price * (1 - discount / 100)).toFixed(2));
}

В заключення, необхідно створити компонент для відображення продукту (буду використовувати синтаксис vue-подібний, але він не надто відрізняється структурно від інших front-end фреймворків):

<template>
  <div class="name">{{ product.name }}</div>
  <div class="price">{{ price }}</div>
  <div v-if="discount" class="discount">-{{ discount }}%</div>
</template>

<script setup lang="ts">
import { getPriceWithDiscount } from '~/helpers';

const product = await (await fetch('/api/product')).json();
const discount: number | undefined = await (await fetch('/api/discount')).json();

const price = computed(() =>
  discount ? getPriceWithDiscount(product.price, discount) : product.price,
);
</script>

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

Ні, відповідь проста — моделі!

Моделі як рішення

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

Давайте розглянемо приклад на основі нашого продукту.

export class Product {
  id!: number;
  price!: number;
  name!: string;
  discount?: number;

  static fromJson(response: Record<string, any>): Product {
    const product = new Product();
    product.id = response.id;
    product.price = response.price;
    product.name = response.name;
    product.discount = response.discount;
    return product;
  }

  getPriceWithDiscount(): number {
    if (this.discount) {
      return Number((this.price * (1 - this.discount / 100)).toFixed(2));
    }
    return this.price;
  }
}

І інтегруємо його в наш компонент.

<template>
  <div class="name">{{ product.name }}</div>
  <div class="price">{{ priceWithDiscount }}</div>
  <div v-if="product.discount" class="discount">-{{ product.discount }}%</div>
</template>

<script setup lang="ts">
import { Product } from '~/models';

const productResponse = await (await fetch('/api/product')).json();
const discount: number | undefined = await (await fetch('/api/discount')).json();

const product = Product.fromJson(productResponse);
product.discount = discount;

const priceWithDiscount = computed(() => product.getPriceWithDiscount());
</script>

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

  • І чим це краще, якщо нам потрібно завжди створювати новий інстанс класу? Імпортували функцію, зараз імпортуємо клас... тільки коду стало більше.
  • Скрізь потрібно буде дублювати логіку створення цієї моделі? Вирішуючи одну проблему, створили ще дві.
  • Статична функція яка заповнює модель даними? Тобто всі поля мають бути публічними? А як же принцип «приховування»?
  • Одна модель в якій всі методи слугують для її обробки? Звучить як щось, що може стати божественним. Перенесемо 100500 функцій з хелперів в один клас, зв’яжемо їх контекстом і зробимо ще гірше.
  • Тобто, я раніше мав, умовно, функцію конвертації валют, в яку подавав прості значення. А тепер мені під кожен наступний клас який працює з валютам доведеться дублювати цю логіку? Або ж виносити назад в хелпери?

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

Варто розуміти, що на даному етапі ми матимемо помилку з Non-Pojo об’єктами. Її розглянемо вкінці статті окремо.

Створення моделі

В попередньому прикладі наведений принцип мапінгу моделей, який є найпростішим для розуміння в світі фронт-енд розробки. Втім, за межами нашого спрощеного світу, існує низка патернів, котрі допомагають організувати такий мапінг: active record, та data mapper. Вони можуть бути вами імплементовані або встановлені як бібліотеки як в класичному виконанні, так і через декоратори TS (варіанту наближенному до доктрини).

Розглянемо варіант з бібліотекою class-transformer, яка вже де-факто стандарт в NestJS. Спочатку код моделі:

import { Expose } from 'class-transformer';
export class Product {
  @Expose({ name: 'id' }) private _id!: number;
  @Expose({ name: 'product_price' }) private _price!: number;
  @Expose({ name: 'title' }) private _name!: string;
  @Expose({ name: 'discount' }) private _discount?: number;
  
  get name(): string {
    return this._name;
  }

  get price(): number {
    return this._price;
  }

  get discount(): number | undefined {
    return this._discount;
  }

  getPriceWithDiscount(): number {
    return this._discount
      ? Number((this._price * (1 - this._discount / 100)).toFixed(2))
      : this._price;
  }
}

І тепер можемо подивитись як змінився код в компоненті:

<template>
  <div class="name">{{ product.name }}</div>
  <div class="price">{{ priceWithDiscount }}</div>
  <div v-if="product.discount" class="discount">-{{ product.discount }}%</div>
</template>
<script setup lang="ts">
import { plainToInstance } from 'class-transformer';
import { Product } from '~/models';

const productData = await (await fetch('/api/product')).json();
const discount: number | undefined = await (await fetch('/api/discount')).json();

const product = plainToInstance(Product, { ...productData, discount }, {
  excludeExtraneousValues: true,
});

const priceWithDiscount = computed(() => product.getPriceWithDiscount());
</script>

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

Для того щоб наданий приклад працював потрібно в конфігурації до проекту задати experimentalDecorators та emitDecoratorMetadata зі значеннями true. А також проімпортити метадату в індексному файлі проекту import ’reflect-metadata’

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

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

Рознесення логіки по прошарках

Тож, як інтегрувати моделі в додаток, щоб стало зручно ними користуватись? Найперше, нам потрібно прийняти правило в додатку: ми маємо працювати лише з моделями. Тобто, якщо дані вже потрапили в додаток (стор, або компонент), вони мають бути у вигляді моделей. Забезпечити це можна створенням транспортного прошарку. Цей прошарок візьме на себе формування інтерфейсу через який ми будемо посилати запити до API, тобто він буде єдиною точкою звідки ми будемо отримувати дані (але за необхідності його можна масштабувати і на БД, і на декілька API).

Другою важливою відповідальністю даного прошарку буде створення моделей на основі респонсів. Це може звучати як порушення принципу Single Responsibility, втім все залежить від реалізації. Давайте поки розглянемо найпростіший варіант:

- app
-- assets
-- components
-- layouts
-- pages
- dal // <- data access layer (наш transport layer)
-- product // <- файл в якому буде відбуватись формування контракту і мапінг моделі
- helpers // <- залишається, але як тимчасове рішення, поки все не переїде в моделі. В рамках нашого прикладу можна було і видалити <cu-emoji-element-dynamic emoji="1f642" name="Slightly Smiling Face"></cu-emoji-element-dynamic>

Тепер щодо опису файлу dal/product :

import { plainToInstance } from 'class-transformer';
import { Product } from '~/models';

export class ProductApi {

  async getProduct(): Promise<Product> {
    const [productRes, discountRes] = await Promise.all([
      fetch('/api/product'),
      fetch('/api/discount'),
    ]);

    const product = await productRes.json();
    const discount: number | undefined = await discountRes.json();
    
    return plainToInstance(Product, { ...product, discount }, {
      excludeExtraneousValues: true,
    });
  }
}

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

<template>
  <div class="name">{{ product.name }}</div>
  <div class="price">{{ priceWithDiscount }}</div>
  <div v-if="product.discount" class="discount">-{{ product.discount }}%</div>
</template>

<script setup lang="ts">
const { data: product } = await useAsyncData(
  'product',
  () => useNuxtApp().$api.product.getProduct(),
);

const priceWithDiscount = computed(() => product.value.getPriceWithDiscount());
</script>

Результат вже очевидний:

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

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

Типи моделей

Є два типи моделей: багаті і бідні (або анемічні).

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

Анемічна (бідна) модель — це модель, яка не має методів. Всі методи виносяться в сервіси, котрі створюються за необхідності в певних контекстах. Спільний функціонал сервісів можна дженералізувати. Таким чином основна модель стає ультратонкою.

Приклад анемічної моделі (трішки зміненої від класичної реалізації, пояснення нижче в анотації):

Фаулер (критик цього підходу) справедливо називає його «антипатерном» через втрату інкапсуляції і приховування. Втім, і я можу виділити низку доволі болючих сайд-ефектів, серед яких: велика кількість сервісів які будуть ускладнювати підтримку та незвичність підходу, яка буде додатковою планкою в порозі входу на проект.

Сервіси для анемічних моделей часто робляться stateless (тобто не агрегуючи модель продукта), посилаючись на книгу Еріка Еванса «Domain-Driven Design». В цього дійсно є багата аргументація, втім в TS моделі передаються за посиланням, тому ми можемо трішки спростити собі життя і покращити інтерфейс, якщо не будемо тримати сервіси як, умовно, «сінглтон-інстанси». Тим паче, що мізерна кількість проектів в фронтенді працюють по DDD, повністю реалізуючи пропоновану структуру. pss.. txt me if u do ))

Втім, рішення, звісно, за вами :)

Value-object

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

Для таких речей ще в кінці минулого тисячоліття (але і досі не набули масового поширення на фронтенді) придумали value-object. Це, по суті, та сама модель, але для примітивного значення. Тобто, ми створюємо клас, який буде «обгортати» примітив (рядок, число), але надавати більше функціональності, яка необхідна для конкретного логічного сенсу, яким є цей рядок або число.

Розглянемо на практиці, створивши value-object для ціни і знижки з нашого прикладу:

export type Currency = 'USD' | 'EUR';

 // у реальному проекті курс приходить з API / конфігурації
const USD_TO_EUR_RATE = 0.92;

export class Money {
  constructor(
    public readonly amount: number,
    public readonly currency: Currency = 'USD',
  ) {}

  toEur(): Money {
    // якщо вже EUR — повертаємо той самий об'єкт, без повторної конвертації
    if (this.currency === 'EUR') {
      return this;
    }

    return new Money(
      Number((this.amount * USD_TO_EUR_RATE).toFixed(2)),
      'EUR',
    );
  }
}

І інтегруємо в нашу модель:

import { Expose, Transform } from 'class-transformer';
import { Money } from './money.vo';
export class Product {
  @Expose({ name: 'id' }) private _id!: number;
  @Expose({ name: 'title' }) private _name!: string;
  
  @Expose({ name: 'product_price' })
  @Transform(({ value }) => new Money(value))
  private _price!: Money;

  @Expose({ name: 'discount' })
  @Transform(({ value }) => new Money(value))
  private _discount?: number;

  get name(): string {
    return this._name;
  }

  get price(): Money {
    return this._price;
  }

  get discount(): number | undefined {
    return this._discount;
  }

  getPriceWithDiscount(): Money {
    if (!this._discount) {
      return this._price;
    }

    return new Money(
      Number((this._price.amount * (1 - this._discount.value / 100)).toFixed(2)),
      this._price.currency,
    );
  }
}

Тепер подивимось, як це можна було б використати в компоненті:

<template>
  <div class="name">{{ product.name }}</div>
  <div class="price">{{ priceInEur }}</div>
  <div v-if="product.discount" class="discount">-{{ product.discount }}%</div>
</template>

<script setup lang="ts">
const { data: product } = await useAsyncData(
  'product',
  () => useNuxtApp().$api.product.getProduct(),
);

const priceInEur = computed(
  () => product.value.getPriceWithDiscount().toEur().amount,
);
</script>

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

Проблеми з сучасними фреймворками

Тож, якщо Вам сподобалось рішення, Ви, ймовірно, захочете спробувати застосувати його в себе на проекті. Його буде досить легко імплементувати, якщо Ви працюєте тільки в браузері (тобто у вас немає SSR). Якщо ж Ви розробляєте на Nuxt (Vue) або Next (React), або будь-яких інших SSR фреймворках, тоді у мене для вас погані новини: доведеться ще трішки почаклувати над інфраструктурою.

Проблема, з якою доведеться зіштовхнутись, звучатиме приблизно так: Cannot stringify arbitrary non-POJOs. В перекладі на людську мову воно означатиме щось на кшталт “Не можу серіалізувати інстанс класу!“. Це відбувається через серіалізацію даних при гідрації.

Тобто, як Ви знаєте, коли на бек-енді фреймворк підготовлює дані і рендерить компоненти, він це все зберігає ізольовано в рамках кожного окремого запиту до завершення рендеру всіх необхідних компонентів. Далі він передає компоненти у вигляді html-темплейтів в body на сторону клієнта, з яких вже, за допомогою JS, збирає додаток. Але, щоб зібрати все докупи, окрім темплейтів ще потрібні дані, які валялись в сторі або десь в глобальній змінній. Такі дані також передаються разом з html-темплейтами в тегу script десь вкінці body. І, щоб такі дані коректно передавались, вони мають бути простими, тобто такими, які можна перетворити в рядок тексту і назад (серіалізувати\десеріалізувати) без втрати даних. Тобто, примітивні типи + звичайні об’єкти JS. Все, що більше може викликати подібну помилку.

Тож як з цим боротись? Більшість сучасних фреймворк пропонує хуки (або інші рішення), які дозволяють втручатись в процеси серіалізації\десеріалізації при гідрації. Ваша задача:

  • Використовуючи хук серіалізації:
  1. Визначити, що об’єкт є інстаном класу (це доволі легко, особливо якщо всі моделі наслідуються від однієї абстрактної)
  2. Використовуючи class-transformer або GoF паттерн Prototype створити з вашої складної моделі структуру.
  3. Обгорнути структуру в обгортку, яка буде містити метадані для відновлення моделі (до прикладу, назву самої моделі)
  • Використовуючи хук десеріалізації:
  1. Визначити, що десеріалізуєте обгортку, яку самі створювали
  2. Використовуючи метадані і бібліотеку class-transformer або дещо інвертовану версію паттерна Prototype, створити інстанс класу і повернути його.

Як бачите, алгоритм досить простий. Настільки, що навіть не малював до нього діаграму.

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

Базовий клас моделі:

export interface Serializable {
  toJSON(): Record<string, unknown>;
}
export interface Prototype<T> {
  clone(): T;
}
export abstract class Model implements Prototype<Model>, Serializable {
  static readonly typeName: string;
  abstract clone(): Model;
  abstract toJSON(): Record<string, unknown>;

  toHydrate(): Record<string, unknown> {
    const ctor = this.constructor as typeof Model;
    return { __type: ctor.typeName, ...this.toJSON() };
  }

  static fromHydrate<T extends Model>(
    this: new (...args: never[]) => T,
    data: Record<string, unknown>,
  ): T {
    const { __type, ...rest } = data;
    return Object.assign(Object.create(this.prototype), rest) as T;
  }
}

Функція для реєстрації скриптів серіалізації\десеріалізації при гідрації:

import { getHydratables } from './registry'
interface HydratableStatic {
  fromHydrate(data: Record<string, unknown>): unknown
}

interface HydratableInstance {
  toHydrate(): Record<string, unknown>
}

export function registerHydrationPayload(): void {
  for (const [name, Ctor] of getHydratables()) {
    definePayloadReducer(name, (data: unknown) => {
      if (
        data !== null
        && typeof data === 'object'
        && (data as object).constructor === Ctor
        && typeof (data as HydratableInstance).toHydrate === 'function'
      ) {
        return (data as HydratableInstance).toHydrate()
      }
      return undefined
    })
    definePayloadReviver(name, (data: unknown) => {
      return (Ctor as unknown as HydratableStatic).fromHydrate(data as Record<string, unknown>)
    })
  }
}

Декоратори для більш зручного позначення моделей і value-object:

import type { Model } from '../model'
import type { ValueObject } from '../value-object'
export type HydratableInstance = Model | ValueObject
export type HydratableCtor = new (...args: never[]) => HydratableInstance
const registry = new Map<string, HydratableCtor>()

// Декоратор класу для реєстрації моделей і велью-обджектів
export function Hydratable(name: string) {
  return <T extends HydratableCtor>(target: T): T => {
    (target as unknown as { typeName: string }).typeName = name;
    registry.set(name, target);
    return target;
  };
}

// Геттер для реєстру
export function getHydratables(): ReadonlyMap<string, HydratableCtor> {
  return registry
}

Реєстрація в застосунку через глобальний плагін:

// імпорт всіх моделей - тимчасове рішення для гарантії що @Hydratable відпрацював для всіх моделей до виклику registerHydrationPayload
import '~~/core/architecture/value-objects/date.vo'
import '~~/core/architecture/value-objects/list-value.vo'
import '../logic/models/task-progress.model'
import '../logic/models/null-task-progress.model'
import '../logic/models/item.model'
import '../logic/models/case.model'
import '../logic/models/money-pack.model'
import '../logic/models/task.model'
import '../logic/models/pending-prize.model'
import '../logic/models/mission.model'
import { registerHydrationPayload } from '~~/core/architecture/hydration/nuxt-payload'
export default definePayloadPlugin(() => {
  registerHydrationPayload()
})

Висновок

Моделі для фронт-енд додатків — одна з найбільш універсальних і ефективних (з точки зору затраченого часу і отриманого результату) речей. Вона допоможе організувати, з мого досвіду, >80% всього, що є в папочках helpers/utils/handlers на більшості проектів. Разом з тим, моделі достатньо гнучкі, щоб адаптуватись під проект будь-якого розміру та домену. А сучасні фреймворки дають можливості для опрацювання деяких природніх обмежень нашого середовища розробки)

Дуже сподіваюсь, що така практика буде приживатись в нашій ніші. Якщо є будь-які питання — велком в коменти, або в ЛС в LinkedIn )

👍ПодобаєтьсяСподобалось10
До обраногоВ обраному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

Хороший структурований підхід до організації стану клієнтського застосунку.

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

У цьому реченні може бути пастка. Гарантія яку ніхто не забезпечує. Джерело з якого приходять ці дані не контролюється клієнтським кодом, і будь-яка помилка, або некоректна відповідь від сервера, і весь UI поводитиметься непередбачувано.

plainToInstance
 не валідує. excludeExtraneousValues лише відкине те, чого немає в @Expose. Бек перейменував product_price на price — і ви маєте цілком «валідний» інстанс Product, у якого _price === undefined, а getPriceWithDiscount() повертає NaN. І ось тут найцікавіше: інкапсуляція віддаляє помилку від її причини. З хелпером ви бачили getPriceWithDiscount(product.price, discount), аргументи прямо на місці виклику. З моделлю ви бачите product.getPriceWithDiscount(), а некоректне значення записалось трьома рівнями вище без жодного сигналу. Тобто чим сильніша інкапсуляція, тим жорсткішим має бути кордон, на якому модель народжується: моделі не знімають потребу у валідації, вони її підвищують.

Далі дисклеймер: це мій власний інструмент, тому читайте зі скепсисом. Я роблю StitchAPI (Apache-2.0, github.com/rejifald/StitchAPI) — інструмент для виклику зовнішніх API так, ніби це локальна функція, тобто рівно та частина, яку стаття лишає на читача. Той самий DAL:

import { drift, stitch } from 'stitchapi';

const ProductDto = z.object({ id: z.number(), title: z.string(), product_price: z.number() });

const getProduct = stitch({
    baseUrl: '/api', path: '/product',
    output: drift(ProductDto), retry: 2, timeout: '3s', cache: '30s',
});
Перейменований product_price кине помилку одразу під час виклику, а не NaN у темплейті. Додане бекендом поле прилетить drift-подією — про зміну контракту ви дізнаєтесь раніше за саппорт. Ретраї, таймаут і кеш стають конфігом, а не кодом у DAL. А new Product(...) за побудовою не може отримати сміття, бо на вхід іде вже валідований DTO.

Проблему non-POJO при гідрації це не вирішує. Хіба що зменшує: якщо в payload приходить валідований plain DTO, а модель збирається з нього вже в сторі, то reducer/reviver не потрібні — але тоді інваріант «скрізь тільки моделі» слабшає.

Дякую за статтю.

Дуже вдячний за комент))

Щодо першого пункту. Звісно я не розписував весь необхідний супроводжувальний функціонал. Зробив зноску лише що данний приклад є «дуже спрощенним», бо реальні кейси занад-то роздували б код в прикладах, і всю схему загалом. А це значно зміщували б увагу з того що я хочу показати, на валідацію, обробку помилок і т.д) (і, звісно, обробити виклик будь-чого з кліенту варто через try/catch, з показом помилки користувачу + логуванням)

Тим не менш, думка про те що додаткові інструменти, такі як class-transformerможуть ховати помилки дійсно має місце. Але бібліотеки, зазвичай, дають можливість це обробити, і згенерувати валідну помилку.. щонайменше можна валідацію завести окремо) (тут можу додати що найбільш канонічним саме з class-transformer буде використання class-validatorєдиний мінус який бачу — опис полів великої моделі буде займати 2 екрани)

Щодо Вашої бібліотеки — мені надзвичайно цікаво! Я зараз шукаю щось подібне як для пет проекту, так і, можливо, для робочого. Підкажіть, а чи є в неї можливість скористатись системою маппінгу без обгортки над викликами апі? І ще питання, якщо дозволите: чи оброблялись якось випадки маппінгу складних моделей, та\або полів які мають декілька можливих варіантів маппінгу (скажімо поле provider а якійсь моделі drug може бути як shop так і hospital, і в залежності від данних в DTO треба замапити на корректну модель... мб з підтримкою дженериків?)

Повністю підтримую що це не «в скоупі» статті, просто думаю це останній пазл для цілісної картини, якого я не знайшов, і який вирішив створити. Доречі теж нещодавно написав статтю про цей інструмент dou.ua/forums/topic/60943.

Стосовно маппінгу то я зробив окрему бібліотек github.com/rejifald/bimorph. І наскільки я розумію то вам потрібно щось таке:

А стосовно stitchapi то він по працює з будь-яким Standard Schema (stitchapi.dev/...​alidation/standard-schema) валідатором, тому якщо ви опишете цей тип у форматі Standard Schema — то отримаєте провалідований і типізований результат назад.

Я зробив невеличкий playground де це можна спробувати без встановлення бібліотеки stitchapi.dev/playground

Якщо будуть питання, з радістю відповім !

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