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(...)) - сегодня это чисто декларативный маркер, ни один адаптер его пока не проверяет.
Гибкость контракта: 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.
