среда, 3 июля 2013 г.

Сериализация и легко расширяемые протоколы в Qt

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

Сразу скажу, что эта заметка в первую очередь ориентирована на сериализацию при передаче данных по сети и едва ли пригодится в приложениях, где ведётся работа с большими объёмами данных, когда каждый байт на счету и оптимизация превыше всего. Если же предполагается, что храниться или передаваться будут небольшие порции данных, то изложенное ниже может быть вам полезно. Впрочем, гениальных решений и "срыва покровов" ждать не стоит.

Итак, сериализация в Qt. Обычно для этой цели используется класс QDataStream. Документация, на мой взгляд, достаточно полно освещает все аспекты его применения, поэтому ограничусь кратким обзором. QDataStream сериализует объекты (тут и далее к объектам я буду причислять также данные таких типов как int, bool и т.д., хотя это и не совсем корректно) разных типов в поток данных, что понятно из его названия. При этом данные могут как добавляться к QByteArray, так и записываться сразу в QIODevice (например, QFile или QTcpSocket). Естественно, присутствует возможность десериализовать объекты обратно из потока данных.

Казалось бы, все замечательно, пишем данные в поток, забираем обратно, тишь да гладь, да божья благодать. Но вот нам потребовалось расширить протокол, добавив новый объект. И тут начинается хаос. Для наглядности предположим, что у нас есть следующий класс:
 class Book  
 {  
 public:  
   QString title;  
   QString text;  
 public:  
   friend QDataStream &operator <<(QDataStream &stream, Book book)  
   {  
     stream << book.title;  
     stream << book.text;  
     return stream;  
   }  
   friend QDataStream &operator >>(QDataStream &stream, Book &book)  
   {  
     stream >> book.title;  
     stream >> book.text;  
     return stream;  
   }  
 };  
Тут мы добавили дружественные операторы, которые позволяют нам делать следующее:
 //на стороне отправителя  
 QTcpSocket *s = new QTcpSocket;  
 //подключение и т.д.  
 QDataStream out(s);  
 for (int i = 0; i < 10; ++i)  
 {  
   Book b;  
   //задаем какие-то значения b.title и b.text  
   out << b; //сериализуем наш объект  
 }  
   
 //на стороне получателя  
 QDataStream in(s);  
 for (int i = 0; i < 10; ++i)  
 {  
   Book b;  
   out >> b; //десериализуем наш объект  
 }  
А теперь мы вдруг вспомнили, что у книги есть ещё и автор:
 class Book  
 {  
 public:  
   QString title;  
   QString text;  
   QString author; //новая переменная класса  
 };  
Добавили переменную, модифицируем оператор сериализации:
 friend QDataStream &operator <<(QDataStream &stream, Book book)  
 {  
   stream << book.title;  
   stream << book.text;  
   stream << book.author;  
   return stream;  
 }  
Ура, мы сломали совместимость! Вот просто так взяли, и сломали. Если получатель не обновил программу, то первый объект будет десериализован нормально, у второго же вместо названия окажется автор первого, а вместо текста - название первого. Чтобы убедиться воочию, попробуйте собрать и запустить пример.

Как же быть? Документация предлагает нам использовать версионирование. Не буду заниматься повторением, однако замечу, что такой подход рано или поздно приведёт к конструкциям вида:
 switch (version)  
 {  
 case 1:  
   //50 строк  
   break;  
 case 2:  
   //55 строк  
   break;  
 case 3:  
   //56 строк  
   break;  
 //ещё около 30 версий  
 }  
Думаю, не надо говорить о поддерживаемости подобного кода (проще застрелиться, чем заниматься этим).

Но что же тогда делать? Выход есть. Не самый оптимальный с точки зрения трафика/места на диске, но идеальный с точки зрения удобства. Воспользуемся замечательным классом QVariantMap (если всё же хочется сэкономить хотя бы на ключах, используем QMap<quint16, QVariant> или даже QMap<quint8, QVariant>). Волшебным образом, неподдерживаемый код превращается в...
 friend QDataStream &operator <<(QDataStream &stream, Book book)  
 {  
   QVariantMap m;  
   m.insert("title", book.title);  
   m.insert("text", book.text);  
   m.insert("author", book.author);  
   m.insert("publisher", book.publisher);  
   //сколько угодно добавляем или удаляем сериализуемые переменные  
   stream << m;  
   return stream;  
 }  
Да, вот в такой удобный, полностью совместимый код. Кто не знаком с шаблоном QMap, поясню: это так называемый словарь, где каждому ключу (идентификатору) соответствует некоторое значение. При попытке обратиться к несуществующему значению возвращается объект этого типа, для создания которого используется конструктор по умолчанию. В нашем случае используется QVariantMap, то есть в качестве типа ключей выступает QString, а в качестве типа значений - QVariant (который может содержать любой зарегистрированный тип данных).

Аналогично поступаем с оператором десериализации:
 friend QDataStream &operator >>(QDataStream &stream, Book &book)  
 {  
   QVariantMap m;  
   stream >> m;  
   book.title = m.value("title").toString();  
   book.text = m.value("text").toString();  
   book.author = m.value("author").toString();  
   book.year = m.value("year").toUInt(); //новая переменная  
   //сколько угодно добавляем или удаляем переменные  
   return stream;  
 }  
В данном случае book.year будет присвоено значение для unsigned integer по умолчанию (то есть 0). А имеющееся значение publisher просто будет проигнорировано. При этом все остальные книги также будут десериализованы корректно.

Кончено, такой протокол является несколько избыточным, ведь даже если получатель никак не использует часть данных, они всё равно ему передаются. Кроме того, размер передаваемых данных увеличивается из-за наличия ключей и из-за прослойки в виде QMap. Если требуется передавать много объектов с большим числом полей, каждое из которых имеет небольшой размер, то издержки приведённого подхода будут особенно заметны. Однако я ещё не встречал более простого в поддержке способа сохранить совместимость при независимой модификации сервера и клиента.

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

Отправить комментарий