基于泛型编程的序列化实现方法

29次阅读

共计 5761 个字符,预计需要花费 15 分钟才能阅读完成。

写在前面
序列化是一个转储 - 恢复的操作过程,即支持将一个对象转储到临时缓冲或者永久文件中和恢复临时缓冲或者永久文件中的内容到一个对象中等操作,其目的是可以在不同的应用程序之间共享和传输数据,以达到跨应用程序、跨语言和跨平台的解耦,以及当应用程序在客户现场发生异常或者崩溃时可以即时保存数据结构各内容的值到文件中,并在发回给开发者时再恢复数据结构各内容的值以协助分析和定位原因。
泛型编程是一个对具有相同功能的不同类型的抽象实现过程,比如 STL 的源码实现,其支持在编译期由编译器自动推导具体类型并生成实现代码,同时依据具体类型的特定性质或者优化需要支持使用特化或者偏特化及模板元编程等特性进行具体实现。
Hello World
#include <iostream>
int main(int argc, char* argv[])
{
std::cout << “Hello World!” << std::endl;
return 0;
}
泛型编程其实就在我们身边,我们经常使用的 std 和 stl 命名空间中的函数和类很多都是泛型编程实现的,如上述代码中的 std::cout 即是模板类 std::basic_ostream 的一种特化
namespace std
{
typedef basic_ostream<char> ostream;
}
从 C ++ 的标准输入输出开始
除了上述提到的 std::cout 和 std::basic_ostream 外,C++ 还提供了各种形式的输入输出模板类,如 std::basic_istream,std::basic_ifstream,std::basic_ofstream,std::basic_istringstream,std::basic_ostringstream 等等,其主要实现了内建类型(built-in)的输入输出接口,比如对于 Hello World 可直接使用于字符串,然而对于自定义类型的输入输出,则需要重载实现操作符 >> 和 <<,如对于下面的自定义类
class MyClip
{
bool mValid;
int mIn;
int mOut;
std::string mFilePath;
};
如使用下面的方式则会出现一连串的编译错误
MyClip clip;
std::cout << clip;
错误内容大致都是一些 clip 不支持 << 操作符并在尝试将 clip 转为 cout 支持的一系列的内建类型如 void* 和 int 等等类型时转换操作不支持等信息。
为了解决编译错误,我们则需要将类 MyClip 支持输入输出操作符 >> 和 <<,类似实现代码如下
inline std::istream& operator>>(std::istream& st, MyClip& clip)
{
st >> clip.mValid;
st >> clip.mIn >> clip.mOut;
st >> clip.mFilePath;
return st;
}
inline std::ostream& operator<<(std::ostream& st, MyClip const& clip)
{
st << clip.mValid << ‘ ‘;
st << clip.mIn << ‘ ‘ << clip.mOut << ‘ ‘;
st << clip.mFilePath << ‘ ‘;
return st;
}
为了能正常访问类对象的私有成员变量,我们还需要在自定义类型里面增加序列化和反序列化的友元函数(回忆一下这里为何必须使用友元函数而不能直接重载操作符 >> 和 <<?),如
friend std::istream& operator>>(std::istream& st, MyClip& clip);
friend std::ostream& operator<<(std::ostream& st, MyClip const& clip);
这种序列化的实现方法是非常直观而且容易理解的,但缺陷是对于大型的项目开发中,由于自定义类型的数量较多,可能达到成千上万个甚至更多时,对于每个类型我们则需要实现 2 个函数,一个是序列化转储数据,另一个则是反序列化恢复数据,不仅仅增加了开发实现的代码数量,如果后期一旦对部分类的成员变量有所修改,则需要同时修改这 2 个函数。
同时考虑到更复杂的自定义类型,比如含有继承关系和自定义类型的成员变量
class MyVideo : public MyClip
{
std::list<MyFilter> mFilters;
};
上述代码需要转储 - 恢复类 MyVideo 的对象内容时,事情会变得更复杂些,因为还需要转储 - 恢复基类,同时成员变量使用了 STL 模板容器 list 与自定义类 ’MyFilter` 的结合,这种情况也需要自己去定义转储 - 恢复的实现方式。
针对以上疑问,有没有一种方法能减少我们代码修改的工作量,同时又易于理解和维护呢?
Boost 序列化库
对于使用 C ++ 标准输入输出的方法遇到的问题,好在 Boost 提供了一种良好的解决方式,则是将所有类型的转储 - 恢复操作抽象到一个函数中,易于理解,如对于上述类型,只需要将上述的 2 个友元函数替换为下面的一个友元函数
template<typename Archive> friend void serialize(Archive&, MyClip&, unsigned int const);
友元函数的实现类似下面的样子
template<typename A>void serialize(A &ar, MyClip &clip, unsigned int const ver)
{
ar & BOOST_SERIALIZATION_NVP(clip.mValid);
ar & BOOST_SERIALIZATION_NVP(clip.mIn);
ar & BOOST_SERIALIZATION_NVP(clip.mOut);
ar & BOOST_SERIALIZATION_NVP(clip.mFilePath);
}
其中 BOOST_SERIALIZATION_NVP 是 Boost 内部定义的一个宏,其主要作用是对各个变量进行打包。
转储 - 恢复的使用则直接作用于操作符 >> 和 <<,比如
// store
MyClip clip;
······
std::ostringstream ostr;
boost::archive::text_oarchive oa(ostr);
oa << clip;

// load
std::istringstream istr(ostr.str());
boost::archive::text_iarchive ia(istr);
ia >> clip;
这里使用的 std::istringstream 和 std::ostringstream 即是分别从字符串流中恢复数据以及将类对象的数据转储到字符串流中。
对于类 MyFilter 和 MyVideo 则使用相同的方式,即分别增加一个友元模板函数 serialize 的实现即可,至于 std::list 模板类,boost 已经帮我们实现了。
这时我们发现,对于每一个定义的类,我们需要做的仅仅是在类内部声明一个友元模板函数,同时类外部实现这个模板函数即可,对于后期类的成员变量的修改,如增加、删除或者重命名成员变量,也仅仅是修改一个函数即可。
Boost 序列化库已经足够完美了,但故事并未结束!
在用于端上开发时,我们发现引用 Boost 序列化库遇到了几个挑战

端上的编译资料很少,官方对端上编译的资料基本没有,在切换不同的版本进行编译时经常会遇到各种奇怪的编译错误问题
Boost 在不同的 C ++ 开发标准之间兼容性不够好,尤其是使用 libc++ 标准进行编译链接时遇到的问题较多
Boost 增加了端上发行包的体积
Boost 每次序列化都会增加序列化库及版本号等私有头信息,反序列化时再重新解析,降低了部分场景下的使用性能

基于泛型编程的序列化实现方法
为了解决使用 Boost 遇到的这些问题,我们觉得有必要重新实现序列化库,以剥离对 Boost 的依赖,同时能满足如下要求

由于现有工程大量使用了 Boost 序列化库,因此兼容现有的代码以及开发者的习惯是首要目标
尽量使得代码修改和重构的工作量最小
兼容不同的 C ++ 开发标准
提供比 Boost 序列化库更高的性能
降低端上发行包的体积

为了兼容现有使用 Boost 的代码以及保持当前开发者的习惯,同时使用代码修改的重构的工作量最小,我们应该保留模板函数 serialize,同时对于模板函数内部的实现,为了提高效率也不需要对各成员变量重新打包,即直接使用如下定义
#define BOOST_SERIALIZATION_NVP(value) value
对于转储 - 恢复的接口调用,仍然延续目前的调用方式,只是将输入输出类修改为
alivc::text_oarchive oa(ostr);
alivc::text_iarchive ia(istr);
好了,到此为止,序列化库对外的接口工作已经做好,剩下的就是内部的事情,应该如何重新设计和实现序列化库的内部框架才能满足要求呢?
先来看一下当前的设计架构的处理流程图

比如对于转储类 text_oarchive,其支持的接口必须包括
explicit text_oarchive(std::ostream& ost, unsigned int version = 0);
template <typename T> text_oarchive& operator<<(T& v);
template <typename T> text_oarchive& operator&(T& v);
开发者调用操作符函数 << 时,需要首先回调到相应类型的模板函数 serialize 中
template <typename T>
text_oarchive& operator<<(T& v)
{
serialize(*this, v, mversion);
return *this;
}
当开始对具体类型的各个成员进行操作时,这时需要进行判断,如果此成员变量的类型已经是内建类型,则直接进行序列化,如果是自定义类型,则需要重新回调到对应类型的模板函数 serialize 中
template <typename T>
text_oarchive& operator&(T& v)
{
basic_save<T>::invoke(*this, v, mversion);
return *this;
}
上述代码中的 basic_save::invoke 则会在编译期完成模板类型推导并选择直接对内建类型进行转储还是重新回调到成员变量对应类型的 serialize 函数继续重复上述过程。
由于内建类型数量有限,因此这里我们选择使模板类 basic_save 的默认行为为回调到相应类型的 serialize 函数中
template <typename T, bool E = false>
struct basic_load_save
{
template <typename A>
static void invoke(A& ar, T& v, unsigned int version)
{
serialize(ar, v, version);
}
};

template <typename T>
struct basic_save : public basic_load_save<T, std::is_enum<T>::value>
{
};
这时会发现上述代码的模板参数多了一个参数 E,这里主要是需要对枚举类型进行特殊处理,使用偏特化的实现如下
template <typename T>
struct basic_load_save<T, true>
{
template <typename A>
static void invoke(A& ar, T& v, unsigned int version)
{
int tmp = v;
ar & tmp;
v = (T)tmp;
}
};
到这里我们已经完成了重载操作符 & 的默认行为,即是不断进行回溯到相应的成员变量的类型中的模板函数 serialize 中,但对于碰到内建模型时,我们则需要让这个回溯过程停止,比如对于 int 类型
template <typename T>
struct basic_pod_save
{
template <typename A>
static void invoke(A& ar, T const& v, unsigned int)
{
ar.template save(v);
}
};

template <>
struct basic_save<int> : public basic_pod_save<int>
{
};
这里对于 int 类型,则直接转储整数值到输出流中,此时 text_oarchive 则还需要增加一个终极转储函数
template <typename T>
void save(T const& v)
{
most << v << ‘ ‘;
}
这里我们发现,在 save 成员函数中,我们已经将具体的成员变量的值输出到流中了。
对于其它的内建类型,则使用相同的方式处理,要以参考 C ++ std::basic_ostream 的源码实现。
相应的,对于恢复操作的 text_iarchive 的操作流程如下图

测试结果
我们对使用 Boost 以及重新实现的序列化库进行了对比测试,其结果如下

代码修改的重构的工作非常小,只需要删除 Boost 的相关头文件,以及将 boost 相关命名空间替换为 alivc,BOOST_SERIALIZATION_FUNCTION 以及 BOOST_SERIALIZATION_NVP 的宏替换
Android 端下的发行包体积大概减少了 500KB
目前的消息处理框架中,处理一次消息的平均时间由 100us 降低到了 25us
代码实现约 300 行,更轻量级

未来还能做什么
由于当前项目的原因,重新实现的序列化还没有支持转储 - 恢复指针所指向的内存数据,但当前的设计框架已经考虑了这种拓展性,未来会考虑支持。
总结

泛型编程能够大幅提高开发效率,尤其是在代码重用方面能发挥其优势,同时由于其类型推导及生成代码均在编译期完成,并不会降低性能
序列化对于需要进行转储 - 恢复的解耦处理以及协助定位异常和崩溃的原因分析具有重要作用
利用 C ++ 及模板自身的语言特性优势,结合合理的架构设计,即易于拓展又能尽量避免过度设计

参考资料
https://www.ibm.com/developerworks/cn/aix/library/au-boostserialization/

本文作者:lifesider 阅读原文
本文为云栖社区原创内容,未经允许不得转载。

正文完
 0