简答:
我真的建议不要自己实现二进制表示的解释。我会改用另一种格式(JSON、XML 等)。
长答案:
但是,如果这是不可能的,当然有办法......
实际的问题是:序列化的 .NET 对象的二进制格式是什么样的,我们如何正确解释它?
我的所有研究都基于.NET Remoting: Binary Format Data Structure 规范。
示例类:
为了有一个工作示例,我创建了一个名为 A 的简单类,其中包含 2 个属性,一个字符串和一个整数值,它们分别称为 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 作为值。
结果数据示例:
如果我们在十六进制编辑器中查看序列化结果,我们会得到如下内容:
让我们解读示例结果数据:
根据上述规范(这里是 PDF 的直接链接:[MS-NRBF].pdf),流中的每条记录都由RecordTypeEnumeration 标识。 2.1.2.1 RecordTypeNumeration 部分声明:
此枚举标识记录的类型。每条记录(MemberPrimitiveUnTyped 除外)都以记录类型枚举开头。枚举的大小为 1 BYTE。
SerializationHeaderRecord:
所以如果我们回顾一下我们得到的数据,我们就可以开始解释第一个字节了:
如2.1.2.1 RecordTypeEnumeration 中所述,0 的值标识在2.6.1 SerializationHeaderRecord 中指定的SerializationHeaderRecord:
SerializationHeaderRecord 记录必须是二进制序列化中的第一条记录。此记录具有格式的主要版本和次要版本以及顶部对象和标题的 ID。
它包括:
- RecordTypeEnum(1 字节)
- RootId(4 字节)
- HeaderId(4 字节)
- 主要版本(4 个字节)
- 次要版本(4 个字节)
有了这些知识,我们就可以解释包含 17 个字节的记录:
00 代表RecordTypeEnumeration,在我们的例子中是SerializationHeaderRecord。
01 00 00 00代表RootId
如果序列化流中既没有 BinaryMethodCall 也没有 BinaryMethodReturn 记录,则该字段的值必须包含序列化流中包含的 Class、Array 或 BinaryObjectString 记录的 ObjectId。
所以在我们的例子中,这应该是 ObjectId 的值 1(因为数据是使用 little-endian 进行序列化的),我们希望能再次看到它;-)
FF FF FF FF 代表HeaderId
01 00 00 00 代表MajorVersion
00 00 00 00 代表MinorVersion
二进制库:
按照规定,每条记录必须以RecordTypeEnumeration 开头。当最后一个记录完成时,我们必须假设一个新的记录开始了。
让我们解释下一个字节:
如我们所见,在我们的示例中,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 byte 编码。有了这些知识,我们可以继续解释流中的字节:
0C 代表RecordTypeEnumeration,它标识了BinaryLibrary 记录。
02 00 00 00 代表LibraryId,在我们的例子中是2。
现在LengthPrefixedString 如下:
42代表LengthPrefixedString的长度信息,其中包含LibraryName。
在我们的例子中,42(十进制 66)的长度信息告诉我们,我们需要读取接下来的 66 个字节并将它们解释为LibraryName。
如前所述,字符串是UTF-8 编码的,因此上面字节的结果将类似于:_WorkSpace_, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
ClassWithMembersAndTypes:
再一次,记录是完整的,所以我们解释下一个的RecordTypeEnumeration:
05 标识ClassWithMembersAndTypes 记录。 2.3.2.1 ClassWithMembersAndTypes 部分声明:
ClassWithMembersAndTypes 记录是最详细的 Class 记录。它包含有关成员的元数据,包括成员的名称和远程处理类型。它还包含一个引用类的库名称的库 ID。
它包括:
- RecordTypeEnum(1 字节)
- ClassInfo(可变字节数)
- MemberTypeInfo(可变字节数)
- LibraryId(4 字节)
类信息:
如2.3.1.1 ClassInfo 中所述,记录包括:
- ObjectId(4 字节)
- 名称(可变字节数(同样是
LengthPrefixedString))
- 成员计数(4 字节)
- MemberNames(这是
LengthPrefixedString 的序列,其中项目数必须等于MemberCount 字段中指定的值。)
回到原始数据,一步一步来:
01 00 00 00 代表ObjectId。我们已经看到了这个,它在SerializationHeaderRecord 中被指定为RootId。
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 作为命名空间的名称。
02 00 00 00 代表MemberCount,它告诉我们有 2 个成员,都以 LengthPrefixedString 代表将跟随。
第一位成员姓名:
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。
第二位成员姓名:
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。
会员类型信息:
ClassInfo 之后是MemberTypeInfo。
2.3.1.2 - MemberTypeInfo 部分指出,该结构包含:
表示正在传输的成员类型的 BinaryTypeEnumeration 值序列。数组必须:
- AdditionalInfos(长度可变),取决于
BinaryTpeEnum 附加信息可能存在也可能不存在。
| BinaryTypeEnum | AdditionalInfos |
|----------------+--------------------------|
| Primitive | PrimitiveTypeEnumeration |
| String | None |
所以考虑到这一点,我们几乎就在那里......
我们期望 2 个 BinaryTypeEnumeration 值(因为我们在 MemberNames 中有 2 个成员)。
再次回到完整的 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之后跟LibraryId,用4个字节表示:
02 00 00 00 代表LibraryId,即 2。
价值观:
如2.3 Class Records中指定:
类成员的值必须序列化为该记录之后的记录,如第 2.7 节所述。记录的顺序必须与 ClassInfo(第 2.3.1.1 节)结构中指定的 MemberNames 的顺序相匹配。
这就是为什么我们现在可以期待成员的价值。
让我们看看最后几个字节:
06 标识一个BinaryObjectString。它代表我们SomeString 属性的值(准确地说是<SomeString>k__BackingField)。
根据2.5.7 BinaryObjectString,它包含:
- RecordTypeEnum(1 字节)
- ObjectId(4 字节)
- 值(可变长度,表示为
LengthPrefixedString)
所以知道了这一点,我们就可以清楚地识别出这一点
03 00 00 00 代表ObjectId。
03 61 62 63 表示Value,其中03 是字符串本身的长度,61 62 63 是转换为abc 的内容字节。
希望您能记住还有第二个成员,Int32。知道Int32用4个字节表示,我们可以得出结论,
必须是我们第二个成员的Value。 7B 十六进制等于 123 十进制,这似乎符合我们的示例代码。
这是完整的ClassWithMembersAndTypes 记录:
消息结束:
最后一个字节0B代表MessageEnd记录。