15 | 序列化:简单通用的数据交换格式有哪些?

你好,我是Chrono。

在前面的三个单元里,我们学习了C++的语言特性和标准库,算是把C++的编程范式、生命周期、核心特性、标准库的内容整体过了一遍。从今天起,我们的学习之旅又将开启一个新的篇章。

C++语言和标准库很强大,功能灵活,组件繁多,但也只能说是构建软件这座大厦的基石。想要仅凭它们去“包打天下”,不能说是绝对不可行,但至少是“吃力难讨好”。

还是那句老话:“不要重复发明轮子。”(Reinventing the wheel)虽然很多C++程序员都热衷于此,但我觉得对于你我这样的“凡人”,还是要珍惜自己的时间和精力,把有限的资源投入到能有更多产出的事情上。

所以,接下来的这几节课,我会介绍一些第三方工具,精选出序列化/反序列化、网络通信、脚本语言混合编程和性能分析这四类工具,弥补标准库的不足,节约你的开发成本,让你的工作更有效率。

今天,我先来说一下序列化和反序列化。这两个功能在软件开发中经常遇到,你可能很熟悉了,所以我只简单解释一下。

序列化,就是把内存里“活的对象”转换成静止的字节序列,便于存储和网络传输;而反序列化则是反向操作,从静止的字节序列重新构建出内存里可用的对象。

我借用《三体》里的内容,打一个形象的比喻:序列化就是“三体人”的脱水,变成干纤维,在乱纪元方便存储运输;反序列化就是“三体人”的浸泡,在恒纪元由干纤维再恢复成活生生的人。(即使没读过《三体》,也是很好理解的吧?)

接下来,我就和你介绍三种既简单又高效的数据交换格式:JSON、MessagePack和ProtoBuffer,看看在C++里怎么对数据做序列化和反序列化。

JSON

JSON是一种轻量级的数据交换格式,采用纯文本表示,所以是“human readable”,阅读和修改都很方便。

由于JSON起源于“最流行的脚本语言”JavaScript,所以它也随之得到了广泛的应用,在Web开发领域几乎已经成为了事实上的标准,而且还渗透到了其他的领域。比如很多数据库就支持直接存储JSON数据,还有很多应用服务使用JSON作为配置接口。

JSON的官方网站上,你可以找到大量的C++实现,不过用起来都差不多。因为JSON本身就是个KV结构,很容易映射到类似map的关联数组操作方式。

如果不是特别在意性能的话,选个你自己喜欢的就好。否则,你就要做一下测试,看哪一个更适合你的应用场景。

不过我觉得,JSON格式注重的是方便易用,在性能上没有太大的优势,所以一般选择JSON来交换数据,通常都不会太在意性能(不然肯定会改换其他格式了),还是自己用着顺手最重要

下面就来说说我的个人推荐:“JSON for Modern C++”这个库。

JSON for Modern C++可能不是最小最快的JSON解析工具,但功能足够完善,而且使用方便,仅需要包含一个头文件“json.hpp”,没有外部依赖,也不需要额外的安装、编译、链接工作,适合快速上手开发。

JSON for Modern C++可以用“git clone”下载源码,或者更简单一点,直接用wget获取头文件就行:

git clone git@github.com:nlohmann/json.git    # git clone
wget https://github.com/nlohmann/json/releases/download/v3.7.3/json.hpp  # wget 

JSON for Modern C++使用一个json类来表示JSON数据,为了避免说的时候弄混,我给这个类起了个别名json_t:

using json_t = nlohmann::json;

json_t的序列化功能很简单,和标准容器map一样,用关联数组的“[]”来添加任意数据。

你不需要特别指定数据的类型,它会自动推导出恰当的类型。比如,连续多个“[]”就是嵌套对象,array、vector或者花括号形式的初始化列表就是JSON数组,map或者是花括号形式的pair就是JSON对象,非常自然:

json_t j;                                   // JSON对象

j["age"] = 23;                              // "age":23
j["name"] = "spiderman";                    // "name":"spiderman"
j["gear"]["suits"] = "2099";                // "gear":{"suits":"2099"}
j["jobs"] = {"superhero"};                  // "jobs":["superhero"]  

vector<int> v = {1,2,3};                   // vector容器
j["numbers"] = v;                          // "numbers":[1,2,3]

map<string, int> m =                       // map容器
    {{"one",1}, {"two", 2}};               // 初始化列表 
j["kv"] = m;                               // "kv":{"one":1,"two":2}

添加完之后,用成员函数dump()就可以序列化,得到它的JSON文本形式。默认的格式是紧凑输出,没有缩进,如果想要更容易阅读的话,可以加上指示缩进的参数:

cout << j.dump() << endl;         // 序列化,无缩进
cout << j.dump(2) << endl;        // 序列化,有缩进,2个空格

json_t的反序列化功能同样也很简单,只要调用静态成员函数parse()就行,直接得到JSON对象,而且可以用auto自动推导类型:

string str = R"({                // JSON文本,原始字符串
    "name": "peter",
    "age" : 23,
    "married" : true
})";

auto j = json_t::parse(str);    // 从字符串反序列化
assert(j["age"] == 23);        // 验证序列化是否正确
assert(j["name"] == "peter");

json_t使用异常来处理解析时可能发生的错误,如果你不能保证JSON数据的完整性,就要使用try-catch来保护代码,防止错误数据导致程序崩溃:

auto txt = "bad:data"s;        // 不是正确的JSON数据

try                             // try保护代码
{
    auto j = json_t::parse(txt);// 从字符串反序列化
}
catch(std::exception& e)        // 捕获异常
{
    cout << e.what() << endl;
}

对于通常的应用来说,掌握了基本的序列化和反序列化就够用了,不过JSON for Modern C++里还有很多高级用法,比如SAX、BSON、自定义类型转换等。如果你需要这些功能,可以去看它的文档,里面写得都很详细。

MessagePack

说完JSON,再来说另外第二种格式:MessagePack。

它也是一种轻量级的数据交换格式,与JSON的不同之处在于它不是纯文本,而是二进制。所以MessagePack就比JSON更小巧,处理起来更快,不过也就没有JSON那么直观、易读、好修改了。

由于二进制这个特点,MessagePack也得到了广泛的应用,著名的有Redis、Pinterest。

MessagePack支持几乎所有的编程语言,你可以在官网上找到它的C++实现。

我常用的是官方库msgpack-c,可以用apt-get直接安装。

apt-get install libmsgpack-dev

但这种安装方式有个问题,可能发行方仓库里的是老版本(像Ubuntu 16.04就是0.57),缺失很多功能,所以最好是从GitHub上下载最新版,编译时手动指定包含路径:

git clone git@github.com:msgpack/msgpack-c.git

g++ msgpack.cpp -std=c++14 -I../common/include -o a.out

和JSON for Modern C++一样,msgpack-c也是仅头文件的库(head only),只要包含一个头文件“msgpack.hpp”就行了,不需要额外的编译链接选项(C版本需要用“-lmsgpackc”链接)。

但MessagePack的设计理念和JSON是完全不同的,它没有定义JSON那样的数据结构,而是比较底层,只能对基本类型和标准容器序列化/反序列化,需要你自己去组织、整理要序列化的数据。

我拿vector容器来举个例子,调用pack()函数序列化为MessagePack格式:

vector<int> v = {1,2,3,4,5};              // vector容器

msgpack::sbuffer sbuf;                    // 输出缓冲区
msgpack::pack(sbuf, v);                   // 序列化

从代码里你可以看到,它的用法不像JSON那么简单直观,必须同时传递序列化的输出目标和被序列化的对象

输出目标sbuffer是个简单的缓冲区,你可以把它理解成是对字符串数组的封装,和vector<char>很像,也可以用data()和size()方法获取内部的数据和长度。

cout << sbuf.size() << endl;            // 查看序列化后数据的长度

除了sbuffer,你还可以选择另外的zbuffer、fbuffer。它们是压缩输出和文件输出,和sbuffer只是格式不同,用法是相同的,所以后面我就都用sbuffer来举例说明。

MessagePack反序列化的时候略微麻烦一些,要用到函数unpack()和两个核心类:object_handle和object。

函数unpack()反序列化数据,得到的是一个object_handle,再调用get(),就是object:

auto handle = msgpack::unpack(          // 反序列化
            sbuf.data(), sbuf.size());  // 输入二进制数据
auto obj = handle.get();                // 得到反序列化对象

这个object是MessagePack对数据的封装,相当于JSON for Modern C++的JSON对象,但你不能直接使用,必须知道数据的原始类型,才能转换还原:

vector<int> v2;                       // vector容器
obj.convert(v2);                      // 转换反序列化的数据

assert(std::equal(                    // 算法比较两个容器
      begin(v), end(v), begin(v2)));

因为MessagePack不能直接打包复杂数据,所以用起来就比JSON麻烦一些,你必须自己把数据逐个序列化,连在一起才行。

好在MessagePack又提供了一个packer类,可以实现串联的序列化操作,简化代码:

msgpack::sbuffer sbuf;                         // 输出缓冲区
msgpack::packer<decltype(sbuf)> packer(sbuf);  // 专门的序列化对象

packer.pack(10).pack("monado"s)                // 连续序列化多个数据
      .pack(vector<int>{1,2,3});

对于多个对象连续序列化后的数据,反序列化的时候可以用一个偏移量(offset)参数来同样连续操作:

for(decltype(sbuf.size()) offset = 0;          // 初始偏移量是0
    offset != sbuf.size();){                   // 直至反序列化结束

    auto handle = msgpack::unpack(            // 反序列化
            sbuf.data(), sbuf.size(), offset);  // 输入二进制数据和偏移量
    auto obj = handle.get();                  // 得到反序列化对象
}

但这样还是比较麻烦,能不能像JSON那样,直接对类型序列化和反序列化呢?

MessagePack为此提供了一个特别的宏:MSGPACK_DEFINE,把它放进你的类定义里,就可以像标准类型一样被MessagePack处理。

下面定义了一个简单的Book类:

class Book final                       // 自定义类
{
public:
    int         id;
    string      title;
    set<string> tags;
public:
    MSGPACK_DEFINE(id, title, tags);   // 实现序列化功能的宏
};

它可以直接用于pack()和unpack(),基本上和JSON差不多了:

Book book1 = {1, "1984", {"a","b"}};  // 自定义类

msgpack::sbuffer sbuf;                    // 输出缓冲区
msgpack::pack(sbuf, book1);              // 序列化

auto obj = msgpack::unpack(              // 反序列化
      sbuf.data(), sbuf.size()).get();   // 得到反序列化对象

Book book2;
obj.convert(book2);                      // 转换反序列化的数据

assert(book2.id == book1.id);
assert(book2.tags.size() == 2);
cout << book2.title << endl;

使用MessagePack的时候,你也要注意数据不完整的问题,必须要用try-catch来保护代码,捕获异常:

auto txt = ""s;                      // 空数据
try                                  // try保护代码
{
    auto handle = msgpack::unpack(   // 反序列化
        txt.data(), txt.size());
}
catch(std::exception& e)            // 捕获异常
{
    cout << e.what() << endl;
}

ProtoBuffer

第三个要说的库就是著名的ProtoBuffer,通常简称为PB,由Google出品。

PB也是一种二进制的数据格式,但毕竟是工业级产品,所以没有JSON和MessagePack那么“轻”,相关的东西比较多,要安装一个预处理器和开发库,编译时还要链接动态库(-lprotobuf):

apt-get install protobuf-compiler
apt-get install libprotobuf-dev

g++ protobuf.cpp -std=c++14 -lprotobuf -o a.out

PB的另一个特点是数据有“模式”(schema),必须要先写一个IDL(Interface Description Language)文件,在里面定义好数据结构,只有预先定义了的数据结构,才能被序列化和反序列化。

这个特点既有好处也有坏处:一方面,接口就是清晰明确的规范文档,沟通交流简单无歧义;而另一方面,就是缺乏灵活性,改接口会导致一连串的操作,有点繁琐。

下面是一个简单的PB定义:

syntax = "proto2";                    // 使用第2版

package sample;                        // 定义名字空间

message Vendor                        // 定义消息
{
    required uint32     id      = 1;  // required表示必须字段
    required string     name    = 2;  // 有int32/string等基本类型
    required bool       valid   = 3;  // 需要指定字段的序号,序列化时用
    optional string     tel     = 4;  // optional字段可以没有
}

有了接口定义文件,需要再用protoc工具生成对应的C++源码,然后把源码文件加入自己的项目中,就可以使用了:

protoc --cpp_out=. sample.proto       // 生成C++代码

由于PB相关的资料实在太多了,这里我就只简单说一下重要的接口:

  • 字段名会生成对应的has/set函数,检查是否存在和设置值;
  • IsInitialized()检查数据是否完整(required字段必须有值);
  • DebugString()输出数据的可读字符串描述;
  • ByteSize()返回序列化数据的长度;
  • SerializeToString()从对象序列化到字符串;
  • ParseFromString()从字符串反序列化到对象;
  • SerializeToArray()/ParseFromArray()序列化的目标是字节数组。

下面的代码示范了PB的用法:

using vendor_t = sample::Vendor;        // 类型别名

vendor_t v;                             // 声明一个PB对象
assert(!v.IsInitialized());            // required等字段未初始化

v.set_id(1);                            // 设置每个字段的值    
v.set_name("sony");
v.set_valid(true);

assert(v.IsInitialized());             // required等字段都设置了,数据完整
assert(v.has_id() && v.id() == 1); 
assert(v.has_name() && v.name() == "sony");
assert(v.has_valid() && v.valid());

cout << v.DebugString() << endl;       // 输出调试字符串

string enc;
v.SerializeToString(&enc);              // 序列化到字符串 

vendor_t v2; 
assert(!v2.IsInitialized());
v2.ParseFromString(enc);               // 反序列化

虽然业界很多大厂都在使用PB,但我觉得它真不能算是最好的,IDL定义和接口都太死板生硬,还只能用最基本的数据类型,不支持标准容器,在现代C++里显得“不太合群”,用起来有点别扭。

不过它后面有Google“撑腰”,而且最近几年又有gRPC“助拳”,所以很多时候也不得不用。

PB的另一个缺点是官方支持的编程语言太少,通用性较差,最常用的proto2只有C++、Java和Python。后来的proto3增加了对Go、Ruby等的支持,但仍然不能和JSON、MessagePack相比。

小结

好了,今天我讲了三种数据交换格式:JSON、MessagePack和ProtoBuffer。

这三种数据格式各有特色,在很多领域都得到了广泛的应用,我来简单小结一下:

  1. JSON是纯文本,容易阅读,方便编辑,适用性最广;
  2. MessagePack是二进制,小巧高效,在开源界接受程度比较高;
  3. ProtoBuffer是工业级的数据格式,注重安全和性能,多用在大公司的商业产品里。

有很多开源库支持这些数据格式,官方的、民间的都有,你应该选择适合自己的高质量库,必要的时候可以做些测试。

再补充一点,除了今天说的这三种,你还可以尝试其他的数据格式,比较知名的有Avro、Thrift,虽然它们有点冷门,但也有自己的独到之处(比如,天生支持RPC、可选择多种序列化格式和传输方式)。

课下作业

最后是课下作业时间,给你留两个思考题:

  1. 为什么要有序列化和反序列化,直接memcpy内存数据行不行呢?
  2. 你最常用的是哪种数据格式?它有什么优缺点?

欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友。我们下节课见。

精选留言

  • 被讨厌的勇气

    2020-06-09 09:46:47

    直接memcpy,同一种语言不同机器,或者不同语言可能存在兼容问题(变量内存存储布局、编码可能不同),而Json是一种标准,由Json库处理编码问题(比如大小端),且不同语言间统一,对头不?
    作者回复

    说的很对。

    2020-06-09 10:00:31

  • 蓝配鸡

    2020-06-12 16:53:51

    序列化/反序列化命名就用 “脱水/浸泡”, 会不会被打死- -
    作者回复

    我觉得可以小范围推广一下,以后没准会流行起来,笑。

    2020-06-13 07:19:50

  • java2c++

    2020-06-10 09:11:31

    问题2:和redis交互一般使用json,主要原因是可读性强,有问题时直接登录到redis控制台可以查看json内容,不过序列化需要的时间成本空间成本都是最高,如果追求性能就需要选择其他的了。
    现在一些网络框架(rpc框架)都支持多种序列化的方式了,msgpack,thift跨语言支持的比较好,性能也不错我工作中相对用的多一些
    作者回复

    很好的经验分享,json主要是对人友好,用来做人机接口很合适。

    2020-06-10 10:25:26

  • 2020-06-25 23:40:27

    json不存在大小端,字节序的问题吧,反正就是一个字符串。messagepack这种二进制格式的东西,才要考虑大小端。
    作者回复

    对,json是字符串的文本形式,数字也表示成了文本,不需要考虑大小端问题。

    2020-06-26 07:22:38

  • hy

    2020-06-11 23:56:49

    pb有2G内存限制问题,如果对象过大会不行
    作者回复

    pb好像确实有限制,我记得好像是64mb还是512mb,不是很确定。

    这也是很合理的考虑,如果有超大对象,是不是要反思一下设计的问题,为什么会有这么大的对象,能否优化。

    2020-06-12 09:11:06

  • 于小咸

    2021-09-15 14:02:15

    除了兼容不同语言外,序列化还有一个重要的作用是数据保存和网络传输,我们很难保证不同平台上内存里存的数据是一样的。

    此外,对于包含指针的数据结构,我们是没法简单的memcpy,这会带来浅拷贝类似的问题。
    作者回复

    说的非常好。

    2021-09-15 18:19:32

  • Why not.

    2020-06-25 23:30:03

    jsoncpp 也挺好用的 不知道老师怎么看
    作者回复

    jsoncpp比较老了,不是那么“现代”,但用起来也还可以。

    2020-06-26 07:23:15

  • do it

    2020-06-12 13:13:25

    1、为什么要有序列化和反序列化,直接 memcpy 内存数据行不行呢?
    字节序问题;序列号反序列化可数据压缩(不确定😁)
    2、你最常用的是哪种数据格式?它有什么优缺点?
    工作中使用json多一点,有时使用pb。
    pb:压缩效率算优点吧。改变结构需重新生成proto文件、proto怎么难阅读算不足之处
    作者回复

    说的挺好,常用的序列化也就是这几种了

    2020-06-12 14:04:12

  • java2c++

    2020-06-10 09:03:56

    问题1:针对跨服务间的调用,涉及到网络传输,需要使用到字节流进行传输,序列化就是发送方将数据对象转化成字节流的过程,反序列就是接收方将字节流转化成数据对象的逆过程
    作者回复

    这是序列化和反序列化的目的,但还没有说到根本上,如果单纯的memcpy,也是可以转换成字节流的。

    可以参考其他同学的留言。

    2020-06-10 10:27:45

  • EncodedStar

    2020-06-09 16:48:26

    序列化和反序列化 可以按用户自定义的方式去读取吧,底层应该也是用类似memcpy这样的操作去处理里,只不过是又封装了一次统一接口方便我们使用,老师,我可以这么理解吗??
    常用json 和 pb ,感觉就是json对C++不是很友好,很多时候有\转义符困扰,pb就是每次比较麻烦,改了协议之后还的生成一下对应文件
    作者回复


    1.序列化不是简单的memcpy,有点类似按照特定的规则去编码解码,屏蔽了硬件体系架构、语言的差异,是一种通用的格式。

    2.json我觉得还行吧,用转义符那是没办法,C的老问题了。pb确实操作起来很麻烦,累。

    2020-06-10 09:08:28

  • 严腾

    2020-12-12 19:04:54

    message pack使用 MSGPACK_DEFINE 来处理自定义类,如果自定义类中有第三方库的成员变量,这时候该如何处理?
    作者回复

    这个时候就不行了,把类结构重新设计一下吧。

    2020-12-14 11:13:50

  • 刘杰

    2022-11-05 20:36:27

    有的像csv的数据,带了时间列,并且数据包含了多种类型,用结构体存盘,这个怎么实现的,有什么好处?
    作者回复

    这个属于特殊格式,可以自己写解析代码,或者找找有没有开源的库。

    2022-11-06 07:18:16

  • 孙新

    2022-03-16 19:36:59

    我们json用的boost做的编解码,不知道横向对比效率是个什么程度。
    作者回复

    boost.json好像效率也不错,具体怎么样还是要自己做测试。

    2022-03-18 08:12:19

  • Stephen

    2021-06-07 00:05:07

    差点把序列化和持久化弄混了,差点答成memcpy是内存操作,不能持久化为文件。我知道json是可以持久化的,不知道另外两种可不可以。
    作者回复

    序列化是把内存里数据做编码,而持久化是把内存里的数据存储到硬盘等介质上长期保存,两者有一点联系,但不完全相同。

    2021-06-07 09:01:59

  • Patrick

    2020-12-05 10:35:52

    老师,您好;代码中的示例,关于msgpack.cpp,我遇见奇怪的问题,单独运行主函数的case3方法会出现std::bad_cast错误,意思是反序列化未能cast,但是如何case1()或者case2()与case3()一起运行,则就没有这问题了,我安装了最新的msgpack库,使用其中的msgpack.hpp,依旧不行。我不得其解,是什么原因造成的。
    作者回复

    我试了一下,确实如此,比较奇怪。

    解决的办法也很简单,把原来的一条语句改成两条,先得到handle,再得到obj就可以了。

    GitHub上的源码已经修改。

    auto handle = msgpack::unpack(sbuf.data(), sbuf.size());
    auto obj = handle.get();

    2020-12-07 09:34:53

  • Sudouble

    2020-09-25 10:06:59

    问题1的,不同机器,大小端,字节的对齐不一样,还原时会产生不一致的问题
    作者回复

    说的很对。

    2020-09-25 13:28:29

  • 锦鲤

    2020-07-23 14:58:22

    C++服务端的gRPC框架有什么可以推荐的吗?
    作者回复

    对gRPC了解不多,它自己就带C++支持吧,应该不需要其他框架支持了。

    2020-07-23 15:09:34

  • 海漩涡

    2020-07-21 19:23:48

    section4里没有Makefile啊
    作者回复

    嗯,Makefile是其他同学添加的,我没有写Makefile,可以直接用源码注释里的g++命令行编译,或者参考其他目录的Makefile自己写。

    2020-07-22 08:54:35

  • 群书

    2020-06-19 09:56:39

    做网络开发时 要将多字节数值型数据转换为网络字节序列兼容不同架构的机器,protobuf也有字节序列转化的过程吗
    作者回复

    字节序问题确实是在网络传输时的一个比较头疼的问题,但对于序列化来说,不一定非要转换成网络序,只要两边用一致的约定即可。

    pb内部具体怎么做的要看它的源码了,但作为用户我们并不需要关心实现的细节。

    2020-06-19 10:41:43

  • 屈肖东

    2020-06-15 10:11:34

    有个问题,如果我把所有的要发送的数据类型,都转成字符数组,例如,int通过hton转为网络字节序,再转为4字节的字符数组,其他的也用类似的处理。在接收端,用相反的操作处理,再解出来,是不是也可以实现上面的这种功能。因为字符数组在网络中传递应该是不会因为编程语言和平台的区别而出现差别的。我不清楚我的这种处理跟序列化和反序列化的操作最终产生的效果有什么区别。仅仅是在通用行上的差别还是有其他更深刻的区别。
    作者回复

    这种做法就是最基本的序列化和反序列化了,但在数据大小、处理效率、易用性上肯定就没有json、msgpack等好。

    2020-06-15 13:24:54