Горшочек варит @gorshochekvarit Channel on Telegram

Горшочек варит

@gorshochekvarit


Про фронтенд и около, над чем работаю, разборы, мысли разные
Пишу для истории и тех, кому интересно как получается то, что у меня получается
// Рома Дворнов (@rdvornov)

Горшочек варит (Russian)

В поисках интересного контента о фронтенде и смежных темах? Тогда канал "Горшочек варит" (@gorshochekvarit) - это то, что вам нужно! Здесь вы найдете разборы, мысли на разные темы и узнаете, над чем работает Рома Дворнов (@rdvornov). Этот канал призван поделиться опытом и знаниями в области фронтенда, а также вдохновить тех, кому интересно узнать, как создаются интересные проекты. Присоединяйтесь к сообществу "Горшочек варит" и узнавайте первыми о новых разработках и тенденциях в мире веб-разработки!

Горшочек варит

09 Aug, 17:23


Еще один вариант, который может помочь, если упираемся в размер кучи (heap), но физической памяти достаточно — это конвертация фрагментов JSON в Uint8Array (TypedArray) сразу после их получения, и сохранение их в таком виде в массив. Это может сработать, так как TypedArray хранятся вне кучи, и всё ограничивается доступной памятью в системе (включая своп и т.д.). Правда нужно надеяться, что GC будет успевать освобождать память под временные строки (фрагменты уже сконвертированные в Uint8Array).


const encoder = new TextEncoder();
const chunks = [];

for (const chunk of stringifyChunked(data)) {
chunks.push(encoder.encode(chunk))
}

Readable.from(chunks)
.pipe(fs.createWriteStream('path/to/file.json'));

Горшочек варит

09 Aug, 14:10


Новая версия json-ext v0.6 опубликована около месяца назад, в которой реализованы ключевые изменения описанные выше. Изменения были нацелены на то, чтобы получить универсальное решение для генерации и парсинга JSON частями. Основной рефакторинг пришелся на stringifyChunked(), которая была переработана с Node.js Readable на функцию-генератор. Даже такой базовый рефакторинг дал небольшое ускорение (5-10%).

Благодаря тому, что вся логика (за исключением нескольких хелперов) и состояние стали изолированы внутри тела генератора, код стал более наглядным и удобным для рефакторинга. Последний месяц я набегами проводил эксперименты и перерабатывал stringifyChunked(), используя новые возможности организации кода. Как результат, реализация стала проще и короче (всего 191 строка), да к тому же быстрее в 1.5-3 раза. И уже в таком виде опубликована на этой неделе в версии json-ext v0.6.1.

Бонусом идет то, что код хорошо минифицируется – текущий размер stringifyChunked() всего 2141 байт (esbuild / ESM / minified), что в 3 раза меньше чем до перехода на генератор (то есть в v0.5). Весь минизированный код представлен на сриншоте выше. Впечатляет как столько сложной логики умещается в таком небольшом количестве символов. И даже начинает казаться, что всё не так уж и сложно.

Горшочек варит

09 Aug, 11:30


Наиболее производительным подходом является использование конструкции, напоминающей конечный автомат, и цикла с единственной точкой возврата значения. На каждой итерации цикла выполняется небольшая операция в соответствии с текущим состоянием, проверяется состояние буфера и, если это необходимо, возвращается значение (в нашем случае – фрагмент JSON).

Общая структура генератора представлена ниже (полный код):


export function* stringifyChunked(value, options) {
// обработка опций
// ...

// внутренее состояние
const visited = [];
const rootValue = { '': value };
let prevState = null;
let state = () => printEntry('', value);
let stateValue = rootValue;
let stateEmpty = true;
let stateKeys = [''];
let stateIndex = 0;
let buffer = '';

// основной цикл
while (true) {
state();

if (buffer.length >= highWaterMark || prevState === null) {
// единственная точка возврата значения
yield buffer;
buffer = '';

if (prevState === null) {
break;
}
}
}

// ключевые функции
function printObject() { /* ... */ }
function printArray() { /* ... */ }
function printEntryPrelude(key) { /* ... */ }
function printEntry(key, value) { /* ... */ }
function pushState() { /* ... */ }
function popState() { /* ... */ }
}

Горшочек варит

09 Aug, 11:22


Я выделяю три способа хранения состояния. При использовании классов (например, для генерации стрима) состояние хранится в свойствах экземпляра класса. В функциональном подходе состояние передается через объект между функциями. В случае генератора состояние можно хранить в переменных скоупа функции, как в обычных функциях, что упрощает реализацию. Вот как это может выглядеть:


function* traverse(value, highWaterMark = 10) {
function* push(str) {
buffer += str;

if (buffer.length >= highWaterMark) {
yield buffer;
buffer = '';
}
}

function* innerTraverse(v) {
if (v !== null && typeof v === 'object') {
yield* push('{');

let first = true;
for (const key of Object.keys(v)) {
yield* push(`${first ? '' : ','}"${key}":`);
yield* innerTraverse(v[key]); // рекурсивный вызов
first = false;
}

yield* push('}');
} else {
yield* push(JSON.stringify(v)); // для простоты
}
}

// внутренее состояние
let buffer = '';

// стартуем обход
yield* innerTraverse(value);

// возвращаем последнее значение
if (buffer !== '') {
yield buffer;
}
}

console.log([...traverse({ foo: 1, bar: { baz: 'hello world' } })]);
// [
// '{"foo":1,"bar":',
// '{"baz":"hello world"',
// '}}'
// ]


Представленный код реализует лишь небольшую часть логики, необходимой для сериализации данных в JSON. Цель примера — продемонстрировать возможный подход. Такой подход имеет место быть, но имеет недостатки: он изобилует оператором yield* и вряд ли будет эффективным. Причина кроется в накладных расходах, связанных с созданием множества итераторов, которые передают значения вверх по цепочке. Мои быстрые эксперименты показали, что производительность такого решения может быть ниже в 2-3 раза по сравнению с другим наиболее оптимальным подходом, который получилось найти на тот момент. Возможно, я еще вернусь к экспериментам с рекурсивными генераторами, но пока сомневаюсь, что такой подход может конкурировать по скорости работы.

Горшочек варит

09 Aug, 11:13


Генератор для сериализации

Многие разработчики редко сталкиваются с необходимостью написания собственного генератора. На первый взгляд, это может показаться несложной задачей. Генераторы напоминают обычные функции, но отличаются синтаксисом (звездочкой в объявлении) и использованием оператора yield. Простой пример:


function* numericSequence(n) {
for (let i = 0; i < n; i++) {
yield i + 1;
}
}

console.log([...numericSequence(5)])
// [1, 2, 3, 4, 5]


Я не буду разбирать принцип работы и все нюансы генераторов, для этого лучше изучить документацию и статьи (раз, два, три – показались интересными, я не знаю "каноничных", посоветуйте). В контексте задачи сериализации следует учесть ключевую особенность генераторов: оператор yield может использоваться только внутри самой функции-генератора. Это логично, но создаёт определённые сложности, поскольку при сериализации в JSON (или другой формат) обычно применяется рекурсивный обход объектов и вызов вспомогательных функций. Чтобы использовать yield в таких функциях, они также должны быть генераторами. Для того чтобы значения из вложенных генераторов возвращались в основной генератор, необходимо использовать yield* (yield со звездочкой).


function* traverse(value) {
yield value;

if (value !== null && typeof value === 'object') {
for (const key of Object.keys(value)) {
yield* traverse(value[key]); // рекурсивный вызов
}
}
}

console.log([...traverse({ foo: 1, bar: { baz: [2, 3] } })]);
// [
// { foo: 1, bar: { baz: [2, 3] } },
// 1,
// { baz: [ 2, 3 ] },
// [ 2, 3 ],
// 2,
// 3
// ]


В этом примере каждый вызов генератора имеет своё собственное состояние в рамках своего скоупа. Это подходит для обхода, но не для задач вроде сериализации в JSON, где требуется общее состояние, доступное на всех этапах операции. Примерами такого состояния могут быть стек посещённых объектов (для предотвращения бесконечной рекурсии) или буфер для накопления фрагмента JSON. Эти элементы составляют внутреннее состояние генератора (операции).

Подробнее о буфере. В задаче сериализации генератор возвращает фрагменты JSON в виде строк. Эти фрагменты собираются из небольших строк (иногда даже из одного символа) в разных частях кода генератора. Если не использовать буфер для накопления строк, а возвращать каждую строку отдельно, это негативно скажется на производительности. Возврат значений из генератора — операция ресурсоёмкая, и частые вызовы могут привести к замедлению работы. Размер буфера обычно регулируется параметром, аналогичным highWaterMark в стримах, который определяет минимальный размер буфера перед возвратом его содержимого. Обычно значение по умолчанию составляет 64Kb. Это настройка помогает уменьшить количество операций и оптимизировать использование памяти.

Горшочек варит

08 Aug, 16:28


[Stream].from()

Из документации Node.js Streams я уже знал про Readable.from(), который создаёт поток из iterable. А теперь зная, что ReadableStream является iterable, метод заиграл новыми красками, так как достаточно:


Readable.from(readableStream)


Я предположил, что может уже есть специализированный методы для преобразований, и такие методы нашлись: Readable.fromWeb() и Readable.toWeb(). Правда помечены методы как экспериментальные и без документации (хотя добавлены еще в Node.js 17). Но можно предположить, что добавлены они для лучшей интеграции, например, для синхронизации highWaterMark и прочего. Таким образом преобразование стримов разных классов (Web <-> Node.js) оказалось тривиальной задачей.

Хотя методы Readable.fromWeb() и Readable.toWeb() экспериментальные и доступны только в Node.js 17, это не выглядит большой проблемой. Вместо Readable.fromWeb() всегда можно использовать Readable.from(), который работает с ReadableStream. Возможно, такая трансформация будет менее эффективна в определённых сценариях по сравнению с использованием Readable.fromWeb(), но в обычных сценариях я не заметил особой разницы.

Остаётся вопрос трансформации Readable в ReadableStream, то есть про альтернативу Readable.toWeb(). Было бы здорово, если бы существовал метод ReadableStream.from(), симметричный Readable.from()... На удачу обратился к статье Web Streams API документации Node.js и не поверил своим глазам, там такой метод обнаружился (полезно обращаться к документации). Выяснилось, что метод был добавлен не так давно и доступен в Node.js 20.6, Deno и Firefox (релизы июль-сентябрь 2023го). Это, конечно, не идеальное решение, но хорошая новость в том, что полифил или специализированная функция пишется несложно, и мы к этому ещё вернёмся.

В итоге, получение стрима нужного класса не является проблемой. База решения не обязана основываться на стримах, достаточно создать iterable. Как мы уже знаем, для этой задачи отлично подходят генераторы. Таким образом, задача сводится к написанию генератора, который потом используем как есть или преобразуем в нужный класс стрима.

Горшочек варит

08 Aug, 16:08


Генерация по частям

Генерация JSON по частям казалась более сложной задачей концептуально. Изначально основой решения был класс, наследующий от Readable, с нестандартным поведением: специальная обработка Promise и инстансов Node.js Streams. Таким образом решение было сильно привязано к Node.js стримам, что требовало переработки.

В таких ситуациях я обычно начинаю с удаления всего лишнего. В данном случае лишним было нестандартное поведение, унаследованное от json-stream-stringify, так как я начинал с улучшения этой библиотеки, пока не написал всё с нуля. Мне казалось удобным, что можно делать следующее:


stringifyStream({
dataFromService: fetch('url').then(res => res.json()),
files: {
foo: fs.createReadStream('path/to/file1'),
bar: fs.createReadStream('path/to/file2')
}
})


Но любое расширение имеет свою цену: нужно учитывать новые кейсы и конфликты (новые правила), опытным путем выявлять проблемы и находить для них решения. Например, Promise в свойстве объекта, который никогда не разрешится, может вызвать зависание сериализации. При сериализации отсутствует управление приоритетами и оптимизациями, например, если добавь await перед fetch() в примере выше, то мало что поменяется – имеет ли смысл имплементировать дополнительную логику внутри самой сериализации? Со стримами сложнее: контент стрима вставлялся как есть, и это невозможно повторить как-то иначе. Но и объяснить все кейсы сложно. Например, если вложенный стрим эмитит не JSON, то stringify не гарантирует валидный JSON. Кроме того, нестандартное поведение в обработке Promise и вложенных стримов было единственной причиной асинхронности генерации (stringify). Новые правила – это всегда головная боль.

Я решил, что следование стандартному поведению JSON.stringify() удовлетворит ожидания пользователей и избавит от необходимости дополнительной документации, поэтому убрал нестандартное поведение. Не зная, что делать дальше, я начал с переписывания сериализации на Web Streams. С этим получилось справиться достаточно быстро (что-то около получаса), так как Web Streams имеют более простой API по сравнению с Node.js Streams. Затем встал вопрос, как преобразовать Web Stream в Node.js Stream. Думал будет сложно, но оказалось всё уже придумано...

Горшочек варит

31 Jul, 15:57


Последнее о чем стоит позаботиться это от "стрельбы по ногам". Передаваемые iterable могут эмитить любые значения, но в нашем случае нам подходят только строки и Uint8ArrayBuffer в Node.js, но он наследует от Uint8Array, поэтому не требует особо внимания в этом контексте). Кроме того, есть встроеные iterable значения, которые не пригодны для парсинго такие как String, TypedArray, Map и Set. Строки не подходят, так как итерируют символы, что даже будет работать, но явно не быстро и несколько странно. Если JSON не разбит на части, то смысл в parseChunked() тяряется, так как под капотом используется все тот же JSON.parse() только с оверхедом, лучше использовать нативное решение напрямую. По той же причине нет смысла особо обрабатывать TypedArray. Но чтобы не обрабатывать все возможные сценарии, достаточно проверить тип значения (чанк), которое эмитится (особый случай строки, так как символы это тоже строки).


function isIterable(value) {
return (
value !== null &&
typeof value === 'object' &&
(
typeof value[Symbol.iterator] === 'function' ||
typeof value[Symbol.asyncIterator] === 'function'
)
);
}

async function parseChunked(chunkEmitter) {
if (typeof chunkEmitter === 'function') {
chunkEmitter = chunkEmitter();
}

if (isIterable(chunkEmitter)) { // отсеивает строки, так как принимает только объекты
for await (const chunk of chunkEmitter) {
if (typeof chunk !== 'string' && !ArrayBuffer.isView(chunk)) {
throw new TypeError('Invalid chunk: Expected string, TypedArray or Buffer');
}

// выполняем парсинг
}

// return ...
}

throw new TypeError(
'Invalid chunk emitter: Expected an Iterable, AsyncIterable, generator, ' +
'async generator, or a function returning an Iterable or AsyncIterable'
);
}


Можно еще поддержать обычные итераторы (не iterable), но пока не увидел в этом необходимости.

С оберткой вокруг парсинга на этом все, можно переходить к stringify.

Горшочек варит

31 Jul, 15:36


Кажется, что дело сделано, однако осталось еще две проблемы. Первая, самая важная, что делать, если имеется асинхронное апи без реализованного iterable протокола, например, тот же ReadableStream без Symbol.asyncIterator. Можно собрать асинхронный итератор вручную:


const data = await parseChunked({
[Symbol.asyncIterator]() {
const reader = stream.getReader();

return {
next: () => reader.read(),
return() { // вызовется в случае ошибки или преждевременного прерывания итерирования
reader.releaseLock();
return { done: true }; // важно
}
}
}
});


Но выглядит это как тайные заклинания. Лучше прибегнуть к асинхроному, в данном случае, генератору – да, да, вот где они нужны! Получается на пару строк больше, но код выглядит более целостным и линейным:


const data = await parseChunked({
[Symbol.asyncIterator]: async function*() {
const reader = stream.getReader();

try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
yield value;
}
} finally {
reader.releaseLock();
}
}
});


Стало лучше, однако если присмотрется, то обертка над генератором выглядит лишней и можно просто вызвать генератор, который вернет async iterable iterator. Внимательный читатель заметит, что здесь появились рядом два слова "iterable" и "iterator", то есть 2 в одном, что-то новенькое. Хоть это и итератор, но главное он iterable и это важно. Сделано это для того, чтобы итератор работал с for ... of и другими конструкциями. А получается это путем добавления итератору метода Symbol.asyncIterator (или Symbol.iterator для синхронного итератора), который возвращает this, то есть сам итератор. Таким элегантным трюком достигается выполнения протоколов и мы можем делать следующее:


const data = await parseChunked(async function*() {
// ...
}());


В такой записи все хорошо, кроме финальных скобок, которые могут визуально теряться и вызывать конфуз. Для этого я добавил возможность передавать в parseChunked() сам генератор, и он будет вызван перед началом парсинга. Кажется, что нужна какая то особая логика для определения, что переданное значение является генератором. Но по факту генератор выглядит как функция и "крякает" как функция, поэтому нет необходимости в особой детекции. Функции не iterable, поэтому не возникает конфликтов в логике. Бонусом становится возможным передавать функцию-фабрику, которая создает или возвращает iterable.


async function parseChunked(chunkEmitter) {
if (typeof chunkEmitter === 'function') {
chunkEmitter = chunkEmitter();
}
// ...
}


Таким образом, генераторы могут служить "мостом" между асинхронным апи и асинхронным итерированием. Протокол iterable делает стримы рядовыми участниками в этой схеме.

Ввиду неполной поддержки ReadableStream[Symbol.asyncIterator], в json-ext введена функция parseFromWebStream(), которую стоит использовать вместо parseChunked() для большей совместимости. Эта оборачивает стрим в асинхронный генератор, если необходимо, и вызывает parseChunked(). В будущем необходимость в ней отпадет, но пока так.

Горшочек варит

31 Jul, 15:28


Как бы то ни было, сегодня Symbol.asyncIterator для стримов доступен почти везде. Это небольшое, казалось бы, изменение значительно меняет эргономику использования стримов. Пример того, как приходилось использовались Node.js Streams раньше (и это по прежнему работает):


function getDataFromFile(filepath) {
return new Promise((resolve, reject) => {
let result = ...;

fs.createReadStream(filepath)
.on('data', chunk => /* chunk -> result */)
.on('end', () => resolve(result))
.on('error', reject);
});
}


Теперь же можно писать более естественный для JavaScript код:


async function getDataFromFile(filepath) {
let result = ...;

for await (const chunk of fs.createReadStream(filepath)) {
// chunk -> result
}

return result;
}


Или если мы говорим про поточный парсинг JSON, то используя json-ext выглядит это так:


import { parseChunked } from '@discoveryjs/json-ext';

const data = await parseChunked(fs.createReadStream('path/to/file.json'));


Для fetch() все тоже достаточно лаконично:


const response = await fetch(url);
const data = await parseChunked(response.body); // reponse.body -> ReadableStream


Чем это отличается от await reponse.json()? Для небольших JSON ничем. Для больших JSON parsedChunked() обычно быстрее, экономичнее по памяти и позволит избежать "заморозки" процесса при парсинге больших JSON. Причина: reponse.json() дожидается полной загрузки данных, а затем парсит за один присест со всеми вытекающими проблемами. Больше деталей и нюансов (например, как сделать не залипающий прогрессбар) в докладе, что я упоминал выше.

Горшочек варит

31 Jul, 15:23


Для начала перечислим основные классы стримов:

- Node.js Streams (модуль node:stream ): Readable, Writable, Duplex (Readable & Writable) и Transform
- Web Streams: ReadableStream, WritableStream и TransformStream

Можно заметить симетрию, за исключением отсутствия DuplexStream в Web Streams. А еще можно вывести простое правило: если в имени класса есть суфикс "Stream", то это Web Stream, нет суффикса – Node.js Stream.

Для нашей темы интересны только Readable и ReadableStream соотвественно. Чтобы они стали iterable, им необходим метод Symbol.asyncIterator. Для класса Readable метод добавили еще в Node.js 10.0. C ReadableStream немного сложнее – в Node.js класс сразу появился с этим методом, а вот в браузерах поддержка стала появляться лишь недавно. Хронология занятная:

- 2015, май: Первая имплементация Web Streams в браузере, первопроходцем стал Chrome 43 – последним был Firefox в январе 2019
- 2017, август: В трекере WhatWG создан ишью по добавлению async iterable для ReadableStream по инициативе Node.js
- 2018, апрель: Выходит Node.js 10 с поддержкой Symbol.asyncIterator и for await ... of в V8, добавлен экпериментальный метод Readable[Symbol.asyncIterator]
- 2019, апрель: Завели тикеты для ReadableStream[Symbol.asyncIterator] в трекерах браузеров
- 2020, май: Релиз Deno 1.0 с полной поддержкой WebStreams, включая ReadableStream[Symbol.asyncIterator]
- 2021, июль: Релиз Node.js 16.5 с экспериментальной поддержкой Web Streams (модуль node:stream/web ), в том числе ReadableStream[Symbol.asyncIterator]
- 2022, апрель: Релиз Node.js 18.0 со стабильными Web Streams и fetch(), все в глобальном скоупе
- 2022, июль: Релиз Bun 0.1.1 с поддержкой Web Streams
- 2023, февраля: В Firefox 110 добавлен ReadableStream[Symbol.asyncIterator]
- 2024, апрель: В Chrome/Edge 124 добавлен ReadableStream[Symbol.asyncIterator]

Достаточно затянутая история, от предложения до поддержки во всех браузерах пройдет больше 7 лет (в Webkit/Safari тикет пока без движения). Интересно, что не смотря более сложное положение в "серверных" JavaScript рантаймах, полная поддержка Web Streams появилась именно в них. Задержка в браузерах связана с внедрением async iterable протокола как такого, судя по всему с этим были некоторые сложности.

Горшочек варит

31 Jul, 15:18


Парсинг по частям

В json-ext "потоковый" аналог JSON.parse() называется parseChunked(). На самом деле, эта функция могла использоваться в браузере и раньше (и активно используется в Discovery.js, например), так как ее интеграция с Node.js Streams была минимальной. Её имплементация уже базировалась на протоколе async iterable, но была отдельная ветка для Node.js Readable, которая в браузере не использовалась и в итоге была спилена.

Сегодня все так или иначе используют iterable protocol, но не всегда знают об этом. Не смотря на то, что это относительно простое и элегантное решение, не всегда очевидно как все устроенно, да я сам не так давно во всем разобрался. По сути все сводится к созданию итераторов используя методы Symbol.iterator или Symbol.asyncIterator. Разница в том, что для вызова методов второго нужно использовать await. Как следствие, для Symbol.iterator используется for ... of, для второго for await ... of. Прекрасная новость, что for await ... of также работает и для синхронного итератора: конструкция делает предпочтение в пользу Symbol.asyncIterator, но если такой метод не определен, то подойдет и Symbol.iterator. Таким образом, чтобы проитерировать чанки для парсинга достаточно следующего кода:


async function parseChunked(iterable) {
let parser = new JsonChunkParser();

for await (const chunk of iterable) {
parser.push(chunk);
}

return parser.finish();
}


Возникает вопрос: как к этому прикрутить стримы? Удивительно, но самом деле все уже работает, по крайней мере в Node.js, Deno, Bun и с недавнего времени в браузерах, за исключением Safari. Посмотрим как так получилось.

Горшочек варит

31 Jul, 15:16


Библиотека json-ext задумавалась для того, чтобы предоставить возможности работы с JSON, которых не хватает в стандартной среде выполнения. В настоящее время она предлагает "поточные" генерацию и парсинг JSON, а также функцию вычисления размера результата JSON.stringify() без генерации самого JSON. Цель – обеспечить максимально быстрые и эффективные по памяти решения для работы с большими JSON.

Изначально реализация поточных stringify() и parse() в json-ext была связана с Node.js Streams, что ограничивало сценарии использования. С развитием Web Streams в браузерах, а также поддержкой в Node.js, Deno и Bun, появилась потребность в их поддержке "из коробки". Но если смотреть дальше и немного пофантазировать, что в JavaScript однажды добавятся "поточные" stringify() и parse(), то такая гипотетическая инмлементация должна основываться не на конкретных стримах, а на протоколах самого языка. То есть получалось три разные реализации для различных вводных. Перспектива так себе, и не столько с точки зрения кода библиотеки, сколько со стороны ее пользователей: что и когда использовать, как обеспечивать работу в изоморфном коде и прочие вопросы.

Пазл был не простой, но в итоге он сложился. Идея состояла в том, чтобы создать реализацию на протоколах JavaScript, поверх которой можно было бы сделать обертку для разных типов стримов. Последнее, впрочем, не потребовалось (почти), но обо всем по порядку.

Горшочек варит

30 Jul, 07:09


JSON не оптимальный формат для больших наборов данных. Однако он прост и широко поддерживается в индустрии, встроен в некоторые среды исполнения, как JSON.parse()/stringify() в JavaScript. К тому же это своего рода протокол, регламентирующий правила сериализации и десериализации данных. Альтернативные форматы возможны, но они, на мой взгляд, должны следовать протоколу JSON, и их преимущество должно быть настолько существенным, чтобы оправдать усложнение и затраты на внедрение. Разница в размере относительно JSON должна быть в разы, а лучше в десятки раз, что вряд ли возможно добиться текстовым представлением, поэтому стоит ожидать бинарного кодирования в качестве альтернативы (и это основное усложнение). Однако доступные в экосистеме JavaScript бинарные кодировки (MessagePack, BSON, CBOR, Node.js v8.serialize; protobuf и ему подобные не учитываются, так как требует схемы, что является другим классом решений) дают в лучшем случае -20% к размеру относительно JSON. Именно с этим я связываю не столь широкое распространение бинарных альтернатив JSON, каким оно могло бы быть. Нужен другой подход, и мы еще однажды вернемся к этой теме. Пока стоит принять как данность, что JSON является основным форматом, и его размер может достигать сотен мегабайт и гигабайтов.

Основные проблемы, связанные с большим размером JSON:

- Ограничение JavaScript движков на максимальную длину строки, что определяет максимальный размер JSON для движка (JSON это строка); для V8 это около 512Мб, у других движков лимиты выше (от 1Гб), но все равно недостаточны.
- Большое потребление памяти в один момент времени; после генерации или парсинга JSON строка находится в памяти (в куче), что может приводить к превышению физического лимита памяти (например, на контейнерах с малыми бюджетами) или размера кучи (обычно 2-4Гб), что в случае V8 приводит к крэшу процесса.
- Блокирование движка для других операций; JSON.parse()/stringify() выполняются синхронно, и для больших JSON могут работать несколько секунд и даже минут. Это не большая проблема для скриптов, чья единственная задача сгенерировать или обработать JSON. Но проблема для скриптов, которые выполняют несколько операций паралельно или обрабатывают запросы, а так же для браузеров, так как эти операции замораживают закладку, лишая пользователя возможности взаимодействовать со страницей.

Решением этих проблем может быть использование поточной (stream) обработки JSON (генерация и парсинг). Встроенных в runtime решений для JSON пока нет. Библиотек тоже не так чтобы много, и они обычно неэффективны, главным образом по времени. Четыре года назад разрыв с нативными JSON.parse()/stringify() был во много десятков раз. В то время я начал оптимизировать одно из таких решений и мне удалось добиться разрыва в 1.5-3 раза относительно нативных синхронных решений, что было более приемлемо. Так появилась библиотека @discoveryjs/json-ext, и я рассказывал о ней и проблемах больших JSON в докладе JSON: Push the limits, который не потерял свою актуальность 3,5 года спустя (сам пересматривал 3-4 раза за это время).

Однако, на этом история только начинается. Чтобы двигаться дальше, нужно было переработать имплементацию json-ext, что я и сделал около месяца назад...

Горшочек варит

30 Jul, 07:09


Для задач, с которыми я часто сталкиваюсь, одной из центральных проблем является размер JSON. За последние 5-6 лет удалось нивелировать эту проблему и даже предложить решения в open source. Тем не менее, по-прежнему встречаются попытки решать проблему по-своему, что приводит к различным обходным путям, ограничениям и усложнениям. Поэтому проблема остается актуальной и стоит о ней поговорить. Существует парадокс: когда я спрашиваю других, есть ли у них проблемы с большими JSON, мне обычно отвечают, что таких проблем нет. Хотя наверняка в их рабочем процессе есть несколько таких мест, где проблема актуальна, но их почему-то не замечают, и проблемы вроде как не существует.

Когда поднимается тема больших JSON, часто спрашивают, в каких задачах это может возникать. Часто даются общие советы: большое количество данных – это плохо, декомпозируйте данные на части. То есть решение проблемы заключается в избегании проблемы. Иногда предлагают использовать базы данных или что-то подобное, но на практике это усложняет проблему, добавляя распределенность и все, что с этим связано. Это объясняется тем, что при обсуждении данных представляют обычные приложения или сайты (непосредственно продукты), и в таком случае рекомендации вполне уместны. Однако данные - это не только сами продукты, но и множество артефактов, связанных с их созданием и развитием.

Большие объемы данных обычно возникают в инструментах разработки и различных частях инфраструктуры. Например, описание результата сборки (stats.json в webpack), результаты выполнения тестов, различные профили (cpu/performance profile), снепшоты (memory heap snapshot) и т.д. Для малых и средних проектов размер данных может быть умеренным, но на больших проектах данные могут достигать сотен мегабайт и гигабайтов в представлении JSON. Например, в нашем основном репозитории 800 тысяч файлов вместе с файлами в node_modules. Только лишь массив строк путей к файлам относительно корня проекта занимает 73Мб в JSON. Добавляя атрибуты к файлам, размер JSON быстро переваливает за первую сотню мегабайт. Это весьма большое количество файлов, и это нужно оптимизировать, чем мы и занимаемся. Но для оптимизации, выявления аномалий и перекосов, нужен анализ, а для анализа необходимы дополнительные данные, что приводит к сотням мегабайт даже на такой относительно простой задаче.

Уменьшение количества деталей для уменьшения размера - это потеря возможностей или точности анализа, разбиение на части лишь усложняет манипуляции с данными. Кроме того, такие опции доступны не всегда. Например, при захвате Heap Snapshot в браузерных devtools можно выбрать включать ли числовые значения в снепшот или нет, что, конечно, дает экономию в размере, но не радикальную (около 10% размера, в зависимости от приложения). Запись профиля на закладке Performance в тех же devtools имеет чуть больше опций, но этого явно недостаточно и приводит к ограничениям. В дев версии нашего продукта есть специальное меню для записи профиля длительностью 15, 30 и 45 секунд, и мне всегда было интересно, почему нельзя записывать более длинные сессии, хотя такая потребность есть. Недавно узнал, что профили для более длинных сессий с высокой вероятностью крэшат devtools при открытии, а раз нельзя открыть, то нет смысла и записывать. Сама по себе запись более длинных сессий тоже проблема: даже если удалось записать сессию без креша, то она может не записать в файл, если итоговый JSON больше 512Mb (ниже будет пояснение), а если все же получится записать в файл, то его нельзя будет открыть в devtools по той же причине (512Мб) или из-за нехватка памяти. С новыми версиями Chromium devtools в профиль добавляется больше событий, сокращая максимальную длительность сессии. В общем, проблемы с большим JSON у обычных разработчиков вроде как нет, пока не нужно отлаживать что-то действительно большое.

Горшочек варит

03 Jun, 07:14


JsonDiscovery: JSON viewer done right

На прошлой неделе Денис Колесников выступил с докладом про JsonDiscovery на митапе PragueJS. Доклад получился динамичным, много живых демо, шикарные слайды (Денис мастер анимаций) и подача – уровень хорошей конференции. И вот стала доступна запись выступления, крайне рекомендую ознакомиться.

JsonDiscovery это расширение для браузеров (Chromium и Firefox), которое позволяет отображать загружаемый по урлу (или из файла) JSON в более удобном виде. Но это не просто вьювер для JSON, которых предостаточно в сторах. JsonDiscovery предлагает непревзойденную скорость в загрузке больших JSON (в докладе есть таблица с замерами, спойлер: минимум в 10 раз быстрее остальных), способен справляться с действительно большими JSON (спойлер: сотни мегабайт и даже гигабайты, другие расширения сдаются уже на 100Мб), и предлагает широкий спектр функциональности для анализа данных. Все это раскрывает и хорошо демонстрирует Денис в своем докладе. Там еще и не преднамеренная демонстрация cpupro случилась на секции вопросов, кривенько, но зато... хотя хватит спойлеров, лучше посмотрите сами 🙂

У меня в системе дефолтный вьювер для JSON это Chrome, в котором включен JsonDiscovery, и я совершенно не переживаю за размер JSON файла, когда их открываю – файл всегда откроется, и откроется быстро. Это один из ключевых инструментов, которым я пользуюсь практически каждый день, и мне уже сложно представить свою работу без JsonDiscovery. Все больше коллег, которые его тоже используют, и как я не пройду мимо их мониторов – у них всегда открыт какой нибудь файл в JsonDiscovery. Что интересно, способы и кейсы использования при этом у всех разные.

JsonDiscovery это обертка над Discovery.js (на нем же сделан и cpupro, который я анонсировал в предыдущем посте), которая немного расширяет его базовые возможности и интегрирует в браузер. Кажется просто и ничего интересного, но на деле интеграция это очень сложно, и это то, над чем Денис бьется уже несколько лет (а я немного помогаю). Основные вызовы это минимизация влияния расширения на загрузку страниц (сейчас это <2ms), поиск решений для преодоления ограничений для расширений, "дружба" с CSP и прочими security policy, а главное лавирование среди багов браузеров (то что работает для обычных страниц - часто забаговано для расширений, тикеты висят годами). Если коротко – нужно чтобы расширение имело влияние на браузер близкое к нулю и работало в максимальном количестве случаев. И часто это из серии "миссия невыполнима". Про эти тонкости в докладе нет, так как эти проблемы интересны и полезны малому кругу инженеров. Но если держать это в голове, когда смотришь как все работает в докладе, или когда пользуешься JsonDiscovery, то это добавляет немного градуса к восприятию. По крайней мере у меня так. Я сам иногда забываю, насколько большой путь был проделан, чтобы получить тот опыт, которые есть сейчас при работе с JSON, когда используешь JsonDiscovery. Но самое интересное, что это еще не конечная и многое еще впереди.

В общем, посмотрите, попробуйте, выскажите свое мнение в комментариях 🖖

Видео на Youtube
Слайды (PDF, 30Mb)

Горшочек варит

10 May, 04:29


Не смотря на то, что профилирование JavaScript является мощным инструментом, мне всегда не хватало возможностей инструментов. Так что последние годы я почти перестал пользоваться этим методом. Для меня это просто не работало: для небольших скриптов – слишком мало деталей, для долго работающих (например, сборка webpack или выполнение unit-тестов) – сплошная каша. Оказывалось, что инструментирование с помощью console.log() работает куда эффективнее, чем ковыряние в CPU профилях. В тоже время, мне казалось это неправильным.

2,5 года назад, очередной раз разбираясь в тормозящей webpack сборке, и делая аналитику что и как работает, представил как это могло бы выглядеть в инструментах. Пришла идея кластеризации функций по модулям, пакетам, категориям. Это казалось очевидным решением, странно, что никто еще не сделал. "Если никто не сделал, наверное это не будет работать" – думал я тогда. Но "не попробуешь не узнаешь", и решил попробовать реализовать – вдруг заработает.

Сначала я начал разбираться с тем как устроен формат ".cpuprofile". Было ничего непонятно, и казалось очень сложным. Но на деле вышло, что ничего сложного в этом нет, сложно только разобраться. Сложность в том, что материалов про то как устроен формат и про логику его работы, и вообще профилирование с точки зрения аналитики, обнаружить не удалось. Обычно ограничиваются базовыми туториалами по профилированию. Информацию приходилось собирать по крупицам: по ишуям, по комментариям, по коду v8 и т.д. И еще много экспериментировать.

Как бы то ни было, получилось собрать прототип кластеризации. И это на удивление хорошо заработало. Правда с кластеризацией появились новые проблемы: как считать разные агрегации, делать это быстро и под разными углами, и на больших масштабах. Информации по этому поводу было бы странно найти, так как никто кластеризацию толком и не делал. Но тем не менее основа была заложена, так появился проект под названием CPUpro – мое переосмысление того, какими должны быть инструменты анализа CPU профилей.

Время от времени я понемногу допиливал CPUpro, продолжал экспериментировать. А некоторое время назад пришло озарение, на фоне размышлении о том, как сделать сравнение профилей "до" и "после" без сопоставления двух флеймчартов глазками. Сравнение профилей пока не реализовано, но многое для этого уже сделано (высокая производительность, низкое потребление памяти), и вообще стало получаться что-то приличное. Теперь я ковыряюсь в CPU профилях каждый день, и все больше коллег делают тоже самое.

В общем, инструмент приобрел состояние, когда его не стыдно показать 🙂 Еще много что предстоит сделать, но он полезен уже сейчас, нам уже не раз помог в работе. Особенно если нужно проанализировать профайл на десятки или сотни мегабайт.

Release notes (там больше деталей, бенчмарки и скриншоты)
Твит анонса (спасибо за лайк, ретвит)
CPUpro (GitHub)

Горшочек варит

17 Jan, 19:50


Кстати, есть достаточно старый бенчмарк CSS минификаторов, которому лет десять не меньше. Видимо на фоне анонса Parcel CSS из него наконец-то убрали мертвые проекты и добавили туда esbuild и Parcel CSS. Время в табличке похоже на время для холодного кода – когда я добавил разогрев, у всех время значительно опустилось.

Горшочек варит

17 Jan, 19:38


Пара графиков для наглядности. На графике слева показано отношение времени между холодным и горячим кодом (график обрезан по 200ms, у cssnano еще 1,5 синиго бара вверх).
На втором графике, как меняется время между вызовами (шкала логарифмическая). Можно увидеть, что код начинает быстрее работать начиная со второго вызова. У csso и clean-css уменьшение времени не такое резкое, как у других – предполагаю там имеют место деоптимизации, которые невелируются к 3-4 вызову. Всплески на графиках связаны со срабатываютщим GC, это как раз про эффекты друг на друга.