Фото: Peretz Partensky / CC BY-SA 2.0
Фото: Peretz Partensky / CC BY-SA 2.0

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

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

Тип «Extension»

Спецификация MessagePack определяет 9 базовых типов:

  • Nil

  • Boolean

  • Integer

  • Float

  • String

  • Binary

  • Array

  • Map

  • Extension.

Последний тип «Extension», по сути, является контейнером для хранения дополнительных типов. Рассмотрим подробнее, как он устроен — эти знания нам пригодятся нам при реализации собственных типов.

Схематически контейнер может быть представлен следующим образом:

Extension
Extension

Header — заголовок контейнера, от 1 до 5 байт, в котором хранится размер полезной нагрузки, т.е. длина поля «Data». Более подробно о том, как формируется заголовок можно посмотреть в спецификации.

Type — идентификатор хранимого типа, который представляет собой 8-битное знаковое целое число. Значения меньше нуля зарезервированы для официальных типов, для пользовательских, соответственно, отведен диапазон от 0 до 127.

Data — произвольная последовательность байт длиной до 4 GiB, содержащая фактические данные. Формат хранения данных для официальных типов описан в самой спецификации, формат пользовательских типов зависит только от фантазии разработчика.

В настоящее время в список официальных дополнительных типов включен только «Timestamp» с идентификатором -1. Периодически поступают предложения о добавлении новых типов (например, UUID, многомерных массивов, geo-координат), но, судя по тому, как неспешно ведутся такие обсуждения, я бы не стал ожидать появления чего-то нового в ближайшем будущем.

Hello, World!

Фото: Brett Ohland  / CC BY-NC-SA 2.0
Фото: Brett Ohland / CC BY-NC-SA 2.0

С теорией покончено, приступим к написанию кода. В качестве MessagePack-библиотеки, которую мы будем использовать в примерах, возьмем msgpack.php — она предоставляет удобный API для работы с дополнительными типами. Тем не менее я надеюсь, что приведенные примеры кода будут достаточно понятны и для пользователей других библиотек.

Раз уж я упомянул UUID, давайте в качестве примера реализуем поддержку этого типа. Для этого нам понадобится написать «расширение» — класс, задачей которого будет сериализация и десериализация UUID-значений. Чтобы облегчить работу с такими значениями, дополнительно воспользуемся библиотекой symfony/uid.

Выбор UUID-библиотеки не принципиален, пример можно легко адаптировать под любую соответствующую библиотеку, будь то популярная ramsey/uuid, PECL-модуль uuid или собственная реализация.

Назовем наш класс UuidExtension. Обязательным требованием к классу расширения является реализация им интерфейса Extension:

use MessagePack\BufferUnpacker;
use MessagePack\Packer;
use MessagePack\TypeTransformer\Extension;
use Symfony\Component\Uid\Uuid;
 
final class UuidExtension implements Extension
{
    public function getType(): int
    {
        // TODO
    }
 
    public function pack(Packer $packer, mixed $value): ?string
    {
        // TODO
    }
 
    public function unpackExt(BufferUnpacker $unpacker, int $extLength): Uuid
    {
        // TODO
    }
}

Что такое тип (идентификатор) расширения, мы выяснили выше, поэтому реализовать метод getType() не составит труда. В самом простом случае этот метод мог бы возвращать некую фиксированную константу, заданную глобально для всего проекта. Однако, для пущей универсальности и возможности переиспользования нашего класса в других проектах, дадим возможность задавать тип при инициализации расширения. Для этого добавим в класс конструктор с единственным целочисленным аргументом $type:

/** @readonly */
private int $type;
 
public function __construct(int $type)
{
    if ($type < 0 || $type > 127) {
        throw new \OutOfRangeException(
            "Extension type is expected to be between 0 and 127, $type given"
        );
    }
 
    $this->type = $type;
}
 
public function getType(): int
{
    return $this->type;
}

Теперь реализуем метод pack(). Как видно из сигнатуры метода, он принимает два параметра: экземпляр класса Packer и значение $value произвольного типа. Метод должен вернуть либо сериализованное значение (обернутое в контейнер «Extension»), либо null, если тип значения не поддерживается классом расширения:

public function pack(Packer $packer, mixed $value): ?string
{
    if (!$value instanceof Uuid) {
        return null;
    }
 
    return $packer->packExt($this->type, $value->toBinary());
}

Обратная операция не намного сложнее. В метод unpackExt() передается экземпляр класса BufferUnpacker и длина сериализованных данных (размер поля «Data» из схемы выше). Так как мы в этом поле сохранили бинарное представление UUID, то все что там нужно сделать — это прочитать эти данные и построить объект Uuid:

public function unpackExt(BufferUnpacker $unpacker, int $extLength): Uuid
{
    return Uuid::fromString($unpacker->read($extLength));
}

Наш расширение готово! Последним шагом перед тем, как начать им пользоваться будет регистрация соответствующего объекта класса с заданным идентификатором типа, пусть он будет равен 0:

$uuidExt = new UuidExtension(0);
$packer = $packer->extendWith($uuidExt);
$unpacker = $unpacker->extendWith($uuidExt);

Убедимся, что все работает как положено:

$uuid = new Uuid('7e3b84a4-0819-473a-9625-5d57ad1c9604');

$packed = $packer->pack($uuid);
$unpacked = $unpacker->reset($packed)->unpack();

assert($uuid->equals($unpacked));

Это был пример простого UUID-расширения. Таким же образом вы можете добавить поддержку любых других типов, используемых в вашем приложении: DateTime, Decimal, Money или написать универсальное расширение для сериализации произвольных объектов (как это, например, сделали в KPHP).

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

«Lorem ipsum» или сжимаем несжимаемое

Фото: dog97209 / CC BY-NC-ND 2.0
Фото: dog97209 / CC BY-NC-ND 2.0

Если вы интересовались MessagePack прежде, то вам наверняка знакома фраза с официального сайта msgpack.org, описывающая этот формат как «подобный JSON, но быстрый и компактный» (“It's like JSON, but fast and small”).

Действительно, сравнив, сколько места занимают одни и те же данные в JSON и в MessagePack, вы поймете, почему последний является более компактным форматом. Например, число 100 в JSON занимает 3 байта, в MessagePack — всего лишь 1. И с ростом разрядности числа эта разница лишь увеличивается. Так, для максимального значения int64 (9223372036854775807) она составит уже 10 байт (19 против 9)!

С булевыми значениями дела обстоят не лучше — 4 или 5 байт в JSON против 1 байта в MessagePack. Такая же ситуация и с массивами — запятые, как разделители элементов; двоеточия, как разделители пар ключ-значение; скобки, как маркеры границ массивов — всего этого нет в в бинарном формате. Очевидно, что чем больше массив, тем больше такого синтаксического «мусора» будет накапливаться вместе с полезными данными.

Вместе с тем со строковыми значениями все не столь однозначно. Если ваши строки не состоят сплошь из кавычек, переводов строки и других специальных символов, требующих экранирования, то большой разницы в размере между JSON и MessagePack вы не заметите. Например, "foobar" в JSON будет занимать 8 байт, а в MessagePack — 7. Замечу, что все вышесказанное касается только UTF-8 строк, с бинарными строками ситуация кардинально отличается не в пользу JSON.

Зная эту «особенность» MessagePack, забавно читать статьи, в которых сравниваются два формата с точки зрения эффективности сжатия данных, но, при этом, для тестов используются данные, состоящие в основном из строк. Как вы понимаете, выводы, сделанные на основе результатов таких сравнений, не имеют практического смысла. Поэтому относитесь с определенной долей скептицизма к такого рода статьям и всегда проводите сравнительное тестирование на ваших данных.

Чтобы сделать сериализацию строк более компактной, в свое время даже поднимался вопрос о добавлении в спецификацию возможности сжатия строк (по отдельности или фреймами). Однако идея была отклонена, и реализация такой функциональности была отдана на откуп пользователям. Ну что ж, попробуем.

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

Выбор наиболее подходящего алгоритма сжатия зависит от характера ваших данных. Например, если вы работаете со множеством коротких строк, возможно, вам стоит обратить внимание на SMAZ.

Начнем с конструктора нашего нового класса TextExtension. Помимо идентификатора расширения, вторым необязательным аргументом добавим минимальную длину сжимаемой строки. Строки короче этого значения будем сериализовать стандартным методом, без сжатия. Это нужно для того, чтобы избежать случаев, когда сжатая строка оказывается длиннее исходной:

final class TextExtension implements Extension
{
    /** @readonly */
    private int $type;

    /** @var positive-int */
    private int $minLength;

    public function __construct(int $type, int $minLength = 100)
    {
        ...
 
        $this->type = $type;
        $this->minLength = $minLength;
    }
 
    ...
}

Для реализации метода pack() мы могли бы написать что-то вроде:

public function pack(Packer $packer, mixed $value): ?string
{
    if (!is_string($value)) {
        return null;
    }
 
    if (strlen($value) < $this->minLength) {
        return $packer->packStr($value);
    }
 
    // compress and pack
    ...
}

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

Следовательно, мы должны каким-то образом сообщить упаковщику не сериализовать определенные строки как строки (простите за тавтологию), а использовать расширение. Как нетрудно догадаться из UUID-примера, сделать это можно с помощью объекта-значения (ValueObject). Назовем его Text, по аналогии с именем класса расширения:

/**
 * @psalm-immutable
 */
final class Text
{
    public function __construct(
        public string $str
    ) {}
 
    
    public function __toString(): string
    {
        return $this->str;
    }
}

То есть, вместо

$packed = $packer->pack("очень длинная строка");

будем использовать объект Text в качестве маркера длинных строк:

$packed = $packer->pack(new Text("очень длинная строка"));

Обновим метод pack():

public function pack(Packer $packer, mixed $value): ?string
{
    if (!$value instanceof Text) {
        return null;
    }
 
    $length = strlen($value->str);
    if ($length < $this->minLength) {
        return $packer->packStr($value->str);
    }
 
    // compress and pack
    ...
}

Осталось дело за малым, сжать строку и положить результат в «Extension». Обратите внимание, что ограничение на минимальную длину строки не гарантирует, что после сжатия такие строки будут занимать меньше места. Поэтому, перед возвратом результата, имеет смысл сравнить длину сжатой строки с оригинальной и выбрать наиболее компактный вариант:

$context = deflate_init(ZLIB_ENCODING_GZIP);
$compressed = deflate_add($context, $value->str, ZLIB_FINISH);
 
return isset($compressed[$length - 1])
    ? $packer->packStr($value->str)
    : $packer->packExt($this->type, $compressed);

Десериализация:

public function unpackExt(BufferUnpacker $unpacker, int $extLength): string
{
    $compressed = $unpacker->read($extLength);
    $context = inflate_init(ZLIB_ENCODING_GZIP);
 
    return inflate_add($context, $compressed, ZLIB_FINISH);
}

Посмотрим, чего мы добились:

$longString = <<<STR
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod 
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, 
quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo 
consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse 
cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat 
non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
STR;
 
$packedString = $packer->pack($longString); // 448 bytes
$packedCompressedString = $packer->pack(new Text($longString)); // 291 bytes

На этом примере видно, как на единственной строке мы сэкономили 157 байт или 35% от стандартного способа сериализации!

От «schema-less» к «schema-mixed»

 Фото: Adventures with E&L / CC BY-NC-ND 2.0
Фото: Adventures with E&L / CC BY-NC-ND 2.0

Сжатие длинных строк — не единственное, на чем можно сэкономить. MessagePack — это schema-less формат (или, как его еще называют, schema-on-read), у которого есть свои преимущества и недостатки. Одним из таких недостатков по сравнению со schema-full (schema-on-write) форматами является то, что повторяющиеся структуры данных сериализуются в MessagePack крайне неэффективно. К таким данным, например, относятся выборки из базы, где все элементы результирующего массива имеют одинаковую структуру:

$userProfiles = [
    [
        'id' => 1,
        'first_name' => 'First name 1',
        'last_name' => 'Last name 1',
    ],
    [
        'id' => 2,
        'first_name' => 'First name 2',
        'last_name' => 'Last name 2',
    ],
    ...
    [
        'id' => 100,
        'first_name' => 'First name 100',
        'last_name' => 'Last name 100',
    ],
];

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

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

/**
 * @psalm-immutable
 */
final class StructList
{
    public function __construct(
        public array $list,
    ) {}
}

Если в вашем проекте используется библиотека для работы с базой данных, вполне вероятно в ней уже есть специальный класс, хранящий результаты выборки из таблиц. В этом случае вы можете использовать такой класс в качестве типа вместо/вместе со StructList.

Сериализовывать такие массивы мы будем следующим образом: сперва будем проверять размер массива. Очевидно, что если он пуст или состоит только из одного элемента, то никакой выгоды от сохранения ключей отдельно от значений мы не получим, скорее наоборот. Поэтому такие массивы будем сериализовывать стандартным образом.

Для остальных случаев будем сперва сохранять список ключей, а затем список значений. То есть вместо стандартного формата хранения списка ассоциативных массивов MessagePack, мы будем записывать данные в более компактном виде:

Реализация:

final class StructListExtension implements Extension
{
    ...
 
    public function pack(Packer $packer, mixed $value): ?string
    {
        if (!$value instanceof StructList) {
            return null;
        }
 
        $size = count($value->list);
        if ($size < 2) {
            return $packer->packArray($value->list);
        }
 
        $keys = array_keys(reset($value->list));
 
        $values = '';
        foreach ($value->list as $item) {
            foreach ($keys as $key) {
                $values .= $packer->pack($item[$key]);
            }
        }
 
        return $packer->packExt($this->type,
            $packer->packArray($keys).
            $packer->packArrayHeader($size).
            $values
        );
    }
 
    ...
}

Соответственно, для десериализации нам нужно сперва распаковать массив ключей, а затем, используя полученные ключи, воссоздать исходный массив:

public function unpackExt(BufferUnpacker $unpacker, int $extLength): array
{
    $keys = $unpacker->unpackArray();
    $size = $unpacker->unpackArrayHeader();
 
    $list = [];
    for ($i = 0; $i < $size; ++$i) {
        foreach ($keys as $key) {
            $list[$i][$key] = $unpacker->unpack();
        }
    }
 
    return $list;
}

Дело сделано, если мы теперь сериализуем $profiles из примера выше как обычный массив и как структурированный StructList, мы получим весьма существенную разницу в размере — последний на 47% компактнее!:

$packedList = $packer->pack($profiles); // 5287 bytes
$packedStructList = $packer->pack(new StructList($profiles)); // 2816 bytes

Можно пойти еще дальше: создать специализированный тип Profiles и хранить информацию о структуре массива в коде расширения, что позволит не  сохранять массив ключей, но такой вариант, конечно, теряет в универсальности.

Заключение

Мы рассмотрели лишь несколько примеров использования дополнительных типов MessagePack. Еще больше примеров, включая описанные в статье, вы можете найти в репозитории библиотеки msgpack.php, а также реализации всех дополнительных типов, поддерживаемых протоколом Tarantool, в библиотеке tarantool/client.

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

С другой стороны, если вы только думаете, какой какой формат сериализации выбрать для своего следующего проекта, полученные знания помогут вам сделать более взвешенный выбор и добавят 1 балл в пользу MessagePack :)