C++ 序列化框架/库





4.00/5 (4投票s)
紧凑型库中的保存、加载和数据迁移
引言
我需要在我的应用程序中集成一个序列化机制(数据的保存和加载),但我未能找到满足我要求的合适库。我主要关注 boost.serialization 库,它在我此处介绍的这个库的设计中也发挥了重要作用。在我的应用程序中,我为所有对象使用自定义分配器,因此无法将其与 boost.serialization 代码集成。然后我决定编写自己的库来满足我的所有要求。
背景
我使用 MFC 序列化(CObject::Serialize
)很长时间了,但我不想将 MFC 集成到我的代码中,所以这不在我的选择范围内。来自 https://boost.ac.cn/doc/libs/1_61_0/libs/serialization/doc/ 的目标大多适用于我的应用程序,但并非全部(例如可移植性)。在我的库中,我专注于以下目标:
- 没有模板的序列化方法/函数 - 我需要从 DLL 导出序列化代码,而使用模板只会使其更困难
- 自定义内存管理(任意的,不仅是自定义分配器,还包括类
new
/delete
运算符) - 每个类定义的独立版本控制。也就是说,当类定义更改时,旧文件仍然可以导入到新版本的类中。
- 深度指针保存和恢复。也就是说,保存和恢复指针会保存和恢复所指向的数据。
- 正确恢复指向共享数据的指针。
- STL 容器和其他常用模板的序列化。
- 非侵入式。允许将序列化应用于未更改的类。也就是说,不需要要序列化的类派生自特定的基类或实现指定的成员函数。这对于轻松地将序列化应用于我们无法或不想更改的类库中的类是必要的。
- 存档接口必须足够简单,以便于创建新类型的存档。
- 支持复杂的迁移(下文将详细介绍)
第3-8点复制自上面的 boost 链接。
我不会在本文中重点介绍 boost.serialization。我认为这个库非常棒,在许多情况下都很有用,并且它也有很棒的文档。我还要说,我不是 boost.serialization 的专家。我在代码中遇到的问题,我只是认为它们无法克服,而拥有自己的库是唯一的出路。最后,我认为我编写的库对任何人都会非常有用,我不想自己保留它。
构建库
该库使用 boost 和 visual leak detector。
从以上链接下载库,将其解压到硬盘驱动器上,编辑 StartVS.bat 文件以设置库的路径,并通过执行此批处理文件启动 Visual Studio。SerializationTests 项目需要构建 boost 测试框架库,但构建库本身不需要它。
使用代码
让我们从一个例子开始(这是附件 zip 文件中的例子,但已改编为单个文件)
// Serialization
#include <Serialization/Archive/BinaryOutArchive.h>
#include <Serialization/Archive/BinaryInArchive.h>
#include <Serialization/Archive/OutArchiveStdFunctors.h>
#include <Serialization/Archive/InArchiveStdFunctors.h>
#include <Serialization/File/MemoryBinaryOutFile.h>
#include <Serialization/File/MemoryBinaryInFile.h>
#include <Serialization/DeclareMacros.h>
#include <Serialization/ImplementMacros.h>
// std
#include <string>
using namespace Serialization;
/// class to serialize
class MyData
{
public:
/// construction
MyData()
: m_Value(0)
{ }
MyData(int value, const std::string& name)
: m_Value(value)
, m_Name(name)
{ }
~MyData()
{ }
/// get/set value
int GetValue() const { return m_Value; }
void SetValue(int value) { m_Value = value; }
/// get/set name
const std::string& GetName() const { return m_Name; }
void SetName(const std::string& name) { m_Name = name; }
private:
int m_Value;
std::string m_Name;
};
/// serialization routines
void Save(BinaryOutArchive& ar, const MyData& data, const int /*classVersion*/)
{
ar << data.GetValue();
ar << data.GetName();
}
void Load(BinaryInArchive& ar, MyData& data, const int /*classVersion*/)
{
int value;
std::string name;
ar >> value;;
ar >> name;
data.SetValue(value);
data.SetName(name);
}
/// enable serialization for MyData
DECLARE_TYPE_INFO_STRING_KEY(MyData);
IMPLEMENT_TYPE_INFO(MyData, "MyData", 0);
REGISTER_KEY_SERIALIZATION(BinaryInArchive, BinaryOutArchive, const char*);
REGISTER_CLASS_SERIALIZATION(BinaryInArchive, BinaryOutArchive, MyData);
//////////////////////////////////////////////////////////////////////////
void main()
{
// save data
void* pBuffer = nullptr;
size_t bufferSize = 0;
try
{
MyData data1(1, "MyData#1"), data2(2, "MyData#2"), data3(3, "MyData#3");
MemoryBinaryOutFile fo(1024);
BinaryOutArchive ao(fo);
ao << data1 << data2 << data3;
pBuffer = fo.Release(bufferSize);
printf_s("Buffer created: %d\n", bufferSize);
printf_s("\tData1: value = %d; name = %s\n", data1.GetValue(), data1.GetName().c_str());
printf_s("\tData2: value = %d; name = %s\n", data2.GetValue(), data2.GetName().c_str());
printf_s("\tData3: value = %d; name = %s\n", data3.GetValue(), data3.GetName().c_str());
}
catch(SerializationException& e)
{
printf_s("Save error: %s\n", e.what());
return;
}
printf_s("-------------------------------------------\n");
// load data
try
{
MyData data1, data2, data3;
MemoryBinaryInFile fi(pBuffer, bufferSize);
BinaryInArchive ai(fi);
ai >> data1 >> data2 >> data3;
printf_s("Loaded data:\n");
printf_s("\tData1: value = %d; name = %s\n", data1.GetValue(), data1.GetName().c_str());
printf_s("\tData2: value = %d; name = %s\n", data2.GetValue(), data2.GetName().c_str());
printf_s("\tData3: value = %d; name = %s\n", data3.GetValue(), data3.GetName().c_str());
}
catch(SerializationException& e)
{
printf_s("Load error: %s\n", e.what());
}
// release
free(pBuffer);
bufferSize = 0;
}
用户类的序列化通过模板的特化实现。为了简化任务,库提供了一组宏来生成所需的特化。在上面的例子中,这些宏在这里使用
/// enable serialization for MyData
DECLARE_TYPE_INFO_STRING_KEY(MyData);
IMPLEMENT_TYPE_INFO(MyData, "MyData", 0);
REGISTER_KEY_SERIALIZATION(BinaryInArchive, BinaryOutArchive, const char*);
REGISTER_CLASS_SERIALIZATION(BinaryInArchive, BinaryOutArchive, MyData);
宏 DECLARE_TYPE_INFO_STRING_KEY
定义了与类绑定的键的类型。序列化指向类的指针需要该键。存档需要存储序列化指针的类型信息,以便以后能够准确创建存储的类型并将其数据加载到其中。宏 DECLARE_TYPE_INFO_STRING_KEY
将字符串键类型与类绑定。库通过通用宏 DECLARE_TYPE_INFO(class_name, key_type_name)
支持任意键类型。
宏 IMPLEMENT_TYPE_INFO
将键值与类绑定。这正是如果必须保存类型时存档将存储到文件中的值。对于所有支持序列化的类,键必须是唯一的。
宏 REGISTER_KEY_SERIALIZATION
将键类型与归档绑定。可以使用不同的键与不同的归档。如果类被序列化,其绑定的键类型必须与归档绑定的键类型相同。否则会抛出异常。
宏 REGISTER_CLASS_SERIALIZATION
将类注册到归档中,以便可以将该类的引用和指针存储到指定的归档中。
更高级的示例
AdvancedExample
展示了序列化更实际的用法。文件 AppVersion.h 包含定义应用程序版本的宏。每个版本都会更改 City.h 中的数据,因此需要迁移。我在那里添加了不同类型的迁移——所有这些都可以在 Load
方法中直接解决。
重要文件
City.h
序列化数据类型的声明
City.cpp
数据类型的实现
metaCity.cpp
数据类型的序列化
Archive.h
City.h 中使用的存档声明
Archive.cpp
档案的实现
AppVersion.h
定义当前应用程序版本(更改 #define
)
应用程序描述
该应用程序创建4个具有预定义数据值的项目(城市)。根据 AppVersion.h 中的应用程序版本,它创建仅该版本支持的数据。数据可以存储到文件中,也可以选择目标应用程序版本。因此,可以导出数据,将应用程序版本切换到更高版本,然后导入文件。或者从更高版本导出文件到更低版本,然后在更低版本中加载文件。
代码文档
宏描述
DeclareMacros.h 包含应在头文件中使用的宏,而 ImplementMacros.h 包含应在实现文件中使用的宏。
DeclareMacros.h
DECLARE_TYPE_INFO(class_name, key_type_name)
将类型与键类型绑定。必须为 key_type_name
提供 DirectValueReader
和 DirectValueWriter
模板的特化。
DECLARE_TYPE_INFO_STRING_KEY(class_name)
将类型与 std::string
键类型绑定。
DECLARE_TYPE_INFO_WSTRING_KEY(class_name)
将类型与 std::wstring
键类型绑定。
ImplementMacros.h
IMPLEMENT_TYPE_INFO(类名, 键, 版本号, ...)
DECLARE_TYPE_INFO
宏的实现。
类名 | 类的名称 |
键 | 键值(在所有 IMPLEMENT_TYPE_INFO 使用中必须是唯一的) |
版本号 | 用于启用加载旧档案。每次类成员集更改时,版本号都应增加。 |
... | 父类列表(仅支持序列化的类) |
REGISTER_CLASS_SERIALIZATION(in_archive_name, out_archive_name, class_name)
创建 TypedInArchiveObjectBinder
和 TypedOutArchiveObjectBinder
的特化。这两个模板的特化要求输入类具有以下两个方法
void Save(out_archive_name& ar, const int classVersion) const;
void Load(in_archive_name& ar, const int classVersion);
或作为独立函数
void Save(out_archive_name& ar, const class_name& obj, const int classVersion);
void Load(in_archive_name& ar, class_name& obj, const int classVersion);
REGISTER_KEY_SERIALIZATION(in_archive_name, out_archive_name, key_type_name)
将 key_type_name 与提供的归档绑定。只有使用相同键类型注册的类型才能与归档序列化。
TypedSharedPtrHolder.h
DECLARE_SHARED_PTR0、DECLARE_SHARED_PTR1、DECLARE_SHARED_PTR2
宏用于为类启用共享指针的序列化。数字定义了类型有多少个父类。
class C_no_parents { ... };
DECLARE_TYPE_INFO(C_no_parents);
DECLARE_SHARED_PTR0(C_no_parents, std::shared_ptr<C_no_parents>);
class C_single_parent : public A { };
DECLARE_TYPE_INFO(C_single_parent );
DECLARE_SHARED_PTR1(C_single_parent, std::shared_ptr<C_single_parent>, std::shared_ptr<A>);
class C_two_parents : public B, public A { };
DECLARE_TYPE_INFO(C_two_parents);
DECLARE_SHARED_PTR2(C_two_parents, std::shared_ptr<C_two_parents>, std::shared_ptr<B>, std::shared_ptr<A>);
该库仅支持最多两个父类的共享指针。但如果需要,编写 DECLARE_SHARED_PTR3
、DECLARE_SHARED_PTR4
等宏也不是问题。
自定义存档类型
详情请查看 BinaryInArchive
和 BinaryOutArchive
。重要的一步是继承自提供流运算符的模板 InArchive
和 OutArchive
。InArchive
模板要求主存档类具有一个成员方法
void Read(void* pBuffer, size_t size);
OutArchive 模板需要以下方法
void Write(const void* pBuffer, size_t size);
Read
方法可以由您自己添加,也可以通过将 BinaryInFileComposition
用作另一个父类来添加。Write
方法也可以由您自己添加,或者通过将 BinaryOutFileComposition
用作另一个父类来添加。
辅助模板
整个库通过模板特化来定制用户类型的序列化。这些特化由下面“类描述”部分中描述的归档对象绑定器使用。
不可默认构造的类
如果类不提供默认构造函数,则必须提供 ReadConstructDataImpl
和 WriteConstructDataImpl
的特化。
template<typename ArchiveT, typename ObjectT, typename Enabled = void>
struct ReadConstructDataImpl
{
static void Invoke(ArchiveT& /*ar*/, ObjectT* pMemory, const int /*classVersion*/)
{
// read input parameters
...
// call class constructor
::new(pMemory) ObjectT(...); // pass input parameters to constructor
}
};
template<typename ArchiveT, typename ObjectT, typename Enable = void>
struct WriteConstructDataImpl
{
static void Invoke(ArchiveT& /*ar*/, const ObjectT& /*obj*/, const int /*classVersion*/)
{
// write all parameters to be able to call constructor in ReadConstructDataImpl::Invoke call
}
};
请注意,最后一个模板参数 Enable
可以通过使用 std::enable_if
用于一组类。假设您有一个中间类 A
接受两个字符串,并且您从 A
继承了三个类,它们具有与 A
相同的构造函数签名(以便能够调用父构造函数)。在这种情况下,您可以编写
template<typename ArchiveT, typename ObjectT>
struct WriteConstructDataImpl<ArchiveT, ObjectT, typename std::enable_if<std::is_base_of<A, ObjectT>::value>::type>
{
static void Invoke(ArchiveT& ar, const ObjectT& obj, const int /*classVersion*/)
{
ar << obj.GetString1();
ar << obj.GetString2();
}
};
template<typename ArchiveT, typename ObjectT>
struct ReadConstructDataImpl<ArchiveT, ObjectT, typename std::enable_if<std::is_base_of<A, ObjectT>::value>::type>
{
static void Invoke(ArchiveT& ar, ObjectT* pMemory, const int /*classVersion*/)
{
std::string s1, s2;
ar >> s1 >> s2;
::new(pMemory) ObjectT(s1, s2);
}
};
不使用 DECLARE_TYPE_INFO 的类型序列化
可以在没有 DECLARE_TYPE_INFO
的情况下序列化类型。但是,在这种情况下,序列化库需要 DirectValueWriter
和 DirectValueReader
模板的特化。
struct MyHelperDataType
{
int a, b, c;
};
template<>
struct DirectValueWriter<MyOutArchive, MyHelperDataType>
{
static void Invoke(MyOutArchive& ar, const MyHelperDataType& value)
{
ar << value.a << value.b << value.c;
}
};
template<>
struct DirectValueReader<MyInArchive, MyHelperDataType>
{
static void Invoke(MyInArchive& ar, MyHelperDataType& value)
{
ar >> value.a >> value.b >> value.c;
}
};
缺点是该类型的指针无法序列化到/从归档中。此外,如果成员发生更改,数据迁移也无法轻松执行。
自定义内存分配支持
template<typename ArchiveT, typename ObjectT, typename Enabled = void>
struct AllocateDataImpl
{
static void* Invoke(ArchiveT& /*ar*/, const int /*classVersion*/)
{
return malloc(sizeof(ObjectT));
}
};
template<typename ArchiveT, typename ObjectT, typename Enabled = void>
struct DeallocateDataImpl
{
static void Invoke(ArchiveT& /*ar*/, const int /*classVersion*/, void* pMemory)
{
free(pMemory);
}
};
您可以编写这些模板的自己的特化,以提供自己的分配。
如果库需要直接构造一个类型,它会使用模板类 ConstructDefaultValue
。这个模板也适用于自定义内存分配支持。例如,如果需要从归档中读取容器的容器,则无法提供内部容器的自定义构造。对 std
容器的支持正是使用 ConstructDefaultValue
模板来构造存储的类型,因此实现能够将自定义分配器传递给这些容器的构造函数。
读写 std::unique_ptr
模板也可以使用一些自定义删除器。该库提供了 WriteUniquePtrDeleter
和 ReadUniquePtrDeleter
,以允许写入绑定到删除器的自定义数据。
共享指针的支持在单独的章节中讨论。
序列化父类内容
由于类的序列化可以作为成员方法或独立函数实现,因此不清楚如何序列化父类数据。为了统一调用,无论父类序列化实现如何,库都包含一个模板 BaseObject
。模板参数是父类型。请注意,如果存储数据,父类型应为 const T
。
void B::Save(MyOutArchive& ar, const int /*classVersion*/) const { ar << BaseObject<const A>(*this); } void B::Load(MyInArchive& ar, const int /*classVersion*/) { ar >> BaseObject<A>(*this); }
Serialization::Access 和友元访问
如果使用成员方法实现序列化,则如果授予 Serialization::Access
对该类的友元访问权限,则这些方法可以声明为私有。
classs MyClass
{
friend Serialization::Access;
private:
void Save(MyOutArchive& ar, const int classVersion) const;
void Load(MyInArchive& ar, const int classVersion);
};
共享指针支持
必须使用宏 DECLARE_SHARED_PTR0
(或 DECLARE_SHARED_PTR1
或 DECLARE_SHARED_PTR2
,根据类型具有多少个父类)为类声明共享指针类型。然后还需要提供模板的特化
template<typename ArchiveT, typename SharedPtrT, typename Enabled = void>
struct CreateSharedPtrImpl
{
static SharedPtrT Invoke(ArchiveT& /*ar*/, SharedPtrT::value_type* /*pMemory*/)
{
// wrap a created object to a shared pointer
...
}
};
除了上述模板特化之外,库还期望以下特化
template<typename T>
struct SharedPtrValueGetter
{
static void* Invoke(const T& sharedPtr)
{
// extract raw pointer from a shared pointer
...
}
};
template<typename WeakPtrT>
struct ToSharedPtr
{
using SharedPtrT = ...; // shared pointer from WeakPtrT
static SharedPtrT Invoke(const WeakPtrT& ptr)
{
// convert weak pointer to shader pointer
return ...;
}
};
template<typename T, typename U>
struct UpCastSharedPtr
{
U Invoke(const T& /*ptr*/)
{
// convert a shared pointer T to a shared point U. It's up to the specialization to verify that types are related and convertible.
return ...;
}
};
该库内置了对 std::shared_ptr
和 std::weak_ptr
的支持(请参阅 StdSharedPtrImpl.h)。
异常处理
错误报告通过异常完成。库抛出的所有异常都继承自 Serialization::SerializationException
,它们位于 Exceptions.h 文件中。
STD 容器
该库内置了对 STD 容器序列化的支持。如果代码中需要,必须包含 InArchiveStdFunctors.h 和 OutArchiveStdFunctors.h。也可以为 boost 容器编写序列化,但 boost 容器太多了,我宁愿不写。
模板类的序列化
模板类很棘手。不同的模板参数会产生不同的类型。序列化库需要为库应支持的每种类型提供唯一的键。
template<typename T>
class MyTemplate
{
...
};
DECLARE_TYPE_INFO(MyTemplate, std::string); // <-- DOESN'T WORK !!
在模板的情况下,可以扩展和调整宏 DECLARE_TYPE_INFO
生成的代码
namespace Serialization
{
namespace Detail
{
template<typename T>
struct TypeInfoTraits<MyTemplate<T>> // partial specialization
{
using value_type = MyTemplate<T>;
using key_type = std::string; // or whatever key type is needed
};
}
}
但不能以这种方式欺骗 IMPLEMENT_TYPE_INFO
,因为对于每种类型,都必须提供一个键值(如上述示例中的字符串)。因此,每个实例化模板都必须有一个 IMPLEMENT_TYPE_INFO
。
IMPLEMENT_TYPE_INFO(MyTemplate<int>, "MyTemplateInt", 0);
IMPLEMENT_TYPE_INFO(MyTemplate<char>, "MyTemplateChar", 0);
IMPLEMENT_TYPE_INFO(MyTemplate<bool>, "MyTemplateBool", 0);
由于需要为每个实例化模板编写 IMPLEMENT_TYPE_INFO
,我也会为这些类型单独编写 DECLARE_TYPE_INFO
,如果序列化放在单独的模块中,则从 DLL 导出实例化模板类型。
存档对象绑定器
ArchiveObjectBinder
将归档和对象类型绑定在一起,以便可以在这两种类型上调用特定方法,而无需使用公共基类。该类作为定义输入和输出归档对象绑定器接口的基类。
InArchiveObjectBinder
从档案读取数据到对象的接口类
void Read(BaseInArchive& ar, void* ptr, const int classVersion) const
读取对象内容。
void ReadConstructData(BaseInArchive& ar, void* ptr, const int classVersion) const
读取用于构造对象的输入数据。
void* AllocateObject(BaseInArchive& ar, const int classVersion) const
分配一个对象(构造函数尚未调用)。
void DeallocateObject(BaseInArchive& ar, const int classVersion, void* pMemory) const
释放之前由 AllocateObject 调用分配的内存。
void DestructObject(BaseInArchive& ar, const int classVersion, void* pMemory) const
调用绑定对象的析构函数。
std::unique_ptr
CreateSharedPtr(BaseInArchive& ar, void* ptr) const
void GetInputObjects(BaseInArchive& ar, void* ptr, LoadedPointerInfoArray& inputObjects) const
支持复杂的对象迁移。在“复杂迁移支持”部分中描述。
bool PostLoad(BaseInArchive& ar, void* ptr, const int classVersion) const
支持复杂的对象迁移。在“复杂迁移支持”部分中描述。
InArchiveKeyBinder
用于从归档读取键的接口。键描述了归档中写入的类类型。重要的归档对象绑定器需要支持写入/读取多态指针。
const TypeInfo::Key& Read(BaseInArchive& ar) const
从档案中读取一个键。
DeferredInArchiveObjectBinder
支持直接读取对象,例如存储在容器中的对象。此归档对象绑定器允许在 std::vector 类似容器中使用不可默认构造的对象。
OutArchiveObjectBinder
用于将数据从对象写入档案的接口类。
void Write(BaseOutArchive& ar, const void* ptr, const int classVersion) const
从输入对象写入内容。
void WriteConstructData(BaseOutArchive& ar, const void* ptr, const int classVersion) const
写入输入对象的构造函数所需的数据。
OutArchiveKeyBinder
用于向归档写入键的接口。该键与类类型绑定在一起,以支持指针的深度写入。
void Write(BaseOutArchive& ar, const TypeInfo::Key& key) const
将键写入档案。
DeferredOutArchiveObjectBinder
支持直接写入对象,这些对象可以通过 DeferredInArchiveObjectBinder 加载。它写入调用写入对象的构造函数所需的所有数据以及对象的内容。
template<typename ArchiveT, typename ObjectT> TypedInArchiveObjectBinder
InArchiveObjectBinder
的主要实现。它将归档和对象的实际类型绑定在一起。实现通过使用更进一步的模板类进行定制,因此无需为特定的归档和类类型重新实现该类,而只需特化子模板。输入 ArchiveT
必须继承自 BaseInArchive
,输入 ObjectT
必须是非指针且不能是 const。此类的实例通过使用宏 REGISTER_CLASS_SERIALIZATION
创建。所有方法的输入参数都由 BaseInArchive
确保是正确的类型。
void Read(BaseInArchive& ar, void* ptr, const int classVersion) const
调用 ObjectT::Load
成员方法或独立的 void Load(ArchiveT&, ObjectT&, int)
函数。
void ReadConstructData(BaseInArchive& ar, void* ptr, const int classVersion) const
对于抽象类,它什么也不做,并且不应该调用该方法(如果调用,它会抛出异常)。对于非抽象类,它会调用默认构造函数。它使用模板类 ReadConstructDataImpl
来定制行为。
void* AllocateObject(BaseInArchive& ar, const int classVersion) const
为一个对象分配内存。对于抽象类,它什么也不做,并且不应该调用该方法(如果调用,它会抛出异常)。对于非抽象类,它使用 malloc
分配内存。可以通过特化 AllocateDataImpl
模板来自定义行为。
void DeallocateObject(BaseInArchive& ar, const int classVersion, void* pMemory) const
释放之前由 AllocateObject
调用分配的对象内存。对于抽象类,它什么也不做,并且不应该调用该方法(如果调用,它会抛出异常)。对于非抽象类,它使用 free
释放内存。可以通过特化 DeallocateDataImpl
模板来自定义行为。
void DestructObject(BaseInArchive& ar, const int classVersion, void* pMemory) const
调用之前由 ReadConstructData
方法构造的对象的析构函数。不应在抽象类上调用此方法(它会抛出异常)。原因是 ReadConstructData
不允许在抽象类上调用,所以此方法也不允许。可以通过特化 DestructDataImpl
模板来自定义行为。
std::unique_ptr<SharedPtrWrapper> CreateSharedPtr(BaseInArchive& ar, void* ptr) const
创建共享指针包装器。在“共享指针支持”部分中描述。
void GetInputObjects(BaseInArchive& ar, void* ptr, LoadedPointerInfoArray& inputObjects) const
如果 ArchiveT
启用了复杂迁移,它会调用 ObjectT::GetInputObjects
成员方法或独立的 void GetInputObjects(ArchiveT&, ObjectT&, LoadedPointerInfoArray&)
函数。在“复杂迁移支持”部分中进行了更详细的描述。
bool PostLoad(BaseInArchive& ar, void* ptr, const int classVersion) const
如果 ArchiveT
启用了复杂迁移,它会调用 ObjectT::PostLoad
成员方法或独立的 bool PostLoad(ArchiveT&, ObjectT&, const int)
函数。在“复杂迁移支持”部分中进行了更详细的描述。
template<typename ArchiveT, typename KeyT> TypedInArchiveKeyBinder
InArchiveKeyBinder
的实现,用于读取特定类型的键。此模板的特化由 REGISTER_KEY_SERIALIZATION
宏创建。
template<typename ArchiveT, typename ObjectT> TypedDeferredInArchiveObjectBinder
DeferredInArchiveObjectBinder
的实现。如果对象必须直接从档案加载,则类的实例会直接在堆栈上创建。
ObjectT Read(BaseInArchive& ar);
从归档读取对象。ObjectT
必须是可移动的,但不一定是可复制的。
template<typename ArchiveT, typename ObjectT> TypedOutArchiveObjectBinder
OutArchiveObjectBinder
的主要实现。它将归档和对象的实际类型绑定在一起。实现通过使用更进一步的模板类进行定制,因此无需为特定的归档和类类型重新实现该类,而只需特化子模板。输入 ArchiveT
必须继承自 BaseInArchive
,输入 ObjectT
必须是非指针且不能是 const。此类的实例通过使用宏 REGISTER_CLASS_SERIALIZATION
创建。所有方法的输入参数都由 BaseOutArchive
确保是正确的类型。
void Write(BaseOutArchive& ar, const void* ptr, const int classVersion) const
调用 ObjectT::Save
成员方法或独立的 void Save(ArchiveT&, const ObjectT&, int)
函数。
void WriteConstructData(BaseOutArchive& ar, const void* ptr, const int classVersion) const
对于抽象类,它什么也不做,并且不应该调用该方法(如果调用,它会抛出异常)。对于非抽象类,它使用模板类 WriteConstructDataImpl
来定制行为。默认情况下,模板什么也不做,但可以编写特化来存储用于构造类的输入数据。
template<typename ArchiveT, typename KeyT> TypedOutArchiveKeyBinder
OutIArchiveKeyBinder
的实现,用于写入特定类型的键。此模板的特化由 REGISTER_KEY_SERIALIZATION
宏创建。
template<typename ArchiveT, typename ObjectT> TypedDeferredOutArchiveObjectBinder
DeferredOutArchiveObjectBinder
的实现。如果对象必须直接保存到档案中,则类的实例将直接在堆栈上创建。
void Write(BaseOutArchive& ar, const ObjectT& obj)
将对象写入档案。它写入构造数据和对象的内容。
创建与旧版本应用程序兼容的文件
通常,该库不支持此功能。如果应用程序需要导出可与同一应用程序的旧版本加载的文件,我建议完全忽略并不要使用类版本控制。在使用类版本控制系统时,文件版本由文件中序列化的所有类版本集合定义。在这种情况下,需要以某种方式跟踪这些集合,并在其中一个类即将更改时导出类版本。我建议使用一个单一数字来跟踪应用程序文件版本并将其传递给存档。然后,每个 Save/Load 方法都可以访问此数字并仅存储/加载当时的内容。在这种情况下,好的做法是创建一个中间文件版本(尚未发布)。如果类即将更改,则 Save/Load 方法的内容将被复制并保留用于保存/加载旧版本,然后添加 if
语句,并可以调整代码。这与使用类版本控制系统非常相似,但所有类都使用相同的数字。
void Save(MyArchive& ar, const int classVersion) const
{
if(ar.GetFileVersion() <= APP_FILE_VERSION_1_0)
{
// file version 1.0
ar << m_Data1;
}
else if(ar.GetFileVersion() <= APP_FILE_VERSION_2_0)
{
// file version 2.0
ar << m_Data1;
ar << m_Data2; // new to 2.0 version
}
else
{
// most recent file version
ar << m_Data1;
ar << m_Data2; // new to 2.0 version
ar << m_Data3; // new to 3.0 version
}
}
请注意,最后一部分也适用于 4.0、5.0 等版本。如果例如类在 3.0-5.0 版本之间没有更改,但在 6.0 版本中更改,则 Save 方法将如下所示
void Save(MyArchive& ar, const int classVersion) const
{
if(ar.GetFileVersion() <= APP_FILE_VERSION_1_0)
{
// file version 1.0
ar << m_Data1;
}
else if(ar.GetFileVersion() <= APP_FILE_VERSION_2_0)
{
// file version 1.1 - 2.0
ar << m_Data1;
ar << m_Data2; // new to 2.0 version
}
else if(ar.GetFileVersion() <= APP_FILE_VERSION_5_0)
{
// file versions 2.1 - 5.0
ar << m_Data1;
ar << m_Data2; // new to 2.0 version
ar << m_Data3; // new to 3.0 version
}
else
{
// most recent file version
ar << m_Data1;
ar << m_Data2; // new to 2.0 version
ar << m_Data3; // new to 3.0 version
ar << m_Data4; // new to 6.0 version
}
}
加载将类似,但在从旧档案加载类时,务必不要忘记初始化新成员。
void Load(MyArchive& ar, const int classVersion)
{
if(ar.GetFileVersion() <= APP_FILE_VERSION_1_0)
{
// file version 1.0
ar >> m_Data1;
m_Data2 = ...;
m_Data3 = ...;
m_Data4 = ...;
}
else if(ar.GetFileVersion() <= APP_FILE_VERSION_2_0)
{
// file version 1.1 - 2.0
ar >> m_Data1;
ar >> m_Data2; // new to 2.0 version
m_Data3 = ...;
m_Data4 = ...;
}
else if(ar.GetFileVersion() <= APP_FILE_VERSION_5_0)
{
// file versions 2.1 - 5.0
ar >> m_Data1;
ar >> m_Data2; // new to 2.0 version
ar >> m_Data3; // new to 3.0 version
m_Data4 = ...;
}
else
{
// most recent file version
ar >> m_Data1;
ar >> m_Data2; // new to 2.0 version
ar >> m_Data3; // new to 3.0 version
ar >> m_Data4; // new to 6.0 version
}
}
所以如果添加了新成员,就必须将其添加到所有部分。我不建议在这里进行任何优化,比如试图避免重复代码。
void Load(MyArchive& ar, const int classVersion)
{
ar >> m_Data1;
if(ar.GetFileVersion() >= APP_FILE_VERSION_2_0)
ar >> m_Data2; // new to 2.0 version
else
m_Data2 = ...;
if(ar.GetFileVersion() >= APP_FILE_VERSION_5_0)
ar >> m_Data3; // new to 3.0 version
else
m_Data3 = ...;
if(ar.GetFileVersion() >= APP_FILE_VERSION_6_0)
ar >> m_Data4; // new to 6.0 version
else
m_Data4 = ...;
}
首先,这很混乱,相信我,在此期间迁移可能会变得很糟糕,而且在这种代码中很容易出错。最重要的规则是,如果应用程序已经发布给公众,并且文件应该可以从该发布的应用程序中导出或导入,最好保持代码不变。这就是为什么最好复制它,包装在条件中,并且只更改当前应用程序版本的复制代码。
复杂迁移支持
数据迁移用于类更改时,仍然需要能够加载已经创建(并可能已经交付给客户)的文件。最简单的迁移类型是类直接更改其成员——例如成员的数据类型更改、添加或删除。为了这个简单的目的,库具有类版本控制。如果每次更改都增加类版本,则可以在加载期间验证类版本并相应地调整数据。然而,实际示例更复杂,并非总是可以通过 Load
方法/函数迁移类数据。在应用程序的生命周期中,单个类可以快速增长,然后可能需要将一个类拆分为两种类型。或者相反的更改——将两种类型合并为一种类型。另一种迁移类型可以是基于多个对象的数据计算。在序列化期间,无法定义对象存储/加载的顺序,因此从类的成员指针读取数据可能会导致问题。该库支持在所有对象加载后执行更复杂的迁移。如果需要这种支持,则输入归档(InArchive
的实现)必须在宏 ENABLE_ARCHIVE_MIGRATION
中使用。通过使用此宏,所有使用 REGISTER_CLASS_SERIALIZATION
注册的类都必须声明额外的接口
As stand-alone functions:
void GetInputObjects(ArchiveT& ar, T& obj, LoadedPointerInfoArray& inputObjects);
bool PostLoad(ArchiveT& ar, T& obj, const int classVersion);
or member methods:
void GetInputObjects(ArchiveT& ar, LoadedPointerInfoArray& inputObjects);
bool PostLoad(ArchiveT& ar, const int classVersion);
迁移应放在 PostLoad
方法/函数中。GetInputObjects
定义了 PostLoad
的调用顺序。让我们考虑以下类
class A
{
...
public:
B* m_pB;
C* m_pC;
D* m_pD; // added in classVersion 1
};
void Save(MyOutArchive& ar, const A& a, const int classVersion)
{
ar << a.m_pB << a.m_pC;
if(classVersion >= 1)
ar << a.m_pD;
}
void Load(MyInArchive& ar, A& a, const int classVersion)
{
ar >> a.m_pB >> a.m_pC;
if(classVersion >= 1)
ar >> a.m_pD;
else
{
// m_pD should be initialize, but how?
...
}
}
撇开糟糕的设计(a.m_pB
和 a.m_pC
在加载前应确保为 nullptr,或者如果类 A
是所有者,则 Load()
函数应释放指针),只需考虑类 A
需要从 B
和 C
指针中提取一些数据,并为 classVersion == 0
初始化 m_pD
。在 Load()
中,不能确定 B
和 C
是否已加载——如果 B
或 C
指回 A
,并且序列化首先调用存储指向 B
的指针,那么在加载 A
期间,指针 B
只是部分初始化——B
的构造函数已被调用,但 B::Load()
尚未完成,因此并非所有成员都已从归档中加载。对于这种情况,需要 PostLoad
。需要告诉输入归档,我们希望在 B
和 C
初始化后接收 A::PostLoad
。GetInputObjects()
正是用于此目的的函数
void GetInputObjects(MyInArchive& ar, A& a, Serialization::LoadedPointerInfoArray& inputObjects)
{
Serialization::AddInputObject(ar, inputObjects, *m_pB);
Serialization::AddInputObject(ar, inputObjects, *m_pC);
}
LoadedPointerInfoArray
的填充不能直接进行,库而是提供了辅助模板函数 AddInputObject
来创建一个项,然后将其存储到数组中。这确保了 PostLoad
首先在 B
和 C
上调用(除非 B
或 C
通过其 GetInputObjects
指定另一个类作为其输入,否则顺序未定义),然后才在 A
上调用。因此,A
的 PostLoad
实现可以正确初始化 m_pD
成员指针。当调用 MigrationManager::Execute
时,会触发通知。如果类是从其他类继承的,在 PostLoad
和 GetInputObjects
方法中,也需要调用父方法。同样,与 Load
/Save
类似,父方法/函数应通过 BaseObject
模板调用。库的 InArchiveMigration
具有接受 BaseObject
引用并调用相应通知的成员方法。
如果需要执行更深层次的、涉及更多不直接相关的对象的层次迁移——例如,迁移一个完整的对象数组,MigrationManager
提供了数据包和迁移器(migrator)的概念。通过这种支持,所有类都应该能够访问 MigrationManager
。该库提供了一个模板 InArchiveMigration
,它具有成员方法 GetMigrationManager()
来提供此访问。只需将自定义存档的基类从 InArchive
更改为 InArchiveMigration
。
数据包是只包含数据的小类。在序列化期间,Load
和 PostLoad
函数可以收集对象和其他数据并将其存储到数据包中。稍后当迁移器执行时,可以访问这些数据包并处理其中的数据。数据包由 MigrationManager
的 RegisterPacket
、UnregisterPacket
和 GetPacket
方法管理。该库包含一个辅助模板类 Serialization::PacketImpl
,它将数据包与指定的键类型绑定。与可序列化类相反,具有不同键类型的数据包可以注册到 MigrationManager
(因为它们从不存储到归档中,所以它不起主要作用)。实现带有静态 getter 的数据包以从归档中提取数据包是很方便的
class MyDataPacket : public Serialization::PacketImpl<std::string>
{
public:
MyDataPacket()
: Serialization::PacketImpl<std::string>("MyDataPacket_key")
{
}
MyDataPacket& Get(MyArchive& ar)
{
MyDataPacket ref; // just to extract a key
auto* pPacketRawPtr = static_cast<MyDataPacket*>(ar.GetMigrationManager().GetPacket(ref.GetKey()));
if(pPacketRawPtr == nullptr)
{
auto pPacketPtr = std::make_unique<MyDataPacket>();
pPacketRawPtr = pPacketPtr.get();
if(!ar.GetMigrationManager().RegisterPacket(std::move(pPacketPtr)))
{
// or whatever exception you prefer
throw std::runtime_error("Packet was not registered!!");
}
}
return *pPacketRawPtr;
}
private:
... // data members
};
缺点是每个数据包都必须有一个类型,所以相同类型的数据包不能注册两次。另一方面,重用数据包需要维护某种键列表。
迁移器是用于进行复杂迁移的类。它是整个文档加载并以 PostLoad
方法迁移/初始化之后的最后一个迁移阶段。迁移器也在 Load/PostLoad 调用期间注册,并且通常与创建数据包一起注册。无法定义迁移器的调用顺序,也不建议在那里创建依赖关系。如果存在依赖关系,最好将两个或更多迁移器合并在一起,并在单个迁移器中解决依赖关系。
MigrationManager 描述
bool Execute(BaseInArchive& ar)
在存档中所有已加载对象(指针)上调用 GetInputObjects
,构建依赖图,并按请求顺序调用 PostLoad
通知。
template<typename T> void AddExternObject(BaseInArchive& ar, T& inputObject)
将外部指针注册到档案。在加载期间,档案不会存储已加载引用的地址,因为这些地址可能会被其他已加载对象使无效。只需考虑存储在 std::vector
中的对象。如果插入新对象,存储空间会重新分配。档案无法跟踪此类更改。如果用户希望对这些对象调用通知,则由用户将这些对象注册到 MigrationManager
。请注意,即使作为引用从档案加载,所有通过 GetInputObjects
添加的对象也将收到 PostLoad
通知。因此,AddExternObject
主要应与不是任何其他对象的输入对象的顶层引用一起调用。
void AddMigrator(MigratorPtr pMigrator)
向 MigrationManager 注册一个迁移器。类型必须是唯一的。尝试注册两个相同类型的迁移器将失败。
Migrator* FindMigrator(const type_info& migratorInfo) const
按类型查找迁移器。如果未找到,则返回 nullptr。
bool RegisterPacket(PacketPtr pPacket)
向 MigrationManager 注册一个数据包。如果数据包持有的键已被另一个数据包使用,则注册失败。
void UnregisterPacket(const TypeInfo::Key& key)
注销已注册的数据包。如果密钥未被任何数据包使用,则该方法不执行任何操作。
Packet* GetPacket(const TypeInfo::Key& key) const
通过键查找数据包。如果未找到数据包,则返回 nullptr。
编译错误,模板混乱等
最初我想拥有一个易于集成的库。对我来说,易于集成意味着如果在集成过程中犯了错误,很容易解决编译错误。我认为我在这里失败了,编译错误很奇怪,就像集成 boost 序列化库时出现的那些错误一样。主要问题在于模板本身。如果一个模板被特化,它就完全是一个新类,程序员可以随心所欲地编写任何东西。当然,库希望这些类具有一些特性,例如具有特定接口的静态函数,但我没有找到验证类接口是否正确的方法。所以编译器会产生关于调用的奇怪错误。其他类型的错误来自模板的可见性。如果库期望模板的特化,您的特化必须在此处可见。我在代码中添加了尽可能多的 static_asserts
,以明确哪里出了问题,但这并非总是可能的。
实施说明
该库仅在 ObjectDependencyGraph.cpp 中使用 boost 进行依赖排序(使用 boost 图库)。单元测试也是使用 boost(boost 框架库)创建的,但它们不需要用于构建库本身。
历史
2016年6月21日 首次发布
2016年6月27日 添加了 PointerOwnershipTests 并修复了加载两个相同类型的空指针作为 std::unique_ptr 时的错误