【技术·真相数据序列化:把大象装进冰箱

2023年 10月 12日 57.3k 0

对于所有的孩子和大部分成年人来说,好奇心都远比钱重要的多。

郑重说明:本文适合对游戏开发感兴趣的初级及中级开发和学习者,本人力图将技术用简单的语言表达清楚。鉴于水平有限,能力一般,文章如有错漏之处,还望批评指正,谢谢。

计算机领域有一个很神奇的词语,叫做 “序列化” 与 “反序列化”。初次接触这对词语的小伙伴一般丈二和尚摸不着头脑,不知所云。今天,我们就来谈谈这个。

一、背景知识

在正式开始之前,我们先来了解一些背景知识,看看计算机中常用的信息数据的表达方式。很多人都知道,计算机中所有的数据都是由0和1这两个二进制数字(比特)来表示的,这也是我们通常所说的数字表示方式。将很多个比特(0或者1)组合起来,就可以表示大千世界无数种信息。

例如,可以用数字方式表示图像。像素是图像的最小单位,它可以是黑白图像中的一个像素点,也可以是彩色图像中的一个像素点。像素值是通过比特来表示的,其中每个比特代表图像的一种状态或颜色。

使用8个比特表示的灰度图像被称为8位灰度图像。每个像素的8个比特直接对应于该像素的灰度级别。例如,比特序列"00000000"对应于黑色,而比特序列"11111111"对应于白色,其他中间序列表示不同程度的灰色。

一般来说,8个比特组成1个 “字节”,因此多个 “字节” 组成的 “字节流” 也就可以表示一幅由多个像素点组成的图片了。不仅仅是图片,很多信息都可以用 “字节流” 来表示。

好,现在更进一步。打开电脑的一个目录,我们通常会看到很多类型的文件:

  • txt 为后缀的文本文件
  • png 为后缀的图片
  • wav 为后缀的音频
  • mp4 为后缀的视频
  • exe 为后缀的程序

等等。为什么不同类型的信息需要用这些不同的后缀来表示?

为什么 png 格式文件一般是图片呢?pmg 行不行?

前面说了,“字节流” 可以表示各种信息,但是“字节流”该怎么组织起来是一个问题,图片就需要一种特定的组织方式。本质上,png 是大家公认的一种图片数据的基本组织形式,一种规范。只有我们的图片数据按照这种格式来组织,图片软件才能正确展现我们的图片;只有按照这种格式来组织,我们的png图片作为字节流传输给别人,别人的电脑也才能正确识别这是一张图片。

我们来看一下,png 图片文件的格式规范:

image.png

png 图片的字节流大致分为4个部分:

  • PNG 文件标识符在最前面,它是固定的8个字节,遇到这固定的8个字节,就会被识别为一张 PNG 图片;
  • 然后是 PNG 文件头,它带有图片的高度、宽度等数值信息;
  • 再然后就是图片的每一个像素的值,所有像素可以是按照从左到右、从上到下的顺序依次排列到字节流中;
  • 最后,是图片结束的标记;
  • 需要注意的是,我们的 png 图片文件可以在不同的电脑,如 windows、mac 系统,可以被不同的图片软件如 Windows照片查看器、Picasa软件等识别出来,就因为这些系统或软件都按照 png 这个公认的规范格式来解析这个文件并展示出来。

    所以,在现实世界中,规则、规范很重要,是信息交流和存储的基础。

    二、数据序列化

    还是以图片为例,如果你通过作图软件画好了一张图,保存成 png 格式的文件,这个过程说白了就是一种数据的序列化,序列化好的图片字节流以文件的形式存在你电脑的磁盘中。

    在作图软件的程序中,这张图其实是程序的一个对象,我们想存储它,就要先将其序列化成字节流的形式。当然,字节流是以 PNG 图片的4个部分的形式来组织。然后写入文件中。

    此外,我们还可以把字节流存储在数据库中;也可以通过网络传输给别人,别人的电脑收到以后,再通过反序列化重新变成软件程序的一个对象,这样别人也就可以看到图片了。

    这些过程可以用下面的图来表示:

    image.png

    计算机世界中的信息,除了前面列举的如文本、图片、视频等,还有其他信息,如过程性的操作数据:

    • 你点开一个商品并下单
    • 你在王者荣耀里操作英雄释放了一个技能
    • 你打开抖音,触发了服务器向你推荐一系列视频

    你的这些操作动作信息,需要手机上的 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)
        ...
    

    但是,这里需要提出的一点是,反射一般都需要额外调用更多的函数,其效率相比直接读取或者写入对象字段要差不少。

  • 基于IDL的序列化框架和库
  • 序列化发展至此,虽然能实现一定程度的智能化,避免低级错误,但是还依赖特定语言和特定场景的实现(比如需要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]

    相关文章

    服务器端口转发,带你了解服务器端口转发
    服务器开放端口,服务器开放端口的步骤
    产品推荐:7月受欢迎AI容器镜像来了,有Qwen系列大模型镜像
    如何使用 WinGet 下载 Microsoft Store 应用
    百度搜索:蓝易云 – 熟悉ubuntu apt-get命令详解
    百度搜索:蓝易云 – 域名解析成功但ping不通解决方案

    发布评论