浅谈序列化
1. 基本定义#
看看维基百科的定义:
In computing, serialization is process of translating a data structure or object state into a format that can be stored or transmitted. 序列化是将内存中的数据结构或对象转换为可存储或传输的格式的过程
不是很懂, 看来需要一个例子来解释, 下面是我看到的一段话, 有一些疑问:
一个表述说: “序列化是将内存中的数据结构或对象转换为可存储或传输的格式(如二进制数据、XML、JSON等)”
内存中的数据结构或者对象 已经是二进制数据了 为什么不可以直接传输?
上面的表述说 “如二进制数据、XML、JSON等”, 网络传输不是只能传输 二进制数据吗, 为什么还有 xml 和 json 呢?
2. 对象在内存中的样子#
表面上看, 内存中的数据确实是二进制字节(0 和 1), 但这些字节的组织方式和语义高度依赖于程序的运行时环境, 直接传输这些字节会导致接收端无法正确解析:
type Student struct {
Name string
Age int
Score float64
Active bool
}
func main() {
student := Student{
Name: "Alice",
Age: 20,
Score: 95.5,
Active: true,
}
fmt.Printf("Student: %+v\n", student)
fmt.Printf("Size of Student: %d bytes\n", unsafe.Sizeof(student))
}
运行结果 arm64 系统:
Student: {Name:Alice Age:20 Score:95.5 Active:true}
Size of Student: 40 bytes
为了理解为什么不能直接传输, 我们需要看看 Student
结构体在内存中的实际布局:
字段 | 大小 | 内存内容(示例) |
---|---|---|
Name (ptr) | 8 字节 | 指针,指向 “Alice” 的内存地址(如 0x12345678 ) |
Name (len) | 8 字节 | 字符串长度(5 ,表示 “Alice” 的长度) |
Age | 8 字节 | 整数 20 (二进制表示) |
Score | 8 字节 | 浮点数 95.5 (IEEE 754 格式) |
Active | 1 字节 | 布尔值 1 (true) |
Padding | 7 字节 | 填充字节(通常为 0,用于对齐) |
在 Go 中, string 是一个结构体, 包含两个字段:
- 指向字符串数据的指针(
unsafe.Pointer
, 8 字节)- 字符串长度(
int
, 8 字节)所以 string 总共占用 16 字节, 例如,
Name: "Alice"
的实际数据("Alice"
的字节[65, 108, 105, 99, 101]
)存储在内存的某个区域,Name
字段只保存指向该区域的指针和长度
假设 student
结构体存储在内存地址 0x1000
,其二进制数据可能如下(简化表示):
0x1000 - 0x1007
:Name
的指针(例如0x12345678
,指向"Alice"
的实际数据的地址)0x1008 - 0x100F
:Name
的长度(5
)0x1010 - 0x1017
:Age
的值(20
,二进制00000014
)0x1018 - 0x101F
:Score
的值(95.5
,IEEE 754 格式的二进制)0x1020
:Active
的值(1
,表示true
)0x1021 - 0x1027
:填充字节(0
)
关键点:
Name
字段的指针(0x12345678
)指向内存中"Alice"
的实际数据([65, 108, 105, 99, 101]
)的地址- 这些二进制数据高度依赖 Go 的运行时环境,比如:
- 指针地址(
0x12345678
)只在当前程序的内存空间有效 - 内存对齐和填充字节依赖于 Go 编译器和 CPU 架构
"Alice"
的实际数据存储在堆上,由 Go 的垃圾回收器管理
- 指针地址(
3. 内存中的数据结构已经是二进制数据,为什么不能直接传输?#
现在,假设我们将这 40 字节的二进制数据(从 0x1000
到 0x1027
)直接传输到另一台机器, 接收端会遇到以下问题:
3.1. 指针无效#
- 问题:
Name
字段的指针(0x12345678
)指向当前程序的内存地址, 在接收端的机器上, 这个地址要么无效(指向不存在的内存), 要么指向完全无关的数据 - 后果:接收端无法访问
"Alice"
的实际数据([65, 108, 105, 99, 101]
),因为这些数据没有随结构体一起传输 - 解决方法:序列化(如 JSON)会将
"Alice"
的实际内容嵌入到序列化数据中, 而不是只传输指针, 例如, JSON 会生成{"name":"Alice",...}
3.2. 缺少类型信息#
- 问题:内存中的二进制数据没有显式的类型信息, 接收端不知道:
- 这 32 字节代表一个
Student
结构体 - 前 16 字节是
string
,接下来的 8 字节是int
,等等
- 这 32 字节代表一个
- 后果:接收端无法正确解析二进制数据,除非它运行完全相同的 Go 程序(相同的结构体定义和编译器)
- 解决方法:序列化格式(如 JSON)通过键值对显式定义字段名和值(
{"name":"Alice","age":20}
),接收端根据字段名重建数据结构
3.3. 内存布局不兼容#
- 问题:不同系统、编译器或 Go 版本可能有不同的内存布局:
- 32 位 vs. 64 位系统:
int
和指针的大小不同(4 字节 vs. 8 字节) - 内存对齐规则:某些系统可能不使用 8 字节对齐,填充字节不同
- 字段顺序:编译器可能重新排列字段以优化内存访问(尽管 Go 通常按声明顺序)
- 32 位 vs. 64 位系统:
- 后果:接收端可能错误解析字段, 例如,接收端可能将
Age
的 8 字节解析为两个 4 字节字段,导致数据错乱 - 解决方法:序列化格式(如 JSON)是标准化的,字段顺序和类型由格式定义(如
{"name":"Alice","age":20}
),与内存布局无关
3.4. 跨语言和跨平台问题#
- 问题:如果接收端不是 Go 程序(比如 Python 或 Java),它无法理解 Go 的内存布局(指针、字符串结构体、内存对齐等)
- 后果:非 Go 程序无法解析这 32 字节的二进制数据
- 解决方法:序列化格式(如 JSON、XML)是语言无关的,Python 和 Java 都能解析
{"name":"Alice","age":20}
3.5. 示例:直接传输的失败场景#
假设我们将 student
的 32 字节内存数据传输到另一台机器:
- 发送端:传输
[0x12345678, 5, 20, 95.5, 1, 0, 0, 0, 0, 0, 0, 0]
- 接收端:
- 看到
0x12345678
,但这个地址无效,无法找到"Alice"
- 不知道前 16 字节是
string
,可能误以为是两个int64
- 内存对齐不同,可能将
Age
的 8 字节解析为其他类型
- 看到
- 结果:数据完全不可用
所以我对序列化的理解是:
序列化是将内存中的对象转换为一种约定好的格式(如 Protobuf、JSON、Gob、XML 等)
这些格式在网络传输时仍然是二进制数据(字节流)
但这些二进制数据是按照约定的格式组织的,而不是内存中对象的原始内存格式
4. 更上一层楼 (加深理解)#
4.1. 序列化是将内存对象转为约定格式#
- 内存中的对象(比如 Go 的
struct
)包含复杂的信息:指针、类型元数据、内存对齐填充、运行时状态等 - 序列化的目的是将这些对象转换为一种标准化的、平台无关的格式
- 去除指针,直接嵌入实际数据(比如
"Alice"
的字符),只保留数据的逻辑内容 - 去除了填充和运行时元数据
- 使用标准化的结构(键值对、字段标签等),确保跨平台、跨语言可解析
- 去除指针,直接嵌入实际数据(比如
- 例如:
- JSON:
{"name":"Alice","age":20}
- Protobuf:紧凑的二进制格式,包含字段标签和值
- Gob:Go 专用的二进制格式,包含类型信息和数据
- XML:
<person><name>Alice</name><age>20</age></person>
- JSON:
4.2. 传输时是二进制数据, 但按约定格式组织:#
- 网络传输只能传输二进制字节流(0 和 1)
- 序列化后的数据(JSON、XML 等)在传输前会被编码为二进制字节流。例如:
- JSON 字符串
{"name":"Alice"}
编码为 UTF-8 字节([123, 34, 110, 97, 109, 101, 34, ...]
) - Protobuf 直接生成紧凑的二进制字节,包含字段编号和值
- JSON 字符串
- 这些字节流的组织方式遵循约定的格式规则(比如 JSON 的键值对结构,Protobuf 的字段标签),接收端根据相同的规则解析
4.4. 代码示例#
type Student struct {
Name string
Age int
Score float64
}
func main() {
student := Student{Name: "Alice", Age: 20, Score: 95.5}
// 1. 序列化为 JSON
jsonData, err := json.Marshal(student)
if err != nil {
log.Fatalf("JSON 序列化失败: %v", err)
}
fmt.Println("JSON 数据:", string(jsonData))
fmt.Println("JSON 字节:", jsonData)
// 2. 序列化为 Gob
var buf bytes.Buffer
enc := gob.NewEncoder(&buf)
err = enc.Encode(student)
if err != nil {
log.Fatalf("Gob 序列化失败: %v", err)
}
fmt.Println("Gob 字节:", buf.Bytes())
// 3. 反序列化 JSON
var jsonStudent Student
err = json.Unmarshal(jsonData, &jsonStudent)
if err != nil {
log.Fatalf("JSON 反序列化失败: %v", err)
}
fmt.Printf("JSON 反序列化结果: %+v\n", jsonStudent)
// 4. 反序列化 Gob
var gobStudent Student
dec := gob.NewDecoder(&buf)
err = dec.Decode(&gobStudent)
if err != nil {
log.Fatalf("Gob 反序列化失败: %v", err)
}
fmt.Printf("Gob 反序列化结果: %+v\n", gobStudent)
}
输出(部分简化)
JSON 数据: {"Name":"Alice","Age":20,"Score":95.5}
JSON 字节: [123 34 78 97 109 101 34 58 34 65 108 105 99 101 34 44 ...]
Gob 字节: [40 255 129 3 1 1 7 83 116 117 100 101 110 116 ...]
JSON 反序列化结果: {Name:Alice Age:20 Score:95.5}
Gob 反序列化结果: {Name:Alice Age:20 Score:95.5}
4.5. 内存数据 vs. 序列化数据的对比#
特性 | 内存中的二进制数据 | 序列化数据(JSON) |
---|---|---|
内容 | 指针、长度、值、填充字节 | 实际数据("Alice" 、20、95.5、true) |
大小 | 32 字节(固定,含指针和填充) | 变长(约 50 字节,视数据内容) |
平台依赖 | 高度依赖(指针、内存对齐、架构) | 平台无关(标准化的文本格式) |
类型信息 | 隐式(依赖 Go 运行时) | 显式(键值对定义字段和类型) |
跨语言支持 | 不支持(仅 Go 程序可解析) | 支持(JSON 被广泛支持) |
传输后可用性 | 不可用(指针无效,布局不同) | 可用(接收端可直接解析) |
5. 常见的序列化库#
因为不同格式有特定的编码/解码规则和数据结构, 通常每种序列化格式(如 JSON、Gob、Protobuf)都会有对应的库来处理:
- JSON 是跨语言的通用格式, 几乎每种语言都有 JSON 库
- Gob 是 Go 特有的二进制格式,仅由 Go 的 encoding/gob 支持
- Protobuf 是一种高效的跨语言二进制格式,需专用库支持
我们分别举例看一下各自怎么做序列化的
5.1. Go 序列化#
JSON 序列化 - 使用 encoding/json
encoding/json
是 Go 标准库提供的 JSON 序列化工具, 最流行且易用:
func main() {
// 创建对象
p := Person{Name: "Alice", Age: 30}
// 序列化:对象 -> JSON
jsonData, err := json.Marshal(p)
if err != nil {
log.Fatal(err)
}
fmt.Println("JSON:", string(jsonData)) // 输出:JSON: {"name":"Alice","age":30}
// 反序列化:JSON -> 对象
var p2 Person
err = json.Unmarshal(jsonData, &p2)
if err != nil {
log.Fatal(err)
}
fmt.Println("Deserialized:", p2) // 输出:Deserialized: {Alice 30}
}
Gob 序列化 - 使用 encoding/gob
encoding/gob
是 Go 标准库提供的二进制序列化工具,仅用于 Go 程序间通信
type Person struct {
Name string
Age int
}
func main() {
// 创建对象
p := Person{Name: "Alice", Age: 30}
// 序列化:对象 -> Gob
var buf bytes.Buffer
enc := gob.NewEncoder(&buf)
err := enc.Encode(p)
if err != nil {
log.Fatal(err)
}
fmt.Println("Gob bytes:", buf.Bytes()) // 输出:Gob bytes: [二进制数据]
// 反序列化:Gob -> 对象
var p2 Person
dec := gob.NewDecoder(&buf)
err = dec.Decode(&p2)
if err != nil {
log.Fatal(err)
}
fmt.Println("Deserialized:", p2) // 输出:Deserialized: {Alice 30}
}
Protobuf 序列化 - 使用 github.com/golang/protobuf
Protobuf 是一种高效的跨语言二进制序列化格式, 需定义 .proto
文件并生成 Go 代码, github.com/golang/protobuf
是最流行的 Protobuf 实现:
syntax = "proto3";
package main;
option go_package = "./main";
message Person {
string name = 1;
int32 age = 2;
}
$ protoc --go_out=. person.proto
func main() {
// 创建 Protobuf 对象
p := &Person{
Name: "Alice",
Age: 30,
}
// 序列化:对象 -> Protobuf
protoData, err := proto.Marshal(p)
if err != nil {
log.Fatal(err)
}
fmt.Println("Protobuf bytes:", protoData) // 输出:Protobuf bytes: [二进制数据]
// 反序列化:Protobuf -> 对象
var p2 Person
err = proto.Unmarshal(protoData, &p2)
if err != nil {
log.Fatal(err)
}
fmt.Println("Deserialized:", p2) // 输出:Deserialized: name:"Alice" age:30
}
- Protobuf 需要预定义
.proto
文件,生成 Go 结构体和序列化代码 - 使用
proto.Marshal
和proto.Unmarshal
进行序列化/反序列化 - Protobuf 是高效的二进制格式,适合跨语言、高性能场景
5.2. Java 序列化#
Java 这里只讨论 JSON 序列化, 至于其其他格式, 类似 Golang, 就不讨论了, Jackson 是 Java 中最流行的 JSON 序列化库
public class Main {
// 定义 Person 类
public static class Person {
private String name;
private int age;
// 构造函数
...
// Getter 和 Setter
...
@Override
public String toString() {
return "Person{name='" + name + "', age=" + age + "}";
}
}
public static void main(String[] args) {
try {
// 创建 ObjectMapper
ObjectMapper mapper = new ObjectMapper();
// 创建对象
Person p = new Person("Alice", 30);
// 序列化:对象 -> JSON
String json = mapper.writeValueAsString(p);
System.out.println("JSON: " + json); // 输出:JSON: {"name":"Alice","age":30}
// 反序列化:JSON -> 对象
Person p2 = mapper.readValue(json, Person.class);
System.out.println("Deserialized: " + p2); // 输出:Deserialized: Person{name='Alice', age=30}
} catch (Exception e) {
e.printStackTrace();
}
}
}
ObjectMapper
是Jackson
的核心类,writeValueAsString
序列化为 JSON 字符串,readValue
反序列化为对象
5.3. C# 序列化#
C# 中最流行的 JSON 库是 Json.NET (Newtonsoft.Json
), 在 .NET 社区中广为人知, 它提供了一组类和方法, 用于:
- 序列化:将 C# 对象转换为 JSON 字符串
- 反序列化:将 JSON 字符串转换回 C# 对象
- 自定义序列化:通过特性(如
[JsonConverter]
)或配置,允许开发者控制 JSON 的格式和行为
var person = new { Name = "Alice", Age = 30 };
string json = JsonConvert.SerializeObject(person);
// 输出: {"Name":"Alice","Age":30}
string json = "{\"Name\":\"Alice\",\"Age\":30}";
var person = JsonConvert.DeserializeObject<dynamic>(json);
// person.Name == "Alice"
JsonConvert 是 Newtonsoft.Json 命名空间中的一个静态类, 属于 Json.NET 库
6. protobuf
vs json
#
protobuf
是用来序列化的, 主要用在进程间通信尤其是 RPC 中, 前面讨论的时候说到:
序列化是将内存中的数据结构或对象转换为可存储或传输的格式的过程
所以 protobuf
也是用来干这个的, 可是为什么要用 protobuf
呢, 它很快?
-
紧凑的二进制格式:Protobuf 相比 JSON 的文本格式,数据体积更小(通常比 JSON 小 3-10 倍,具体取决于数据结构)
-
高效的序列化/反序列化:Protobuf 的编码规则(如变长编码 Varint)优化了 CPU 和内存使用,解析速度比 JSON 快(通常快 5-100 倍,视场景而定)
6.1. 为什么体积这么小#
type Person struct {
Name string // "Alice"
Age int // 30
}
(1) JSON 的编码#
JSON 是文本格式,序列化后的结果是:
{"name":"Alice","age":30}
数据体积:
{"name":"Alice","age":30}
总共 24 个字符(假设 UTF-8 编码,每个字符 1 字节)- 字段名:
"name"
(6 字节,含引号),"age"
(5 字节) - 值:
"Alice"
(7 字节,含引号),30
(2 字节) - 分隔符:
{
,}
,,
,:
(4 字节)
- 字段名:
- 总计:24 字节
(2) Protobuf 的编码#
Protobuf 需要先定义 .proto
文件:
syntax = "proto3";
message Person {
string name = 1;
int32 age = 2;
}
序列化后的结果是二进制数据(不可读),我们逐步分析其编码
数据体积:
Protobuf 的二进制编码格式为 [field_number][type][value]
的组合:
- 字段
name
(字符串,编号 1):- 字段编号和类型:编号
1
,类型为string
(长度编码,Wire Type = 2)- 编号
1
和 Wire Type 组合编码为 1 字节:00001010
(二进制,0x0A
)
- 编号
- 值:字符串
"Alice"
(5 字节)- 字符串前缀长度:
5
(1 字节,Varint 编码) - 字符串内容:
Alice
(5 字节)
- 字符串前缀长度:
- 小计:1(编号+类型) + 1(长度) + 5(内容) = 7 字节
- 字段编号和类型:编号
- 字段
age
(int32,编号 2):- 字段编号和类型:编号
2
,类型为int32
(Varint,Wire Type = 0)- 编号
2
和 Wire Type 组合编码为 1 字节:00010000
(二进制,0x10
)
- 编号
- 值:整数
30
(Varint 编码)30
在 Varint 中编码为 1 字节:00011110
(二进制,0x1E
)
- 小计:1(编号+类型) + 1(值) = 2 字节
- 字段编号和类型:编号
- 总计:7(name) + 2(age) = 9 字节
性能优势:
- Varint 编码:
age = 30
只用 1 字节,JSON 用 2 字节(文本"30"
), 对于小整数,Varint 极大地节省空间 - 无字段名:Protobuf 用编号
1
和2
(1 字节)替代"name"
(6 字节)和"age"
(5 字节) - 无分隔符:JSON 的
{
,}
,,
等占用 4 字节,Protobuf 无需这些 - 硬编码逻辑:Protobuf 的生成代码避免了 JSON 的动态解析和反射
6.2. 为什么这么快?#
JSON序列化本质上是一个数据转换过程,目标是将内存中的数据结构(例如Python的字典、Java的对象、Go的结构体等)转换为符合JSON规范的字符串, JSON规范定义了数据结构,包括对象({}
)、数组([]
)、字符串(""
)、数字、布尔值(true
/false
)、null
等, 序列化的底层实现通常涉及:
- 数据结构解析:递归遍历输入数据结构的层次结构
- 类型映射:将编程语言的原生类型映射到JSON支持的类型
- 编码:将数据按照JSON语法规则生成字符串,通常使用UTF-8编码
- 内存管理:高效分配和操作字符串缓冲区
- 错误处理:处理不支持的类型或循环引用等问题
Protobuf 使用 protoc
生成静态 Go 代码,序列化逻辑是硬编码的,直接访问结构体字段(通过偏移量),无需反射
protoc
是 Protobuf 的编译器(Protocol Buffers Compiler),用于将用户定义的 .proto
文件(描述数据结构的 Schema)转换为特定语言(如 Go)的源代码, .proto
文件定义了消息(message)的结构,例如:
message User {
string name = 1;
int32 age = 2;
}
运行命令 protoc –go_out=. user.proto,protoc 会生成一个 Go 文件(例如 user.pb.go),包含:
- Go 结构体(如
type User struct
) - 硬编码: 序列化/反序列化方法(如
Marshal 和 Unmarshal
) - 其他辅助代码
什么是静态 Go 代码?
- “静态”意味着这些代码在编译时生成, 运行时无需动态解析数据结构
- 生成的代码是针对特定
.proto
文件的, 包含了所有必要的序列化逻辑, 例如,user.pb.go
中会为User
结构体生成一个Marshal
方法,明确知道如何处理name
和age
字段 - 对比 JSON 的
encoding/json
, 后者在运行时通过反射动态解析任意结构体, 生成的 JSON 依赖于运行时的类型信息
.proto
文件定义了 User
消息,protoc
生成的 user.pb.go
可能包含:
type User struct {
Name string `protobuf:"bytes,1,opt,name=name" json:"name,omitempty"`
Age int32 `protobuf:"varint,2,opt,name=age" json:"age,omitempty"`
}
func (m *User) Marshal() ([]byte, error) {
// 硬编码的序列化逻辑
}
什么是硬编码?
- “硬编码”意味着序列化逻辑是直接写死的代码,针对特定的数据结构(如
User
结构体),而不是通用的解析逻辑 - 在 JSON 中,
encoding/json
使用通用的反射逻辑,适用于任何 Go 数据结构(结构体、映射、切片等)它在运行时检查类型、字段名、标签等,逻辑是“通用的” - 在 Protobuf 中,
protoc
生成的 Marshal 方法是针对特定结构体的专用代码, 例如,User.Marshal
只知道如何序列化User
结构体的Name
和Age
字段,逻辑是固定的
综上效率更高是因为
protoc
根据 预先定义的.proto
文件, 专门为 User 结构生成静态的user.pb.go
, 也就是生成 硬编码的序列化和反序列化方法, 而 JSON 序列化逻辑是通用的,