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

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

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

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

一、背景知识

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

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

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

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

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

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

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

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

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

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

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

  1. PNG 文件标识符在最前面,它是固定的8个字节,遇到这固定的8个字节,就会被识别为一张 PNG 图片;
  2. 然后是 PNG 文件头,它带有图片的高度、宽度等数值信息;
  3. 再然后就是图片的每一个像素的值,所有像素可以是按照从左到右、从上到下的顺序依次排列到字节流中;
  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 为例来定义前面提过的释放技能的操作信息。

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">

  <!-- 定义释放技能的操作信息 -->
  <xs:element name="cast_skill">
    <xs:complexType>
      <xs:sequence>
        <xs:element name="user" type="xs:string"/> <!-- 玩家姓名 -->
        <xs:element name="hero_id" type="xs:int"/> <!-- 操作的英雄id -->
        <xs:element name="skill_id" type="xs:int"/> <!-- 释放的技能id -->
      </xs:sequence>
    </xs:complexType>
  </xs:element>

</xs:schema>

玩家姓名、操作的英雄、释放的技能分别用特定的基本类型来表示,如字符串 string、整型 int。通过这些基本的类型的组合和嵌套,可以表示多种多样的非常复杂的信息。

有了 IDL 这种定义对象信息的基本类型的规范,我们就可以根据它来写入对象中具体的信息。

现在,我们就可以这样定义序列化了:按照一定的规范,如 PNG 的格式,或者 IDL,将对象写入并组织成字节流的过程。

类似的,反序列化可以这样定义:按照一定的规范,如 PNG 的格式,或者 IDL,将字节流恢复成对象的过程。

三、序列化方式的发展

在计算机行业发展的过程中,序列化经历了一系列形式,来依次看下序列化是怎么发展的:

  1. 原始社会:手动序列化

在早期的编程语言中,开发人员通常手动编写序列化和反序列化代码,以在不同的系统之间传输数据。这种手动序列化方法需要开发人员了解数据结构,并编写适当的代码来处理序列化和反序列化操作。

这种方式并没有一个显示的 IDL 定义,而是传输信息的双方约定好信息的格式规范,将要传输的信息硬编码在代码中。我们还是通过例子的方式加以说明。

在下面一个 C++ 程序中,内存中存在一个叫 MyStruct 结构体对象,我们欲将其序列化成字节流的形式,存储在 buff 中(buff 中的字节流可以通过网络传输或者存入文件或数据库),同时也能从 buff 中通过反序列化还原出结构体对象。

cpp 复制代码
#include <iostream>
#include <vector>

struct MyStruct {
    int id;
    std::string name;
    double value;
};

void SerializeStruct(const MyStruct& obj, std::vector<char>& buffer) {
    // 写入 id
    const char* idPtr = reinterpret_cast<const char*>(&obj.id);
    buffer.insert(buffer.end(), idPtr, idPtr + sizeof(obj.id));

    // 再写入 name
    int nameLength = obj.name.length();
    const char* nameLengthPtr = reinterpret_cast<const char*>(&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<const char*>(&obj.value);
    buffer.insert(buffer.end(), valuePtr, valuePtr + sizeof(obj.value));
}

void DeserializeStruct(MyStruct& obj, const std::vector<char>& buffer) {
    size_t offset = 0;

    // 读取 id
    const char* idPtr = buffer.data() + offset;
    obj.id = *reinterpret_cast<const int*>(idPtr);
    offset += sizeof(obj.id);

    // 读取 name
    const char* nameLengthPtr = buffer.data() + offset;
    int nameLength = *reinterpret_cast<const int*>(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<const double*>(valuePtr);
}

在这种手写的序列化代码中,需要利用指针操作内存,按顺序将每个字段的值写入指针指向的内存块,并及时移动 offset 来控制写入的起始位置;

很明显,这种方式低效、不自动、容易出错,而且极难定位bug。笔者早年经历的项目中,经常会因为手写序列化代码导致解包失败,如果出错了,需要利用抓包软件,逐个分析二进制数据,定位哪个字段写入错误,效率极低。

  1. 文本序列化

序列化的结果是二进制数据流,二进制形式对人来说可读性极差,为了提高可读性和可维护性,开发人员也开始使用文本格式来作为结果序列化数据。常见的文本序列化格式包括XML(可扩展标记语言)和 JSON(JavaScript对象表示法)。这些格式使用文本表示数据,并提供了一种结构化的方式来存储和传输数据。

下面是一个 python 中用 xml 序列化的例子:

python 复制代码
# 序列化为 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

文本序列化的方式,最终的结果是文本,由于可读性很好,因此出问题定位起来也很方便;

但是,我们还是要手动编写代码来组织数据,将字段依次写入或读出,这还是很可能出错。有没有更智能的方式?

  1. 基于反射的序列化

基于很多语言提供的反射特性,我们可以自动解析对象的字段,通过反射这种智能的方式读取或写入字段的值,我们就实现了自动化,不需要针对每个对象都来手写代码。

下面是一个例子:

python 复制代码
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 反射模块,我们可以依次遍历对象的所有字段,自动组织数据结构,然后调用序列化接口。

python 复制代码
# 自动遍历对象字段
for name, value in self._get_properties(obj)
    ...

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

  1. 基于IDL的序列化框架和库

序列化发展至此,虽然能实现一定程度的智能化,避免低级错误,但是还依赖特定语言和特定场景的实现(比如需要python 语言中 inspect 反射模块的支持),尚没有一款通用化的框架和库来一劳永逸的解决问题。

序列化一般用于存储或客户端服务器通信,客户端和服务器的结构、系统、语言往往都有很大差异,因此跨平台跨语言也非常重要。

2008年 Google的 Protocol Buffers(protobuf)横空出世,彻底解决了序列化的痛点和效率问题。它简化了序列化过程并提供了更高级别的抽象,提供了自动序列化和反序列化的功能,支持跨平台和跨语言。

前面说了,反射能实现智能化的序列化,但是效率不高。

Protobuf 采用了另外一种方法,它依赖自己的 IDL 语言,直接生成依次读取或写入对象字段的代码,和用户的代码一起编译或解释执行,避免了反射这种在运行时间接调用的方式,大大提高了效率。

protobuf 提供了一种各种语言都能识别的 IDL 来充当中间规范,定义好要传递的数据对象格式(基于 protobuf 提供的基础类型如 int、string 等),然后生成操作字段的各种语言形式的代码。

例如一个 Student 的 IDL 定义如下

ini 复制代码
syntax = "proto3";

message Student {
  string name = 1;
  int32 age = 2;
  string grade = 3;
}

生成的 python 版本代码如下:

python 复制代码
# student_pb2.py

# Generated by the protocol buffer compiler. DO NOT EDIT!

import sys
_b=sys.version_info[0]<3 and (lambda x:x) or (lambda x:x.encode('latin1'))
from google.protobuf import descriptor as _descriptor
from google.protobuf import message as _message
from google.protobuf import reflection as _reflection
from google.protobuf import symbol_database as _symbol_database
from google.protobuf import descriptor_pb2
# @@protoc_insertion_point(imports)

_sym_db = _symbol_database.Default()




DESCRIPTOR = _descriptor.FileDescriptor(
  name='student.proto',
  package='',
  syntax='proto3',
  serialized_pb=_b('C\n\rstudent.proto\x12\x05\x66\x6f\x6f\x62\x61\x72\xC8\x01\n\x07Student\x12\x12\n\x04name\x18\x01 \x01(\t\x12\x10\n\x03age\x18\x02 \x01(\x05\x12\x14\n\x05grade\x18\x03 \x01(\t\x42\x02\x10\x01'))
_sym_db.RegisterFileDescriptor(DESCRIPTOR)




_STUDENT = _descriptor.Descriptor(
  name='Student',
  full_name='Student',
  filename=None,
  file=DESCRIPTOR,
  containing_type=None,
  fields=[
    _descriptor.FieldDescriptor(
      name='name', full_name='Student.name', index=0,
      number=1, type=9, cpp_type=9, label=1, has_default_value=False,
      default_value=_b("").decode('utf-8'),
      message_type=None, enum_type=None, containing_type=None,
      is_extension=False, extension_scope=None,
      options=None),
    _descriptor.FieldDescriptor(
      name='age', full_name='Student.age', index=1,
      number=2, type=5, cpp_type=1, label=1, has_default_value=False,
      default_value=0,
      message_type=None, enum_type=None, containing_type=None,
      is_extension=False, extension_scope=None,
      options=None),
    _descriptor.FieldDescriptor(
      name='grade', full_name='Student.grade', index=2,
      number=3, type=9, cpp_type=9, label=1, has_default_value=False,
      default_value=_b("").decode('utf-8'),
      message_type=None, enum_type=None, containing_type=None,
      is_extension=False, extension_scope=None,
      options=None),
  ],
  extensions=[
  ],
  nested_types=[],
  enum_types=[
  ],
  options=None,
  is_extendable=False,
  syntax='proto3',
  extension_ranges=[],
  oneofs=[
  ],
  serialized_start=42,
  serialized_end=102,
)


DESCRIPTOR.message_types_by_name['Student'] = _STUDENT

Student = _reflection.GeneratedProtocolMessageType('Student', (_message.Message,), dict(
  DESCRIPTOR = _STUDENT,
  __module__ = 'student_pb2',
  # @@protoc_insertion_point(class_scope:Student)
  ))
_sym_db.RegisterMessage(Student)


# @@protoc_insertion_point(module_scope)

有了这个代码,我们使用起来就方便很多了:

python 复制代码
import student_pb2

# 创建学生对象
student = student_pb2.Student()
student.name = "John Doe"
student.age = 20
student.grade = "A"

# 将学生对象序列化为字节流
serialized_data = student.SerializeToString()

# 将字节流反序列化为学生对象
deserialized_student = student_pb2.Student()
deserialized_student.ParseFromString(serialized_data)

protobuf IDL 用来定义对象的是基于其基础的 string、int 等类型的,那这些基础类型又是怎么序列化为字节流的呢?

protobuf 官方提供了具体的 encoding 的说明,采用了很多精巧的设计提高效率、压缩大小,有兴趣的可以看 protobuf encoding 说明 ,这里不再赘述。

四、程序对象的传输

在结束之前,咱们再来问一个根本性的问题:为什么需要序列化?直接将程序对象直接传输或者存储不行吗?

其实,在计算机内存中,数据对象通常以二进制形式表示,并且在内存中的存储方式是与特定的计算机体系结构和编程语言相关的。数据对象在内存中的表示通常包括对象的字段、指针和其他元数据。

每种语言的表示方式都不同,例如 C/C++ 是直接通过指针来操作内存中的数据的,没有额外的元数据,更高级一些的语言如 Golang 不太会直接操作内存,它有自己专门的各种对象的组织形式。

内存中的数据对象的布局也可能会受到字节对齐、填充位和内存地址等因素的影响。这意味着在一个计算机系统上存储的对象不能直接在另一个计算机系统上使用,因为它们的内存布局可能不兼容。

内存中的数据对象通常也是与运行时环境和运行时状态相关的。例如,对象可能依赖于其他对象、操作系统的状态或正在运行的程序的状态。在没有这些依赖关系和环境的情况下,直接传输内存中的数据对象可能无法正确地还原对象的完整状态。

基于上述原因,将内存中的数据对象直接传输走是不可行的。为了在不同的系统之间传输数据对象,需要使用序列化技术将对象转换为可传输的格式,如二进制流、文本格式(如JSON或XML)。序列化技术可以将对象的状态编码为一系列可以传输的数据,同时还提供了适当的机制来处理不同系统之间的兼容性和环境依赖关系。

小结

本文中,我们讨论了序列化与反序列化技术:

  • 首先介绍了计算机中信息存储或传输的背景知识;
  • 介绍了序列化的基本过程
  • 介绍了序列化技术演化的过程及改进历史
  • 最后阐明了序列化技术在传输数据对象过程中的必要性

作者:我是码财小子,会点编程代码,懂些投资理财;期待你的关注,不要错过我后续的文章更新。

相关推荐
涡能增压发动积20 小时前
同样的代码循环 10次正常 循环 100次就抛异常?自定义 Comparator 的 bug 让我丢尽颜面
后端
Wenweno0o20 小时前
0基础Go语言Eino框架智能体实战-chatModel
开发语言·后端·golang
于慨20 小时前
Lambda 表达式、方法引用(Method Reference)语法
java·前端·servlet
石小石Orz20 小时前
油猴脚本实现生产环境加载本地qiankun子应用
前端·架构
swg32132120 小时前
Spring Boot 3.X Oauth2 认证服务与资源服务
java·spring boot·后端
从前慢丶20 小时前
前端交互规范(Web 端)
前端
tyung20 小时前
一个 main.go 搞定协作白板:你画一笔,全世界都看见
后端·go
gelald20 小时前
SpringBoot - 自动配置原理
java·spring boot·后端
CHU72903520 小时前
便捷约玩,沉浸推理:线上剧本杀APP功能版块设计详解
前端·小程序
GISer_Jing20 小时前
Page-agent MCP结构
前端·人工智能