65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.87/5 (28投票s)

2012年3月21日

MPL

13分钟阅读

viewsIcon

104235

downloadIcon

270

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
  • 字段按相同的顺序序列化和反序列化

上述限制在您计划将数据长时间保存到磁盘时非常不方便,因为当数据稍后反序列化时,应用程序可能已升级到新版本,稍微更改了类。但由于我们的用例是通过网络序列化和反序列化数据,客户端可以首先验证它与服务器兼容(如果不兼容则升级),这不成问题。

简单情况 - 代码

我们应该使用什么样的代码将对象序列化为上述数据,然后反序列化它?

首先,我们假设我们有从流读取和写入原始类型(如 intshort)的方法。这里不需要实现这些方法,但它们是简单的方法,只是按原样读取和写入值。写入字节是这些原始方法中最简单的,下面是一个例子。其他方法遵循相同的原理。

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)或 typeids(字段 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 扩展到支持 ISerializableIDeserializationCallback,这意味着 .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 在几乎所有情况下都明显更快,内存占用也更小。例如,许多测试表明 NetSerializerMemStream Serialize 导致零垃圾回收,即使正在序列化数千万字节的数据。

序列化器的速度当然非常依赖于正在序列化的数据。对于某些特定的负载,protobuf-net 可能比 NetSerializer 更快。然而,我相信那些情况总是可以优化的,最终 NetSerializer 将会更快,因为 NetSerializer 的设计非常简洁。而且,从上面的数字可以看出,序列化 strings 是 NetSerializer 的一个薄弱环节。原因是 .NET 上很难高效地序列化 string,而且由于我在我的用例中不使用很多 strings ,所以我没有花时间在这上面。

来源

最新源代码可在 GitHub 此处找到。

历史

版本 1

  • 首次发布版本

版本 2

  • 添加了关于 NetSerializer 在 Mono 上工作的说明
  • 添加了关于序列化 Dictionary<,> 的说明
  • 删除了关于 sealed 类的错误说法

版本 3

  • 许可证更改为 MPL-2
© . All rights reserved.