CONTRACT: одна схема вместо N×M сериализаторов

от автора

One contract. Any format. Zero mapping.

Любой C+±проект, где данные не остаются внутри одного процесса, рано или поздно упирается в одно и то же: одну и ту же структуру нужно уметь показать в дебаге, записать в бинарный протокол, отдать по protobuf во внешний сервис и залогировать в JSON. Без общего механизма получается россыпь ручных мэппингов на каждый тип: toJson, toProto, debugPrint, writeBinary. Каждое изменение схемы приходится синхронизировать руками во всех них.

N классов данных × M форматов = N×M ручных мэппингов

Мы не единственные, кто в C++ уперся именно в эту стену. Рядом стоят reflect-cpp (C++20-рефлексия, JSON/BSON/CBOR/msgpack/TOML/XML/YAML/Avro/Cap’n Proto и другие), serde-cpp (вдохновлен Rust serde) и более старые Boost.Serialization/Cereal. Вопрос в том, что именно предлагает CONTRACT в дополнение к “одна схема — много форматов”, раз эта идея уже не нова.

Не еще один сериализатор

CONTRACT решает N×M иначе, чем библиотека сериализации:

N контрактов + M адаптеров

Схема объявляется один раз, прямо в C+±структуре:

struct Order {    std::uint64_t id;    std::string customer;    double amount;    bool paid;    CONTRACT(Order,        (id, 1),        (customer, 2),        (amount, 3),        (paid, 4)    )};

CONTRACT(...) не сериализует ничего сам. Он дает стабильный список полей: id, имя, тип, порядок обхода. Им может воспользоваться сериализатор, а может и что-то другое: валидатор, экспортер схемы, аудит-дамп. CONTRACT — не рантайм-рефлексия, не generic-сериализатор и не schema-first кодогенератор. Ядро отвечает за то, что такое поле и как до него добраться, а не за то, как оно должно выглядеть в wire-формате. Это уже задача адаптера.

Отсюда и разница с reflect-cpp: reflect-cpp отвечает на вопрос “как сериализовать структуру в N форматов”. CONTRACT отвечает на другой вопрос: “как дать структуре стабильный контракт, которым сериализация может воспользоваться, а может и не воспользоваться”.

Было / стало

Без общей схемы Order из примера выше выглядела бы примерно так:

struct Order {    std::uint64_t id;    std::string customer;    double amount;    bool paid;    std::string toJson() const {        std::ostringstream out;        out << "{\"id\":" << id            << ",\"customer\":\"" << customer << "\""            << ",\"amount\":" << amount            << ",\"paid\":" << (paid ? "true" : "false") << "}";        return out.str();    }    void debugPrint(std::ostream& out) const {        out << "Order{id=" << id << ", customer=" << customer            << ", amount=" << amount << ", paid=" << paid << "}";    }    void writeBinary(std::vector<std::uint8_t>& buf) const { /* ... */ }};

Плюс отдельный order.proto, protoc, сгенерированные .pb.h/.pb.cc и ручной toProto/fromProto между Order и OrderProto. Четыре формата — четыре места, куда нужно не забыть внести любое изменение схемы.

С CONTRACT Order объявляется один раз (см. выше), а дальше формат — это просто выбор адаптера:

contract::cout << order;contract::adapters::json::to_string(order);binary_out << order;proto_out << order;

Order в этом коде не меняется вообще — меняется только то, через что его пропускают.

Разница видна и в обратную сторону: если нужно добавить новое поле, в CONTRACT(...) это одна строка. В “было”-варианте это правка сразу в нескольких местах: toJson, debugPrint, writeBinary, .proto-файле и ручном toProto/fromProto. И в каждом легко забыть.

Чего мы хотели от модели

На этом простом примере уже видна модель шире, чем “одна декларация вместо N мэппингов”. Хотелось, чтобы:

  1. Контракт был стабильной схемой, объявленной один раз в самом C++ типе, независимо от формата:

    • id

    • имя

    • тип

    • способ доступа к полю.

  2. Поле не обязано было быть физическим членом структуры:

    • переиспользовать схему через наследование (BASE)

    • вычислять значение на лету (PROPERTY)

    • ссылаться на данные, которыми тип не владеет (REFERENCE).

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

    • сериализация (protobuf, JSON, compact, binary, …)

    • валидация

    • экспорт схемы

    • аудит-дамп.

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

    • security

    • check

    • unit.

  5. Все это не создавало излишнюю нагрузку на рантайм.

Ядро контракта

В основе лежит то, что мы хотели первым пунктом — стабильная схема с id, именем, типом и способом доступа к полю. На практике это небольшой compile-time API, поверх которого построено все остальное:

contract::field_count<Order>();                        // сколько полейcontract::field_at<0, Order>();                        // дескриптор поля по индексуcontract::dispatch_field_by_id<Order>(2, fn);          // найти поле по idcontract::dispatch_field_by_name<Order>("amount", fn); // то же самое по имениcontract::type_name<Order>();                          // "Order"

Дескриптор поля несет id, имя и способ доступа (get/set/ref). Этого достаточно, чтобы адаптер построил вокруг него что угодно, от wire-кодека до дебаг-дампа, ни разу не заглянув внутрь самой структуры напрямую.

Кстати, зачем вообще id, а не просто имя: дело не только в размере на wire (имя длиннее, дольше сравнивать при упаковке) — реальная опасность в другом. Если один и тот же идентификатор, имя это или число, переиспользовать для поля с несовместимым типом, старый и новый код начнут по-разному трактовать одни и те же байты. Для этого случая в контракте можно явно зарезервировать id (contract::schema::reserved_id(...)) — сегодня это чисто декларативный маркер, ни один адаптер его пока не проверяет.

Гибкость контракта: BASE, PROPERTY и REFERENCE

Это и есть второй пункт: поле не обязано быть физическим членом структуры. У CONTRACT для этого есть три механизма.

BASE(Type, offset) подключает контракт другого C+±типа как часть текущего через обычное наследование, со сдвигом id, чтобы поля базового типа не столкнулись с полями производного:

struct Header {    std::uint64_t request_id;    CONTRACT(Header, (request_id, 1))};struct Event : public Header {    std::string name;    CONTRACT(Event,        BASE(Header, 100),        (name, 1)    )};

Event получает request_id под id 101 (100 + 1) и свое name под id 1. Общая часть схемы объявлена один раз в Header и переиспользуется, а не копируется в каждый тип, где она нужна.

PROPERTY(name, id, type) — поле контракта, за которым не стоит физический член структуры, а стоит пара contract_get/contract_set:

struct Metric {    std::uint32_t raw_count = 0;    CONTRACT(Metric,        (raw_count, 1),        PROPERTY(doubled_count, 2, std::uint32_t)    )    std::uint32_t contract_get(const contract_fields::doubled_count&) const {        return raw_count * 2;    }    void contract_set(const contract_fields::doubled_count&, std::uint32_t value) {        raw_count = value / 2;    }};

Адаптеры видят doubled_count как обычное поле: читают и пишут его тем же путем, что и raw_count, хотя в памяти Metric такого поля вообще нет. Значение вычисляется на лету через contract_get/contract_set.

REFERENCE(name, id) — третий вид поля: контракт на данные, которыми структура не владеет, а только ссылается. Этот механизм используется в структурном логгере CONTRACT, чтобы не копировать значение на горячем пути:

template<class T>struct payload_field {    std::string_view name;    const T& value;    CONTRACT(payload_field,        (name, 1),        REFERENCE(value, 2)    )};

value — ссылка, а не копия; адаптер читает ее как обычное поле контракта, но лог-вызов не платит за аллокацию/копирование логируемого значения.

Экосистема адаптеров

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

Адаптер

Запись

Чтение

Комментарий

protobuf

полный: wire-совместим с настоящим protobuf, обгоняет libprotobuf в 20/28 замеров (отдельная статья)

binary

полный: нативная раскладка без wire-оверхеда, самый быстрый вариант — но не кросс-платформенный формат по умолчанию

compact

полный: свой компактный wire-формат, единственный, кто сегодня реально пропускает незнакомые поля при чтении

JSON

только запись, зато с security-режимами (redact/omit) — на нем построен structured logging

structured logging

тонкая надстройка над JSON-адаптером для логов, не отдельный wire-формат

console/debug

человекочитаемый дебаг-вывод

YAML

только чтение: строгий config-reader, а не экспортный формат — писать в YAML CONTRACT пока не умеет

Общая для всех архитектура одна и та же: contract знает поля и их идентичность и ничего не знает про формат, io работает с байтами и курсором. А вот writer/reader и codec<T> уже принадлежат конкретному адаптеру и знают его wire-правила — у каждого формата свои.

Слой атрибутов

Четвертым пунктом был отдельный слой атрибутов поверх полей, который вешается на поле в списке рядом с id и интерпретируется каждым адаптером по-своему. Набор словарей расширяем — новый можно добавить, не трогая ядро; сегодня реально работают security и check.

Возьмем типичное событие авторизации с PII и секретом внутри:

struct AuthEvent {    std::string user_email;    std::string access_token;    std::uint64_t duration_ns;    CONTRACT(AuthEvent,        (user_email, 1, contract::security::sensitive()),        (access_token, 2,            contract::security::secret(),            contract::security::no_log(),            contract::security::encrypt()),        (duration_ns, 3)    )};

Один и тот же AuthEvent, без единого if в бизнес-коде, ведет себя по-разному в зависимости от адаптера. Console/debug и JSON пока учитывают secret/no_log/sensitive — у каждого свой дефолт, а как включить нужный режим через options, показывает пример ниже. А в binary encrypt() сегодня — это просто обфускация по ключу, не тяжелая криптография. Такая per-field политика возможна и у обычных сериализаторов (у protobuf есть свои field options); разница CONTRACT в том, что один и тот же атрибут одинаково понимают разные, независимо реализованные адаптеры — а не в том, что для остальных это принципиально недостижимо.

Так это выглядит в структурированном логе (упрощенный вариант examples/logging.cpp):

struct SecretPayment {    std::uint64_t order_id;    std::string_view token;    CONTRACT(SecretPayment,        (order_id, 1),        (token, 2, contract::security::secret()))};contract::logging::options opt{};opt.json.secret = contract::adapters::json::security_mode::redact;contract::logging::logger log{out, opt};SecretPayment secret_payment{18, "tok_live_123"};log.info("payment_sensitive", "Captured sensitive payment metadata",    contract::logging::attribute("payment", secret_payment));

Фрагмент вывода (полностью — см. examples/logging.cpp):

{"name":"payment_sensitive","attributes":[{"name":"payment","value":{"order_id":18,"token":"<redacted>"}}]}

token попал в лог как "<redacted>", потому что так решил вызывающий код через opt.json.secret.

Не в ущерб скорости

И последнее, пятое: ничего из этого не должно создавать лишнюю нагрузку на рантайм. Одна декларация вместо N×M — это, в первую очередь, про удобство, но это не покупается ценой производительности: protobuf-адаптер CONTRACT сравнивали с настоящим libprotobuf на 14 сценариях. CONTRACT оказался быстрее. Подробности, методология и исключения — в отдельной статье про protobuf-адаптер.

Чего CONTRACT не делает

Чтобы не создавать впечатления, что это решение “на все”:

  • не рантайм-рефлексия — обход полей раскрывается на этапе компиляции.

  • не generic-сериализатор — формат и его правила целиком принадлежат адаптеру, ядро формат не выбирает и не диктует.

  • не schema-first кодогенератор — нет отдельного файла схемы и шага генерации, схема — это сама C++ структура.

  • не место для буферов, SQL или стороннего рантайм-кода — это ответственность конкретного адаптера, а не ядра.

Пример: конфиг из YAML + дебаг-вывод

Напоследок — код из репозитория (examples/yaml_file_read.cpp): один и тот же контракт читает YAML-адаптер, а печатает — debug-адаптер.

struct PaymentConfig {    std::string service;    std::uint32_t port = 0;    bool enabled = false;    std::vector<std::string> tags;    CONTRACT(PaymentConfig,        (service, 1),        (port, 2),        (enabled, 3),        (tags, 4))};contract::adapters::yaml::reader<contract::io::file_buffer_input> in(    contract::io::file_buffer_input{"payment_config.yaml"});PaymentConfig config{};in >> config;contract::cout.debug() << config;

При таком payment_config.yaml:

service: paymentport: 8080enabled: truetags:  - api  - payments  - production

вывод — снят с собранного бинарника:

PaymentConfig:  service: "payment"  # #1 std::string  port: 8080          # #2 u32  enabled: true       # #3 bool  tags:               # #4 std::vector<std::string>, size=3    - "api"           # [0]    - "payments"      # [1]    - "production"    # [2]

Тот же PaymentConfig, тот же контракт. Id и тип каждого поля попадают в вывод сами, без единой строчки кода, написанной специально под форматирование.

Почему макрос, а не C++26 reflection

Не отменит ли reflection нужность CONTRACT целиком? C++26 reflection умеет перечислять члены структуры без макроса. Это, скорее всего, действительно упростит объявление и реализацию контракта — меньше ручного текста на перечисление физических полей. Но сама модель никуда не денется: CONTRACT все равно должен определить, что такое стабильный id, который не меняется при эволюции схемы (см. выше), что такое атрибут-политика (security::secret(), schema::reserved_id()), и что считать полем, если физического члена за ним нет (PROPERTY). И адаптеров это вообще не касается: они как работали с уже собранным контрактом, так и продолжат работать, каким бы способом ни была объявлена схема — макросом или рефлексией. Reflect-cpp уже сегодня показывает, чего не хватает одной рефлексии для этой модели: ни стабильного id, ни attribute-слоя, ни вычисляемых полей у него нет.

А сами макросы — не плохая ли это практика? Отчасти справедливо: текстовая подстановка без области видимости — это реальная цена. А вот с нечитаемыми ошибками компиляции мы прицельно боролись: опечатался и дал двум полям один id — падает понятный static_assert ("CONTRACT field ids must be unique after BASE offsets are applied"), а не страница шаблонного мусора. Но CONTRACT(...) — не макрос, который прячет логику или control flow; он генерирует декларативные дескрипторы полей, тем же путем, что Q_OBJECT в Qt, TEST(...) в gtest или BOOST_DESCRIBE_STRUCT в Boost.Describe. И пока static reflection не стала мейнстримом, это самый практичный инструмент, чтобы объявить метаданные поля один раз, в самом C+±типе.

Итог

Смысл CONTRACT простой: схема объявляется один раз рядом с типом, после чего одни и те же данные можно писать в binary или protobuf, читать из YAML, выводить в debug-представлении или отправлять в структурированный лог — без отдельных списков полей и ручных мэппингов для каждого формата. При этом адаптеры не платят за удобство лишней работой в рантайме.

Один контракт, разные форматы, никаких ручных мэппингов.

CONTRACT — открытый проект. Если вам интересны compile-time метаданные, сериализация или разработка новых адаптеров, присоединяйтесь. Буду рад обратной связи, обсуждению архитектуры и участию в развитии библиотеки.

Код — github.com/antako76/Contract.

ссылка на оригинал статьи https://habr.com/ru/articles/1065488/