对于所有的孩子和大部分成年人来说,好奇心都远比钱重要的多。
郑重说明:本文适合对游戏开发感兴趣的初级及中级开发和学习者,本人力图将技术用简单的语言表达清楚。鉴于水平有限,能力一般,文章如有错漏之处,还望批评指正,谢谢。
计算机领域有一个很神奇的词语,叫做 “序列化” 与 “反序列化”。初次接触这对词语的小伙伴一般丈二和尚摸不着头脑,不知所云。今天,我们就来谈谈这个。
一、背景知识
在正式开始之前,我们先来了解一些背景知识,看看计算机中常用的信息数据的表达方式。很多人都知道,计算机中所有的数据都是由0和1这两个二进制数字(比特)来表示的,这也是我们通常所说的数字表示方式。将很多个比特(0或者1)组合起来,就可以表示大千世界无数种信息。
例如,可以用数字方式表示图像。像素是图像的最小单位,它可以是黑白图像中的一个像素点,也可以是彩色图像中的一个像素点。像素值是通过比特来表示的,其中每个比特代表图像的一种状态或颜色。
使用8个比特表示的灰度图像被称为8位灰度图像。每个像素的8个比特直接对应于该像素的灰度级别。例如,比特序列"00000000"对应于黑色,而比特序列"11111111"对应于白色,其他中间序列表示不同程度的灰色。
一般来说,8个比特组成1个 “字节”,因此多个 “字节” 组成的 “字节流” 也就可以表示一幅由多个像素点组成的图片了。不仅仅是图片,很多信息都可以用 “字节流” 来表示。
好,现在更进一步。打开电脑的一个目录,我们通常会看到很多类型的文件:
- txt 为后缀的文本文件
- png 为后缀的图片
- wav 为后缀的音频
- mp4 为后缀的视频
- exe 为后缀的程序
等等。为什么不同类型的信息需要用这些不同的后缀来表示?
为什么 png 格式文件一般是图片呢?pmg 行不行?
前面说了,“字节流” 可以表示各种信息,但是“字节流”该怎么组织起来是一个问题,图片就需要一种特定的组织方式。本质上,png 是大家公认的一种图片数据的基本组织形式,一种规范。只有我们的图片数据按照这种格式来组织,图片软件才能正确展现我们的图片;只有按照这种格式来组织,我们的png图片作为字节流传输给别人,别人的电脑也才能正确识别这是一张图片。
我们来看一下,png 图片文件的格式规范:
png 图片的字节流大致分为4个部分:
需要注意的是,我们的 png 图片文件可以在不同的电脑,如 windows、mac 系统,可以被不同的图片软件如 Windows照片查看器、Picasa软件等识别出来,就因为这些系统或软件都按照 png 这个公认的规范格式来解析这个文件并展示出来。
所以,在现实世界中,规则、规范很重要,是信息交流和存储的基础。
二、数据序列化
还是以图片为例,如果你通过作图软件画好了一张图,保存成 png 格式的文件,这个过程说白了就是一种数据的序列化,序列化好的图片字节流以文件的形式存在你电脑的磁盘中。
在作图软件的程序中,这张图其实是程序的一个对象,我们想存储它,就要先将其序列化成字节流的形式。当然,字节流是以 PNG 图片的4个部分的形式来组织。然后写入文件中。
此外,我们还可以把字节流存储在数据库中;也可以通过网络传输给别人,别人的电脑收到以后,再通过反序列化重新变成软件程序的一个对象,这样别人也就可以看到图片了。
这些过程可以用下面的图来表示:
计算机世界中的信息,除了前面列举的如文本、图片、视频等,还有其他信息,如过程性的操作数据:
- 你点开一个商品并下单
- 你在王者荣耀里操作英雄释放了一个技能
- 你打开抖音,触发了服务器向你推荐一系列视频
你的这些操作动作信息,需要手机上的 app 将它告知远端的服务器,好让服务器做出合适的响应。
以前面说的游戏释放技能为例,我要传输的操作信息可能是:
玩家:张三
操作的英雄:李白
释放的技能:将进酒
这个信息也需要以特定的方式组织成字节流,然后通过网络发送给服务器,服务器收到这个信息之后,才知道你做了释放技能的操作。
那应该以什么方式来组织字节流呢?毕竟这种信息和 PNG 图片不一样,并没有一个标准化组织告诉我们应该用哪个特定的格式。
通常的解决方法,是双方硬编码格式,或者利用一种叫做 IDL 的东西。
三、规范与IDL
前面我们说过,如果要交换或者传递信息,就需要规范,使用规范来组织字节流。
IDL 就是一种规范。前面我们也提到,传输信息的双方,对应的终端类型、操作系统、程序语言都可能不同,比如在我的一台手机上的图片可以通过 ios 程序上传到图片网站后台的linux系统的服务器上,对应的服务器程序语言可能是 golang,因此 IDL 也充当了一种双方都能识别的中间媒介的作用。
IDL(Interface Definition Language,接口定义语言)是一种用于描述数据结构的语言,常用于不同系统或语言之间的通信和数据交换。在序列化过程中,IDL定义了对象的数据结构的规范,以便在不同的平台和编程语言中进行对象的序列化和反序列化操作。
常见的 IDL 有 XML、protobuf、Thrift 等。我们先以 XML 为例来定义前面提过的释放技能的操作信息。
玩家姓名、操作的英雄、释放的技能分别用特定的基本类型来表示,如字符串 string、整型 int。通过这些基本的类型的组合和嵌套,可以表示多种多样的非常复杂的信息。
有了 IDL 这种定义对象信息的基本类型的规范,我们就可以根据它来写入对象中具体的信息。
现在,我们就可以这样定义序列化了:按照一定的规范,如 PNG 的格式,或者 IDL,将对象写入并组织成字节流的过程。
类似的,反序列化可以这样定义:按照一定的规范,如 PNG 的格式,或者 IDL,将字节流恢复成对象的过程。
四、序列化方式的发展
在计算机行业发展的过程中,序列化经历了一系列形式,来依次看下序列化是怎么发展的:
在早期的编程语言中,开发人员通常手动编写序列化和反序列化代码,以在不同的系统之间传输数据。这种手动序列化方法需要开发人员了解数据结构,并编写适当的代码来处理序列化和反序列化操作。
这种方式并没有一个显示的 IDL 定义,而是传输信息的双方约定好信息的格式规范,将要传输的信息硬编码在代码中。我们还是通过例子的方式加以说明。
在下面一个 C++ 程序中,内存中存在一个叫 MyStruct 结构体对象,我们欲将其序列化成字节流的形式,存储在 buff 中(buff 中的字节流可以通过网络传输或者存入文件或数据库),同时也能从 buff 中通过反序列化还原出结构体对象。
#include
#include
struct MyStruct {
int id;
std::string name;
double value;
};
void SerializeStruct(const MyStruct& obj, std::vector& buffer) {
// 写入 id
const char* idPtr = reinterpret_cast(&obj.id);
buffer.insert(buffer.end(), idPtr, idPtr + sizeof(obj.id));
// 再写入 name
int nameLength = obj.name.length();
const char* nameLengthPtr = reinterpret_cast(&nameLength);
buffer.insert(buffer.end(), nameLengthPtr, nameLengthPtr + sizeof(nameLength));
buffer.insert(buffer.end(), obj.name.begin(), obj.name.end());
// 最后写入 value
const char* valuePtr = reinterpret_cast(&obj.value);
buffer.insert(buffer.end(), valuePtr, valuePtr + sizeof(obj.value));
}
void DeserializeStruct(MyStruct& obj, const std::vector& buffer) {
size_t offset = 0;
// 读取 id
const char* idPtr = buffer.data() + offset;
obj.id = *reinterpret_cast(idPtr);
offset += sizeof(obj.id);
// 读取 name
const char* nameLengthPtr = buffer.data() + offset;
int nameLength = *reinterpret_cast(nameLengthPtr);
offset += sizeof(nameLength);
obj.name.assign(buffer.data() + offset, buffer.data() + offset + nameLength);
offset += nameLength;
// 读取 value
const char* valuePtr = buffer.data() + offset;
obj.value = *reinterpret_cast(valuePtr);
}
在这种手写的序列化代码中,需要利用指针操作内存,按顺序将每个字段的值写入指针指向的内存块,并及时移动 offset 来控制写入的起始位置;
很明显,这种方式低效、不自动、容易出错,而且极难定位bug。笔者早年经历的项目中,经常会因为手写序列化代码导致解包失败,如果出错了,需要利用抓包软件,逐个分析二进制数据,定位哪个字段写入错误,效率极低。
序列化的结果是二进制数据流,二进制形式对人来说可读性极差,为了提高可读性和可维护性,开发人员也开始使用文本格式来作为结果序列化数据。常见的文本序列化格式包括XML(可扩展标记语言)和 JSON(JavaScript对象表示法)。这些格式使用文本表示数据,并提供了一种结构化的方式来存储和传输数据。
下面是一个 python 中用 xml 序列化的例子:
# 序列化为 XML
def serialize_to_xml(data):
# 组织数据结构
root = ET.Element('students')
for student_data in data['students']:
student = ET.SubElement(root, 'student')
name = ET.SubElement(student, 'name')
name.text = student_data['name']
age = ET.SubElement(student, 'age')
age.text = str(student_data['age'])
grade = ET.SubElement(student, 'grade')
grade.text = student_data['grade']
# 序列化
xml_string = ET.tostring(root, encoding='utf-8')
return xml_string
文本序列化的方式,最终的结果是文本,由于可读性很好,因此出问题定位起来也很方便;
但是,我们还是要手动编写代码来组织数据,将字段依次写入或读出,这还是很可能出错。有没有更智能的方式?
基于很多语言提供的反射特性,我们可以自动解析对象的字段,通过反射这种智能的方式读取或写入字段的值,我们就实现了自动化,不需要针对每个对象都来手写代码。
下面是一个例子:
import xml.etree.ElementTree as ET
import inspect
class Serializer:
def to_xml(self, obj):
root = ET.Element(obj.__class__.__name__) # 使用类名作为根元素名称
self._serialize(obj, root)
xml_string = ET.tostring(root, encoding='utf-8')
return xml_string
def _serialize(self, obj, parent):
# 依次遍历对象的所有字段
for name, value in self._get_properties(obj):
if isinstance(value, (str, int, float, bool)):
elem = ET.SubElement(parent, name)
elem.text = str(value)
elif isinstance(value, list):
for item in value:
list_elem = ET.SubElement(parent, name)
self._serialize(item, list_elem)
else:
child_elem = ET.SubElement(parent, name)
self._serialize(value, child_elem)
def _get_properties(self, obj):
properties = inspect.getmembers(obj, lambda x: not(inspect.isroutine(x)))
return [(name, value) for name, value in properties if not name.startswith('_')]
# 示例类
class Student:
def __init__(self, name, age, grade):
self.name = name
self.age = age
self.grade = grade
def get_info(self):
return f"Name: {self.name}, Age: {self.age}, Grade: {self.grade}"
# 使用反射进行序列化
serializer = Serializer()
student = Student('John Doe', 20, 'A')
xml_data = serializer.to_xml(student)
print(xml_data.decode())
在上面的例子中,通过 python 语言的 inpect 反射模块,我们可以依次遍历对象的所有字段,自动组织数据结构,然后调用序列化接口。
# 自动遍历对象字段
for name, value in self._get_properties(obj)
...
但是,这里需要提出的一点是,反射一般都需要额外调用更多的函数,其效率相比直接读取或者写入对象字段要差不少。
序列化发展至此,虽然能实现一定程度的智能化,避免低级错误,但是还依赖特定语言和特定场景的实现(比如需要python 语言中 inspect 反射模块的支持),尚没有一款通用化的框架和库来一劳永逸的解决问题。
序列化一般用于存储或客户端服务器通信,客户端和服务器的结构、系统、语言往往都有很大差异,因此跨平台跨语言也非常重要。
2008年 Google的 Protocol Buffers(protobuf)横空出世,彻底解决了序列化的痛点和效率问题。它简化了序列化过程并提供了更高级别的抽象,提供了自动序列化和反序列化的功能,支持跨平台和跨语言。
前面说了,反射能实现智能化的序列化,但是效率不高。
Protobuf 采用了另外一种方法,它依赖自己的 IDL 语言,直接生成依次读取或写入对象字段的代码,和用户的代码一起编译或解释执行,避免了反射这种在运行时间接调用的方式,大大提高了效率。
protobuf 提供了一种各种语言都能识别的 IDL 来充当中间规范,定义好要传递的数据对象格式(基于 protobuf 提供的基础类型如 int、string 等),然后生成操作字段的各种语言形式的代码。
例如一个 Student 的 IDL 定义如下
syntax = "proto3";
message Student {
string name = 1;
int32 age = 2;
string grade = 3;
}
生成的 python 版本代码如下:
# student_pb2.py
# Generated by the protocol buffer compiler. DO NOT EDIT!
import sys
_b=sys.version_info[0]