如何分析二进制序列化流的内容?

我正在使用二进制序列化(BinaryFormatter)作为临时机制,将状态信息存储在一个相对复杂(游戏)对象结构的文件中; 文件比我预期的大得多,我的数据结构包括递归引用 – 所以我想知道BinaryFormatter是否实际存储了相同对象的多个副本,或者我的基本“对象和值的数量是否应该有“arithmentic是偏离基础的,或者其他地方的过度规模来自于。

搜索堆栈溢出我能够找到Microsoft的二进制远程格式的规范: http : //msdn.microsoft.com/en-us/library/cc236844( PROT.10) .aspx

我找不到的是任何现有的查看器,它使您能够“窥视”二进制格式化输出文件的内容 – 获取文件中不同对象类型的对象计数和总字节数等;

我觉得这一定是我的“google-fu”让我失望(我什么都没有) – 任何人都可以帮忙吗? 这一定是以前做过的,对吧?


更新 :我找不到它并且没有得到答案所以我把一些相对较快的东西放在一起(链接到下面的可下载项目); 我可以确认BinaryFormatter不存储同一对象的多个副本,但它确实向流中打印了大量元数据。 如果您需要高效存储,请构建自己的自定义序列化方法。

因为对于我决定做这篇文章的人来说可能感兴趣的是关于序列化.NET对象的二进制格式是什么样子以及我们如何正确解释它?

我的所有研究都基于.NET Remoting:二进制格式数据结构规范。

示例类:

为了有一个工作示例,我创建了一个名为A的简单类,它包含2个属性,一个字符串和一个整数值,它们被称为SomeStringSomeValue

A类看起来像这样:

 [Serializable()] public class A { public string SomeString { get; set; } public int SomeValue { get; set; } } 

对于序列化,我当然使用了BinaryFormatter

 BinaryFormatter bf = new BinaryFormatter(); StreamWriter sw = new StreamWriter("test.txt"); bf.Serialize(sw.BaseStream, new A() { SomeString = "abc", SomeValue = 123 }); sw.Close(); 

可以看出,我传递了A包含abc123作为值的A类新实例。

示例结果数据:

如果我们在hex编辑器中查看序列化结果,我们会得到这样的结果:

示例结果数据

让我们解释一下示例结果数据:

根据上述规范(这里是PDF的直接链接: [MS-NRBF] .pdf ),流中的每个记录都由RecordTypeEnumeration标识。 第2.1.2.1 RecordTypeNumeration2.1.2.1 RecordTypeNumeration状态:

此枚举标识记录的类型。 每个记录(MemberPrimitiveUnTyped除外)都以记录类型枚举开头。 枚举的大小是一个BYTE。

SerializationHeaderRecord:

因此,如果我们回顾一下我们得到的数据,我们就可以开始解释第一个字节了:

SerializationHeaderRecord_RecordTypeEnumeration

2.1.2.1 RecordTypeEnumeration所述,值0标识在2.6.1 SerializationHeaderRecord指定的2.6.1 SerializationHeaderRecord

SerializationHeaderRecord记录必须是二进制序列化中的第一条记录。 此记录具有格式的主要版本和次要版本以及顶部对象和标题的ID。

它包括:

  • RecordTypeEnum(1个字节)
  • RootId(4个字节)
  • HeaderId(4个字节)
  • MajorVersion(4个字节)
  • MinorVersion(4个字节)

有了这些知识,我们可以解释包含17个字节的记录:

SerializationHeaderRecord_Complete

00表示RecordTypeEnumeration ,在我们的例子中是SerializationHeaderRecord

01 00 00 00表示RootId

如果序列化流中既不存在BinaryMethodCall也不存在BinaryMethodReturn记录,则该字段的值必须包含序列化流中包含的Class,Array或BinaryObjectString记录的ObjectId。

所以在我们的例子中,这应该是值为1ObjectId (因为数据是使用little-endian序列化的),我们希望再次看到它们;-)

FF FF FF FF表示HeaderId

01 00 00 00表示MajorVersion

00 00 00 00表示MinorVersion

BinaryLibrary:

按照规定,每条记录必须以RecordTypeEnumeration开头。 当最后一条记录完成时,我们必须假设一个新记录开始。

让我们解释下一个字节:

BinaryLibraryRecord_RecordTypeEnumeration

我们可以看到,在我们的示例中, SerializationHeaderRecord后面是BinaryLibrary记录:

BinaryLibrary记录将INT32 ID(在[MS-DTYP]部分2.2.22中指定)与库名称相关联。 这允许其他记录使用ID引用库名称。 当有多个记录引用相同的库名称时,此方法会减小电线大小。

它包括:

  • RecordTypeEnum(1个字节)
  • LibraryId(4个字节)
  • LibraryName(可变字节数(这是一个LengthPrefixedString ))

2.1.1.6 LengthPrefixedString中所2.1.1.6 LengthPrefixedString

LengthPrefixedString表示字符串值。 该字符串以UTF-8编码字符串的长度为前缀,以字节为单位。 长度编码在可变长度字段中,最小为1个字节,最多为5个字节。 为了最小化线尺寸,将长度编码为可变长度字段。

在我们的简单示例中,长度始终使用1 byte编码。 有了这些知识,我们可以继续解释流中的字节:

BinaryLibraryRecord_RecordTypeEnumeration_LibraryId

0C表示标识BinaryLibrary记录的RecordTypeEnumeration

02 00 00 00代表LibraryId ,在我们的例子中是2

现在LengthPrefixedString如下:

BinaryLibraryRecord_RecordTypeEnumeration_LibraryId_LibraryName

42表示包含LibraryNameLengthPrefixedString的长度信息。

在我们的例子中, 42 (十进制66)的长度信息告诉我们,我们需要读取接下来的66个字节并将它们解释为LibraryName

如前所述,字符串是UTF-8编码的,因此上面字节的结果类似于: _WorkSpace_, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null

ClassWithMembersAndTypes:

同样,记录已完成,因此我们解释下一个记录的RecordTypeEnumeration

ClassWithMembersAndTypesRecord_RecordTypeEnumeration

05标识ClassWithMembersAndTypes记录。 第2.3.2.1 ClassWithMembersAndTypes2.3.2.1 ClassWithMembersAndTypes状态:

ClassWithMembersAndTypes记录是类记录中最详细的。 它包含有关成员的元数据,包括成员的名称和远程处理类型。 它还包含一个引用类的库名称的库ID。

它包括:

  • RecordTypeEnum(1个字节)
  • ClassInfo(可变字节数)
  • MemberTypeInfo(可变字节数)
  • LibraryId(4个字节)

的ClassInfo:

2.3.1.1 ClassInfo所述,记录包括:

  • ObjectId(4个字节)
  • 名称(可变字节数(也是LengthPrefixedString ))
  • MemberCount(4个字节)
  • MemberNames(这是一个LengthPrefixedString序列,其中项目数必须等于MemberCount字段中指定的值。)

回到原始数据,一步一步:

ClassWithMembersAndTypesRecord_RecordTypeEnumeration_ClassInfo_ObjectId

01 00 00 00表示ObjectId 。 我们已经看过这个,它被指定为SerializationHeaderRecordRootId

ClassWithMembersAndTypesRecord_RecordTypeEnumeration_ClassInfo_ObjectId_Name

0F 53 74 61 63 6B 4F 76 65 72 46 6C 6F 77 2E 41表示使用LengthPrefixedString表示的类的Name 。 如上所述,在我们的示例中,字符串的长度定义为1个字节,因此第一个字节0F指定必须使用UTF-8读取和解码15个字节。 结果看起来像这样: StackOverFlow.A – 显然我使用StackOverFlow作为命名空间的名称。

ClassWithMembersAndTypesRecord_RecordTypeEnumeration_ClassInfo_ObjectId_Name_MemberCount

02 00 00 00表示MemberCount ,它告诉我们2个成员,两个都用LengthPrefixedString代表。

第一个成员的名字: ClassWithMembersAndTypesRecord_MemberNameOne

1B 3C 53 6F 6D 65 53 74 72 69 6E 67 3E 6B 5F 5F 42 61 63 6B 69 6E 67 46 69 65 6C 64表示第一个MemberName1B再次是字符串的长度,长度为27个字节,导致像这样: k__BackingField

第二个成员的名字: ClassWithMembersAndTypesRecord_MemberNameTwo

1A 3C 53 6F 6D 65 56 61 6C 75 65 3E 6B 5F 5F 42 61 63 6B 69 6E 67 46 69 65 6C 64表示第二个MemberName1A指定该字符串长度为26个字节。 结果是这样的: k__BackingField

MemberTypeInfo:

ClassInfo之后, ClassInfo MemberTypeInfo

2.3.1.2 - MemberTypeInfo2.3.1.2 - MemberTypeInfo状态,该结构包含:

  • BinaryTypeEnums(长度可变)

一系列BinaryTypeEnumeration值,表示正在传输的成员类型。 数组必须:

  • 与ClassInfo结构的MemberNames字段具有相同数量的项目。

  • 按顺序排列,使BinaryTypeEnumeration对应ClassInfo结构的MemberNames字段中的成员名称。

  • AdditionalInfos(长度可变),取决于BinaryTpeEnum ,可能存在也可能不存在其他信息。

| BinaryTypeEnum | AdditionalInfos |
|----------------+--------------------------|
| Primitive | PrimitiveTypeEnumeration |
| String | None |

所以考虑到这一点,我们几乎就在那里……我们期待2个BinaryTypeEnumeration值(因为我们在MemberNames有2个成员)。

再次,回到完整的MemberTypeInfo记录的原始数据:

ClassWithMembersAndTypesRecord_MemberTypeInfo

01表示第一个成员的BinaryTypeEnumeration ,根据2.1.2.2 BinaryTypeEnumeration我们可以期望一个String并使用LengthPrefixedString表示它。

00表示第二成员的BinaryTypeEnumeration ,并且再次,根据规范,它是基Primitive 。 如上所述, Primitive后面跟着附加信息,在本例中是PrimitiveTypeEnumeration 。 这就是为什么我们需要读取下一个字节,即08 ,与2.1.2.3 PrimitiveTypeEnumeration所述的表匹配,并惊讶地注意到我们可以期待一个由4个字节表示的Int32 ,如其他一些文档所述基本数据类型。

库Id:

MemerTypeInfo之后的MemerTypeInfo之后,它由4个字节表示:

ClassWithMembersAndTypesRecord_LibraryId

02 00 00 00表示LibraryId ,即2。

价值:

2.3 Class Records

必须将类的成员值序列化为该记录后面的记录,如2.7节所述。 记录的顺序必须与ClassInfo(第2.3.1.1节)结构中指定的MemberNames的顺序相匹配。

这就是我们现在可以期待成员价值的原因。

让我们看看最后几个字节:

BinaryObjectStringRecord_RecordTypeEnumeration

06标识BinaryObjectString 。 它表示我们的SomeString属性的值( k__BackingFieldk__BackingField )。

根据2.5.7 BinaryObjectString它包含:

  • RecordTypeEnum(1个字节)
  • ObjectId(4个字节)
  • 值(可变长度,表示为LengthPrefixedString

所以我们可以清楚地认识到这一点

BinaryObjectStringRecord_RecordTypeEnumeration_ObjectId_MemberOneValue

03 00 00 00表示ObjectId

03 61 62 63表示Value ,其中03是字符串本身的长度, 61 62 63是转换为abc的内容字节。

希望你能记得有第二个成员, Int32 。 知道Int32是用4个字节表示的,我们可以得出结论

BinaryObjectStringRecord_RecordTypeEnumeration_ObjectId_MemberOneValue_MemberTwoValue

必须是我们第二个成员的Value7Bhex等于123十进制,似乎适合我们的示例代码。

所以这是完整的ClassWithMembersAndTypes记录: ClassWithMembersAndTypesRecord_Complete

MessageEnd:

MessageEnd_RecordTypeEnumeration

最后,最后一个字节0B代表MessageEnd记录。

Vasiliy是正确的,我最终需要实现自己的格式化程序/序列化过程,以更好地处理版本控制并输出更紧凑的流(压缩前)。

我确实想要了解流中发生的事情,所以我编写了一个(相对)快速的类来做我想要的事情:

  • 在流中解析它,构建对象名称,计数和大小的集合
  • 完成后,输出它找到的内容的快速摘要 – 流中的类,计数和总大小

对我来说,把它放在像codeproject这样可见的地方是没有用的,所以我只是将项目转储到我的网站上的zip文件中: http : //www.architectshack.com/BinarySerializationAnalysis.ashx

在我的具体情况下,事实certificate问题是双重的:

  • BinaryFormatter非常冗长(这是众所周知的,我只是没有意识到程度)
  • 我的class上确实遇到过问题,结果发现我存放的是我不想要的东西

希望这在某些方面帮助某人!


更新:Ian Wright与原始代码的问题联系我,当源对象包含“十进制”值时,它崩溃了。 现在已经更正了,我已经利用这个机会将代码移动到GitHub并给它一个(许可的,BSD)许可证。

我们的应用运行大量数据。 它可能需要1-2 GB的RAM,就像你的游戏一样。 我们遇到了同样的“存储同一对象的多个副本”问题。 二进制序列化也存储了太多的元数据。 首次实现时,序列化文件大约需要1-2 GB。 现在我设法减少了价值 – 50-100 MB。 我们做了什么。

简短的回答 – 不要使用.Net二进制序列化,创建自己的二进制序列化机制。 我们有自己的BinaryFormatter类和ISerializable接口(有两种方法Serialize,Deserialize)。

同一个对象不应该多次序列化。 我们保存它的唯一ID并从缓存中恢复对象。

如果你问,我可以分享一些代码。

编辑:看来你是对的。 请参阅以下代码 – 它certificate我错了。

 [Serializable] public class Item { public string Data { get; set; } } [Serializable] public class ItemHolder { public Item Item1 { get; set; } public Item Item2 { get; set; } } public class Program { public static void Main(params string[] args) { { Item item0 = new Item() { Data = "0000000000" }; ItemHolder holderOneInstance = new ItemHolder() { Item1 = item0, Item2 = item0 }; var fs0 = File.Create("temp-file0.txt"); var formatter0 = new BinaryFormatter(); formatter0.Serialize(fs0, holderOneInstance); fs0.Close(); Console.WriteLine("One instance: " + new FileInfo(fs0.Name).Length); // 335 //File.Delete(fs0.Name); } { Item item1 = new Item() { Data = "1111111111" }; Item item2 = new Item() { Data = "2222222222" }; ItemHolder holderTwoInstances = new ItemHolder() { Item1 = item1, Item2 = item2 }; var fs1 = File.Create("temp-file1.txt"); var formatter1 = new BinaryFormatter(); formatter1.Serialize(fs1, holderTwoInstances); fs1.Close(); Console.WriteLine("Two instances: " + new FileInfo(fs1.Name).Length); // 360 //File.Delete(fs1.Name); } } } 

看起来像BinaryFormatter使用object.Equals来查找相同的对象。

你有没有看过生成的文件? 如果从代码示例中打开“temp-file0.txt”和“temp-file1.txt”,您会看到它有很多元数据。 这就是为什么我建议你创建自己的序列化机制。

对不起,因为要共同使用。

也许您可以在调试模式下运行程序并尝试添加控制点。

如果由于游戏的大小或其他依赖性而无法做到这一点,您可以随时处理包含反序列化代码的简单/小型应用程序,并从那里查看调试模式。