В этом посте, по горячим следам, хотелось бы рассказать о том, как появилась новая фича в нашем небольшом проектике 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:
template< typename 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 ) ); } |
сделать вот такое:
template< typename 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 { template< typename 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 { template< typename 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" }; } template< typename 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; template< typename 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
Так ведь и на концептах все будет зависить от того, какие концепты будут указаны в библиотечных функциях. Грубо говоря, концепты позволят заменить громоздкие выражения SFINAE на перечень концептов. Но ведь суть будет в том, какие концепты задал разработчик библиотеки, а какие можно будет задать пользователю.
Нет, потому что концепты ведут себя аналогично не темплэйтным перегруженным функциям. Если подходят две концептифицированные функции, то выбирается более специализированная.
@sergegers
Ну будем посмотреть. Хотя, боюсь, возможность полноценно использовать C++20 раньше 2023 года не появится.
Отправить комментарий