Одна C++ схема — protobuf, binary, JSON, YAML и structured logging без ручных маппингов. В статье разбираю, как CONTRACT отделяет стабильный контракт типа от конкретных форматов, зачем нужны BASE, PROPERTY и REFERENCE и как атрибуты полей задают общую политику для разных адаптеров. Всё - на реальном коде из репозитория: YAML-конфиг, структурированный лог и сравнение производительности с libprotobuf. GitHub. Читать далее
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 мэппингов”. Хотелось, чтобы:
Контракт был стабильной схемой, объявленной один раз в самом C++ типе, независимо от формата:
id
имя
тип
способ доступа к полю.
Поле не обязано было быть физическим членом структуры:
переиспользовать схему через наследование (BASE)
вычислять значение на лету (PROPERTY)
ссылаться на данные, которыми тип не владеет (REFERENCE).
Формат и поведение целиком принадлежали адаптеру, а не ядру: сериализация тут только одна из возможных ролей, не единственная:
сериализация (protobuf, JSON, compact, binary, …)
валидация
экспорт схемы
аудит-дамп.
Поверх полей был отдельный слой атрибутов: политика, которую разные адаптеры трактуют по-своему:
security
check
unit.
Все это не создавало излишнюю нагрузку на рантайм.
В основе лежит то, что мы хотели первым пунктом - стабильная схема с 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(...)) - сегодня это чисто декларативный маркер, ни один адаптер его пока не проверяет.
Это и есть второй пункт: поле не обязано быть физическим членом структуры. У 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 или стороннего рантайм-кода - это ответственность конкретного адаптера, а не ядра.
Напоследок - код из репозитория (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 и тип каждого поля попадают в вывод сами, без единой строчки кода, написанной специально под форматирование.
Не отменит ли 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.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Если ссылки схлопываются, значит это кому‑то нужно | 0 | 8.26 | 30-07-2026 |
| 2 | YaFF в опенсорсе: как и зачем мы сделали zero‑copy представление для Protobuf | 0 | 6.89 | 17-06-2026 |
| 3 | Что внутри #[derive(Serialize)]: TokenStream, syn, quote и почему этот serde так долго компилируется | 0 | 7.62 | 16-07-2026 |
| 4 | Дайджест C++: новости, полезные материалы и «свой язык» на десерт | 0 | 7.94 | 27-05-2026 |
| 5 | Шаблоны C++ как инструмент архитектуры: compile-time dispatch, type traits и type erasure | 5 | 7 | 28-06-2026 |
| 6 | Что лучше — C++ или C#? | 0 | 7.5 | 29-06-2026 |
| 7 | Docker Fundamentals: что внутри compose.yaml и как там всё устроено | 0 | 8.14 | 24-07-2026 |
| 8 | Трилемма Святого Грааля типизации: почему нельзя всё сразу | 0 | 8.44 | 29-07-2026 |
| 9 | Бенчмаркая System.Text.Json: те же данные, те же настройки, до ×4,3 разницы | 0 | 11.26 | 30-07-2026 |
| 10 | Худший язык программирования всех времён /s | -2 | 6 | 01-07-2026 |