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);          // найти поле по id
contract::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: payment
port: 8080
enabled: true
tags:
  - 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.

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