вторник, 23 июля 2024 г.

[prog.c++] Как в json_dto можно сделать так, чтобы элемент в JSON был либо единичным объектом, либо вектором объектов?

В проекте для текущего заказчика возникла потребность сделать так, чтобы элемент в JSON-е мог быть либо одиночным объектом:

{
  "message": {
    ...,
    "extension": { ... }
  }
}

либо же вектором объектов:

{
  "message": {
    ...,
    "extension": [
      { ... },
      { ... },
      { ... }
    ]
  }
}

Сделать это оказалось не сложно, т.к. в проекте для работы с JSON используется весьма низкоуровневый инструмент, в котором, как в RapidJSON, приходится вручную разбираться с типами элементов. Буквально что-то вроде item.SetObject("extension", value) или item.SetArray("extension").PushBack(...).

Но мне стало интересно, а можно ли будет сделать что-то подобное с помощью нашей разработки json_dto.

Мы делали json_dto как раз для того, чтобы не нужно было вручную возиться с SetObject и SetArray, чтобы сериализатор/десериализатор сам разбирался с этой скучной рутиной. А такая высокоуровневость временами выходит боком, когда пытаешься сделать что-то, что изначальным дизайном библиотеки не предусматривалось. Так что я подумал, что это своего рода "вызов" и попробовал сделать такой трюк средствами json_dto.

Выполнить этот трюк удалось без каких-либо проблем и без доработок json_dto, хотя и несколько многословно: достаточно было определить кастомный Reader_Writer.

Есть у нас в json_dto такое понятие, как кастомный Reader_Writer -- это объект с двумя методами, read и write. Именно этот объект берет на себя ответственность за то, чтобы прочитать/записать значение поля объекта посредством RapidJSON. По умолчанию для это делает сам json_dto, но когда нужно что-то кастомизировать, то пользователь может реализовать собственный Reader_Writer и приказать json_dto использовать именно этот Reader_Writer.

Как же это все выглядит?

Допустим, что у нас есть структура, объекты которой в JSON-е присутствуют либо в единичном, либо во множественном числе:

struct extension_t
{
   std::string m_id;
   std::string m_payload;

   extension_t() = default;
   extension_t( std::string id, std::string payload )
      : m_id{ std::move(id) }
      , m_payload{ std::move(payload) }
   {}

   templatetypename Json_Io >
   void json_io( Json_Io & io )
   {
      io & json_dto::mandatory("Id", m_id)
         & json_dto::mandatory("Payload", m_payload)
         ;
   }
};

Тут все просто. Внутри этой структуры есть json_io, который и выполняет сериализацию/десериализацию объектов extension_t. Причем сериализация/десериализация полей extension_t выполняется обычным образом, штатными средствами json_dto, программисту здесь ничего дополнительного делать не нужно.

Объекты extension_t существуют не сами по себе, они должны агрегироваться объемлющим типом. В данном примере это тип message_t:

struct message_t
{
   message_t() {}

   message_t(
      std::string from,
      std::int64_t when,
      std::string text )
      :  m_from{ std::move( from ) }
      ,  m_when{ when }
      ,  m_text{ std::move( text ) }
   {}

   std::string m_from;
   std::int64_t m_when;
   std::string m_text;

   std::vector< extension_t > m_extension;

   ...
};

Итак, у нас есть объемлющая структура message_t, в которой хранится набор extension_t. И нам нужно, чтобы при записи экземпляра message_t в зависимости от размера m_extension в JSON попадал либо одиночный объект, либо вектор объектов.

Для этого нам потребуется тип Reader_Writer, который будет выполнять чтение/запись объектов extension_t:

struct extension_reader_writer_t
{
   void
   read( std::vector< extension_t > & to, const rapidjson::Value & from ) const
   {
      ...
   }

   void
   write(
      const std::vector< extension_t > & v,
      rapidjson::Value & to,
      rapidjson::MemoryPoolAllocator<> & allocator ) const
   {
      ...
   }
};

Метод read вызывается когда нам нужно прочитать (десериализовать) поле, тогда как метод write вызывается когда нам нужно записать (сериализовать) поле.

Можно обратить внимание, что в read/write передаются ссылки не на одиночный объект extension_t, а на вектор таких объектов. Это потому, что мы же работаем с набором объектов extension_t, который представляется std::vector.

Итак, у нас есть Reader_Writer, который мы и укажем при реализации json_io для message_t:

struct message_t
{
   ...

   templatetypename Json_Io >
   void json_io( Json_Io & io )
   {
      io & json_dto::mandatory( "from", m_from )
         & json_dto::mandatory( "when", m_when )
         & json_dto::mandatory( "text", m_text )
         & json_dto::mandatory( extension_reader_writer_t{},
               "extension", m_extension )
         ;
   }
};

И вот здесь видно, что при работе с полем m_extension в mandatory мы передаем не только название элемента и само поле, но еще и экземпляр Reader_Writer-а. Именно это и заставляет json_dto при (де)сериализации m_extension задействовать Reader_Writer вместо встроенных средств json_dto.

Теперь нужно посмотреть что же из себя представляет реализация Reader_Writer:

struct extension_reader_writer_t
{
   void
   read( std::vector< extension_t > & to, const rapidjson::Value & from ) const
   {
      using json_dto::read_json_value;

      to.clear();

      if( from.IsObject() )
      {
         extension_t single_value;
         read_json_value( single_value, from );
         to.push_back( std::move(single_value) );
      }
      else if( from.IsArray() )
      {
         read_json_value( to, from );
      }
      else
      {
         throw std::runtime_error{ "Unexpected format of extension_t value" };
      }
   }

   void
   write(
      const std::vector< extension_t > & v,
      rapidjson::Value & to,
      rapidjson::MemoryPoolAllocator<> & allocator ) const
   {
      using json_dto::write_json_value;
      if1u == v.size() )
         write_json_value( v.front(), to, allocator );
      else
         write_json_value( v, to, allocator );
   }
};

json_dto вызывает метод read когда нужно десериализовать значение. В read мы проверяем имеем ли дело с одиночным объектом (from.IsObject) или же с вектором объектов (from.IsArray). Если с одиночным объектом, то создаем новый пустой объект на стеке, затем десериализуем его посредством read_json_value, а после десериализации запихиваем в результирующий вектор. Если же на входе у нас вектор, то та же самая функция json_dto::read_json_value используется для чтения вектора (причем здесь мы можем и не создавать промежуточные объекты, а заталкивать прочитанное сразу же в результирующий вектор).

Фокус здесь в функции read_json_value. Эта функция является одной из точек кастомизации в json_dto для поддержки разных типов сериализуемых данных. В самом json_dto есть целый ряд реализаций read_json_value, как для примитивных типов (вроде int или long), так и для контейнеров из стандартной библиотеки (а также мимикрирующих под них). Так что, фактически, десериализация отдается на откуп средствам из json_dto. В случае одиночного объекта будет вызвана read_json_value которая напрямую дернет json_io из extension_t, а в случае вектора объектов -- перегрузка read_json_value для std::vector (а у нее внутри будут опять таки дергаться json_io из extension_t).

В методе write все аналогично и даже чуть-чуть попроще. Если в сериализуемом векторе один объект, то записываем только его. Если же в векторе несколько объектов, то записываем их все именно как вектор. Причем и здесь основная работа выполняется json_dto-шной функцией write_json_value. Это полный аналог описанной выше read_json_value, но для сериализации.


В общем, был удивлен тому, насколько все оказалось просто и безболезненно.

Конечно, с непривычки глядя на показанный выше код, сложно сказать, что это просто. Однако, если есть понимание того, как json_dto поступает с Reader_Writer и read_json_value/write_json_value, то никаких трудностей нет. Причем, что важно, все-таки основную рутину удалось переложить на json_dto (точнее говоря на штатные реализации read_json_value/write_json_value).


Интересная ситуация складывается с json_dto. Мы сделали ее для какого-то проекта не то, чтобы наспех, но с меньшим тщанием, чем те же SObjectizer или RESTinio, открыли для того, чтобы расширить свое OpenSource-портфолио, использовали ее пару-тройку раз после этого... И особо больше в нее не вкладываемся. Ну есть и есть, лежит себе на github-е и лежит.

Но, оказывается, что она нравится кому-то ещё, кто-то ее использует. И даже обращается к нам с новыми идеями. И даже присылает пул-реквесты. В общем, в прямом смысле слова "нам не дано предугадать как наше слово отзовется..."

Ну а раз народ пользуется, то и развиваем json_dto время от времени. Вот, давеча, после принятия очередного PR, новую версию зафиксировали. Так что мне самому интересно, куда это все приведет.

И, конечно же, очень интересный опыт был приобретен за годы развития json_dto (если не ошибаюсь, json_dto появилась лет восемь назад, в 2016-ом). Казалось бы, очень простая штука -- сериализация и десериализация несложных объектов в JSON, да еще и не в чистом виде, а посредством замечательной RapidJSON. Т.е. за нас-то уже, по сути, все сделали, нам нужно только тоненькую удобную нашлепку сверху прилепить и всех делов...

А на практике нашлось столько частных случаев и хотелок из реальной жизни, что прям "Ой". На собственной шкуре пришлось усвоить, что реальная работа с JSON -- это ни разу не просто ;)

PS. Если честно, не знаю, взялись ли бы мы за json_dto, если бы знали про nlohmann::json в 2016-ом. Вероятно, не взялись бы. Хотя я пробовал nlohmann::json, там есть свои приколы, так что не могу сказать, что nlohmann::json -- это тот единственный инструмент, которого будет достаточно. Ну а теперь, когда json_dto обзавелся разнообразными точками кастомизации для тех или иных ситуаций, я уверен в том, что обоим инструментам найдется место под Солнцем, т.к. по фичам они в ряде мест не пересекаются.

Комментариев нет: