序列化概述
当两个服务在进行通信时,彼此可以发送各种类型的数据。无论是何种类型的数据,都会以字节序列的形式在网络上发送。发送方需要把这个对象转换为字节序列,才能在网络上发送;接收方需要把字节序列再恢复为对象。
当服务上线后,将领域对象以字节序列的方式存储在分布式数据库中。当该服务突然宕机后,其上的既有业务迁移到了其他同类服务实例上,这时需要从数据库中获取字节序列反构领域对象,使得业务不中断。
这个把对象转换为字节序列的过程被称为“序列化”(serialization),而它的逆过程则被称为“反序列化” (deserialization)。这两个过程结合起来,可以在异构系统中轻松地存储和传输数据。
两种用途:
- 把对象的字节序列保存在文件或数据库中;
- 在网络上传送对象的字节序列。
必须序列化吗?
是的,核心问题是数据版本的前后项兼容,有了这个约束,就必须将对象序列化。
其他问题比如异构系统,虽然不是核心问题,但是序列化使得处理更加灵活。
C++序列化工具比较
对于通信系统,大多都是C/C++开发的,而C/C++语言没有反射机制,所以对象序列化的实现比较复杂,一般需要借助序列化工具。开源的序列化工具比较多,具体选择哪一个是受诸多因素约束的:
- 效率高;
- 前后向兼容性好;
- 支持异构系统;
- 稳定且被广泛使用;
- 接口友好;
- ...
下面我们比较几个常见的C++序列化工具。
msgpack是一个基于二进制的高效的对象序列化类库,可用于跨语言通信,号称比protobuf还要快4倍,但没有类似于optional的关键字,所以msgpack至少不满足前后项兼容的约束。
cereal是一个开源的(BSD License)、轻量级的、支持C++ 11特性的、仅仅包含头文件实现的、跨平台的C++序列化库。它可以将任意的数据类型序列化成不同的表现形式,比如二进制、XML格式或JSON。cereal的设计目标是快速、轻量级、易扩展——它没有外部的依赖关系,而且可以很容易的和其他代码封装在一块或者单独使用,但不能跨语言,所以cereal至少不满足异构系统系统的约束。
protobuf是一种轻便高效的结构化数据存储格式,可用于结构化数据串行化,很适合做数据存储或RPC数据交换格式。它可用于通讯协议、数据存储等领域的语言无关、平台无关、可扩展的序列化结构数据格式。
在PC上单线程测试protobuf的性能结果如下:
单位 | 数量 |
---|---|
平均字节数 | 35 |
序列化(1w次)时间(us) | 6803 |
反序列化(1w次)时间(us) | 11952 |
通过表格来综合比较一下这三种序列化工具:
序列化方法 | 效率高 | 前后向兼容性好 | 支持异构系统 | 稳定且被广泛使用 | 接口友好
----|----
msgpack | Y | N | Y | Y | Y
cereal | Y | N | N | Y | Y
protobuf | Y | Y | Y | Y | Y
protobuf满足通信系统对序列化工具的选型约束,同时具有简单和高效的优点,所以protobuf比其他的序列化工具更具有吸引力。
protobuf C++使用指导
protobuf安装
在github上下载protobuf C++版本,并根据README.md的说明进行安装,此处不再赘述。
定义.proto文件
proto文件即消息协议原型定义文件,在该文件中我们可以通过使用描述性语言,来良好的定义我们程序中需要用到数据格式。
我们先通过一个电话簿的例子来了解下:
//AppExam.proto
syntax = "proto3";
package App;
message Person
{
string name = 1;
int32 id = 2;
string email = 3;
enum PhoneType
{
MOBILE = 0;
HOME = 1;
WORK = 2;
}
message PhoneNumber
{
required string number = 1;
optional PhoneType type = 2 [default = HOME];
}
repeated PhoneNumber phone = 4;
}
message AddressBook
{
repeated Person person = 1;
}
正你看到的一样,消息格式定义很简单,对于每个字段而言可能有一个修饰符(repeated)、字段类型(bool/string/bytes/int32等)和字段标签(Tag)组成。
对于repeated的字段而言,该字段可以重复多个,即用于标记数组类型。
对于protobuf v2版本,除过repeated,还有required和optional,由于设计的不合理,在v3版本把这两个修饰符去掉了。
字段标签标示了字段在二进制流中存放的位置,这个是必须的,而且序列化与反序列化的时候相同的字段的Tag值必须对应,否则反序列化会出现意想不到的问题。
生成.h&.cc文件
进入protobuf的bin目录,输入命令:
./protoc -I=../../test/protobuf --cpp_out=../../test/protobuf ../../test/protobuf/AppExam.proto
I的值为.proto文件的目录,cpp_out的值为.h和.cc文件生成的目录,运行该命令后,在$cpp_out路径下生成了AppExam.pb.h和AppExam.pb.cc文件。
protobuf C++ API
生成的文件中有以下方法:
// name
inline bool has_name() const;
inline void clear_name();
inline const ::std::string& name() const;
inline void set_name(const ::std::string& value);
inline void set_name(const char* value);
inline ::std::string* mutable_name();
// id
inline bool has_id() const;
inline void clear_id();
inline int32_t id() const;
inline void set_id(int32_t value);
// email
inline bool has_email() const;
inline void clear_email();
inline const ::std::string& email() const;
inline void set_email(const ::std::string& value);
inline void set_email(const char* value);
inline ::std::string* mutable_email();
// phone
inline int phone_size() const;
inline void clear_phone();
inline const ::google::protobuf::RepeatedPtrField< ::tutorial::Person_PhoneNumber >& phone() const;
inline ::google::protobuf::RepeatedPtrField< ::tutorial::Person_PhoneNumber >* mutable_phone();
inline const ::tutorial::Person_PhoneNumber& phone(int index) const;
inline ::tutorial::Person_PhoneNumber* mutable_phone(int index);
inline ::tutorial::Person_PhoneNumber* add_phone();
解析与序列化接口:
/*
序列化消息,将存储字节的以string方式输出,注意字节是二进制,而非文本;string!=text,
serializes the message and stores the bytes in the given string. Note that the bytes
are binary, not text; we only use the string class as a convenient container.
*/
bool SerializeToString(string* output) const;
//解析给定的string
bool ParseFromString(const string& data);
Any Message Type
protobuf在V3版本引入Any Message Type。
顾名思义,Any Message Type可以匹配任意的Message,包含Any类型的Message可以嵌套其他的Messages而不用包含它们的.proto文件。使用Any Message Type时,需要import文件google/protobuf/any.proto。
syntax = "proto3";
package App;
import "google/protobuf/any.proto";
message ErrorStatus
{
repeated google.protobuf.Any details = 1;
}
message NetworkErrorDetails
{
int32 a = 1;
int32 b = 2;
}
message LocalErrorDetails
{
int64 x = 1;
string y = 2;
}
序列化时,通过pack操作将一个任意的Message存储到Any。
// Storing an arbitrary message type in Any.
App::NetworkErrorDetails details;
details.set_a(1);
details.set_b(2);
App::ErrorStatus status;
status.add_details()->PackFrom(details);
std::string str;
status.SerializeToString(&str);
反序列化时,通过unpack操作从Any中读取一个任意的Message。
// Reading an arbitrary message from Any.
App::ErrorStatus status;
std::string str;
status.ParseFromString(str);
for (const google::protobuf::Any& detail : status1.details())
{
if (detail.Is<App::NetworkErrorDetails>())
{
App::NetworkErrorDetails network_error;
detail.UnpackTo(&network_error);
INFO_LOG("NetworkErrorDetails: %d, %d", network_error.a(),
network_error.b());
}
}
protobuf的最佳实践
对象序列化设计
- 序列化的单位为聚合或独立的实体,我们统一称为领域对象;
- 每个聚合可以引用其他聚合,序列化时将引用的对象指针存储为key,反序列化时根据key查询领域对象,将指针恢复为引用的领域对象的地址;
- 每个与序列化相关的类都要定义序列化和反序列化方法,可以通过通用的宏在头文件中声明,这样每个类只需关注本层的序列化,子对象的序列化由子对象来完成;
- 通过中间层来隔离protobuf对业务代码的污染,这个中间层暂时通过物理文件的分割来实现,即每个参与序列化的类都对应两个cpp文件,一个文件中专门用于实现序列化相关的方法,另一个文件中看不到protobuf的pb文件,序列化相关的cpp可以和领域相关cpp从目录隔离;
- 业务人员完成.proto文件的编写,Message结构要求简单稳定,数据对外扁平化呈现,一个领域对象对应一个.proto文件;
- 序列化过程可以看作是根据领域对象数据填充Message结构数据,反序列化过程则是根据Message结构数据填充领域对象数据;
- 领域对象的内部结构关系是不稳定的,比如重构,由于数据没变,所以不需要数据迁移;
- 当数据变了,同步修改.proto文件和序列化代码,不需要数据迁移;
- 当数据没变,但领域对象出现分裂或合并时,尽管概率很小,必须写数据迁移程序,而且要有数据迁移用例长期在CI运行,除非该用例对应的版本已不再维护;
- 服务宕机后,由其他服务接管既有业务,这时触发领域对象反构,反构过程包括反序列化过程,对业务是透明的。
对象序列化实战
假设有一个领域对象Movie,有3个数据成员,分别是电影名字name、电影类型type和电影评分列表scores。Movie初始化时需要输入name和type,name输入后不能rename,可以看作Movie的key,而type输入后可以通过set来变更。scores是用户看完电影后的评分列表,而子项score也是一个对象,包括分值value和评论comment两个数据成员。
下面通过代码来说明电影对象的序列化和反序列化过程。
编写.proto文件
//AppObjSerializeExam.proto
syntax = "proto3";
package App;
message Score
{
int32 value = 1;
string comment = 2;
}
message Movie
{
string name = 1;
int32 type = 2;
repeated Score score = 3;
}
领域对象的主要代码
序列化和反序列化接口是通用的,在每个序列化的类(包括成员对象所在的类)里面都要定义,因此定义一个宏,既增强了表达力又消除了重复。
// SerializationMacro.h
#define DECL_SERIALIZABLE_METHOD(T) \
void serialize(T& t) const; \
void deserialize(const T& t);
//MovieType.h
enum MovieType {HUMOR, SCIENCE, LOVE, OTHER};
//Score.h
namespace App
{
struct Score;
}
struct Score
{
Score(U32 val = 0, std::string comment = "");
operator int() const;
DECL_SERIALIZABLE_METHOD(App::Score);
private:
int value;
std::string comment;
};
//Movie.h
typedef std::vector<Score> Scores;
const std::string UNKNOWN_NAME = "Unknown Name";
struct Movie
{
Movie(const std::string& name = UNKNOWN_NAME,
MovieType type = OTHER);
MovieType getType() const;
void setType(MovieType type);
void addScore(const Score& score);
BOOL hasScore() const;
const Scores& getScores() const;
DECL_SERIALIZABLE_METHOD(std::string);
private:
std::string name;
MovieType type;
Scores scores;
};
类Movie声明了序列化接口,而其数据成员scores对应的具体类Score也声明了序列化接口,这就是说
序列化是一个递归的过程,一个类的序列化依赖于数据成员对应类的序列化。
序列化代码实现
首先通过物理隔离来减少依赖。
对于Score,有一个头文件Score.h,有两个实现文件Score.cpp和ScoreSerialization.cpp,其中ScoreSerialization.cpp为序列化代码实现文件。
//ScoreSerialization.cpp
void Score::serialize(App::Score& score) const
{
score.set_value(value);
score.set_comment(comment);
}
void Score::deserialize(const App::Score& score)
{
value = score.value();
comment = score.comment();
INFO_LOG("%d, %s", value, comment.c_str());
}
同理,对于Movie,有一个头文件Movie.h,有两个实现文件Movie.cpp和MovieSerialization.cpp,其中MovieSerialization.cpp为序列化代码实现文件。
//MovieSerialization.cpp
void Movie::serialize(std::string& str) const
{
App::Movie movie;
movie.set_name(name);
movie.set_type(type);
INFO_LOG("%d", scores.size());
for (size_t i = 0; i < scores.size(); i++)
{
App::Score* score = movie.add_score();
scores[i].serialize(*score);
}
movie.SerializeToString(&str);
}
void Movie::deserialize(const std::string& str)
{
App::Movie movie;
movie.ParseFromString(str);
name = movie.name(),
type = static_cast<MovieType>(movie.type());
U32 size = movie.score_size();
INFO_LOG("%s, %d, %d", name.c_str(), type, size);
google::protobuf::RepeatedPtrField<App::Score>* scores =
movie.mutable_score();
google::protobuf::RepeatedPtrField<App::Score>::iterator it =
scores->begin();
for (; it != scores->end(); ++it)
{
Score score;
score.deserialize(*it);
addScore(score);
}
}
Any Message Type最佳实践
笔者对Any Message Type也进行了一定的实践,同时通过函数模板等方式提炼出了通用代码,但由于篇幅所限,本文不再展开。
小结
本文先介绍了序列化的基本概念和应用场景,并对常用的C++序列化工具进行了比较,发现protobuf比其他的序列化工具更具有吸引力,然后对protobuf C++的使用和特性进行了介绍,最后通过一个电影评分系统的案例展示了protobuf C++的最佳实践,希望对大家有一定的价值。