пятница, 30 августа 2013 г.

[prog.thougts] Для разных целей разная сериализация...

Я уже давно имею дело с сериализацией различных структур данных в C++. Началось это где-то в 1995-м, когда я предпринял попытку создания объектной СУБД. Объектная СУБД -- это такая штука, в которой объекты хранятся как есть, без расщепления на составляющие. Например, если мы попытаемся сохранить в реляционной СУБД C++ный объект, который содержит не только простые атрибуты (int-ы или string-и), но и агрегаты (например, вектора или множества строк), то нам потребуется в схеме данных БД предусмотреть дополнительные таблицы для хранения значений этих агрегатов. А при сохранении объекта проводить раскидывание значений его атрибутов по разным таблицам (собственные атрибуты в одну таблицу, атрибуты из объектов внутри агрегата -- в другую). Аналогично и при чтении объекта из БД. В свое время евангелисты ООСУБД проводили такую аналогию: хранение объектов в РСУБД похоже на хранение автомобиля в гараже в полностью разобранном виде :)

Различные системы объектно-реляционного мэппинга (ORM) могут скрывать от разработчика всю трудоемкость этих преобразований объектной модели в реляционную, но, тем не менее, разработчик должен понимать, хотя бы в общих чертах, что именно происходит и чем ему за это придется платить. А вот при работе с ООСУБД такого расщепления объекта на составляющие не происходит, сама СУБД отвечает за то, чтобы из сложного объекта получить один сериализованный образ и сохранить его в БД. Поэтому продолжая аналогию с автомобилем, в ООСУБД автомобиль помещается в гараж и извлекается из гаража в полностью собранном виде, не тратя время на сборку/разборку. В определенных прикладных областях подход ООСУБД оказывался намного эффективнее (например, при работе с векторными изображениями, где объектами являются элементы изображения).

Так вот, занимаясь объектной СУБД пришлось решать задачу сериализации сложных C++ных объектов без участия разработчика. Под сложными я подразумеваю объекты, которые состоят не только из значений примитивных типов (т.к. int, float/double, char[]/string). В качестве типов атрибутов могут указываться другие сериализуемые пользовательские типы. А так же агрегаты (простые C-шные вектора, STL-контейнеры), элементами которых могут быть как примитивные, так и сложные пользовательские типы. Кроме того, поскольку речь идет об объектах, обязательно должно поддерживаться наследование. А уж коль речь зашла о C++, то и множественное наследование, ну и раз множественное, то и виртуальное множественное. Например, в следующем псевдокоде класс Polygon является сложным объектом (в отличии, например, от Color или Point):

struct Color {
   int8_t r;
   int8_t g;
   int8_t b;
};

struct Point {
   float x;
   float y;
};

struct Polygoin : public ImageItem {
   std::vector< Point > points;
   Color border_color;
   Color fill_color;
   int32_t fill_pattern_id;
};

Под "сериализацией без участия разработчика" я подразумеваю то, что разработчику не нужно писать код, управляющий (де)сериализацией. Т.е. программист не должен вручную писать что-то вроде:

Archive &
operator<<( Archive & a, const Polygon & o )
{
   a << o.points.size();
   forauto & p : o.points )
      a << p;

   a << o.border_color
      << o.fill_color
      << o.fill_pattern_id;

   return a;
}

Archive &
operator>>( Archive & a, Polygon & o )
{
   std::size_t points_count;
   a >> points_count;
   while( points_count-- )
   {
      Point p;
      a >> p;
      o.points.push_back( p );
   }

   a >> o.border_color
      >> o.fill_color
      >> o.fill_pattern_id;

   return a;
}

Применительно к объектным СУБД возможность (де)сериализации без участия программиста была нужна вовсе не для того, чтобы упростить жизнь пользователю СУБД. Там это было необходимо для того, чтобы СУБД могла получать доступ к значениям атрибутов объекта (для построения индексов, проведения поисковых запросов, просмотра/модификации данных из административных GUI-инструментов) и для поддержки эволюции схемы данных (например, для автоматической конвертации значений объектов при изменении типа Point::x/y с float на double). Но, на мой взгляд, именно автоматическое управление (де)сериализацией объектов делает практически осуществимой работу с действительно сложными структурами данных.

Мой первый опыт работы с автоматической (де)сериализацией C++ных структур данных был накоплен в период с 1995-го по 2000-й год во время попытки создать полноценную клиент-серверную объектно-ориентированную СУБД. Через два года, в 2002-м этот опыт был использован в новой разработке ObjESSty, которая задумывалась как a) встраиваемая в приложения система долговременного хранения объектных данных + b) система (де)сериализации этих самых объектных данных.

Спустя 10 лет от поддержки в ObjESSty встраиваемого долговременного хранилища я решил оказаться. А вот в качестве системы (де)сериализации данных ObjESSty продолжает развиваться и сейчас. Как раз длительный опыт использования ObjESSty в разных качествах привел меня к мысли, что не смотря на то, что одна и та же сериализация может использоваться для разных целей, все-таки есть важные отличия между двумя основными задачами сериализации данных:

  • для долговременного хранения и последующего чтения/воспроизведения/модификации информации. Например, бинарный Word-овский doc-файл, который может внутри себя содержать текст, картинки, таблицы и пр. Или sav-файл компьютерной игры, в котором сохраняется текущее состояние игрового процесса;
  • для поддержки транспортных протоколов (коммуникация между взаимодействующими между собой приложениями/узлами сети). Например, текстовый UCP/EMI. Или бинарный SMPP. Или же текстовый SOAP.

Фокус в том, что универсальная система (де)сериализации может применяться и для того, и для другого. Например, XML-представление может использоваться как для долговременного хранения данных (см., например, Open Document Format), так и в качестве формата транспортного протокола (SOAP), хотя за применение XML в транспортном протоколе нужно изгонять из профессии ;) Поэтому такое различие несколько условно. Между тем, есть моменты, которые показывают, что дьявол таки скрывается в деталях.

Первый момент -- это вопрос сложности поддерживаемых системой (де)сериализации структур данных. Если речь идет о транспортном протоколе, то вряд ли имеет смысл выходить за рамки простого использования объектов в качестве атрибутов других объектов и агрегатов сложнее векторов однотипных объектов. Нужна ли, например, в транспортном протоколе поддержка multimap-ов? Или наследования объектов (и уж тем более множественного наследования)? Это, конечно, зависит от протокола, но, скорее всего, вряд ли. Однако, если речь идет о долговременном хранении сложных данных, то поддержка основных структур данных языка и наследования должны быть обязательно, иначе разработчики придется слишком много возится с преобразованием данных вручную. Так же крайне полезным будет поддержка атрибутов-указателей, причем указателей на полиморфные объекты (т.е. атрибут имеет тип указателя на базовый класс, а содержать будет указатели на объекты производных классов). А так же, в некоторых случаях, окажется востребованной и поддержка циклических ссылок между объектами.

Второй момент -- это сложность формата сериализованного представления данных. С ростом вычислительной мощности обычных компьютеров, объемов доступной оперативной памяти и размеров жестких дисков этот вопрос для долговременного хранения данных все менее и менее важен. Разработчики офисных пакетов могут себе позволить применять XML, который затем будет еще сжат zip-ом, не сильно считаясь с расходами вычислительной мощности, оперативной памяти и диска. А вот разработчики транспортного протокола могут быть вынуждены уделять вопросам сложности формата данных самое серьезное внимание. Например, если одна из взаимодействующих сторон -- это маломощное встроенное устройство, программу для которого нужно писать учитывая каждый байт и контролируя глубину стека. Парсить в таких условиях XML или даже JSON не очень разумно. Или если транспортный протокол разрабатывается для мощных серверов, которые обмениваются друг с другом сообщениями в темпе 15000 в секунду.

Интересная сторона сложности формата -- это ручной его разбор. В случае долговременного хранения информации разработчикам не так уж часто приходится вручную разбирать сериализованный образ объекта, чтобы понять где и что лежит. Как правило, этим приходится заниматься разработчикам самой системы сериализации, но не ее пользователям. А вот в случае транспортного протокола ситуация меняется. Пользователям системы сериализации довольно часто бывает необходимо вручную расшифровать полученный от кого-то дамп коммуникационного обмена. И если система сериализации как-то хитро упаковывает объекты, то эта задача может оказаться еще той работенкой. Например, очень неприятно разбирать дампы двоичных протоколов, в которых информация передается в Little Endian представлении. Особенно когда там много 32-х или 64-х битовых целочисленных значений :)

Третий момент, плотно взаимосвязанный со вторым, -- это объем сериализованного представления данных. В общем случае транспортные протоколы обычно требовательнее к объемам, чем долговременное хранение данных. Но тут не так все очевидно. Например, если для долговременного хранения сериализуются объемные данные (видео, аудио, сложные векторные изображения, результаты интенсивных измерений), то объем их сериализованного представления, а так же скорость их (де)сериализации могут оказаться критичнее, чем объемы единичных пакетов какого-нибудь транспортного протокола, вроде SMPP. С другой стороны для некоторых транспортных протоколов могут существовать специфические ограничения на формат данных. Например, если протокол используется для взаимодействия устройств по 32-х битовой транспортной шине, где передача одного двойного слова (4 байта) осуществляется за один такт, очень желательно размещать поля сериализуемого объекта на границе 4-х байт. Поэтому, если поле A имеет размер шесть байт, а следующее за ним поле B -- три байта, то для двоичного представления полей A и B потребуется 12 байт: первые 8 для поля A (из которых два последних будут пустыми, используемыми только для выравнивания -- т.н. padding bytes), затем 4 для поля B (где первые 3 содержат значение B, а четверый -- это padding byte).

Четвертый момент, плотно связанный с третьим, -- это минимизация объема сериализованного представления за счет опциональных значений атрибутов. Представьте себе, что у вас в программе у множества объектов есть пара-тройка атрибутов, имеющих значение по умолчанию. Например, пусть у вас есть объект PhotoDescription с атрибутами title, subject, rating, tags, comments, authors, copyright и т.д. В ряде случаев для большинства объектов PhotoDescription эти поля будут иметь пустые значения. Нужно ли их сериализовать? Ведь это и объем, и вычислительные ресурсы. Допустим, что сохранять опциональные значения не нужно. Как это будет выглядеть в сериализованном представлении объекта? Например, если используется структурированный текстовый формат (XML, JSON) можно не указывать соответствующие теги. Если же используется двоичный формат, как указывать опциональность атрибутов? Используется ли при этом теговое представление данных или нет? Можно ли использовать битовые флаги?

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

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

Седьмой момент -- это поддержка межъязыковой и межплатформенной интероперабильности. В случае с транспортным протоколом очень велика вероятность, что одна из взаимодействующих сторон будет написана совсем на другом языке программирования. Не на C++, к примеру, а на Java, C#, Python или Ruby. И работать она будет совсем на другой аппаратной платформе. Поэтому для транспортных протоколов очень желательно, чтобы выбранная система (де)сериализации поддерживала несколько разных языков программирования и не была привязана к конкретной аппаратной платформе. Для долговременного хранения информации это не так актуально, но наличие такой интероперабильности так же будет большим плюсом.

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

По хорошему, об этом нужно думать всем: и пользователям систем сериализации, и их разработчикам. Но с пользователями особенно-то ничего не сделаешь. В простейших случаях они вообще не думают ;) Ну а чего думать -- есть XML, его и возьмем. Ну не нравится XML, возьмем JSON. Когда повезло настолько, что уперлись в производительность/доступные объемы, тогда уже можно будет посмотреть на что-нибудь более шустрое и компактное: ASN.1, Google Protobuf, Apache Thrift. Ну а тех, кому реально приходится работать с большими и сложными структурами данных, похоже, не так уж и много. Тем не менее, полезно задумываться о том, а заточена ли выбранная вами система сериализации под те нужды, для которых вы ее выбрали. Скажем, Google Protobuf вполне сгодится для создания транспорта. Но вряд ли стоит брать его для хранения sav-файлов в RTS-игре с большим количеством разнообразных юнитов. Здесь Apache Thrift подошел бы получше. А ObjESSty еще лучше ;)

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

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

Но теперь, по прошествии более десяти лет использования и развития ObjESSty, приходится всерьез задумываться о том, на чем же именно сделать акцент. И вопрос этот непростой. Отчасти потому, что ObjESSty успешно зарекомендовала себя при организации транспорта. Но здесь сильная конкуренция со стороны тех же Protobuf, Thrift, XML и JSON. С другой стороны, не так уж много для C++ готовых инструментов для сериализации сложных структур данных. И это можно было бы обратить в свою пользу. Так что есть о чем подумать.


Еще на эту тему:

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