четверг, 17 сентября 2020 г.

[prog.c++] Эволюция развития новой фичи в json_dto: от простого к сложному, а затем к менее сложному

В этом посте, по горячим следам, хотелось бы рассказать о том, как появилась новая фича в нашем небольшом проектике json_dto, который очень сильно упрощает работу с JSON в C++ посредством RapidJSON. Надеюсь, что этот рассказ будет еще одним подтверждением известного афоризма о том, что простота не предшествует сложности, а следует за ней.

Пару слов о json_dto для тех, кто не в курсе что это

Библиотека json_dto была создана нами года 4.5 назад для упрощения работы с JSON посредством RapidJSON. Если делать (де)сериализацию собственных структур с использованием API RapidJSON, то получаются большие портянки кода. Что не есть хорошо: требует много времени и отнимает много сил и внимания. Мы же взяли за основу идею из Boost.Serialization и сделали небольшую обертку над RapidJSON, которая позволяет описывать (де)сериализацию практически в декларативном виде. Например:

struct message_t
{
   std::string m_from;
   std::int64_t m_when;
   std::string m_text;

   // Entry point for json_dto.
   template<typename 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_io, в котором перечисляются подлежащие (де)сериализации поля структуры.

При этом json_dto получилась весьма тонкой оберткой вокруг RapidJSON, поэтому при использовании json_dto получается одновременно поиметь выгоды и от качества/скорости RapidJSON, и от декларативности json_dto.

json_dto -- это открытый проект, который жил сперва на BitBucket-е, а затем переехал на GitHub. Популярности json_dto не достиг, но кто-то json_dto все-таки использует и время от времени мы получаем просьбы добавить в json_dto что-то еще. Так что в последние года 2-2.5 json_dto развивается за счет воплощения в жизнь пожеланий пользователей.

Кастомная (де)сериализация значений в json_dto

В json_dto "искаропки" реализована поддержка (де)сериализации нескольких наиболее употребимых типов, вроде int32, int64, double, std::string. Но когда этого недостаточно, то у пользователя есть две возможности написать собственную (де)сериализацию для своего типа.

Первая возможность -- это перегрузка (или специализация) свободных функций read_json_value/write_json_value для конкретного типа. Вот здесь можно найти пример того, как подобные специализации делаются для (де)сериализации структуры std::tm.

Именно эта возможность рассматривалась как основной способ реализации собственной (де)сериализации для каких-то типов. И именно эту возможность мы сами активно использовали в json_dto, например, для (де)сериализации контейнеров из STL.

Вторая возможность -- это перегрузка (или специализация) свободной функции json_io (пример можно увидеть здесь). И хотя эта возможность в большей степени предназначена для реализации (де)сериализации чужих структур/классов, внутрь которых шаблонный метод json_io не засунуть, но все-таки никто не запрещает реализовывать json_io для своих типов вместо read_json_value/write_json_value.

И вот в новой версии 0.2.10 к этим двум возможностям была добавлена и третья, т.к. двух уже существующих не хватило.

Откуда есть пошла фича новая

Началось все с того, что один из старых пользователей открыл issue на GitHub-е, в котором указал, что у него не получается сделать перегрузку read/write_json_value для того, чтобы делать (де)сериализацию перечислений (т.е. enum-ов) посредством библиотеки magic_enum. Т.е. если делать отдельную перегрузку/специализацию read/write_json_value для каждого enum-а, то все работает. Но вот если попробовать сделать одну-единственную перегрузку/специализацию, вроде вот такой:

template <typename T>
std::enable_if_t<std::is_enum_v<T>>
write_json_value(const T& value, rapidjson::Value& object, rapidjson::MemoryPoolAllocator<>& allocator)
{
  // first retrieve the enum name
  std::string name{ magic_enum::enum_name(value) };

  // now write it to the value
  write_json_value(name, object, allocator);
}

То ожидаемого эффекта не будет, т.к. среди всех имеющихся перегрузок write_json_value выбирается другой вариант, а этот с is_enum_v игнорируется.

Фактически, этот issue выявил следующую проблему (или ограничение) текущей версии json_dto: подход с перегрузкой как read/write_json_value, так и json_io, хорош для отдельных типов. Вроде написания собственного варианта read/write_json_value для std::tm. Но этот подход не работает в случае семейств типов, вроде перечислений. Т.е. как только возникает необходимость написать одну-единственную read/write_json_value, которая сможет поддерживать сразу несколько типов, то тут могут возникнуть проблемы.

Очевидный способ закрыть issue и почему он не был принят

Простейший вариант, который позволил бы закрыть описанный выше issue -- это изменить прототип штатных реализаций read/write_json_value в json_dto так, чтобы эти реализации явно игнорировали бы перечисления. Т.е. вместо вот такого определения write_json_value:

templatetypename Dto >
std::enable_if_t<
      !details::meta::is_stl_like_container<Dto>::value,
      void >
write_json_value(
   const Dto & v,
   rapidjson::Value & object,
   rapidjson::MemoryPoolAllocator<> & allocator )
{
   json_output_t ouput( object, allocator );
   json_io( ouput, const_cast< Dto & >( v ) );
}

сделать вот такое:

templatetypename Dto >
std::enable_if_t<
      !details::meta::is_stl_like_container<Dto>::value && !std::is_enum<Dto>::value,
      void >
write_json_value(
   const Dto & v,
   rapidjson::Value & object,
   rapidjson::MemoryPoolAllocator<> & allocator )
{
   json_output_t ouput( object, allocator );
   json_io( ouput, const_cast< Dto & >( v ) );
}

В этом случае для типа Dto, где Dto -- это перечисление, штатные версии read/write_json_value выбираться не будут.

В принципе, это нормально. Т.к., если Dto -- это тип перечисления, то штатная версия read/write_json_value все равно приведет к ошибке компиляции, т.к. она попробует вызвать Dto::json_io, которого нет. Так что пользователю для своего Dto в любом случае нужно делать собственные read/write_json_value. Посему добавление условия !std::is_enum<Dto>::value для штатных read/write_json_value вроде как пользователей затронуть не должно.

Но есть одно но.

Если поступить таким образом, то можно оказаться в ситуации, когда в одном проекте невозможно будет объеденить несколько разных подходов к (де)сериализации enum-ов.

Представьте себе, что у вас есть библиотека A, в которой enum-ы (де)сериализуются простым преобразованием enum-а в число и обратно. И есть библиотека B, в которой enum-ы (де)сериализуются посредством какой-то самодельной системы рефлексии (скажем посредством уже упомянутой выше библиотеки magic_enum). Каждая из них определяет свои перегрузки для read/write_json_value как раз с использованием простейшего SFINAE с std::is_enum.

Каждая из этих библиотек по отдельности работает прекрасно. Но вот если потребуется задействовать A и B в одном cpp-файле, то может возникнуть проблема, т.к. варианты read/write_json_value из A и B будут конфликтовать друг с другом.

Может показаться, что вероятность такой коллизии настолько невысока, что ей можно пренебречь. Может быть. Но мне, как мейнтенеру библиотеки, которая используется разными людьми в совершенно разных условиях, подобный подход не нравится. Практика показывает, что если что-то плохое может произойти, то это наверняка произойдет. Ну и библиотеки A и B вполне могут возникнуть даже в рамках одного большого проекта, разные куски которого разрабатываются разными командами.

Так что простейший вариант с включением выражения !std::is_enum<Dto>::value в штатную версии read/write_json_value практически сразу же был выведен из рассмотрения.

Альтернативные варианты, которые рассматривались и были проверены на прототипах

Описание полей с помощью tagged_proxy и специализация нового типа tagged_proxy_io_t под типы пользователя

Первая идея состояла в том, чтобы:

1. Пользователь объявляет какой-то собственный пустой тип-тег. Например:

struct my_enum_image {}; // Tag type.

2. Пользователь использует новую функцию tagged_proxy при перечислении подлежащих (де)сериализации полей в json_io:

// Enums to be serialized a special way.
enum class level_t {...};
enum class category_t {...};

struct data_t
{
   level_t m_level;
   category_t m_category;

   template<typename IO>
   void json_io( IO & io )
   {
      io & json_dto::mandatory( "level",
            json_dto::tagged_proxy<my_enum_image>(m_level) )
         & json_dto::mandatory( "cat",
            json_dto::tagged_proxy<my_enum_image>(m_category) );
   }
};

3. Пользователь делает специализацию нового типа tagged_proxy_io_t под свой тип-тег:

namespace json_dto 


templatetypename T > 
struct tagged_proxy_io_t< my_enum_image, T > 

   static_assert( std::is_enum<T>::value, "T should be an enum type" ); 

   static void 
   read_json_value( 
      T & value, 
      const rapidjson::Value & from ) 
   { ...  } 

   static void 
   write_json_value( 
      const T & value, 
      rapidjson::Value & object, 
      rapidjson::MemoryPoolAllocator<> & allocator ) 
   { ... } 
};

/* namespace json_dto */

В общем-то идея простая: поля, которые заданы посредством tagged_proxy, (де)сериализуются не привычными read/write_json_value, а посредством tagged_proxy_io_t. При этом пользователь может сделать специализацию tagged_proxy_io_t под свои нужды. Как частичную специализацию (как в показанном выше примере, когда фиксируется только тип-тег). Так и полную специализацию. В общем, полный простор для фантазии.

Больше всего смущало то, что меняется форма описания полей в json_io. Так что этот вариант в работу не пошел, а на рассмотрение был принят следующий вариант.

Специализация новых типов tagged_io_t и tagged_t под типы пользователя

Идея следующая:

1. Пользователь объявляет какой-то собственный пустой тип-тег. Например:

struct my_enum_image {}; // Tag type.

2. Пользователь описывает подлежащие (де)сериализации поля в json_io привычным образом:

// Enums to be serialized a special way.
enum class level_t {...};
enum class category_t {...};

struct data_t
{
   level_t m_level;
   category_t m_category;

   template<typename IO>
   void json_io( IO & io )
   {
      io & json_dto::mandatory( "level", m_level )
         & json_dto::mandatory( "cat", m_category );
   }
};

3. Пользователь для своих типов должен сделать в пространстве имен json_dto следующие специализации:

namespace json_dto {

// Partial specialization for tagged_io_t.
template<typename T>
struct tagged_io_t<my_enum_image, T> {
   static void read_json_value(...) {...}
   static void write_json_value(...) {...}
};

// THE MAIN TRICK: specialization of tagged_t for every user's enum.
template<> struct tagged_t<level_t> : public tagged_as<my_enum_image> {};
template<> struct tagged_t<category_t> : public tagged_as<my_enum_image> {};

/* namespace json_dto */

Суть этого подхода в том, что json_dto для каждого типа T проверяет, есть ли определение tagged_t<T>. Если есть, то для (де)сериализации значений типа T используется класс tagged_io_t, а не привычные read/write_json_value.

Этот подход мне нравился больше, т.к. он позволял оставить в прежнем виде перечисление полей в json_io. Т.е. если бы пользователь захотел заменить формат представления какого-то своего типа T, то ему не нужно было бы менять описание полей этого типа в json_io. Так что сам я склонялся к тому, чтобы реализовать именно этот подход, не видя на тот момент, что у подхода есть свой серьезный недостаток.

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

Новый issue на тему невозможности обработать значение "Nan"

Буквально через пару дней после открытия issue на тему enum-ов и magic_enum, возник еще один issue: json_dto не может обработать значение "Nan".

Суть в том, что если во входном JSON-документе для поля типа float/double задано значение "Nan", то парсинг такого JSON-документа завершается неудачно.

А происходит это потому, что в самом JSON-не нет таких разрешенных значений как "Nan" или "inf", или чего-то подобного. Зато такие значения могут использоваться как нестандартные расширения в тех или иных библиотеках. При этом в RapidJSON есть поддержка подобных расширений, которая должна включаться явно специальными флагами. Но, как водится, есть но.

RapidJSON распознает только значения NaN, Inf и Infinity. Именно в таком виде. Никак иначе. Т.е. {"d":NaN} RapidJSON разберет, а вот {"d":"NaN"} или {"d":"Nan"} нет.

Можно было бы, конечно, перевести стрелки на RapidJSON, мол, мы делаем всего лишь тонкую обертку вокруг RapidJSON, поэтому если RapidJSON в принципе не может чего-нибудь сделать, то и мы этого сделать не можем.

Но подумалось вот что: если пользователь работает с RapidJSON напрямую, то он может сделать собственную обработку ситуаций, когда значение задано не числом, а строкой. И что в этой строке хранятся, пусть и нестандартные, но вполне себе понятные значения. А раз пользователь может сделать это с "голым" RapidJSON, то было бы правильным дать возможность пользователю получить тоже самое и посредством json_dto, пусть даже опустившись на уровень API RapidJSON-а.

И оказалось, что решение проблемы с NaN может использоваться так же и для решения проблемы с enum-ами...

Решение, которое было реализовано

В итоге было реализовано решение, в котором при описании поля для (де)сериализации пользователь задает дополнительный объект Reader_Writer. Когда такой объект задан, то json_dto для (де)сериализации поля использует не read/write_json_value, а методы read/write объекта Reader_Writer. Соответственно, пользователь пишет тот Reader_Writer, который ему нужен.

Вот, скажем, как может выглядеть Reader_Writer для работы с float/double и поддержкой нестандартных значений вроде "nan" и "inf":

struct custom_floating_point_reader_writer
{
   templatetypename T >
   void read( T & v, const rapidjson::Value & from ) const
   {
      if( from.IsNumber() )
      {
         json_dto::read_json_value( v, from );
         return;
      }
      else if( from.IsString() )
      {
         const json_dto::string_ref_t str_v{ from.GetString() };
         if( equal_caseless( str_v, "nan" ) )
         {
            v = std::numeric_limits<T>::quiet_NaN();
            return;
         }
         else if( equal_caseless( str_v, "inf" ) )
         {
            v = std::numeric_limits<T>::infinity();
            return;
         }
         else if( equal_caseless( str_v, "-inf" ) )
         {
            v = -std::numeric_limits<T>::infinity();
            return;
         }
      }

      throw json_dto::ex_t{ "unable to parse value" };
   }

   templatetypename T >
   void
   write(
      T & v,
      rapidjson::Value & to,
      rapidjson::MemoryPoolAllocator<> & allocator ) const
   {
      using json_dto::write_json_value;
      using json_dto::string_ref_t;

      if( std::isnan(v) )
         write_json_value( string_ref_t{"nan"}, to, allocator );
      else if( v > std::numeric_limits<T>::max() )
         write_json_value( string_ref_t{"inf"}, to, allocator );
      else if( v < std::numeric_limits<T>::min() )
         write_json_value( string_ref_t{"-inf"}, to, allocator );
      else
         write_json_value( v, to, allocator );
   }
};

А вот так будет выглядеть использовании этого Reader_Writer-а:

struct struct_with_floats_t
{
   float m_num_float;
   double m_num_double;

   templatetypename Json_Io >
   void
   json_io( Json_Io & io )
   {
      io
         & optional( custom_floating_point_reader_writer{},
               "num_float", m_num_float, 0.0f )
         & optional( custom_floating_point_reader_writer{},
               "num_double", m_num_double, 0.0 );
   }
};

Этот же подход будет работать и для (де)сериализации enum-ов посредством magic_enum: достаточно просто сделать свой Reader_Writer и указать его при описании подлежащих (де)сериализации полей.

Да, при этом пришлось делать новые версии функций mandatory, optional и optional_no_default, в которые нужно передавать дополнительные значения в виде Reader_Writer-ов. Поэтому, если вы сперва захотели поля какого-то типа T форматировать одним способом, а затем вам потребовалось сменить формат, то придется править все json_io, в которых эти поля перечислены.

Но, с другой стороны, появилась новая возможность, которой раньше не было.

Допустим, у вас есть некоторый enum, типа вот такого: enum class log_level { low, normal, high }. И вы хотите в каких-то JSON-документах представлять значения этого перечисления в числовом виде, скажем: {"level":0, "msg":"..."}. А в других документах в виде названий, вроде вот такого: {"path":"/var/log/demo", "level":"low"}.

При использовании подхода с Reader_Writer этого можно достичь легко и непринужденно. Тогда как подход, к которому я склонялся, со специализацией tagged_t и tagged_io_t, этого не позволил бы в принципе.

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

Вместо заключения

Хочу поблагодарить всех, кто сумел осилить весь текст. Давненько ничего подобного в блоге не писал.

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

А вот при разработке библиотеки этой роскоши нет. Все, что добавляется в библиотеку, должно затем там жить. И доставлять боль мейнтенерам библиотеки. Либо же пользователям, если мейнтейнеры решат что-то со временем из библиотеки выбросить.

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

4 комментария:

sergegers комментирует...

С концептами это решается автоматически. А так да, всё как бусте.

eao197 комментирует...

@sergegers

Так ведь и на концептах все будет зависить от того, какие концепты будут указаны в библиотечных функциях. Грубо говоря, концепты позволят заменить громоздкие выражения SFINAE на перечень концептов. Но ведь суть будет в том, какие концепты задал разработчик библиотеки, а какие можно будет задать пользователю.

sergegers комментирует...

Нет, потому что концепты ведут себя аналогично не темплэйтным перегруженным функциям. Если подходят две концептифицированные функции, то выбирается более специализированная.

eao197 комментирует...

@sergegers

Ну будем посмотреть. Хотя, боюсь, возможность полноценно использовать C++20 раньше 2023 года не появится.