Здравствуйте, меня зовут Дмитрий Карловский и я.. да не важно кто я. Важно о чём я говорю, и как аргументирую.

Кто меня знает, тому и не надо рассказывать. А кто не знает — у того есть прекрасная возможность подойти к вопросу с чистым разумом. А это крайне важно, если мы хотим спроектировать что-то по настоящему хорошо, а не как обычно.

PiterJS #84

Это — текстовая расшифровка одноимённого доклада с PiterJS #84. Можете глянуть видео запись тут в конце:

Или читать далее...

Serialization

Для сохранения и передачи объектов, требуется их сериализация. Она бывает двух видов:

? Stringification — текстовая сериализация

? Binarization — бинарная сериализация

Текстовые форматы

Стрингификация упаковывает данные в текстовую строку.

✅ Можно читать глазами.

❌ Бинарные данные не прочитать.

❌ Много весит.

✅ Хорошо жмётся.

❌ Медленная обработка.

❌ Требует ещё и бинаризации.

Однако строку всё равно надо перегонять в последовательность байт, а значит эффективнее было бы сразу бинаризировать.

Бинарные форматы

❌ Нужны спец тулы.

❌ Сложная отладка.

✅ Быстрая обработка.

✅ Может быть компактен.

Нужна ли схема?

Можно выделить следующие виды форматов бинаризации:

? Schema-Full

? Schema-Less

? Schema-Inside

? Shape-Inside

Schema-Full

Эти форматы предполагают существование схемы данных у нас в коде, благодаря которой мы понимаем какие байты что означают, не тратя байты на мета-информацию.

✅ Не тратим байты на типы.

✅ Прекомпилируем обработку.

❌ Схему надо откуда-то брать.

❌ Изменение схемы - боль.

Известные форматы:

Proto Buffers

Flat Buffers

Schema-Less

Эти форматы позволяют сериализовывать произвольные структуры и потом восстанавливать их благодаря поставляемой вместе с ними мета-информации. Схема в коде тут не требуется, но может быть использована для валидации данных.

❌ Тратим байты на типы.

❌ Обобщённый код медленный.

✅ Обобщённый код компактный.

✅ Кодирует любые данные.

Известные форматы:

MsgPack

CBOR

BSON

BSON гвоздями прибит к MongoDB, так что его далее не рассматриваем.

Schema-Inside

Компромиссный вариант — поставлять схему вместе с данными, что даёт скорость и компактность, без потери гибкости.

❌ Тратим байты на имена и типы...

✅ ... но один раз.

❌ Обобщённый код медленный...

✅ ... но его можно кешировать.

✅ Обобщённый код компактный.

✅ Кодирует любые данные.

Таких форматов мне не известно, но частный случай этого подхода — поставлять вместе с денными не всю схему с типами полей, а только шейпы — имена полей.

Shape-Inside

MsgPack + расширение

CBOR + расширения

VaryPack — наш герой

Проектируем

Сперва нам надо определиться с тем, какие типы данных должны поддерживаться нативно в нашем формате.

? Базовые примитивы.

? Структурные типы.

Базовые примитивы

? Логические значения (null, undefined, false, true) — 1B.

? Натуральные числа — 1B, 2B, 4B, 8B, ...

? Целые числа — 1B, 2B, 4B, 8B, ...

? Вещественные числа — 2B, 4B, 8B, ...

? Строки — UTF-8.

? Бинарники — массивы чисел одного типа.

Структурные типы

? Ссылки на любые значения.

? Списки любых элементов.

? Списки именованных элементов.

? Кастомные типы.

Первый байт

Если очень постараться, то все перечисленные типы можно упихнуть в 8 групп, а значит первый байт мы можем разделить на 3 бита для тега типа и оставшиеся 5 для некоторого числа, смысл которого зависит от типа.

Но 5 бит для числа может не хватить, поэтому резервируем несколько старших значений для кодирования числа байт в которых будет далее записано число.

Тип+число

Таким образом мы получили базовый кирпичик: tnum — типизированное число от 1 до 9 байт.

Порядок байт

Байты числа можно записывать в разном порядке:

? Big-Endian

? Little-Endian

Big-Endian

Последовательность от старших байт к младшим более распространена среди форматом, но менее среди процессоров.

? Сетевые протоколы: TCP, IP, ...

? Криптография: SHA, ...

? Картинки: PNG, JPG, ...

? CBOR, MsgPack, ...

✅ Сортировка как строк...

❌ ... но лишь при равных размерах.

❌ Современные процессоры.

Little-Endian

Последовательность от младших к старшим более распространена в современных архитектурах — на ней и остановимся, чтобы не делать лишнее преобразование туда-сюда.

? Шины данных: USB, PCI, ...

? VaryPack, ...

✅ Современные процессоры.

✅ Большие числа совместимы с малыми.

Натуральные числа

Натуральное число — это просто tnum с типом 000, благодаря чему небольшие значения представляются в одном байте нативно, что позволяет легко читать их в любом hex-вьювере.

VaryPack: от 0 до 27 — как есть.

✅ MsgPack: от 0 до 127 — как есть.

⭕ CBOR: от 0 до 23 — как есть.

У MsgPack диапазон существенно шире, но это не бесплатно — другие типы требуют больше байт.

Целые числа

Благодаря типу 111 небольшие отрицательные числа тоже представляются в одном байте нативно. Особое значение -32 оставляем для обозначения больших целых чисел, длина которых кодируется дополнительными 2 байтами вначале. Кажется 64КБ для таких чисел должно хватить для любых разумных потребностей.

VaryPack: от -1 до -27 — как есть.

✅ MsgPack: от -1 до -31 — как есть.

❌ CBOR: Тег 001. 8B+ — через расширение

В CBOR мало того, что на ровном месте осложнили дебаг, используя тег 001, так ещё и не предусмотрели поддержку больших чисел.

Специальные типы

Для загончика с особыми значениями выделим тип 010, особенностью которого является соответствие ему в таблице ASCII кодов заглавных латинских букв. Это позволяет подобрать такие значения первого байта, чтобы hex-вьювер показывал первую букву названия соответствующего специального значения.

Как видно, пока что из спец значений есть лишь логические значения и вещественные числа разных размеров. Остальные зарезервированы на будущее.

VaryPack: ASCII коды первых букв.

⭕ MsgPack: аналогично, но без букв.

⭕ CBOR: аналогично, но без букв.

Бинарники

Для типизированных массивов чисел tnum будет задавать длину бинарника в байтах. А перед самими данными добавим ещё один байт для кодирования типа.

VaryPack: массивы чисел разных типоразмеров.

❌ MsgPack: дикий запад.

⭕ CBOR: расширение Typed Arrays.

Строки

Со строками всё просто — tnum представляет длину строки в символах. И далее идёт UTF-8 представление, ставшее наиболее популярным, благодаря компактности и совместимости с ASCII.

VaryPack: 0xA0..0xBF — первый байт. Длина в символах.

❌ MsgPack: 0xC0..0xDF — первый байт. Длина в байтах.

❌ CBOR: 0x40..0x5F — первый байт. Длина в байтах.

Обратите внимание на выбор кода 101 для этого типа — он даёт значения первого байта начинающиеся на A и B в HEX представлении, что позволяет быстро понимать при отладке, когда перед нами строки.

Длина в байтах

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

❌ Не известна до бинаризации.

❌ А значит длина длины тоже.

✅ Можно декодировать лениво.

❌ Кратно растёт от числа символов.

Длина в символах

Длина в символах же обычно известна заранее, так как в памяти строки обычно хранятся в двухбайтовой кодировке. Это позволяет быстро сериализовывать, однако при парсинге уже не получится делать декодирование utf-8 лениво.

✅ Известна заранее ибо UCS2.

❌ Декодер должен уметь считать символы.

❌ Декодировать приходится сразу.

✅ Растёт монотонно.

Ссылки

Для ссылок tnum хранит номер уже обработанного значения. При сериализации уже ранее сериализованного значения вставляется лишь ссылка на него. А при парсинге встреченная ссылка распаковывается в соответствующее ранее прочитанное значение.

VaryPack: номера уже обработанных значений.

❌ MsgPack: дикий запад.

⭕ CBOR: расширение value-sharing.

Списки

Списки сеариализуются наивно: число элементов, после которого идут сами элементы подряд.

Неопределённая длина

CBOR поддерживает списки (и строки) неопределённой длины. Аргументируется это возможностью потоковой обработки. Однако, в случае обрыва соединения, нельзя будет продолжить обработку — её придётся начинать с начала.

✅ Можно бинаризировать не имея всех данных.

❌ Повтор с начала при обрыве передачи.

Определённая длина

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

? Разбиваем на независимые пакеты.

✅ Не шлём доставленные пакеты дважды.

Кортежи

Наконец, самое интересное — списки именованных элементов. Тут перед первым элементом добавляется шейп (список имён), который при повторениях заменяется на ссылку, давая весьма компактное бинарное представление.

VaryPack: кортеж именованных элементов

❌ MsgPack: дикий запад.

⭕ CBOR: расширение Records.

Объекты и Словари

В виде кортежей как раз и сериализуются любые объекты. Обратите внимание, что словари с произвольным набором ключей, кодируются так же как и объект из двух полей: список ключей и список значений.

VaryPack:

⚽ Объект: ( shape, vals... )

? Словарь: ( [ "keys", "vals" ], keys, vals )

❌ CBOR: только словари, как списки пар.

❌ MsgPack: только словари, как списки пар.

Кастомные типы

Всё многообразие типов предметной области упаковывается в кортежи, из которых потом можно восстановить исходные типы. Тут возможны два подхода:

? Структурная типизация

? Номинативная типизация

Структурная типизация

При обеднении значения (lean) перед бинаризацией поля объекта образуют кортеж. А при обогащении (rich) по шейпу этого кортежа выбирается функция, создающая тип предметной области.

Vary.type({
	type: Coord,
	keys: [ 'x', 'y' ],
	lean: obj => [ obj.x, obj.y ],
	rich: ([ x, y ])=> new Coord( x, y ),
})

Lean

VaryPack

Rich

Coord( 123, 456 )

( [ "x", "y" ], 123, 456 )

{ x: 123, y: 456 }

{ x: 234, y: 567 }

( #0, 234, 567 )

Coord( 234, 567 )

Обратите внимание, что тут можно полноценно работать с координатами даже не имея специальной поддержки типа Coord ибо его представление полностью совместимо с обобщённым объектом с полями "x" и "y".

Номинативная типизация

Частный случай структурной типизации — номинативная, где кортеж содержит лишь одно обеднённое значение, именем которого является глобально уникальное название формата представления.

Vary.type({
	type: Moment,
	keys: [ 'ISO8601' ],
	lean: obj => [ obj.toString( 'YYYY-MM-DD' ) ],
	rich: ([ str ])=> Moment.parse( str ),
})

Lean

VaryPack

Rich

Moment( 2015, 11, 27 )

( [ "ISO8601" ], "2025-11-27" )

{ ISO8601: "2025-11-27" }

{ ISO8601: "2025-11-28" }

( #0, "2025-11-28" )

Moment( 2015, 11, 28 )

Теги

В других форматах для расширения набора типов используются теги — специальные типы, которые помимо пониженного значения хранят и некоторый номер типа.

В MsgPack для тега есть лишь диапазон в 256 значений, что даёт высокий риск конфликтов.

❌ MsgPack: дикий запад в 1 байте.

⭕ Типично: +2B

T ; 127 ; [ 123, 456 ]

В случае CBOR ситуация получше — тут и диапазон значений большой (ценой дополнительных байт), и глобальный реестр стандартных расширений. Однако, типам конкретно вашей предметной области нечего делать в общем реестре, а выделенный для кастомных тегов диапазон требует от 5 дополнительных байт.

⭕ CBOR: 1..8B + реестр тегов.

❌ Типично: +5B

T+100500 ; [ 123, 456 ]

Для сравнения, наш подход требует от 1B для ссылки на шейп, а риск совпадения шейпов при этом куда ниже риска совпадения тегов.

Итоговое сравнение

Формат

VaryPack

CBOR

MsgPack

Совместимость

✅ общий стандарт

⭕ спеки на расширения

❌ расширения без спеки

Схематичность

✅ Shape-Inside

❌ Schema-Less

⭕ Shape-Inside (ext)

❌ Schema-Less

⭕ Shape-Inside (ext)

Сохранение структуры

⭕ без циклов

❌ Нет / ⭕ Да (ext)

❌ Нет / ⭕ Да (ext)

Порядок байт

✅ Little-Endian

❌ Big-Endian

❌ Big-Endian

Удобство отладки

✅ number, string, special

⭕ natural number

⭕ number

Неопределённые длины

❎ Нет

✅ string, list

❎ Нет

Размеры

Библиотека

$mol_vary

cbor-x

msgpackr

Размер библиотеки

✅ 4KB

❌ 11 KB

❌ 11 KB

Размер данных

✅ 100%

⭕ +40%

⭕ +30%

Как видно, даже со включёнными спец расширениями, CBOR и MsgPack дают существенно большие бинарники.

А зипование?

⭕ Нивелирует разницу.

❌ Замедляет в несколько раз.

При зиповании размер в большей степени зависит от энтропии исходных данных, а не формата бинаризации. А вот замедление из-за сжатия/разжатия приемлемо далеко не всегда.

Скорость

Библиотека

$mol_vary

cbor-x

msgpackr

Скорость

⭕ 100%

⭕ +20%

✅ +50%

Референсная реализация VaryPack не сильно уступает самым оптимизированным реализациям CBOR и MsgPack.

Расширения

Разные библиотеки предоставляют разный набор расширенных типов в комплекте.

Библиотека

$mol_vary

cbor-x

msgpackr

Структурные типы

✅ Map, Set

✅ Map, Set

✅ Map, Set

Особые типы

✅ Date, Element

✅ Date, RegExp, Error

✅ Date, RegExp, Error

Изоляция

✅ Дерево зон

❌ Нет

❌ Нет

Одно из пенальти по производительности — обеспечение изоляции кастомных типов. В $mol_vary не возможна ситуация, когда подключаешь библиотеку, которая регистрирует поддержку своих типов, и она вдруг появляется и у тебя, заменяя твою реализацию.

Открытые вопросы

❓ Циклические структуры.

❓ Схемы вместо шейпов.

Поддержка циклов потребовала бы при обработке их детектировать на прикладном уровне, что сложно и багоёмко. А кодирование схем снижает гибкость, не уменьшая бинарник, а наоборот увеличивая его.

Выводы

✅ Существенно компактней даже без расширений.

✅ Проще и гибче альтернатив.

⭕ Достаточно быстро, но можно лучше.

Послесловие

Спецификация формата и референсная реализация на NPM:

Спека VaryPack

mol_vary

Этот формат мы используем в нашей децентрализованной Гипер Базе, на базе которой мы делаем экосистему веб-сервисов нового поколения — Гипер Веб:

Giper Baza

giper_web

Подключайтесь к нашему сообществу Гипер Дев, а также заглядывайте на мою страничку почитать ещё чего интересного:

giper_dev

jin.hyoo.ru


Актуальный оригинал на $hyoo_page.

Комментарии (23)


  1. sokoloid
    09.12.2025 18:46

    В CBOR мало того, что на ровном месте осложнили дебаг, используя тег 001, так ещё и не предусмотрели поддержку больших чисел.

    Можно подробнее об этом? На cbor.me без проблем кодируется c избыточностью в 1 байт.

    Еще такие вопросы:

    1. Реализация только одна? Только TS? По этому параметру тоже нужно сравнивать!

    2. Сравнение на реальных данных где-то уже выкладывали? Есть предположение, что CBOR на простых типах нифига не проиграет по избыточности.

    3. Есть ли конвертируемость в/из JSON как в CBOR (без ext)?


    1. nin-jin Автор
      09.12.2025 18:46

      1. Чтобы что?

      2. Ну вот возьмите свои реальные данные и сравните.

      3. А это зачем?


  1. shai_hulud
    09.12.2025 18:46

    Непонятно, чем BSON прибит к MongoDB, там есть все базовые типы, что и в MsgPack или JSON и несколько MongoDB specific, которые можно не использовать.


    1. nin-jin Автор
      09.12.2025 18:46

      Поддерживать их всё равно придётся, даже если не используешь.


      1. mentin
        09.12.2025 18:46

        кто вас заставит поддерживать, если не нужно?

        вот, скажем, библиотека поддерживает Bson, но только те типы, что есть в Json
        https://github.com/nlohmann/json


        1. nin-jin Автор
          09.12.2025 18:46

          Прекрасно, нужные мне типы она не поддерживает, зато встретив не нужный мне тип она падает.


          1. mentin
            09.12.2025 18:46

            Они поддержали что им нужно, кто вам мешает поддержать что вам нужно, и не поддержать что вам не нужно?


            1. nin-jin Автор
              09.12.2025 18:46

              Это задача эквивалентная созданию нового кривого формата поверх существующего. Смысл в этом не просто нулевой - он отрицательный.


  1. rivo
    09.12.2025 18:46

    В сравнении нет примеров, какие данные упаковывались и какой бинарь получился.

    CBOR мало того, что на ровном месте осложнили дебаг, используя тег 001, так ещё и не предусмотрели поддержку больших чисел.

    Маленькие числа упаковывает в 1 байт. Поддержка bigint заявлена с 2021 года.

    Добавьте web-playgorund, будет убедительнее тысячи скриншотов.


    1. sokoloid
      09.12.2025 18:46

        Добавьте web-playgorund

      100%


    1. nin-jin Автор
      09.12.2025 18:46

      Вы что-то попутали, я вам ничего не продаю.


      1. rivo
        09.12.2025 18:46

        Выглядят как типичный маркетинг, чеклисты сравнения зеленые у вашей библиотеки, у других красные. Где и на каких данных производились замеры, не указывается.


        1. nin-jin Автор
          09.12.2025 18:46

          Хотите я вас в жопу пошлю, чтобы избавить от подозрений?


          1. dan_sw
            09.12.2025 18:46

            да не важно кто я. Важно о чём я говорю, и как аргументирую.


          1. kgenius
            09.12.2025 18:46

            Быдлокодер? :-)


  1. cpud47
    09.12.2025 18:46

    Правда ли что из-за backref-ов требует линейную память при декодировании? Аналогичный вопрос при кодировании (но там проще, т.к. можно поддерживать только для шейпов (если пользователю ок)).


    1. nin-jin Автор
      09.12.2025 18:46

      Требуется память под один дополнительный список.


  1. Finesse
    09.12.2025 18:46

    Продукт выглядит здорово. Спасибо, что делитесь им с миром.

    Длина в символах же обычно известна заранее, так как в памяти строки обычно хранятся в двухбайтовой кодировке. Это позволяет быстро сериализовывать

    Разные ЯП хранят строки по разному. Кто-то в UTF-16, кто-то в UTF-8, кто-то, возможно, иным способом.

    К сожалению, 2 байт не достаточно для кодирования 1112064 символов Юникода, поэтому с даухбайтовой кодировкой (поддерживающей весь Юникод) нельзя за О(1) понять, сколько в строке символов. JavaScript хранит строки в UTF-16, а не UCS-2, то есть в строках могут быть суррогатные пары, кодирующие символ 4 байтами.

    Вы не учли это и допустили ошибку в коде VaryPack. То есть в заголовке строки хранится не количество символов (как написано в статье), а количество пар байт в кодировке UTF-16.