Галоўная / Артыкулы / Чаму мапавання TypeScript на адной заснованай на рэфлексіі пагубна для працяздатнасці V8

Чаму мапавання TypeScript на адной заснованай на рэфлексіі пагубна для працяздатнасці V8

Пасловуе, як скрытыя класы V8 і кэшы унутранага выкарыстоўвання паслабляюцься пад час мапавання об’ектаў на адной заснованай на рэфлексіі, і як функцыі мономорфнага типу, скомпільаваныя за дапамою 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. Мегаморфныя вбудованы кэшы (IC)

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, ніколі не выконваецца пад час запыту.

Тэсты на выконання: 4-вымерныя паказнікі

Наступныя рэзультаты атрыманы пасля 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() дазваляе запускаць кампілюванне коду ў часе выконання разам з перакантрольваннем на адным кроку працы безпосередна на рэвею:

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);
  }
}

Саўместнае працаванне з сістэмай выконання

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

fast-class-transformer ўжо є адкрытым кодам. Командам, якіе запускаюць сервісы Node.js або Bun з высокай праўодзямасцю, рэкамендуецца прыступіць да яго викорыстання ў средовышчы тэставання і параваць значэнні показніка п99 latency да і пасля.

Як прыдзягнення якісна, так і некальканыя: проблемы, даныя аналізу працэўным процесам і запросы да змены коду ўсе прыемліваюцца. Канстантны код проекту знаходзіцца на mohit07dec/fast-class-transformer, а выданы пакет можна знайсці пад назвой fast-class-transformer у реестры npm.

Супаўязаныя матэрыялы

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