Привет, Хабр! Я Никита Пешаков, ведущий инженер-программист в компании YADRO. Шесть лет работаю в телеком-направлении, а прямо сейчас разрабатываю ядро опорной сети 5G. Хочу поделиться, как в нашем продукте мы кодируем и декодируем JSON, и сравнить бенчмарки нашего кодека с библиотекой Glaze. 

Откуда JSON в телекоме

Давайте разбираться.

Архитектура 5G-сети
Архитектура 5G-сети

 Это архитектура 5G-сети: синие квадраты — сервисы пакетного ядра сети, красные стрелки — интерфейсы, так называемые SBI. Эти SBI-интерфейсы описываются в стандарте OAS-схемами — с помощью OpenAPI и YAML. 

Мы рассматривали несколько open source-решений для работы с OAS-схемами, но они нам не подошли. Часть инструментов не поддерживала стандарт OpenAPI в необходимой нам степени, другая часть не устраивала нас по производительности или иным параметрам. Поэтому решили написать свое. 

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

Мы выставили для кодека требования:

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

  • Контроль процесса кодирования и декодирования и возможность кастомизировать его — например, подкрутить оптимизации. 

  • Валидация прямо в процессе декодирования. В OpenAPI-схемах на разные поля JSON есть разные ограничения. Мы хотели бы их проверять прямо в процессе декодирования. 

Размер JSON у нас не особо большой — примерно килобайты данных. Соответственно, потоковое кодирование, когда JSON постепенно подгружается и декодируется, нам было не нужно.

Для опытных разработчиков на С++ у YADRO есть рассылка. Письма готовят инженеры: пишут об интересных задачах и делятся полезными материалами. Подписывайтесь, письма приходят раз в месяц

Как устроен наш JSON-кодек

Перед тем, как рассказать про кодек, приведу два примера из стандарта 3GPP, по которым ведется разработка в телеком. 

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

3GPP TS 29.571: ServiceAreaRestriction:

allOf:
 - oneOf:
   - not:
     required: [ restrictionType ]
   - required: [ areas ]
 - anyOf:
   - not:
     required: [ restrictionType ]
     properties:
      restrictionType:
       type: string
       enum: [ NOT_ALLOWED_AREAS ]
   - not:
     required: [ maxNumOfTAs ]
 - anyOf:
   - not:
     required: [ restrictionType ]
     properties:
      restrictionType:
       type: string
       enum: [ ALLOWED_AREAS ]
   - not:
     required: [ maxNumOfTAsForNotAllowedAreas ]

Ниже — рекурсивный объект: SearchExpression, который содержит SearchCondition, который содержит в себе SearchExpression. 

3GPP TS 29.598: SearchExpression & SearchCondition:

SearchExpression:
  type: object
  oneOf:
    - $ref: '#/components/schemas/SearchCondition'
    - $ref: '#/components/schemas/SearchComparison'
    - $ref: '#/components/schemas/RecordIdList'

SearchCondition:
  type: object
  properties:
    units:
      type: array
      items:
        $ref: '#/components/schemas/SearchExpression'
        minItems: 1
      ...

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

Разбирать наш JSON-кодек будем на примере следующей схемы:

MyObject:
 type: object
 required:
 - str
 - myArray
 properties:
  myInt:
   type: integer,
   minimum: 1,
   maximum: 2048,
   default: 2000
  str:
   type: string,
   pattern: '^(imsi-[0-9]{5,15}|.+)$'
  objMap:
   type: object
   additionalProperties: 
    type: integer
  myArray:
   type: array,
   uniqueItems: true
   items:
    oneOf:
    - type: boolean
    - type: integer
{
 "myInt": 30,
 "str": "imsi-123456789",
 "objMap":
 {
  "a": 5,
  "b": -3
 },
 "myArray": [true, 1024, false]
}

Давайте будем разбирать его по порядку. 

Тривиальные типы: boolean, integer, number и enum

Начнем с простейших типов: boolean, integer, number и enum. Рассматривать их будем на примере integer. Вот пример сгенерированного кода. Имя поля, а также ограничения, которые написаны в схеме, у нас задаются static constexpr-полями сгенерированной структуры:

/*
  myInt:
   type: integer,
   minimum: 1,
   maximum: 2048,
   default: 2000
*/

struct myInt : oas::integer<uint16_t>
{
    static constexpr auto _name_ = R"("myInt":)"sv;
    static constexpr value_t _minimum_ = 1; 
    static constexpr value_t _maximum_ = 2048; 
    static constexpr value_t _default_ = 2000;
};

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

template <ATrivial T>
struct trivial
{
    using value_t = T;
    void set(value_t v);
    value_t get();

private:
    bool m_set{false}; 
    value_t m_value{};
};

template <AnInteger T>
struct integer : trivial<T> {};

trivial — это такой самописный Optional. Для boolean, number, enum есть такие же структуры, как для integer, поэтому рассматривать мы их не будем. Там все работает одинаково.

string

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

/*
  str:
   type: string,
   pattern: '^(imsi-[0-9]{5,15}|.+)$'
*/

struct str : oas::string<>, oas::mandatory<>
{
    static constexpr auto _name_ = R"("str":)"sv;
    static constexpr std::array _patterns_ =
                	{R"(^(imsi-[0-9]{5,15}|.+)$)"sv};
};

Мы решили, что не будем проверять регулярные выражения в процессе декодирования по двум причинам:

  • Не все строки обязательно валидировать, а для остальных валидации будет сделана в момент перекладывания их во внутренние структуры.

  • Разработчики стандартов 3GPP любят добавлять в регулярные выражения структуру типа «.+» или «.*». В итоге под регулярное выражение подпадает практически любая строка, и тогда вся валидация по таким выражениям бессмысленна. Но это регулярное выражение содержится в наших сгенерированных структурах, и мы можем его использовать при необходимости.

Также в примере поле str находится в списке обязательных полей для родительского объекта. Соответственно, у нас это обозначается как наследование от класса mandatory. mandatory — это пустая структура, которая показывает, что поле обязательно должно быть представлено в родительском объекте. Оно используется для валидации в процессе кодирования и декодирования.

В основе наших строк лежит класс string, похожий на trivial со string_view внутри. 

template <ctad_string DEFAULT>
struct string
{
    using value_t = std::string_view;
    void set(value_t v);
    void setWithCopy(value_t v, AnAllocator auto&);
    value_t get();

private:
    bool m_set{false}
    value_t m_value{};
};

Таким образом, наши строки указывают на данные, но не содержат их. И почему мы выделили это отдельно от trivial? Потому что захотели сделать несколько своих сеттеров для string. Один из примеров — метод setWithCopy. Он принимает в себя аллокатор, чтобы в случае чего продлить время жизни строки путем копирования данных в память, выделенную аллокатором.

Additional Properties 

Дальше рассмотрим объекты с так называемыми Additional Properties. Это, по сути, объекты, у которых ключи в JSON неизвестны, а тип, который лежит под этими ключами, задан. Похоже на std::map в C++. В данном случае хранимый тип — это integer.

/*
  objMap:
   type: object
   additionalProperties: 
    type: integer
*/

struct property_type : oas::integer<int64_t> {};

struct objMap : oas::object_map<property_type>
{
    static constexpr auto _name_ = R"("objMap":)"sv;
};

Сгенерированный код у нас для этих классов базируется вот на такой структуре object_map:

template <AnIe ADDITIONAL_PROPERTIES>
struct object_map_value_s
    : boost::intrusive::slist_base_hook<boost::intrusive::link_mode<bi::normal_link>>
{
    using key_t = string<>;
    using value_t = ADDITIONAL_PROPERTIES;
    key_t key;
    value_t value;
};

template <AnIe ADDITIONAL_PROPERTIES>
struct object_map : raw_data_holder
{
public:
    using ap_t = ADDITIONAL_PROPERTIES;
    using value_t = object_map_value_s<ap_t>;
    value_t& emplace(AnAllocator auto&)
private:
    using map_t = boost::intrusive::slist<value_t, boost::intrusive::constant_time_size<false>>;
    bool m_set{false};
    map_t m_map;
};

Мы решили не встраивать аналог map в эти типы, а взять простой list. Данные у нас относительно небольшие, и тут не будет списков даже с сотнями значений. Так что на маленьких размерах преимущества map по сравнению с list не будут заметны. А еще данные структуры — это классы для кодирования и декодирования JSON, а не для хранения. Основная операция с такими структурами будет выглядеть как «пройтись по всем элементам и что-то с ними сделать», а не «искать элемент с конкретным заданным значением». Поэтому list здесь подходит. Элемент list — простая структура с key-value. Тут ничего особо интересного. А для добавления элементов используется тот же аллокатор. Без него тут, к сожалению, никак.

Array

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

/*
  myArray:
   type: array,
   uniqueItems: true
   items:
    oneOf:
    - type: boolean
    - type: integer
*/

struct items : oas::one_of<Boolean_1, Integer_2>;

struct myArray : oas::array<items>, oas::mandatory<>
{
    static constexpr auto _name_ = R"("myArray":)"sv;
    static constexpr bool _uniqueItems_ = true;
};

В основе него лежит list, и элементы также добавляются с помощью аллокатора.

template <AnIe ITEM>
struct array : raw_data_holder
{
private:
    struct item_s
        : ITEM
        , private utils::noncopyable
        , private utils::nonmovable
        , bi::list_base_hook<bi::link_mode<bi::normal_link>>
    {};
    using array_t = boost::intrusive::list<item_s, bi::constant_time_size<false>>;
public:
    using value_t = typename array_t::value_type;

    ITEM& emplaceBack(utils::AnAllocator auto& alloc);

private:
    bool m_set{false};
    array_t m_array;
};

oneOf

Внутри массива лежит типа oneOf. Его рассмотрим следующим. 

/*
    oneOf:
    - type: boolean
    - type: integer
*/

struct Boolean_1 : oas::boolean<> {};
struct Integer_2 : oas::integer<int64_t> {};

struct items : oas::one_of<Boolean_1, Integer_2>
{
    constexpr auto& refBoolean_1() noexcept;
    constexpr decltype(auto) getBoolean_1() noexcept;
    ...
};

По сути, oneOf в OpenAPI — это аналог variant в С++. Структура one_of наследуется от Variant, который, в свою очередь, является просто надстройкой над std::variant, но с более удобным для нас интерфейсом.

template <AnIe... IEs>
struct one_of : Variant<IEs...>, raw_data_holder
{
    using variant_t = Variant<IEs...>;
};

template <class... Ts> requires (sizeof...(Ts) > 0)
class Variant
{
public:
    using value_type = std::variant<Ts..., std::monostate>;
    template <class T>
    constexpr auto& emplace() noexcept { return m_value.template emplace<T>(); }

    template <class T>
    constexpr T& ref() noexcept;

    template <class T>
    constexpr T const* get() const noexcept;

private:
    value_type m_value{std::in_place_type<std::monostate>};
};

object, anyOf, allOf

Рассмотрим типы object, anyOf, allOf. 

/*
MyObject:
 type: object
 required:
 - str
 - myArray
 properties:
  myInt:
   type: integer,
   minimum: 1,
   maximum: 2048,
   default: 200
  str:
   type: string,
   pattern: '^(imsi-[0-9]{5,15}|.+)$'
  objMap:
   type: object
   additionalProperties: 
    type: integer
  myArray:
   type: array,
   uniqueItems: true
   items:
    oneOf:
    - type: boolean
    - type: integer
*/

struct MyObject : oas::object<
    myInt, 
    str, 
    objMap, 
    myArray>
{

    static constexpr auto _name_ = R"("MyObject":)"sv;

    constexpr myInt& refMyInt() noexcept;
    constexpr myInt* getMyInt() const noexcept;
};

Эти типы похожи друг на друга, они играют роль контейнеров для других типов. 

Рассмотрим их на примере object.

template <AnIe... PROPERTIES>
struct object : Container<PROPERTIES...>, raw_data_holder
{
    using container_t = Container<PROPERTIES...>;
    static constexpr auto _type_ = type_e::OBJECT;
private:
    bool m_set{false};
};

template <AnIe... PROPERTIES>
struct any_of : Container<PROPERTIES...>, raw_data_holder
{
    using container_t = Container<PROPERTIES...>;
    static constexpr auto _type_ = type_e::ANY_OF;
};

template <AnIe... PROPERTIES>
struct all_of : Container<PROPERTIES...>, raw_data_holder
{
    using container_t = Container<PROPERTIES...>;
    static constexpr auto _type_ = type_e::ALL_OF;
};

Типы, которые должны храниться в объекте, задаются параметрами шаблона. Для каждого есть базовый тип, содержащий поле _type_, в котором указывается, что это — object, anyOf или allOf. Так encoder и decoder знают, что они декодируют в данный момент.

Все три типа очень похожи, поэтому наследуются от одного и того же класса Container. Выглядит это так:

template <class... Ts>
class Container
{
public:
    struct ies_t : Ts...
    {
        template <class T> 
        constexpr T const& as() const noexcept { return static_cast<T const&>(*this); }

        template <class T> 
        constexpr T& as() noexcept { return static_cast<T&>(*this); }
    };

    template <class T>
    constexpr decltype(auto) ref() noexcept;

    template <class T> 
    constexpr decltype(auto) get() const noexcept;
private:
    iest_t m_ies;
};

Он в шаблоне принимает все типы, которые должен хранить. Внутренняя структура ies наследуется от этих типов и хранится как поле. А доступ до конкретного типа осуществляется с помощью static_cast. Наш кодогенератор создаёт отдельный класс для каждого поля object и коллизий в случае одинаковых типов не возникает.

Аллокатор

Мы решили написать свой аллокатор, чтобы реже выделять память на heap и лучше локализовать данные, тем самым сократив частоту инвалидаций кэша.
У нашего аллокатора есть два режима работы:

class Allocator
{
    explicit Allocator(std::span<T, N> buf);

    explicit Allocator(std::size_t chunkSize);

    ...
};
  • С фиксированным буфером, который выделяется на stack, а в аллокатор просто передается указатель на него. 

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

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

Очевидно, второй способ медленнее. Я измерил время работы некоторого кода с двумя вариантами аллокатора. В принципе, если задать достаточно большой размер блока, то время работы с heap стремится к времени работы со stack, поэтому потери вполне приемлемые.

Далее расскажу про несколько особенностей нашего кодека. 

Особенности нашего кодека

IeSizeCalculator

Это специальная структура, которая по нашим объектам может посчитать, какой размер выходного буфера им нужен. Она используется, чтобы перед кодированием посчитать, сколько памяти нам надо выделить под закодированный объект. Работает IeSizeCalculator так же, как encoder, только вместо непосредственно кодирования считает размер необходимый для кодирования элемента. При таком подходе мы не выделяем лишней памяти, что позволяет существенно сократить ее потребление, особенно во время высокой нагрузки.

MyJsonObj obj;
std::vector<char> buffer;
buffer.reserve(IeSizeCalculator{}(obj));

raw_data_holder

Каждый сложный тип, например, object или array, содержит в себе string_view. string_view в свою очередь, указывает на данные, из которых этот тип был декодирован. Decoder в процессе декодирования сохраняет в эту переменную кусочек JSON, из которого этот тип был декодирован. И encoder, если он видит потом, что эта переменная хранит значение, просто копирует его вместо честного кодирования всего дерева структур.

struct raw_data_holder
{
    constexpr bool isRawDataSet() const noexcept;
    constexpr std::string_view getRawData() const noexcept;
    constexpr void setRawData(std::string_view data) noexcept;
    constexpr void clearRawData() noexcept;

private:
    std::string_view m_rawData;
};

Зачем это нужно? JSON часто передается между разными сервисами. Бывает так: нам не нужно вручную заполнять или кодировать весь JSON или его часть — мы просто берем его, например, из базы данных, извлекаем кусок JSON и помещаем в структуру. Когда encoder увидел, что там есть это поле, он просто скопировал данные. Это экономит время и упрощает работу.

Кодирование

Давайте разберемся, как работает процесс кодирования. Чтобы закодировать объект, помимо самого объекта нужен специальный класс — EncoderContext. Он отвечает за запись данных в буфер. Кроме того, нужен JsonEncoder — своего рода visitor, который проходит по всему дереву объектов и вызывает нужные функции для каждого типа данных.

char buf[2048];
oas::EncoderContext ectx{buf};
oas::JsonEncoder enc{ectx}

MyObject obj;
enc(obj);

Ниже мы видим функцию кодирования для объекта. Она шаблонная: в результате компиляции для каждого конкретного типа инстанцируется функция и JsonEncoder в каждый момент знает, какой тип ему нужно кодировать следующим.

template <AnIe IE> 
requires (AnIeType<type_e::OBJECT, IE>)
void encodeIe(IE const& ie)
{
    if (setFromRawData(ie)) { return; }
    m_ctx.pushChar('{');
    encodeObject(ie);
    m_ctx.pushChar('}');
}

Декодирование

Здесь все похоже на кодирование, только добавляется еще один важный компонент — аллокатор. Он нужен для выделения памяти под типы с неизвестным размером, например, массивы. Также используем DecoderContext — он отвечает за чтение данных из строки. JsonDecoder, который, как и JsonEncoder, играет роль visitor, который инстанцируется на этапе компиляции для каждого типа, и всегда знает, что именно должен декодировать. JsonDecoder так же на ходу валидирует JSON на основе схемы. Проверяет, что в объекте представлены все поля, помеченные как required или что значение integer попадает в диапазон ограниченный minimum и maximum.

std::string_view json = ...;
char allocBuf[8 * 1024];
utils::Allocator alloc{allocBuf};
oas::DecoderContext dctx{json, alloc};
oas::JsonDecoder dec{dctx};

MyObject obj;
dec(obj);

На пример, в функции декодирования типа object в основе лежит цикл. Он проходит по строке, читает имена полей. Так как схема известна заранее, мы можем посчитать хеш от каждого имени поля и по нему найти, какой конкретно тип имеет это поле. После этого уже происходит непосредственно декодирование поля.

template <AnIe IE, AnIe PARENT = dummy> 
requires (AnIeType<type_e::OBJECT, IE>)
void decodeIe(IE& ie)
{
    ...
    while (...)
    {
        auto const [name, hash] = m_ctx.popHashName();
        // find field type by hash and name and decode it 
    }
    ...
}
Кодогенерация

Писать код с множеством сервисов и интерфейсов руками было бы слишком сложно. Поэтому мы сделали генератор. У нас есть OpenAPI-схемы в YAML. Они проходят через YAML-парсер — мы выбрали Ruamel, потому что он лучше поддерживает новые стандарты YAML по сравнению с стандартным PyYAML. Результат парсинга поступает на вход нашему препроцессору. Он проверяет схему на валидность (ведь в стандартах тоже бывают ошибки) и учитывает особенности нашего кодека. После этого результат идет на шаблонизатор Jinja2, который превращает все в готовый код на C++.

Преимущества и недостатки нашего кодека для JSON

Мы написали свой кодек. Какие у него плюсы и минусы? Начну с минусов:

  • Громоздкость структур. У нас все лежит на stack, структуры весят очень много, целые килобайты.

  • Зависимость объектов от времени жизни исходной строки. 

  • Размер бинарного файла.

  • Время компиляции. 

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

Плюсы:

  • Мы полностью контролируем кодирование и декодирования, при необходимости можем добавлять оптимизации 

  • У нас есть валидация по схеме. Программистам не нужно писать свою валидацию каждый раз, когда они распарсили JSON.

  • Скорость (?).

Скорость работы я поставил со знаком вопроса. С одной стороны, мы сделали все, что хотели — у нас практически не используется heap, нет копирований, нет поиска. Модуль работы по OpenAPI-схемам прошел наши внутренние тесты, а значит, и JSON-кодек прошел их тоже. 

Но, с другой стороны, мы не сравнивали наш JSON-кодек вообще ни с чем после того, как его разработали, поэтому сделаем это сейчас.

Сравниваю наш кодек с библиотекой Glaze

Для сравнения я выбрал библиотеку Glaze. С Glaze довольно просто работать, можно быстро написать структуры данных под нужную схему. Библиотека выглядит подходящей под наши требования благодаря большим возможностям кастомизации. А еще у нее довольно хороший перформанс, согласно заверениям авторов.

Библиотека

Roundtrip Time (s)

Write (MB/s)

Read (MB/s)

Glaze

1,04

1366

1224

simdjson (on demand)

N/A

N/A

yyjson

1,23

1005

1107

daw_json_link

2,93

365

553

RapidJSON

3,65

290

450

Boost.JSON (direct)

4,76

199

447

json_struct

5,50

182

326

nlohmann

15,71

84

80

Таблица перформанса Glaze в сравнении с другими кодеками (Github)

Ниже — пример работы Glaze, которая практически любую свою структуру позволяет закодировать в JSON с помощью метаструктур.

struct my_struct {
    int i;
    std::string hello;
};

template <>
struct glz::meta<my_struct> {
    using T = my_struct;
    static constexpr auto value = object(
        &T::i,
        &T::hello
    );
};

Я сравнивал данные: взял JSON размером примерно 3 Кб прямо из наших тестов. Использовал Catch2, просто потому что он был под рукой. Что замерял:

  • заполнение,

  • кодирование,

  • декодирование. 

Мне пришлось немного модифицировать наш кодек ради честного сравнения. Я не успел реализовать несколько фич в Glaze, поэтому исключил их при создании бенчмарков нашего кодека.

Заполнение

Посмотрим на первую часть — заполнение. Заполняем мы JSON существенно быстрее. Но, честно говоря, я думаю, что просто неудачно выбрал контейнеры для Glaze, возможно, его можно разогнать еще быстрее.

Кодирование отдельных типов

Здесь у нас получается по-разному: где-то быстрее, где-то нет. Особенно удивил результат по boolean. Его кодирование у нас происходит примерно с такой же скоростью, как и у строк, что кажется странным. Показатели для enum и oneOf совпадают, и это легко объяснить: внутри одного из них кодировался именно enum. Поэтому их скорости сопоставимы, и отличаться не должны.

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

Декодирование

Давайте посмотрим на декодирование — эта ситуация похожа на кодирование. Во многих случаях мы быстрее декодируем отдельные объекты. Интересный момент — у Glaze по каким-то причинам выборочный выброс: он декодирует enum примерно на 50% медленнее, чем строку при сравнимых размерах. У нас при этом разница всего около 35%.

Объяснить это можно следующим образом: при декодировании enum, как и полей объектов, вместо прямого сравнения строк сравниваются их хеши. И возможно система хешей в Glaze дала не лучший результат в конкретно таком примере.

Однако при декодировании больших объектов мы снова проигрываем. И тут у меня есть два объяснения:

  • Отсутствие inline, как и при кодировании 

  • Система хешей. В Glaze сделали интересную оптимизацию: хеширование производится по-разному, когда хеш-таблица содержит один, два или, например четыре элемента. А в некоторых случаях вместо хеширования делается сравнение по первым символам. Для больших объектов такие хитрости дают существенный прирост. Мы же всегда полностью считываем строку, считаем ее хеш и сравниваем. А если не считать хеш полностью, а, например, сравнивать первый символ, можно добиться лучшей производительности.

Что нам дало сравнение

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

Так же у меня возник вопрос: могли ли мы частично переписать работу с OpenAPI-схемами, использовав Glaze? На мой взгляд, особого смысла в этом нет. Glaze — не та библиотека, с которой просто декодируешь JSON и сразу с ним работаешь, как, например, nlohmann. Это скорее конструктор типа Lego, из которого нужно собрать свой кодек. Поэтому выигрыш во времени разработки по сравнению с созданием собственного кодека с нуля был бы небольшой.

Кроме того, Glaze из коробки не поддерживает некоторые вещи, которые мы делаем в своем кодеке. Например, декодирование oneOf из string и enum вызывает ошибку, если данные не подходят под первый тип.

И внутреннее решение дает больший контроль для подстраивания кодека именно под наши сценарии.

А с какими интересными кодеками вы встречались? Напишите в комментариях.

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


  1. ohrenet
    05.12.2025 11:55

    Сериализация/десериализация чтоли?