Головна / Статті / Чому мапування TypeScript на основі рефлексії погіршує продуктивність V8

Чому мапування TypeScript на основі рефлексії погіршує продуктивність V8

Пояснює, як приховані класи V8 та кеші типу inline погіршують свою продуктивність під час мапування об’єктів на основі рефлексії, та як мономорфні функції, скомпільовані за допомогою JIT, відновлюють швидкість у API NestJS.

1402 слів

Якщо ви запускаєте бекенд-сервіси на TypeScript з високою пропускною здатністю, існує велика ймовірність того, що ви вже стикалися з проблемою, яка тихо «з’їдає» ресурси CPU: серіалізація та верифікація об’єктів.

Незалежно від того, чи використовуєте ви NestJS, Express, Fastify, чи просто перетворюєте відповіді API у об’єкти з типами всередині фронтенду на React чи Angular, конвертація сирого JSON у екземпляри класів з типами може значно сповільнити роботу циклу подій.

Ця проблема особливо помітна в NestJS, де стандартний механізм верифікації проходить ваш дані через два окремі кроки, засновані на рефлексії: спочатку class-transformer перетворює сирі дані у екземпляр DTO, а потім class-validator ще раз перевіряє цей екземпляр згідно з вашими правилами.

Щоб вирішити цю проблему загалом, була створена нова бібліотека під назвою fast-class-transformer. Вона не має жодних залежностей та використовує JIT-компіляцію замість рефлексії, пропонуючи прискорення у 32 рази або більше завдяки перетворенню метаданих мапування на статичні, мономорфні функції JavaScript, які V8 може інтенсивно оптимізувати.

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

Основна проблема: як оптимізація V8 порушується

Щоб зрозуміти, чому бібліотеки на кшталт початкового class-transformer мають проблеми з продуктивністю, корисно зрозуміти, як двигун V8 — середовище виконання JavaScript, яке лежить в основі Node.js та Bun — компілює та оптимізує код під час його виконання.

1. Приховані класи (форми) та режим словника

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

Розгляньмо визначення класу наступного типу:

class User {
  id: number;
  name: string;
}

V8 припускає, що властивості будуть встановлюватися у фіксованій, передбачуваній послідовності — спочатку id, потім name тощо.

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

// Inside standard class-transformer loop:
for (const key in plainObject) {
  instance[key] = plainObject[key]; // Dynamic key assignment
}

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

2. Мегаморфні вбудовані кеші (ICs)

V8 також використовує механізм під назвою Inline Caches для зберігання офсетів пам’яті властивостей, які він бачив раніше. Коли функція неодноразово викликається з об’єктами, які мають однакову структуру — це так званий мономорфний патерн — V8 може зберігати ці офсети та уникати зайвих операцій пошуку. Проблема полягає у тому, що традиційні бібліотеки мапування зазвичай використовують одну універсальну функцію мапування для обробки кожного DTO у вашому кодбазі. Передача цій єдиній функції багатьох різних структур об’єктів робить її Inline Cache мегаморфним. Як тільки це відбувається, V8 фактично припиняє використовувати кешування, і Node чи Bun змушені виконувати повільні, динамічні операції пошуку властивостей для кожного запиту.

Рішення JIT: компіляція мономорфного коду в режимі часу виконання

Замість того, щоб знову обробляти метадані та запускати універсальні цикли для кожного надходячого запиту, fast-class-transformer використовує інший підхід: він компілює спеціальну функцію для кожного класу під час виконання, фактично діючи як свій власний невеликий компілятор типу Just-in-Time.

Під час першої мапування певного класу відбувається наступна послідовність дій:

  1. Бібліотека читає відповідні декоратори — @Expose, @Exclude, @Type, @Transform — рівно один раз.
  2. Вона створює рядок вихідного коду JavaScript, який містить статичні, жорстко закодовані призначення властивостей, адаптовані саме під структуру цього DTO.
  3. Цей рядок перетворюється на справжню, викликану функцію за допомогою new Function().
  4. Отриману скомпільовану функцію-мапер зберігається у кеші для подальшого використання під час усіх майбутніх викликів.

Ось типовий приклад функції, яку крок JIT створює для певної структури DTO:

// Compiled JIT Mapper (V8 Optimized)
function mapUser(plain) {
  const inst = new User();
  // Static property writes preserve V8 Hidden Classes!
  inst.id = plain.id;
  inst.firstName = plain.first_name; // Rename mappings resolved at compile-time
  inst.createdAt = new Date(plain.createdAt);
  return inst;
}

Оскільки кожна створена функція прив’язана саме до однієї структури об’єкта, V8 вважає її суто мономорфною. Це дозволяє двигуну негайно її оптимізувати, виконуючи присвоєння властивостей зі швидкістю, близькою до швидкості нативного скомпільованого коду.

Перевірка за один прохід (необов’язкова інтеграція)

Для серверних додатків, зокрема NestJS, підхід JIT може йти ще далі шляхом інтеграції з class-validator. Замість того, щоб виконувати мапування та перевірку як два окремі етапи, компілятор читає ваші декоратори перевірки та вбудовує їх безпосередньо у створену функцію мапування:

// Compiled JIT Mapper with Inlined Validation checks
function mapAndValidateUser(plain) {
  const inst = new User();
  // Single-pass validation checks (Zero runtime reflection)
  if (typeof plain.first_name !== 'string') {
    throw new ValidationError('firstName must be a string');
  }
  inst.id = plain.id;
  inst.firstName = plain.first_name;
  return inst;
}

Це поєднує інстанціювання та перевірку в один етап виконання, тому цикл перевірки, заснований на рефлексії у бібліотеці class-validator, ніколи не виконується під час обробки запиту.

Тестування продуктивності: чотиривимірні показники

Наведені нижче результати отримано після 100 000 ітерацій за допомогою інструменту тестування продуктивності mitata на процесорі Intel i5-12500H з використанням середовища виконання Bun 1.3.0.

Підхід до тестування продуктивності був наступним: кожен тест виконувався за допомогою mitata на Bun 1.3.0 лише після того, як мапери, скомпільовані за допомогою JIT, вже прогрілися, тож ці числа відображають стабільну швидкість виконання, а не одноразові витрати на створення функції. Кожен результат проходив через функцію do_not_optimize(), щоб V8 не міг видалити цей код як непотрібний, а дані вхіду циклізувалися через 1 024 різних об’єктів даних, щоб двигун не міг скоротити тестування за допомогою постійного поширення значень.

Для простого мапування плоских об’єктів V8 перетворює присвоєння властивостей на статичні, мономорфні операції запису, які виконуються приблизно за 17 наносекунд — що на 134 рази швидше. Мапування вкладених масивів відбувається приблизно у 186 разів швидше, а поєднана вбудована перевірка працює приблизно у 64 рази швидше, ніж при звичайному способі перевірки.

Універсальна інтеграція (як використовувати)

fast-class-transformer розроблений як заміна для бібліотеки, яку ви вже використовуєте, і його можна встановити у будь-який проєкт Node.js чи Bun, як на бекенді, так і на фронтенді:

npm install fast-class-transformer

1. Загальне використання у Node.js / Express / Fastify / фронтенді

Замініть свої існуючі імпорти, і ви зможете негайно почати мапування даних — API, засноване на декораторах, відповідає конвенціям, з якими ви вже знайомі:

import { Expose, Type, plainToInstance } from 'fast-class-transformer';
class Profile {
  @Expose() bio!: string;
}
class User {
  @Expose() id!: number;
  @Expose() @Type(() => Profile) profile!: Profile;
}
const user = plainToInstance(User, rawPayload);

2. Оптимізація специфічно для NestJS

Для кінцевих точок контролерів NestJS конкретно декоратор @FastMap() дозволяє активувати мапування, скомпільоване за допомогою JIT, у поєднанні з перевіркою даних одним проходом, безпосередньо на рівні маршруту:

import { Controller, Post } from '@nestjs/common';
import { FastMap } from 'fast-class-transformer';
import { CreateUserDto } from './create-user.dto';
@Controller('users')
export class UsersController {
  @Post()
  async create(@FastMap() createUserDto: CreateUserDto) {
    // Fully instantiated, validated, and optimized
    return this.usersService.create(createUserDto);
  }
}

Співпраця з середовищем виконання

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

fast-class-transformer є програмним забезпеченням з відкритим кодом. Командам, які запускають сервіси Node.js або Bun із високою пропускною здатністю, рекомендується протестувати його в тестовому середовищі та порівняти показники затримки p99 до та після використання.

Проблеми, дані про профілювання продукції та запити на зміни є корисними внесками. Вихідний код проекту знаходиться за адресою mohit07dec/fast-class-transformer, а опублікований пакет можна знайти у реєстрі npm під назвою fast-class-transformer.

Пов’язана література

  • Inside NestJS Interceptors: Fixing a 96% Latency Regression at Scale — Дізнайтеся, як механізм виконання AOP у NestJS та проблеми під час розбирання об’єктів у RxJS спричинили різкий зростання значення P99, та як створити інтерцептор для перевірки без використання додаткових ресурсів для його усунення.
  • Чому NestJS є кращим вибором для команд та кодових баз, які розвиваються — Дізнайтеся, як структура NestJS, вставка залежностей та підхід, орієнтований на TypeScript, допомагають інженерним командам розширюватися, не впадаючи у хаос.
  • Шість правил DDD для структурування доменів у застосунках NestJS — Ознайомтесь із шістьма практичними правилами проектування, орієнтованого на домен, для організації модулів, ентитетів та подій у NestJS, щоб функції залишалися ізольованими та легкими для підтримки.