C++序列化工具最佳实践

序列化概述

当两个服务在进行通信时,彼此可以发送各种类型的数据。无论是何种类型的数据,都会以字节序列的形式在网络上发送。发送方需要把这个对象转换为字节序列,才能在网络上发送;接收方需要把字节序列再恢复为对象。

当服务上线后,将领域对象以字节序列的方式存储在分布式数据库中。当该服务突然宕机后,其上的既有业务迁移到了其他同类服务实例上,这时需要从数据库中获取字节序列反构领域对象,使得业务不中断。

这个把对象转换为字节序列的过程被称为“序列化”(serialization),而它的逆过程则被称为“反序列化” (deserialization)。这两个过程结合起来,可以在异构系统中轻松地存储和传输数据。

两种用途:

  1. 把对象的字节序列保存在文件或数据库中;
  2. 在网络上传送对象的字节序列。

必须序列化吗?
是的,核心问题是数据版本的前后项兼容,有了这个约束,就必须将对象序列化。
其他问题比如异构系统,虽然不是核心问题,但是序列化使得处理更加灵活。

C++序列化工具比较

对于通信系统,大多都是C/C++开发的,而C/C++语言没有反射机制,所以对象序列化的实现比较复杂,一般需要借助序列化工具。开源的序列化工具比较多,具体选择哪一个是受诸多因素约束的:

  1. 效率高;
  2. 前后向兼容性好;
  3. 支持异构系统;
  4. 稳定且被广泛使用;
  5. 接口友好;
  6. ...

下面我们比较几个常见的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的最佳实践

对象序列化设计

  1. 序列化的单位为聚合或独立的实体,我们统一称为领域对象;
  2. 每个聚合可以引用其他聚合,序列化时将引用的对象指针存储为key,反序列化时根据key查询领域对象,将指针恢复为引用的领域对象的地址;
  3. 每个与序列化相关的类都要定义序列化和反序列化方法,可以通过通用的宏在头文件中声明,这样每个类只需关注本层的序列化,子对象的序列化由子对象来完成;
  4. 通过中间层来隔离protobuf对业务代码的污染,这个中间层暂时通过物理文件的分割来实现,即每个参与序列化的类都对应两个cpp文件,一个文件中专门用于实现序列化相关的方法,另一个文件中看不到protobuf的pb文件,序列化相关的cpp可以和领域相关cpp从目录隔离;
  5. 业务人员完成.proto文件的编写,Message结构要求简单稳定,数据对外扁平化呈现,一个领域对象对应一个.proto文件;
  6. 序列化过程可以看作是根据领域对象数据填充Message结构数据,反序列化过程则是根据Message结构数据填充领域对象数据;
  7. 领域对象的内部结构关系是不稳定的,比如重构,由于数据没变,所以不需要数据迁移;
  8. 当数据变了,同步修改.proto文件和序列化代码,不需要数据迁移;
  9. 当数据没变,但领域对象出现分裂或合并时,尽管概率很小,必须写数据迁移程序,而且要有数据迁移用例长期在CI运行,除非该用例对应的版本已不再维护;
  10. 服务宕机后,由其他服务接管既有业务,这时触发领域对象反构,反构过程包括反序列化过程,对业务是透明的。

对象序列化实战

假设有一个领域对象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++的最佳实践,希望对大家有一定的价值。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,406评论 6 503
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,732评论 3 393
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,711评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,380评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,432评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,301评论 1 301
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,145评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,008评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,443评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,649评论 3 334
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,795评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,501评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,119评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,731评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,865评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,899评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,724评论 2 354

推荐阅读更多精彩内容

  • 由于工程项目中拟采用一种简便高效的数据交换格式,百度了一下发现除了采用 xml、JSON 还有 ProtoBuf(...
    黄海佳阅读 48,637评论 1 23
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,654评论 18 139
  • 随着微服务架构的流行,RPC框架渐渐地成为服务框架的一个重要部分。在很多RPC的设计中,都采用了高性能的编解码技术...
    ginobefun阅读 5,649评论 2 2
  • 我不敢再在别人面前提及你了,因为我三句不理你已经被听烂了,别人不会懂我的感受,谁都可以长篇大论的大道理安慰人,但是...
    晚安有情人阅读 100评论 0 0
  • 一首修炼爱情,道出了JJ对出道二十年间的爱情感悟,心底的那个女孩,无论用什么方式,总会占据一席地位!虽说敬往事一杯...
    矫情的胖子阅读 604评论 0 2