找回密码
 立即注册
收起左侧

KS04-18 类的二进制格式序列化-向前兼容

0
回复
5823
查看
[复制链接]
累计签到:41 天
连续签到:1 天
来源: 原创 2019-8-22 20:28:39 显示全部楼层 |阅读模式

马上注册,查看详细内容!注册请先查看:注册须知

您需要 登录 才可以下载或查看,没有帐号?立即注册

x
本帖最后由 baizy77 于 2019-8-28 17:13 编辑

该文章原创于Qter开源社区(www.qter.org
作者: 女儿叫老白
转载请注明出处!
---------------------------------------------------------------------------------------------------------------------


----------------------------------------------------------------------------------------------------------------------

引言
----------------------------------------------------------------
   在软件的生命周期中,软件的代码一直是在不断变化的。即使在投运之后的型号维护过程中,软件仍然在不断发生改变。如果一套软件涉及到对二进制文件的操作,而且新版本改变了二进制文件的结构,那么当我们更换软件版本时,我们的软件如何正确识别并读取旧版本软件保存的二进制文件就成了一个必须要面对的问题。本节,我们讨论一下这个问题有什么解决方案。

正文
----------------------------------------------------------------
   我们所说的兼容性一般指向前兼容,也就是新程序可以打开旧程序保存的文件,这可以简称为新程序打开旧文件。而兼容性还有另一层含义也就是向后兼容。什么叫向后兼容呢?依照前面的说法,应该是旧程序可以打开新文件。旧程序怎么能识别新文件呢?别急,我们现在就来为朋友们揭晓答案。
为二进制文件增加扩展性和兼容性有两个思路:第一个思路是为文件创建版本号,通过版本号来识别文件的版本,进而做不同的处理;第二个思路是借鉴XML格式文件的设计

思路1
先说第一个思路:为文件创建版本号。为此我们设计了文件属性类SFileAttr:
代码清单04-18-01
fileattribute.h
  1. namespace ns_train {
  2. // ......
  3. const quint16 c_MD5_Length = 16;/// md5码的长度,单位:字节。
  4. /// 文件的属性
  5. struct BASE_API SFileAttr
  6. {
  7.         quint16        mainVer;                        /// 主版本号
  8.         quint16        subVer;                                /// 次版本号
  9.         quint8        md5[c_MD5_Length];        /// 本文件的md5码,二进制格式使用,
  10.                                 /// 文本格式不用该属性。

  11.         SFileAttr(){ mainVer = 1; subVer = 0; memset(md5, 0, c_MD5_Length); }
  12. };
  13. // ......
  14. } // namespace ns_train
复制代码
代码清单04-18-01中,SFileAttr属于可公用的结构体,所以我们把它放到公共库basedll中。
SFileAttr拥有3个成员:主版本号、次版本号、md5码。
主次版本号用来区分大的改动和小的改动。当文件结构发生大的变化时就更新大版本号。而如果只是将类的新增成员变量序列化到文件,这种属于小改动,只需要变更小版本号。
请注意,代码清单04-18-01中将所示代码放到了命名空间ns_train里面,以便跟basedll中地其他类保持在同一个命名空间中。后面的代码将不再专门展示命名空间。
现在继续看fileattribute.h头文件:
代码清单04-18-02

fileattribute.h
  1. static const quint16 c_MainVersion= 1;                /// 当前文件的主版本号
  2. static const quint16 c_SubVersion = 1;                /// 当前文件的次版本号

  3. /// 获取当前文件的主版本号(使用本程序保存文件时的版本号)
  4. static quint16 getMainVersion() {
  5.         return c_MainVersion;
  6. }
  7. /// 获取当前文件的次版本号(使用本程序保存文件时的版本号)
  8. static quint16 getSubVersion() {
  9.         return c_SubVersion;
  10. }
复制代码
代码清单04-18-02中提供了两个静态变量用来指示当前文件的主次版本号。也就是当前程序执行保存(序列化)操作时保存的文件版本。为了方便,在第5行、第9行提供了两个静态接口用来访问当前主次版本号。
除此之外,还设计了文件头类SFileHead:
代码清单04-18-03

filehead.h
  1. /// 文件头类
  2. class SFileHead {
  3. public:
  4.         SFileHead()        {
  5.                 m_nMainVersion = c_MainVersion;
  6.                 m_nSubVersion = c_SubVersion;
  7.         }

  8.         SFileHead(quint16 nMainVersion, quint16 nSubVersion)        {
  9.                 m_nMainVersion = nMainVersion;
  10.                 m_nSubVersion = nSubVersion;
  11.         }
  12.     // ......
  13. private:
  14.         quint16        m_nMainVersion;                /// 主版本号
  15.         quint16        m_nSubVersion;                /// 次版本号
  16. };
复制代码
代码清单04-18-03展示了SFileHead的部分定义。该类主要被用来指示文件的版本号,这些信息一般被放置在文件头,所以我们可以把它称作文件头类。
    下面我们分别介绍一下该类提供的接口。
代码清单04-18-04

filehead.h
  1. /// 文件(程序)版本号是否比传入的版本号旧
  2.         bool isEarlierVersion(quint16 nMainVersion, quint16 nSubVersion) const        {
  3.                 if ((m_nMainVersion < nMainVersion)
  4.                         || (m_nMainVersion == nMainVersion && m_nSubVersion < nSubVersion))        {
  5.                         return true;
  6.                 }
  7.                 else        {
  8.                         return false;
  9.                 }
  10.         }
复制代码
isEarlierVersion()接口用来判断SFileHead对象的版本号是否比传入的版本号旧(低)。
代码清单04-18-05

filehead.h
  1. /// 文件版本号是否比传入的版本号新
  2.         bool isLaterVersion(quint16 nMainVersion, quint16 nSubVersion) const        {
  3.                 if ((m_nMainVersion > nMainVersion)
  4.                         || (m_nMainVersion == nMainVersion && m_nSubVersion >= nSubVersion)        {
  5.                         return true;
  6.                 }
  7.                 else        {
  8.                         return false;
  9.                 }
  10.         }
复制代码
isLaterVersion()接口用来判断SFileHead对象的版本号是否比传入的版本号新(高)。
还有下面几个接口,比较简单,注释也比较明确,在此不再详述。
代码清单04-18-06
filehead.h
  1. /// 当前文件主版本号是否比传入的主版本号新
  2.         bool isLaterMainVersion(quint16 nMainVersion) const        {
  3.                 if (m_nMainVersion > nMainVersion)        {
  4.                         return true;
  5.                 }
  6.                 else        {
  7.                         return false;
  8.                 }
  9.         }
  10.         /// 文件版本号是否与传入的版本号相同
  11.         bool isSameVersion(quint16 nMainVersion, quint16 nSubVersion) const        {
  12.                 if ((m_nMainVersion == nMainVersion)
  13.                         && (m_nSubVersion == nSubVersion))        {
  14.                         return true;
  15.                 }
  16.                 else{
  17.                         return false;
  18.                 }
  19.         }

  20.         /// 将版本号转化为QString类型字符串,如版本1.0,转后为"1.0"
  21.         QString toQString() const        {
  22.                 QString str = QString::number(m_nMainVersion).
  23.                            append(".").
  24.                            append(QString::number(m_nSubVersion));
  25.                 return str;
  26.         }

  27.         /// 将QString类型字符串转化为版本号,如字符串"1.0",转后为版本1.0
  28.         static SFileHead fromQString(QString str)        {
  29.                 SFileHead FileHead;
  30.                 if (str.contains('.'))        {
  31.                         qint32 index = str.indexOf('.');
  32.                         FileHead.m_nMainVersion = str.left(index).toUShort();
  33.                         FileHead.m_nSubVersion = str.right(str.length() –
  34.                                  index - 1).toUShort();
  35.                 }
  36.                 else {
  37.                         FileHead.m_nMainVersion = 0;
  38.                         FileHead.m_nSubVersion = 0;
  39.                 }
  40.                 return FileHead;
  41.         }
复制代码
当执行序列化时,我们就可以把当前版本号保存到文件:
代码清单04-18-07

country.h
  1. ESerializeCode   CCountry::serializeBinary(QDataStream& ds, QString* pError) const  {
  2.   
  3.     ns_train::SFileAttr  attrs;
  4.   
  5.     // 保存文件头信息(保存时总是保存为当前程序版本所对应的文件格式)
  6.   
  7.     attrs.mainVer  = ns_train::getMainVersion();
  8.   
  9.     attrs.subVer =  ns_train::getSubVersion();
  10.   
  11.     ds <<  attrs;
  12.   
  13.   
  14.     ds <<  m_strName;
  15.   
  16.     ds <<  m_strContinent;
  17.   
  18. quint16 nCount = 0;
  19.   
  20. // ……
  21.   
  22. }
复制代码
  从代码清单04-18-07可以看出,第7行处,在文件开头部分就要将文件属性进行保存,以便在反序列化时先读到文件版本从而执行相应的判断。代码清单04-18-08是CCountry反序列化接口:
代码清单04-18-08
country.cpp
  1. ESerializeCode CCountry::deSerializeBinary(QDataStream& ds, QString* pError) {
  2.     ds.setByteOrder(QDataStream::LittleEndian);
  3.     ESerializeCode retcode = ESERIALIZECODE_OK;

  4.     ns_train::SFileAttr attr;
  5.     ds >> attr;
  6.     ns_train::SFileHead fileHead(attr.mainVer, attr.subVer);

  7.     ds >> m_strName;
  8.     ds >> m_strContinent;
  9. quint16 nCount = 0; // 需要明确指定数据类型(长度),
  10.                     // 否则跨平台时可能出问题。
  11.                     // 比如int在各个平台上可能长度不一样。
  12. if (fileHead.isLaterVersion(1, 1)) {
  13. // ……
  14. }
复制代码
代码清单04-18-08中,
第6行,首先将保存在文件开头的信息读出来并存放到SFileAttr类型的对象attr。
第7行,用attr构造了SFileHead类型的对象fileHead。

第14行,使用下面的代码来判断文件的版本是否晚于1.1版(含1.1版),从而进行区别处理:
  1. if (fileHead.isLaterVersion(1, 1))
复制代码

思路2:
再来看第二个思路:借鉴XML文件的设计。XML文件有什么设计呢?通过分析XML的解析过程我们可以知道,XML格式之所以具备向前向后兼容性原因就是因为采用字符串作为识别手段。比如它的标签是字符串;它的属性名也是字符串。当程序读取到某个标签或者某个属性名称时,我们会将其与期望的标签或属性名的字符串常量进行比较,从而完成识别和解析。从这个理念出发,我们为二进制格式也可以提供一定程度的向前向后兼容性。具体怎么做呢?我们可以为将要序列化的类提供自定义属性接口。所谓自定义属性接口就是像XML一样,可以通过属性名来访问属性值。我们以CCountry类为例,假设自定义属性接口像下面这样:
代码清单04-18-09

country.h
  1.     /**
  2.     * @brief 根据自定义属性名获取自定义属性值
  3.     * @param[in] name 自定义属性名
  4.     * @return 自定义属性值
  5.     */
  6.     QVariant getCustomData(const QString& name) const;

  7.     /**
  8.     * @brief 设置自定义属性值
  9.     * @param[in] name 自定义属性名
  10.     * @param[in] data 自定义属性值
  11.     * @return true:找到自定义属性并赋值,false:未找到该属性
  12.     */
  13.     bool setCustomData(const QString& name, const QVariant& data);
复制代码
代码清单04-18-09中,提供了一对get、set接口。功能是通过自定义属性名访问对应的值。为了实现扩展性,我们采用QVariant作为自定义属性值的数据类型。从这两个接口继续思考:因为这是些键值对(key-value),所以我们应该把类的自定义属性的数据结构设计成map,即映射。这就可以方便的通过名字快速查找到属性值了。map就像下面这样设计:
  1. QMap<QString, QVariant> m_mapCustomData;
复制代码
我们来看一下get、set接口的实现:
代码清单04-18-10

country.cpp
  1. QVariant CCountry::getCustomData(const QString& name) const {
  2.     QMap<QString, QVariant>::ConstIterator iteMap = m_mapCustomData.constFind(name);
  3.     if (iteMap != m_mapCustomData.constEnd()) {
  4.         return iteMap.value();
  5.     }
  6.     return QVariant();
  7. }

  8. bool CCountry::setCustomData(const QString& name, const QVariant& data) {
  9.     QMap<QString, QVariant>::Iterator iteMap = m_mapCustomData.find(name);
  10.     if (iteMap != m_mapCustomData.end()) {
  11.         m_mapCustomData[name] = data;
  12.         return true;
  13.     }
  14.     return false;
  15. }
复制代码
  代码清单04-18-10所示,这两个接口都通过QMap的find接口查找属性,有所不同的是getCustomData()是const接口,因此调用了QMap::constFind(),而且迭代器也是同QMap::constEnd()进行比较。而setCustomData()则调用了QMap::find(),其中迭代器是同QMap::end()进行比较。
    为了方便,除了这对get、set接口之外我们还设计了其他接口:
代码清单04-18-11

country.h
  1. ////////////////////////////////////////////////////////////////////////////////////
  2. // 自定义属性相关
  3. /**
  4. * @brief 添加自定义属性名
  5. * @param[in] name 自定义属性名
  6. * @return true:成功,false:已存在
  7. */
  8. bool addCustomData(const QString& name);

  9. /**
  10. * @brief 添加自定义属性值,找到自定义属性并赋值,没找到则添加.
  11. * @param[in] name 自定义属性名
  12. * @param[in] data 自定义属性值
  13. * @return void
  14. */
  15. void addCustomData(const QString& name, const QVariant& data);

  16.         /**
  17.         * @brief 获取自定义属性名称列表
  18.         * @param[out] lst 自定义属性名称列表
  19.         * @return 自定义属性名称个数。
  20.         */   
  21. int getAllCustomDataName(QStringList& lst) const;
复制代码
这些接口分别用来添加自定义属性和获取自定义属性名列表。其具体实现我们不再详细介绍,见代码清单04-18-12。
代码清单04-18-12

country.cpp
  1. bool CCountry::addCustomData(const QString& name) {
  2.     QMap<QString, QVariant>::Iterator iteMap = m_mapCustomData.find(name);
  3.     if (iteMap == m_mapCustomData.constEnd()) {
  4.         m_mapCustomData[name] = QVariant();
  5.         return true;
  6.     }
  7.     return false;
  8. }
  9. void CCountry::addCustomData(const QString& name, const QVariant& data) {
  10.     m_mapCustomData[name] = data;
  11. }
  12. int CCountry::getAllCustomDataName(QStringList& lst) const {
  13.     lst.clear();
  14.     QMap<QString, QVariant>::ConstIterator iteMap = m_mapCustomData.constBegin();
  15.     while (iteMap != m_mapCustomData.constEnd()) {
  16.         lst.push_back(iteMap.key());
  17.         iteMap++;
  18.     }
  19.     return m_mapCustomData.size();
  20. }
复制代码
怎样将这些自定义属性进行序列化和反序列化呢?
代码清单04-18-13

country.cpp
  1. ESerializeCode  CCountry::serializeBinary(QDataStream& ds, QString* pError) const {
  2.         ns_train::SFileAttr attrs;
  3.         // 保存文件头信息(保存时总是保存为当前程序版本所对应的文件格式)
  4.         attrs.mainVer = ns_train::getMainVersion();
  5.         attrs.subVer = ns_train::getSubVersion();
  6.         ds << attrs;
  7.         ds << m_strName;
  8.         ds << m_strContinent;
  9.     quint16 nCount = 0;
  10.     // 自定义属性的存储
  11.     nCount = m_mapCustomData.size();
  12.     ds << nCount;
  13.     QMap<QString, QVariant>::ConstIterator iteMap = m_mapCustomData.constBegin();
  14.     while (iteMap != m_mapCustomData.constEnd()) {
  15.         ds << iteMap.key();
  16.         ds << iteMap.value();
  17.         iteMap++;
  18. }
  19. // ……
  20. }
复制代码
在代码清单04-18-13中:
第13行,保存自定义属性时先保存其个数,我们也借用了一个quint16类型的临时变量。至于到底用哪种类型的变量,建议您根据实际需求定义。如果quint16不够用,您可以选择quint32或者更大的类型。
第16~20行,遍历自定义属性,按照属性名、属性值的方式将自定义属性全部序列化。
可以看出,从第13行到第20行,将自定义属性进行了序列化(保存到文件),其实这已经改变了二进制文件的结构,
下面,我们看一下反序列化接口:
代码清单04-18-14

country.cpp
  1. ESerializeCode CCountry::deSerializeBinary(QDataStream& ds, QString* pError) {
  2.     // ……
  3.     quint16 nCount = 0; // 需要明确指定数据类型(长度),否则跨平台时可能出问题。比如int在各个平台上可能长度不一样。
  4.     if (fileHead.isLaterVersion(1, 1)) {
  5.         ds >> nCount;
  6.         QString strName;
  7.         QVariant var;
  8.         quint16 idx = 0;
  9.         for (; idx < nCount; idx++) {
  10.             ds >> strName;
  11.             ds >> var;
  12.             addCustomData(strName, var);
  13.         }
  14.     }
  15. //……
  16. }
复制代码
在代码清单04-18-14中:
第5行,我们通过版本判断的方法,根据版本是否高于1.1(含1.1版)来确定二进制流中是否保存了自定义属性。
在第6行,先解析得到自定义属性个数。
第10~14行,解析得到全部自定义属性,并调用addCustomData()添加到CCountry中。
代码清单04-18-15

country.cpp
  1. ESerializeCode CCountry::serializeXML(QDomDocument& doc, QString* pError) const {
  2.         QDomElement rootDoc = doc.createElement(c_tag_doc);
  3.         doc.appendChild(rootDoc);

  4.         // 图形属性
  5.         ns_train::SFileAttr attrs;
  6.         // 保存文件头信息(保存时总是保存为当前程序版本所对应的文件格式)
  7.         attrs.mainVer = ns_train::getMainVersion();
  8.         attrs.subVer = ns_train::getSubVersion();
  9.         rootDoc << attrs;
  10.     // ……
  11. }
复制代码
代码清单04-18-15中:
同二进制格式一样,在XML格式中版本号也能发挥作用。我们在XML中也可以保存版本号:

第11行的rootDoc << attrs就起到了这个作用。在此之前,我们已经在fileattribute.h中定义了SFileAttr 通过"<<"操作符流入QDomElement的接口:
  1. /// 序列化文件的基本数据(XML)
  2. BASE_API QDomElement& operator<<(QDomElement& ele, const SFileAttr& attrs);
复制代码

代码清单04-18-16
  1. <?xml version="1.0" encoding="UTF-8"?>
  2. <doc ver="1.1">
  3.     <content name="中国" continent="">
  4.         <customdata 国歌="义勇军进行曲" flag="五星红旗"/>
  5.     </content>
  6. </doc>
复制代码
代码清单04-18-16中:
第1行代码中,在写入该XML文件时使用了UTF-8编码。那么我们编写的代码文件country.cpp也应该用UTF-8编码,而且用文本编辑器查看这个XML文件时也需要使用UTF-8编码,否则看到的将是乱码。
第2行中,请注意doc元素的ver属性。
第4行,customdata元素用来描述CCountry的自定义属性。该类的所有自定义属性(国歌、flag等)都以属性-值的键值对方式保存到XML中。

代码清单04-18-17

country.cpp
  1. ESerializeCode CCountry::deSerializeXML(const QDomDocument& doc, QString* pError) {
  2.         // ……
  3.         ns_train::SFileAttr attrs;
  4.         rootDoc >> attrs;
  5.         ns_train::SFileHead fileHead(attrs.mainVer, attrs.subVer);
  6.         if (fileHead.isLaterMainVersion(ns_train::getMainVersion())) {
  7.                 if (NULL != pError)        {
  8.                         *pError = QObject::tr("Unable to open higher version graphics files!");
  9.                 }
  10.                 return ESERIALIZECODE_VERSION_NOTRECOGNIZE;
  11.         }
复制代码
在代码清单04-18-17中,
在第5行,在读取XML时,我们也可以将版本号读取出来,并在第6行用来判断文件版本号。在本示例中,并未展示怎样利用该版本号。其实,当我们想改变XML文件结构的时候,就可以用版本号进行判断(比如,为某个元素增加了子元素)。


结语
----------------------------------------------------------------
   我们利用版本号实现了二进制格式、XML格式的兼容性处理,同时利用自定义属性也实现了扩展性和一定程度的兼容性。当不再改变文件结构而只是增加自定义属性时,我们的二进制格式还支持向后兼容,也就是说我们可以用当前版本的程序读取日后新版本的二进制文件(比如新增了自定义属性)。是不是很棒呢?当然,如果您愿意,您还可以在此基础上做一些优化,比如只有当自定义属性的值跟默认值不同时执行才序列化,否则就跳过这个自定义属性而不用保存它。这样做可以减少文件尺寸,降低磁盘空间占用率。朋友们可以尝试一下。

课程目录: 【独家连载】《Qt入门与提高-GUI产品开发》目录

回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

公告
可以关注我们的微信公众号yafeilinux_friends获取最新动态,或者加入QQ会员群进行交流:190741849、186601429(已满) 我知道了