Обновить

Комментарии 9

Я правильно понял, что нет поддержки версионности объектов? Это очень сильно ограничивает применимость, несмотря на все очевидные плюсы.

Зависит от того, что именно понимать под версионностью.

Сам CONTRACT задаёт основу для эволюции схемы: у полей есть стабильные ID, а формат и правила чтения принадлежат адаптеру. Поэтому конкретный адаптер может реализовать нужную стратегию — пропуск неизвестных полей, сохранение default-значений для отсутствующих, строгую проверку схемы или явные преобразования.

В compact базовая эволюция схемы уже реализована: неизвестные поля пропускаются, а при отсутствии известных полей сохраняются их текущие или default-значения. Поэтому поля можно добавлять, удалять и переименовывать без изменения ID — старые и новые сообщения остаются читаемыми.

Protobuf-адаптер сейчас намеренно строгий: неизвестное поле считается ошибкой. Для совместимости при развитии схемы reader должен пропускать неизвестные значения с учётом их wire type. Это локальное и вполне реализуемое расширение, которое логично оформить как настройку адаптера. Буду благодарен за issue, особенно с описанием вашего сценария.

Полноценной системы миграций v1 → v2 → v3 для несовместимых изменений типов или семантики сейчас действительно нет. Это отдельный слой. Поэтому интересно, что требуется в вашем случае: rolling upgrade сервисов или чтение объектов, сохранённых несколько лет назад?

На уровне схемы это дополняется словарём reserved_id/reserved_range/deprecated/alias — можно явно объявить, что конкретный id зарезервирован или устарел, прямо в контракте, а не в комментарии рядом с полем. Сегодня это декларативная информация: ни один адаптер её не проверяет в рантайме, но история схемы уже фиксируется в одном месте.

То есть, если нужна версионность, то поле версии надо самостоятельно добавить, как обычный член класса и необходимое поведение реализовать в адаптере, в первую очередь проверяя это поле? Но мне показалось, что тут киллер-фича, что адаптер универсален и пишется один раз. Написал адаптер для какого-то формата (пусть json), передаёшь в него любые объекты с заданным CONTRACT и он их сохраняет-загружает.

Пример... Вопрос возник сам-собой, как самая первая очевидная мысль, без конкретного примера. Допустим, бинарный формат. Была какая-то переменная во float и её заменили на double. Или, ещё лучше, был int32, потом его заменили на int64, а потом заменили вообще на текстовый BCD. И надо уметь считать все три версии сохранённых данных. Из БД или из файла сохранения игры, например. Откуда-то, куда данные могут быть прочитаны спустя месяцы и годы обновлений.

Даже если в прямом эфире записать и сразу же прочитать, например, при передаче данных между устройствами, но на которых установлены разные версии приложения. Когда улиентское приложение ещё старое, а сервер уже новый и блокировать работу клиента до получения обновления не хочется...

адаптер действительно универсален, его менять не нужно, ты просто используешь его. ну и клиллер фича еще в том что ты пишешь одну схему и работают все поддерживаемые адаптеры. ты можешь данные передавать в сеть по протобафу, выводить в лог в json, выводить в дебаг на экран, писать compact в файл. и это просто удобно)

по поводу совместимости типов, тут уже дело адаптера конкретного, тот же протобаф и compact целые числа сжимают и любое целое можно распаковать в другое. с плавающей точкой сложнее, там это разные типы и каста нет. но при желании это можно сделать в кодеках типов адаптеров. BCD примерно тоже самое тоже нужно делать поддержку конверта в codec<T> - они в целом для этого и вынесены отдельно.

а вот если добавилось новое поле или удалилось, то все проблемы решаются легко за счет ID в схеме, как я писал выше.

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

такая же схема когда данные сохранил в файл, и через год читаешь. что возможно прочитаешь. мы такую схему использовали с compact и сохраняли стейт приложений для бекапа или рестарта между версиями, это работало. Единственно ограничение когда у тебя схема меняется настолько что нужно писать конвертер от v1 к v2 потому что у тебя все поменялось и ни о какой совместимости речи уже на уровне схемы не идет и автоматически тут ничего не сделать. можно подумать над слоем версионности структур, но это усложнить CONTRACT и вводить в типы версии, и функции конверта... нада подумать.

все твои пожелания можно закрыть через приведение типов в конкретных адаптерах в codec<T>. Думаю даже это не сильно снизит скорость работы, hot path можно сделать на совпадения типа, а попытку каста на fallback.

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

это сигнатура кодека:
codec<T>::read(Reader&, Field&, wire_type wire, T& value)

в ее придется добавить ветку на fallback

if (wire == detail::wire_type::fixed64) {

Пока что уровня понимания хватает только на то, что вместо версионности объекта следует делать отдельные версии объекта с последующей внешней конвертацией при необходимости. То есть делать Class_v1, Class_v2, Class_v3, а не изменять один и тот же Class с течением времени.

на самом деле новый класс нужен далеко не всегда, это самый сложный путь, он нужен когда тебе нужно проводить major миграцию схемы по сути, давай на примерах: когда новый класс точно не нужен.

давай на примерах:

struct Order {    

std::uint64_t id;    

std::string customer;    

double amount;

CONTRACT(Order, (id, 1), (customer, 2),  (amount, 3) )

};

ты решил добавить новое поле  bool paid; ты решаешь какое значение по умолчанию к него будет paid = false; и записываешь его в контракт (paid, 4). и этого хватит, схема поменялось но все может работать.

если старое приложение получило новые пакет, оно прочитает ID 4, не найдет его у себя в структуре и при желании сможет пропустить (так сейчакс делает compact адаптер, а протобаф можно просто научить).

если новое приложение читает старые данные из бекапа, базы, импорта: оно не найдет ID 4 и останется значение по умолчанию.

это поведение и есть ленивая миграция схемы.

но она требует понимания что контракт данных сможет так работать, там бывает часто, но не всегда. а вот если это не подходит, как раз нужно делать Class_v1, Class_v2, Class_v3, а это отдельное боль и страдание. когда это точно нужно… когда ты поменял типы так что их даже нельзя автосконвертить через кодеки, например е тебя был int, а ты решил там хранить vector, и нужно явная функция конверта v1 - v2.

в целом думаю через CONTRACT это на уровне схемы описать реально, и даже функуции конверта можно сделать удобные. я подумаю) это очень похоже на наследование BASE(Header, 100). типа:

CONTRACT (Event_v2, #2

OLD(Event_v1)

)

ну и функцию конверта придется явно писать на все дерево.

Ещё показалось, что CONTRACT будет уязвим к ошибкам неосторожных модификаций, если используется формат без сохранения типов полей. Если по глупости модифицировал класс, поменяв типы полей, а при чтении старого файла попадётся поле другой размерности, то его ведь не удастся корерктн осчитать и будут испорчены все последующие поля. Либо так, либо сохраняется очень много служебных данных (столько же, сколько и в любом другом формате с сохранением типов полей, это не претензия к библиотеке, просто констатация факта). Когда выгружаешь большие объёмы данных, хочется и размер объекта сократить и плюшки не потерять. Обычно это делается как раз отдельным сохранением scheme или тому подобного в отдельном файле или прям в этом же. И вот стало интересно, как у CONTRACT с этим дела обстоят, можно ли разметку класса сохранить отдельно и при сохранении каждого поля объекта чтобы типы полей не сохранять? Раз есть обход всех полей класса, наверное, всё это можно закостылить внутри адаптера с сохранением типов в отдельный файл.

тут конечно от адаптера все зависит.
лучше всего это поддерживает в compact: там типы записываются так что любое поле может быть пропущено. то есть любую структуру можно прочитать, не знаю куда она должна быть декодирована, будь то простой тип, вектор, структура, вложенная древовидная структура. не нашли куда ее расспокавать по ID и ее просто скипнут из бинарной записи, и данные не будут испорчены, следующие данные будут прочитаны.  Но тут вопрос к кодекам, они не должны вернуть ошибку. как таковая схема не нужна, текущая схема у тебя уже в коде.

типы пишутся вместе с данными как wire_type, но тут не имена типов конечно. и пишутся размеры которые облегчают декодирование.

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

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]

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации