1. 介绍
简而言之:二进制序列化格式把对象变成字节序列,这些字节尽可能紧凑地编码对象的结构和数值。想象一下,你不是用文字描述对象(像 JSON 或 XML),而是把每一位都按在内存里的真实形式精确写下来。
在文本格式中数据像写给朋友的中文信(每个字符对人是可读的)。在二进制格式中更像摩尔斯电码,每个点和划都尽可能短地记录,人工“读”是不现实的。
比较:格式对比
| 格式 | 人可读 | 文件大小 | 速度(写/读) | 兼容性 |
|---|---|---|---|---|
| XML/JSON | 是 | 大 | 慢 | 好 |
| 二进制 | 否 | 小 | 非常快 | 有限 |
.NET 中二进制序列化如何工作?
在 .NET 生态里,历史上主要的二进制序列化工具是类 BinaryFormatter。但随着平台发展,它被认为不安全并且在 .NET 9 中被移除。现在的常用方式是其他方法:BinaryWriter/BinaryReader,以及用于复杂对象的第三方库(比如 protobuf-net)。
关于老东西的简短回顾
BinaryFormatter 能把任何标记了属性 [Serializable] 的类变成字节,在反序列化时恢复对象结构。听起来像魔法,但里面隐藏了很多问题(下面会说到)。
现代手段
对于原始类型和简单结构,使用 BinaryWriter 和 BinaryReader 很方便。对于复杂对象,推荐第三方库(比如 protobuf-net、MessagePack-CSharp 等)。
2. 用 BinaryWriter 序列化原始类型
我们接着完善教学应用。比如,我们想把用户设置(用户名、得分、登录时间)写入二进制文件。
public class UserProfile
{
public string Name { get; set; }
public int Score { get; set; }
public DateTime LoginTime { get; set; }
}
public static Task SaveUserProfile(UserProfile profile, string filePath)
{
// 打开文件写入
using var stream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None);
using var writer = new BinaryWriter(stream);
// 分段写入数据。先字符串,然后数字,然后时间
writer.Write(profile.Name ?? string.Empty); // 字符串
writer.Write(profile.Score); // 整数
writer.Write(profile.LoginTime.ToBinary()); // 日期转换成 "long"
}
现在是读取的例子:
public static Task<UserProfile> LoadUserProfile(string filePath)
{
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new BinaryReader(stream);
string name = reader.ReadString();
int score = reader.ReadInt32();
long dateData = reader.ReadInt64();
DateTime loginTime = DateTime.FromBinary(dateData);
return new UserProfile { Name = name, Score = score, LoginTime = loginTime };
}
原始二进制序列化的特性
用 BinaryWriter 我们逐个属性地序列化。这是一个可靠且 可预测 的方法:如果数据结构改变,你能在代码里看到并处理它。
3. 经典二进制序列化的问题
现在看下反面。为什么 Microsoft 如此强烈地反对并禁止使用 BinaryFormatter?
模式脆弱(Schema Evolution Hell)
二进制数据与类的结构 紧密 绑定。改了类(改名字段、加字段、删字段)——旧的二进制文件就可能读不出来。改了属性顺序——同样会出问题。
示例:
// 昨天
public class Profile
{
public string Name;
public int Score;
}
// 今天
public class Profile
{
public string Name;
public double Rating; // 新增字段
public int Score;
}
读取旧文件会抛异常,或者把字段读成“混合”数据。不同于 JSON 或 XML,可以跳过不存在的元素,二进制格式不会自适应变化——它更像一条浇铸好的混凝土路:轻微偏移就会“摔车”。
反序列化漏洞
最大的安全问题在于 BinaryFormatter。如果你的程序反序列化来自不可信来源(比如网络用户)的二进制数据,攻击者可以构造恶意“对象”。过去这甚至导致远程执行任意代码。
跨平台和兼容性问题
二进制序列化通常绑定到 .NET 的内部表示、运行时版本、编译器和架构(比如 x64/ARM)。在 Windows 上序列化然后在 Linux 上反序列化——很可能会遇到问题!即使在不同的 .NET 版本间也可能不兼容。
诊断不方便
遇到文本格式的问题,你可以打开文件看看内容,大致判断哪里错了。二进制文件是七封印的谜题。你看到的只是一串没意义的字节。要分析这种文件,需要专门工具和耐心。
4. 复杂对象的二进制序列化
引用
BinaryFormatter 能记住对象间的引用关系(比如两个属性引用同一个对象),但 BinaryWriter 和大多数第三方库并不会自动做这件事。通常的序列化方式是把一个对象嵌入到另一个对象并按顺序写入。
循环引用
对有循环引用的对象进行序列化(比如“妈妈”有属性 Child,而“孩子”有指向父母的属性 Parent)会抛异常或导致无限循环。
示例:
public class Node
{
public Node? Next { get; set; }
public Node? Prev { get; set; }
}
天真的序列化该对象会导致循环。
5. 二进制序列化与可移植性
任何二进制格式(尤其是自定义的)通常是“只能自己用”的。如果你需要与其它程序交换数据或长期保存数据——选用开放标准:JSON、XML 或 ProtoBuf。
什么时候适合用二进制序列化?
- 数据在同一个应用内使用且仅短期保存。
- 需要速度和紧凑性(比如大量日志或在同一生态内的服务间传输)。
- 你能够严格控制序列化和反序列化的双方。
替代方案:protobuf、MessagePack 等
- protobuf-net:Google Protocol Buffers 在 .NET 的移植,适合跨平台交换和兼容性。
- MessagePack-CSharp:面向 .NET 的快速 MessagePack 实现。
与“裸”BinaryWriter 不同,这些库实现了 schema、支持格式演进、跨平台和安全性。如果你计划和其他系统兼容,优先考虑它们。
6. “手工”二进制序列化
如果你确实需要写二进制(例如对性能敏感的应用),使用 BinaryWriter/BinaryReader ——并且必须 显式 编码读取顺序与数据类型。
建议:
- 总是以固定顺序写入数据,按同样顺序读取。
- 在改变文件结构时保持版本号,或写入“魔数头部”(Magic Header)。
- 在写入字符串/数组之前先写入长度。
- 记录并文档化文件结构:否则一年后你自己也看不懂。
示例:版本控制
// 把格式版本号放在最前面
writer.Write((byte)1); // 版本 1
writer.Write(profile.Name ?? "");
writer.Write(profile.Score);
writer.Write(profile.LoginTime.ToBinary());
/*
方便在格式变更后根据版本做不同的读取逻辑
*/
7. 使用二进制序列化时的典型错误
写入字段顺序和读取时顺序不一致。 结果是值“错位”:字符串被当成 int 读,int 被当成日期,等等。
写入了 10 个对象,但读取时期望 11。流会被破坏:会抛出到达文件末尾的异常。
更改了类结构,但无法读取旧的二进制文件 —— 历史数据丢失。
读取重要文件时忘记处理异常 —— 应用在磁盘第一次出问题时崩溃(比如抛出 EndOfStreamException)。
在不同编程语言间无明确格式地交换二进制文件 —— 在 99% 的情况下这会非常痛苦。
反序列化来自网络上未知用户的数据 —— 哈喽,漏洞来了!绝不要使用 BinaryFormatter;要校验输入并使用更安全的格式。
GO TO FULL VERSION