本系列文章以我的个人博客的搭建为线索(GitHub 仓库:Evian-Zhang/evian-blog),记录我在现代化程序设计中的一些笔记。在这篇文章中,我将介绍的是序列化与反序列化的相关技术与数据格式标准。
需求背景
众所周知,任何一个大型程序都需要和外界进行数据沟通交流,既需要接收外界传入的数据,也需要把内部计算得出的数据持久化。同时,随着程序规模的扩大和逻辑的复杂化,我们需要将数据妥善地组织才能进一步地接收或存储。比方说,我需要接收的数据同时包括一个人的姓名、性别、年龄,那么我们就需要与数据的发送方约定好数据是怎样组织的,发送方不能单纯地发送一个
小明男26
那我们的程序还需要额外判断这个人的姓名是“小明”还是“小明男”。这样组织不好的数据,会增加数据接收端的工作压力,也容易造成一些数据解析错误。而我们如果采用一些常见的数据格式,比如说JSON:
{
"name": "小明",
"gender": "男",
"age": 26
}
这样就可以十分方便清楚地进行数据解析了。
因此,我们处理数据的时候,很重要的一个环节就是对数据格式的组织。接下来,我将介绍常见的数据格式。
常见数据格式
我们在选择数据格式的时候,有几点原则:
- 如果有成熟的解决方案,那就不要自己定数据格式
- 根据需求选择数据格式,不要无脑用同一种数据格式
第一点的意思是,现在业界已经有了很多成熟的数据格式方案,如JSON、TOML等,那么我们应该尽量使用这些成熟的数据格式,而不是自定义一个数据格式。这是因为,自己设定一个数据格式,序列化的时候很方便,但反序列化的时候就相对比较麻烦,同时如果不好好考虑,可能会出现二义性等严重的问题。而业内成熟的数据格式方案,在大多数常用语言中都会有相应的解析器的支持,也就不需要我们手写序列化与反序列化的代码,更加保险、高效一些。
第二点的意思是,在不同的环境中会有最符合需求的数据格式,我们需要根据需求选择数据格式,因为不同的数据格式的优缺点是不同的。下面,我就来介绍在各个需求中的数据格式方案。
配置文件
一个很常见的数据处理的场景就是配置文件。我们的服务器程序需要读取配置文件才知道应该监听哪个端口,应该连接哪个数据库等等。
配置文件有两个特点:
- 需要较强的可读性
- 没有太多的层次结构
配置文件的最主要的要求在于可读性。因为我们配置文件一般只是存储在一个固定的地方,不需要经常通过网络传播,所以我们不需要考虑其大小问题。可读性一般可以体现为两点:一是结构清晰,二是可以有注释。
配置文件一般并不会有无限递归的层次结构,至多三到四层,所以并不需要在较高层次结构下仍具有可读性的要求。
最常见的现代配置文件格式是TOML和YAML。这两个语言都有实际应用于配置文件中的例子,TOML是Rust项目的官方配置文件格式,而YAML则是Docker compose的配置文件格式。就我个人而言,我比较喜欢使用TOML,因为YAML是采用缩进来判断格式层级的,总会给人一种不安全感,而TOML则不用在意缩进。
用于传输交换的文件
这种需求则更为常见,一般用于客户端和服务器之间,通过网络交换数据。
这类文件的特点在于:
- 不需要较强的可读性
- 可能会有较高的层次结构
- 对数据大小要求较高
说白了,这类文件不是给人看的,是单纯用来给电脑解析的。因此,我们不需要太高的可读性,也不需要注释。同时,一些复杂的数据也会产生较高的层次结构,所以我们的数据格式应该能够妥善解决这种层次性。而对数据大小的要求,则是由于这些数据是要通过网络传播的,那么对于相同信息量的数据,自然数据的大小越小越好。我们来看一个例子:刚刚小明的数据,我们用XML来记录为:
<name>小明</name><gender>男</gender><age>26</age>
而我们用JSON来记录为:
{"name":"小明","gender":"男","age":26}
这两个字符串的信息量都是相同的,记录了小明这个人的三个信息,但是,第二个数据格式存储的字符串长度明显小于第一个,所以说,在这个场景下,用JSON记录就比用XML记录要好。
我们常用的数据格式是JSON和Protocol Buffer。对于要传输字符串的数据来说,使用JSON是不二之选。而我们有时候需要更进一步,在相同信息量下数据长度能不能更小?这时就要使用Protocol Buffer了。这种数据格式是Google提出的,采用二进制数据的方式来存储。Protocol Buffer的思路可以简化为:我和你约定,我传给你的二进制数据,第一块传一个字符串,它是名字,第二块传一个整型值,它代表性别,第三块传一个整型值,它代表年龄。然后我按照这样的格式发送数据,你按照这样的格式解析数据。通过这种方式,除了字符串以外,其它的所有类型都可以占用更少的空间,因为JSON中这些类型依然是按照字符串发送的,而Protocol Buffer中则使用二进制格式来发送。当然,这样也造成了发送方和接收方都需要有一个Protocol Buffer的Schema,也就是每一块数据它代表什么,以及它的类型。所以虽然传输过程中的体积变小了,但是它对数据传输双方的要求也增加了。
常见语言的序列化与反序列化
讲完了常见的数据格式,我们接下来讲讲常见语言是怎么把内存中的数据转化为相应的数据格式字符串的。将内存数据转化为数据格式字符串的过程叫做序列化,将数据格式字符串转化为内存数据的过程叫做反序列化。下面以一个JSON字符串为例:
{"name":"小明","gender":"男","age":26}
首先,如果我们只是处理字典类型,那么任何语言只要支持字典类型,那么是非常好做的。我们只需要遍历这个字典的所有key,然后把每个key和它对应的value序列化就行了。比方说对于Python来说,我有一个字典:
person = {'name': '小明', 'gender': '男', 'age': 26}
那么将person
序列化的思路就是,遍历其所有key,并将这个key和其对应的value一起序列化即可。同样地,反序列化也就是返回一个字典。
然而,我们有更进一步的追求,那就是把结构体/类的字段进行序列化。比方说,我们有一个这样的C结构体:
struct Person {
char name[16];
char gender[16];
unsigned int age;
};
我们的序列化和反序列化过程就有什么难点了呢?
首先,对于序列化来说,我们有这样一个对象:
struct Person person = (struct Person){
.name = "小明",
.gender = "男",
.age = 26
};
那么我们要将其序列化成
{"name":"小明","gender":"男","age":26}
也就是说,我们希望有一个函数:
char *serialize(struct Person *person);
它接收一个Person
结构体的对象,然后将其序列化成一个字符串。这和字典的序列化有什么区别呢?
我们知道,C语言是比较底层的语言,它的结构体实际上就是一个内存布局的语法糖。我们在程序中写的person.gender
,会被编译器翻译成,person
这个结构体的地址,偏移一个长度为16的char
数组(name
字段)长度之后,长度为16的char
数组。所以,整个程序执行过程中,我们知道的,只是某个字段相对于整个结构体首地址偏移的距离,并不知道每个字段自己的名字,也就是说,我们并不知道这个结构体偏移一个长度为16的char
数组长度之后,长度为16的char
数组这个字段,叫gender
。也就是说,C语言并没有把额外的信息编译进二进制文件中,从而导致我们很难实现这样一个序列化函数。
同理,如果我们希望有一个函数
struct Person unserialize(char *json);
将JSON字符串转化为相应的结构体,也是很难做到的。因为我们得到的信息只是“这个结构体gender
字段的值是"男"
”,但依然无法知道,这个结构体偏移多少地址才能得到gender
字段。
C语言这样比较底层的语言没办法做到,那我们实际生产中使用的高级语言是怎样实现这个过程的呢?
解释型语言
对于解释型语言来说,这个很好做。C语言并没有放额外的信息到二进制文件中,可解释型语言并不会生成二进制文件,而是直接对代码进行逐行解释。因此,在解释的过程中,每个变量的所有信息都可以获取,所以,解释型语言提供了遍历某个对象所有属性的功能。
比方说,我有一个JavaScript的类:
class Person {
constructor(name, gender, age) {
this.name = name;
this.gender = gender;
this.age = age;
}
}
那么对于它的任何一个对象:
let person = new Person("小明", "男", 26);
解释器此时拥有的信息就有,这个类有三个属性,它们的名字分别是name
, gender
和age
,它们的值分别是小明
, 男
和26
。此时,解释器拥有的信息就和序列化字典时是等同的,所以可以轻而易举地序列化。同理,其也可以轻而易举地反序列化。JavaScript提供的用于序列化和反序列化JSON格式的API为:
let person = new Person("小明", "男", 26);
let serializedJSON = JSON.stringify(person);
let personJSON = `{"name":"小明","gender":"男","age":26}`;
let unserializedPerson = JSON.parse(personJSON);
运行期反射
既然解释型语言可以轻而易举地实现我们的需求,那咱编译型语言学它不就好了嘛!解释型语言之所以能够轻松实现序列化和反序列化,是因为它在解释的时候能获得这个对象的所有信息,那么我们在编译的时候,把对象的信息也编译进去不就好了嘛!
采用这种策略的语言,在编译的过程中,不再只是把person.gender
翻译成person
地址偏移多少多少字节,而且还把这个字段的名字、类型信息给编译进去了,我们调查这个可执行文件的符号表,就能发现里面还会有gender
这个字符串。采用这种策略的语言一般都会提供一种功能,让我们能在运行期间根据字段的名称(字符串)读取或写入相应的值,这种功能叫做运行期反射。那么我们在使用这类语言进行序列化的时候,类似地只需要反射出字段的名称就完全ok了。
Swift,和它的前任Objective-C,都是把属性的名称信息编译进可执行文件中去的。Swift同时也提供了Mirror框架供我们进行运行期反射。
但是,因为相关的文档比较少,我并没有办法确定Swift是否是使用运行期反射来做序列化与反序列化的。
在Swift 4之后,我们可以直接使用Codable
协议来做序列化与反序列化:
struct Person: Codable {
var name: String
var gender: String
var age: Int
init(name: String, gender: String, age: Int) {
self.name = name
self.gender = gender
self.age = age
}
}
将结构体声明为Codable
协议之后,我们可以轻松地进行序列化和反序列化了:
let person = Person("小明", "男", 26)
let serializedJSON = try JSONEncoder().encode(person)
let personJSON = "{\"name\":\"小明\",\"gender\":\"男\",\"age\":26}"
let unserializedPerson = try JSONDecoder().decode(Person.Self, from: personJSON.data(using: .utf8)!)
编译期反射
运行期反射会把好多好多信息都编译进可执行文件中,导致可执行文件的体积会有明显的提升。如果我们仅仅是想做序列化和反序列化的话,其实并不需要把所有信息都编译进去,甚至不需要在运行时知道某个字段的名字。
我们来回想一下序列化和反序列化究竟需要什么:事实上,我们需要的是知道把这个字段序列化时使用什么字符串作为key,反序列化时这个key填充到哪个字段里。这个需求看上去似乎就是需要在运行时知道字段的名字,然而,我们来换个角度思考,能不能在编译期自动生成序列化和反序列化的代码,在相应的函数里指出字段的名字就好了。编译器在编译期间是能够知道某个字段的名字的,那么我们直接生成这样序列化和反序列化的代码行不行呢?
当然可以,Rust就是这个策略的践行者。Rust的Serde库是使用最广泛的序列化和反序列化库。
我们可以使用Serialize
和Deserialize
的trait来完成这种事:
#[derive(Serialize, Deserialize)]
struct Person {
name: String,
gender: String,
age: usize,
}
然后就可以愉快地序列化与反序列化啦:
let person = Person {
name = "小明",
gender = "男",
age = 26,
};
let serializedJSON = serde_json::to_string(&person).unwrap();
let personJSON = "{\"name\":\"小明\",\"gender\":\"男\",\"age\":26}";
let unserializedPerson = serde_json::from_str::<Person>(&personJSON).unwrap();
其核心原理就是在编译期,获得Person
结构体每个字段的名称,然后自动生成相应的序列化和反序列化代码。
Kotlin官方推出的序列化和反序列化框架kotlinx.serialization标明是reflectionless的,它也是自动生成相应的visitor代码来通过编译期反射实现序列化与反序列化。
我们只需要对类加上一个@serializable
的annotation:
@Serializable
data class Person(var name: String, var gender: String, var age: Int)
就可以类似地进行序列化和反序列化了:
val person = Person("小明", "男", 26)
val serializedJSON = json.stringify(Person.serializer(), person)
val personJSON = """{"name":"小明","gender":"男","age":26}"""
val unserializedPerson = json.parse(Person.serializer(), personJSON)