NetSerializer - .NET 的快速、简单序列化器






4.87/5 (28投票s)
NetSerializer - .NET 的快速、简单序列化器
引言
本文介绍了一种在 .NET 平台上序列化对象的简单方法,我认为这是序列化的最佳方法,前提是兼容性和版本控制不是问题。本文还包含了一个名为 NetSerializer
的该方法实现,并对 NetSerializer 和另一个序列化器 protobuf-net 进行了性能比较。
背景
事实上,并不存在“最佳”序列化器,即在所有用例中都最快、最有效的序列化器。例如,如果需要遵循某个序列化数据标准,该标准将限制可用的优化选项。同样,如果您需要在数据结构的旧版本和新版本之间保持兼容性,则需要元数据,而这又会限制优化选项。
市面上有很多出色的序列化器。protobuf-net 就是其中一个例子,此处用于性能比较。然而,我觉得我找到的所有序列化器都做了额外的处理,尽管这些处理在某些用例中非常有用,但在我的用例中却没用,反而降低了性能。
本文介绍的用例是您不关心标准兼容性、版本控制或其他任何东西。唯一关心的是将数据从一端序列化,然后在另一端尽可能快速高效地反序列化。这种序列化的主要用例是在客户端和服务器之间通过网络发送数据,其中类和结构体集是预先已知的,并且客户端和服务器具有相同版本的类和结构体。
理论
让我们通过查看一些示例如何尽可能高效地序列化,来考虑如何构建一个最优的序列化器(针对上述用例最优)。
简单情况 - 数据结构
首先,我们看一个非常简单的情况。考虑序列化器需要写入哪些数据才能使反序列化器能够反序列化对象,以及在对象如下时,哪种代码可以实现这一点:
class ClassA
{
int a;
short b;
}
现在,对于每个字段,我们可以写入字段的名称、字段的类型以及字段的值。这可以使反序列化器在反序列化对象时更容易找到相应的字段,并反序列化值。但考虑到 ClassA
中的实际数据量是 48 位(int + short),额外写入字段的名称和类型意味着写入更多数据。所有这些其他数据都是仅用于序列化目的的“元数据”。
因此,不言而喻,发送上述对象字段的最佳方法是发送 32 位用于“a
”和 16 位用于“b
”。如果我们这样做,我们就只发送数据本身,没有额外的元数据,因此效率是 100%。但这里有一个问题:如果没有元数据来指导反序列化,序列化器和反序列化器必须具有完全相同版本的类,并且字段必须以相同的顺序写入和读取。
上述数据足以让反序列化器知道接收到的对象是 ClassA
类。然而,这通常不是情况,我们需要某种类型的标识符,以便反序列化器知道创建 ClassA
的实例。所以,让我们在数据前面添加一个类型标识符,这基本上只是一个告诉它是哪个类型的数字。我们使用一个 16 位标识符,这对于大多数用例来说应该足够了。此类型 ID 也可以用于对象为 null
的情况,通过为 null
保留一个特殊的类型 ID(例如,0
)。所以要发送的数据是:
<typeid for ClassA> <value of a> <value of b>
上述数据足以在接收端重建对象,前提是满足两点:
- 序列化器和反序列化器对相同的类具有相同的 typeid
- 字段按相同的顺序序列化和反序列化
上述限制在您计划将数据长时间保存到磁盘时非常不方便,因为当数据稍后反序列化时,应用程序可能已升级到新版本,稍微更改了类。但由于我们的用例是通过网络序列化和反序列化数据,客户端可以首先验证它与服务器兼容(如果不兼容则升级),这不成问题。
简单情况 - 代码
我们应该使用什么样的代码将对象序列化为上述数据,然后反序列化它?
首先,我们假设我们有从流读取和写入原始类型(如 int
和 short
)的方法。这里不需要实现这些方法,但它们是简单的方法,只是按原样读取和写入值。写入字节是这些原始方法中最简单的,下面是一个例子。其他方法遵循相同的原理。
void WritePrimitive(Stream stream, int value)
{
stream.WriteByte(value);
}
void ReadPrimitive(Stream stream, out int value)
{
value = (byte)stream.ReadByte();
}
这些原始函数不需要将数据直接写入流,但它们可以采用不同类型的编码来减少保存的位数。一种这样的编码是 Google Protocol Buffers 中使用的 base 128 varints。
因此,有了这些工具,编写代码来序列化和反序列化 ClassA
很容易:
/* Serialize the fields of ClassA */
void Serialize(Stream stream, ClassA value)
{
WritePrimitive(stream, value.a);
WritePrimitive(stream, value.b);
}
/* Create an empty instance of ClassA, and deserialize the fields */
void Deserialize(Stream stream, out ClassA value)
{
value = (ClassA)FormatterServices.GetUninitializedObject(typeof(ClassA));
ReadPrimitive(stream, out value.a);
ReadPrimitive(stream, out value.b);
}
/* return an ushort typeid for the given object */
ushort GetTypeID(object ob)
{
if (ob == null)
return 0;
/* a more advanced implementation could use a lookup table */
if (ob is ClassA)
return 1;
else
... handle other types
}
/* Write the typeid of the given object and then serialize the fields */
void SerializerSwitch(Stream stream, object ob)
{
/* find typeid for the given object from a table */
ushort typeid = GetTypeID(ob);
WritePrimitive(stream, typeid);
switch (typeid) {
case 0: /* null, nothing more to be done */
return;
case 1: /* typeid for ClassA is 1 here */
Serialize(stream, (ClassA)ob);
return;
case 2:
... handle other types
}
/* Read the typeid, and call the appropriate deserializer */
void DeserializerSwitch(Stream stream, out object ob)
{
ushort typeid;
ReadPrimitive(stream, out typeid);
switch (typeid) {
case 0:
ob = null;
return;
case 1:
ClassA value;
Deserialize(stream, out value);
ob = value;
return;
case 2:
... handle other types
}
}
就是这样!这就是序列化 ClassA
并将其反序列化回来所需的所有内容。
请注意,上面没有使用反射,也没有使用动态数据容器等,因此它的速度几乎能达到最快。内存占用也非常小,因为数据直接从对象写入流,同样直接从流读取到对象。没有创建临时实例,不需要缓冲区。
复杂情况
ClassA
是一个相当简单的情况,那么对于更复杂一些的情况,我们有 struct
和类引用作为字段呢?
struct StructB
{
int a;
}
class ClassC
{
ClassA a;
StructB b;
}
实际上,上面的情况并没有比简单情况复杂多少。
void Serialize(Stream stream, StructB value)
{
WritePrimitive(stream, value.a);
}
void Deserialize(Stream stream, out StructB value)
{
ReadPrimitive(stream, out value.a);
}
void Serialize(Stream stream, ClassC value)
{
SerializerSwitch(stream, value.a);
Serialize(stream, value.b);
}
void Deserialize(Stream stream, out ClassC value)
{
value = (ClassC)FormatterServices.GetUninitializedObject(typeof(ClassC));
DeserializerSwitch(stream, out value.a);
Deserialize(stream, out value.b);
}
请注意我们如何序列化 ClassC
:对于字段“a
”,它是 ClassA
,我们调用 SerializerSwitch()
,它将处理 null
,写入 typeid
,最后跳转到 ClassA
的序列化器。这对于处理 null
和继承是必需的,因为字段“a
”可能是 ClassFoo
,如果 ClassFoo
碰巧继承了 ClassA
。对于字段“b
”,它是 StructB
,我们只需要调用 StructB
的序列化器即可。无需 null
检查(struct
不能为 null
)或 typeid
s(字段 b
始终是 StructB
,它不可能是其他任何东西)。
SerializerSwitch()
和 DeserializerSwitch()
方法需要扩展以处理 ClassC
,但这与 ClassA
的情况相同。
下一步
对于一两个类来说,这一切都很好。但是,谁会为,比如说,1000 个类编写这样的序列化器呢?嗯,这就是计算机的作用,做重复性的工作,这样您就不必做了。更确切地说,我们在这里需要的工具是反射和 DynamicMethod
。
使用反射,我们可以分析类并使用 DynamicMethod
生成序列化器代码,从而自动化整个过程。我不会详细介绍代码生成,但所需的 IL 相当简单,可以从上面的代码示例中猜到。
这大致就是 NetSerializer
项目所做的事情。
NetSerializer - .NET 的快速、简单序列化器
使用上述方法,我实现了一个名为 NetSerializer
的序列化库,它同时适用于 Microsoft 的 .NET Framework 和 Mono。在我针对我的用例考虑序列化器时,它是我找到的最快的序列化器。NetSerialiser
托管在 GitHub 此处。
NetSerializer
的主要优点是:
- 非常适合网络序列化
- 支持类、结构体、枚举、接口、抽象类
- 不序列化版本控制或其他额外信息,仅纯数据
- 原始类型或结构体没有类型 ID,因此发送的数据更少
- 原始类型或结构体没有动态类型查找,因此反序列化速度更快
- 不需要额外的属性(如 DataContract/Member),只需添加标准的
[Serializable]
- 无锁线程安全
- 数据直接写入流并从流读取,无需临时缓冲区或大缓冲区
NetSerializer
的简洁性有一个用户必须考虑的缺点:不发送版本控制或其他元信息,这意味着发送者和接收者必须具有正在序列化的类型的相同版本。这意味着将序列化数据长时间保存是一个坏主意,因为版本升级可能会导致数据无法反序列化。出于这个原因,我认为 NetSerializer
的最佳(也许是唯一)用途是通过网络发送数据,在客户端和服务器之间,并在建立连接时已验证版本兼容性。
此外,必须注意,我没有将 NetSerializer
扩展到支持 ISerializable
或 IDeserializationCallback
,这意味着 .NET Framework 中的某些类型无法直接序列化。但是,NetSerializer
支持序列化 Dictionary<,>
(自 v1.1 起)。
用法
用法很简单。要序列化的类型需要标记为标准的 [Serializable]
。您也可以为不想序列化的字段使用 [NonSerialized]
。对于类型要序列化,无需做任何其他事情。
然后,您需要通过提供要序列化的类型列表来初始化 NetSerializer
。NetSerializer
将扫描提供的类型,以及由提供的类型递归使用的所有类型,并创建序列化器和反序列化器。
初始化
NetSerializer.Serializer.Initialize(types);
序列化
NetSerializer.Serializer.Serialize(stream, ob);
反序列化
(YourType)NetSerializer.Serializer.Deserialize(stream);
性能
下面是 NetSerializer 和 protobuf-net 之间的性能比较。Protobuf-net 是一个快速的 Protocol Buffers 兼容序列化器,在我为我的用例考虑序列化器时,它是我能找到的最佳序列化器。
该表列出了运行测试所需的时间、测试期间发生的 GC 集合次数(按代),以及输出的序列化数据的大小(可用时)。
有三个测试:
- MemStream Serialize - 将对象数组序列化到内存流。
- MemStream Deserialize - 反序列化由 MemStream Serialize 测试创建的流。
- NetTest - 使用两个线程,第一个线程序列化对象并通过本地套接字发送它们,第二个线程接收数据并反序列化对象。请注意,NetTest 无法获得大小,因为跟踪发送的数据并不容易。但是,数据集与 MemStream 相同,数据大小也相同。
测试针对不同类型的数据集运行。这些数据集由相同类型的对象组成。但是,每个对象都用随机数据初始化。数据集中使用的类型是:
U8Message
- 包含单个byte
字段S16Message
- 包含单个short
字段S32Message
- 包含单个int
字段PrimitivesMessage
- 包含多个原始类型字段ComplexMessage
- 包含具有接口和抽象引用的字段StringMessage
- 包含随机长度的string
ByteArrayMessage
- 包含随机长度的byte
数组IntArrayMessage
- 包含随机长度的int
数组
测试详情可在源代码中找到。测试在 32 位 Windows XP 笔记本电脑上运行。
2000000 U8Message | 时间(毫秒) | GC0 | GC1 | GC2 | 大小(字节) | |
---|---|---|---|---|---|---|
NetSerializer | MemStream Serialize | 323 | 0 | 0 | 0 | 4000000 |
NetSerializer | MemStream Deserialize | 454 | 4 | 2 | 0 | |
protobuf-net | MemStream Serialize | 1041 | 138 | 1 | 1 | 10984586 |
protobuf-net | MemStream Deserialize | 2200 | 42 | 16 | 0 | |
NetSerializer | NetTest | 715 | 4 | 2 | 0 | |
protobuf-net | NetTest | 10969 | 222 | 66 | 1 | |
2000000 S16Message | 时间(毫秒) | GC0 | GC1 | GC2 | 大小(字节) | |
NetSerializer | MemStream Serialize | 244 | 0 | 0 | 0 | 7496110 |
NetSerializer | MemStream Deserialize | 609 | 6 | 4 | 1 | |
protobuf-net | MemStream Serialize | 853 | 138 | 1 | 1 | 20492059 |
protobuf-net | MemStream Deserialize | 2701 | 43 | 11 | 1 | |
NetSerializer | NetTest | 730 | 5 | 4 | 0 | |
protobuf-net | NetTest | 11143 | 217 | 51 | 1 | |
2000000 S32Message | 时间(毫秒) | GC0 | GC1 | GC2 | 大小(字节) | |
NetSerializer | MemStream Serialize | 420 | 0 | 0 | 0 | 11874526 |
NetSerializer | MemStream Deserialize | 795 | 4 | 3 | 0 | |
protobuf-net | MemStream Serialize | 928 | 138 | 1 | 1 | 17748783 |
protobuf-net | MemStream Deserialize | 2477 | 43 | 11 | 1 | |
NetSerializer | NetTest | 803 | 4 | 3 | 0 | |
protobuf-net | NetTest | 10917 | 216 | 47 | 1 | |
1000000 PrimitivesMessage | 时间(毫秒) | GC0 | GC1 | GC2 | 大小(字节) | |
NetSerializer | MemStream Serialize | 986 | 1 | 1 | 1 | 45867626 |
NetSerializer | MemStream Deserialize | 1055 | 10 | 6 | 0 | |
protobuf-net | MemStream Serialize | 1160 | 70 | 2 | 2 | 65223933 |
protobuf-net | MemStream Deserialize | 1997 | 29 | 21 | 1 | |
NetSerializer | NetTest | 990 | 10 | 5 | 0 | |
protobuf-net | NetTest | 6621 | 75 | 31 | 1 | |
300000 ComplexMessage | 时间(毫秒) | GC0 | GC1 | GC2 | 大小(字节) | |
NetSerializer | MemStream Serialize | 401 | 0 | 0 | 0 | 22147415 |
NetSerializer | MemStream Deserialize | 788 | 15 | 9 | 0 | |
protobuf-net | MemStream Serialize | 897 | 21 | 1 | 1 | 43046672 |
protobuf-net | MemStream Deserialize | 2285 | 58 | 44 | 1 | |
NetSerializer | NetTest | 1110 | 16 | 13 | 0 | |
protobuf-net | NetTest | 3853 | 65 | 27 | 2 | |
200000 StringMessage | 时间(毫秒) | GC0 | GC1 | GC2 | 大小(字节) | |
NetSerializer | MemStream Serialize | 487 | 73 | 1 | 1 | 100256848 |
NetSerializer | MemStream Deserialize | 744 | 70 | 44 | 1 | |
protobuf-net | MemStream Serialize | 479 | 14 | 1 | 1 | 101206237 |
protobuf-net | MemStream Deserialize | 909 | 44 | 24 | 1 | |
NetSerializer | NetTest | 1101 | 120 | 65 | 1 | |
protobuf-net | NetTest | 2283 | 47 | 27 | 1 | |
5000 ByteArrayMessage | 时间(毫秒) | GC0 | GC1 | GC2 | 大小(字节) | |
NetSerializer | MemStream Serialize | 387 | 1 | 1 | 1 | 253320407 |
NetSerializer | MemStream Deserialize | 356 | 33 | 20 | 1 | |
protobuf-net | MemStream Serialize | 789 | 170 | 5 | 3 | 253353761 |
protobuf-net | MemStream Deserialize | 441 | 33 | 24 | 1 | |
NetSerializer | NetTest | 1300 | 34 | 22 | 1 | |
protobuf-net | NetTest | 1285 | 83 | 34 | 3 | |
800 IntArrayMessage | 时间(毫秒) | GC0 | GC1 | GC2 | 大小(字节) | |
NetSerializer | MemStream Serialize | 2040 | 1 | 1 | 1 | 198093146 |
NetSerializer | MemStream Deserialize | 1464 | 2 | 1 | 1 | |
protobuf-net | MemStream Serialize | 2212 | 65 | 3 | 3 | 235691847 |
protobuf-net | MemStream Deserialize | 1862 | 20 | 3 | 1 | |
NetSerializer | NetTest | 2220 | 3 | 2 | 1 | |
protobuf-net | NetTest | 2906 | 76 | 6 | 3 |
从测试可以看出,NetSerializer
在几乎所有情况下都明显更快,内存占用也更小。例如,许多测试表明 NetSerializer
的 MemStream
Serialize
导致零垃圾回收,即使正在序列化数千万字节的数据。
序列化器的速度当然非常依赖于正在序列化的数据。对于某些特定的负载,protobuf-net 可能比 NetSerializer 更快。然而,我相信那些情况总是可以优化的,最终 NetSerializer 将会更快,因为 NetSerializer 的设计非常简洁。而且,从上面的数字可以看出,序列化 string
s 是 NetSerializer 的一个薄弱环节。原因是 .NET 上很难高效地序列化 string
,而且由于我在我的用例中不使用很多 strings
,所以我没有花时间在这上面。
来源
最新源代码可在 GitHub 此处找到。
历史
版本 1
- 首次发布版本
版本 2
- 添加了关于 NetSerializer 在 Mono 上工作的说明
- 添加了关于序列化
Dictionary<,>
的说明 - 删除了关于
sealed
类的错误说法
版本 3
- 许可证更改为 MPL-2