因为这可能会对某些人有帮助,所以我决定写这篇关于“序列化的.NET对象的二进制格式是什么样的,我们如何正确地解释它”的文章。
我所有的研究都基于.NET Remoting: Binary Format Data Structure规范。
示例类:
为了举一个实际的例子,我创建了一个简单的类叫做A
,它包含两个属性,一个字符串和一个整数值,它们分别被称为SomeString
和SomeValue
。
类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
,其中包含值
abc
和
123
。
示例结果数据:
如果我们在十六进制编辑器中查看序列化结果,会得到如下内容:
![Example result data](https://istack.dev59.com/R4qYq.webp)
让我们解释一下示例结果数据:
根据上述规范(这里是到PDF的直接链接:[MS-NRBF].pdf),流中的每个记录都由RecordTypeEnumeration
标识。章节2.1.2.1 RecordTypeNumeration
指出:
此枚举标识记录的类型。每个记录(除了MemberPrimitiveUnTyped)都以记录类型枚举开始。枚举的大小为一个字节。
SerializationHeaderRecord:
因此,如果我们回顾一下得到的数据,我们可以开始解释第一个字节:
![SerializationHeaderRecord_RecordTypeEnumeration](https://istack.dev59.com/KycI3.webp)
如
2.1.2.1 RecordTypeEnumeration
中所述,值为
0
的标识符用于指定
2.6.1 SerializationHeaderRecord
中的
SerializationHeaderRecord
:
SerializationHeaderRecord
记录必须是二进制序列化中的第一个记录。该记录包含格式的主版本和次版本以及顶级对象和标题的ID。
它由以下内容组成:
- RecordTypeEnum(1字节)
- RootId(4字节)
- HeaderId(4字节)
- MajorVersion(4字节)
- MinorVersion(4字节)
有了这些知识,我们可以解释包含17个字节的记录:
![SerializationHeaderRecord_Complete](https://istack.dev59.com/clLke.webp)
00
代表RecordTypeEnumeration
,在我们的情况下是SerializationHeaderRecord
。
01 00 00 00
代表RootId
如果序列化流中不存在BinaryMethodCall或BinaryMethodReturn记录,则此字段的值必须包含序列化流中包含的Class、Array或BinaryObjectString记录的ObjectId。
所以在我们的情况下,这应该是具有值1
的ObjectId
(因为数据使用little-endian进行序列化),我们希望再次看到它;-)
FF FF FF FF
代表HeaderId
01 00 00 00
代表MajorVersion
00 00 00 00
代表MinorVersion
BinaryLibrary:
按照规定,每个记录必须以RecordTypeEnumeration
开始。由于最后一个记录已经完成,我们必须假设一个新的记录开始了。
让我们解释下一个字节:
![BinaryLibraryRecord_RecordTypeEnumeration](https://istack.dev59.com/UNvQC.webp)
正如我们所看到的,在我们的示例中,
SerializationHeaderRecord
之后是
BinaryLibrary
记录:
BinaryLibrary记录将INT32 ID(如[MS-DTYP]第2.2.22节中所指定的)与库名称相关联。这允许其他记录通过使用ID引用库名称。当存在多个记录引用相同的库名称时,此方法可以减少线路大小。
它包括:
- RecordTypeEnum(1字节)
- LibraryId(4字节)
- LibraryName(可变数量的字节(即
LengthPrefixedString
))
如2.1.1.6 LengthPrefixedString
中所述...
LengthPrefixedString表示字符串值。该字符串以UTF-8编码的字节数组长度为前缀。长度在一个最小为1个字节、最大为5个字节的可变长度字段中进行编码。为了最小化线路大小,长度被编码为可变长度字段。
在我们的简单示例中,长度始终使用
1字节
进行编码。有了这个知识,我们可以继续解释流中的字节:
![BinaryLibraryRecord_RecordTypeEnumeration_LibraryId](https://istack.dev59.com/r3O4h.webp)
0C
代表RecordTypeEnumeration
,用于标识BinaryLibrary
记录。
02 00 00 00
代表LibraryId
,在我们的情况下是2
。
现在是LengthPrefixedString
:
![BinaryLibraryRecord_RecordTypeEnumeration_LibraryId_LibraryName](https://istack.dev59.com/PVFUD.webp)
42
代表包含LibraryName
的LengthPrefixedString
的长度信息。
在我们的情况下,42
(十进制66)的长度信息告诉我们需要读取接下来的66个字节并将它们解释为LibraryName
。
如前所述,该字符串是UTF-8
编码的,因此上面的字节结果可能类似于:_WorkSpace_,Version=1.0.0.0,Culture=neutral,PublicKeyToken=null
ClassWithMembersAndTypes:
同样,记录已经完整,因此我们解释下一个RecordTypeEnumeration
:
![ClassWithMembersAndTypesRecord_RecordTypeEnumeration](https://istack.dev59.com/ltyQm.webp)
05
代表一个ClassWithMembersAndTypes
记录。在 2.3.2.1 ClassWithMembersAndTypes
部分中说明:
ClassWithMembersAndTypes记录是最冗长的类记录之一。它包含有关成员的元数据,包括成员的名称和Remoting类型。它还包含引用类库名称的库ID。
它由以下内容组成:
- RecordTypeEnum(1字节)
- ClassInfo(可变字节数)
- MemberTypeInfo(可变字节数)
- LibraryId(4字节)
ClassInfo:
如 2.3.1.1 ClassInfo
中所述,该记录包含以下内容:
- ObjectId(4字节)
- Name(可变字节数(又是一个
LengthPrefixedString
))
- MemberCount(4字节)
- MemberNames(这是一系列
LengthPrefixedString
,其中项目数必须等于MemberCount
字段中指定的值。)
回到原始数据,一步一步:
![ClassWithMembersAndTypesRecord_RecordTypeEnumeration_ClassInfo_ObjectId](https://istack.dev59.com/DHnRc.webp)
"
01 00 00 00
"代表
ObjectId
。我们已经看到过这个,它被指定为
SerializationHeaderRecord
中的
RootId
。
![ClassWithMembersAndTypesRecord_RecordTypeEnumeration_ClassInfo_ObjectId_Name](https://istack.dev59.com/qSNL6.webp)
"
0F 53 74 61 63 6B 4F 76 65 72 46 6C 6F 77 2E 41
"代表使用
LengthPrefixedString
表示的类的
Name
。如前所述,在我们的示例中,字符串的长度由1字节定义,因此第一个字节
0F
指定必须读取和解码15个字节,使用UTF-8进行解码。结果看起来像这样:
StackOverFlow.A
- 因此显然我将
StackOverFlow
用作命名空间的名称。
![ClassWithMembersAndTypesRecord_RecordTypeEnumeration_ClassInfo_ObjectId_Name_MemberCount](https://istack.dev59.com/zVnF2.webp)
"
02 00 00 00
表示MemberCount
,它告诉我们有2个成员,两个都用LengthPrefixedString
表示,将会跟随。
第一个成员的名称:
![ClassWithMembersAndTypesRecord_MemberNameOne](https://istack.dev59.com/OOGko.webp)
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
表示第一个MemberName
,1B
再次是字符串的长度,长度为27个字节,结果类似于这样:<SomeString>k__BackingField
。
第二个成员的名称:
![ClassWithMembersAndTypesRecord_MemberNameTwo](https://istack.dev59.com/ImKn6.webp)
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
代表第二个MemberName
,其中1A
指定该字符串长度为26个字节。结果类似于这样:<SomeValue>k__BackingField
。
MemberTypeInfo:
ClassInfo
之后是MemberTypeInfo
。
第2.3.1.2节 - MemberTypeInfo
指出,该结构包含:
一个 BinaryTypeEnumeration 值序列,表示正在传输的成员类型。数组必须满足以下条件:
- 与 ClassInfo 结构的 MemberNames 字段具有相同数量的项。
- 排序方式应使 BinaryTypeEnumeration 对应于 ClassInfo 结构的 MemberNames 字段中的成员名称。
根据 BinaryTpeEnum 的不同,AdditionalInfos 的长度可能会有所不同。
| BinaryTypeEnum | AdditionalInfos |
|----------------+--------------------------|
| Primitive | PrimitiveTypeEnumeration |
| String | None |
因此,考虑到这一点,我们已经接近成功了......
我们期望有 2 个 BinaryTypeEnumeration 值(因为在 MemberNames 中有 2 个成员)。
再次回到完整的 MemberTypeInfo 记录的原始数据:
![ClassWithMembersAndTypesRecord_MemberTypeInfo](https://istack.dev59.com/2m5Ki.webp)
01
代表第一个成员的BinaryTypeEnumeration
,根据2.1.2.2 BinaryTypeEnumeration
,我们可以期望得到一个String
,并且它使用LengthPrefixedString
表示。
00
代表第二个成员的BinaryTypeEnumeration
,同样地,根据规范,它是一个Primitive
。如上所述,Primitive
后面跟着额外的信息,在这种情况下是一个PrimitiveTypeEnumeration
。这就是为什么我们需要读取下一个字节,即08
,将其与2.1.2.3 PrimitiveTypeEnumeration
中的表匹配,并惊讶地发现我们可以期望得到一个由4个字节表示的Int32
,正如其他关于基本数据类型的文档中所述。
LibraryId:
MemberTypeInfo
之后是LibraryId
,由4个字节表示:
![ClassWithMembersAndTypesRecord_LibraryId](https://istack.dev59.com/v3fST.webp)
02 00 00 00
代表的是LibraryId
,它的值为2。
数值:
如2.3类记录
中所述:
类成员的值必须作为记录序列化,遵循本节中指定的记录,如2.7节所述。记录的顺序必须与类信息(2.3.1.1节)结构中的MemberNames的顺序相匹配。
这就是为什么我们现在可以期望成员的值。
让我们看一下最后几个字节:
![BinaryObjectStringRecord_RecordTypeEnumeration](https://istack.dev59.com/YAAhY.webp)
06
代表一个BinaryObjectString
。它表示我们的SomeString
属性的值(确切地说是<SomeString>k__BackingField
)。
根据2.5.7 BinaryObjectString
,它包含:
- RecordTypeEnum(1个字节)
- ObjectId(4个字节)
- Value(可变长度,表示为
LengthPrefixedString
)
因此,了解这些,我们可以清楚地确定
![BinaryObjectStringRecord_RecordTypeEnumeration_ObjectId_MemberOneValue](https://istack.dev59.com/R2hXX.webp)
03 00 00 00
表示 ObjectId
。
03 61 62 63
表示 Value
,其中03
是字符串本身的长度,61 62 63
是对应的字节内容,可以解析为 abc
。
希望你还记得有第二个成员,一个 Int32
。由于知道 Int32
使用 4 个字节表示,我们可以得出结论:
![BinaryObjectStringRecord_RecordTypeEnumeration_ObjectId_MemberOneValue_MemberTwoValue](https://istack.dev59.com/fc7z4.webp)
必须是我们第二个成员的
Value
。十六进制的
7B
等于十进制的
123
,似乎符合我们的示例代码。
因此,这是完整的ClassWithMembersAndTypes
记录:
![ClassWithMembersAndTypesRecord_Complete](https://istack.dev59.com/Mh7gt.webp)
MessageEnd:
![MessageEnd_RecordTypeEnumeration](https://istack.dev59.com/gsYe3.webp)
最后一个字节
0B
表示
MessageEnd
记录。
Data_reSerialized.dat
是否能够进行反序列化,并报告它所产生的序列化大小;即Data_reReSerialized.dat
的大小是多少? - Mark Hurd