Поскольку для кого-то может быть интересно, что я решил сделать этот пост о Как выглядит двоичный формат сериализованных объектов .NET и как мы можем правильно его интерпретировать?
Все мои исследования основаны на спецификации .NET Remoting: структура данных двоичного формата .
Пример класса:
Чтобы иметь рабочий пример, я создал простой класс с именем 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) начинается с перечисления типа записи. Размер перечисления составляет один байт.
SerializationHeaderRecord:
Итак, если мы посмотрим на полученные данные, мы можем начать интерпретацию первого байта:
Как указано в 2.1.2.1 RecordTypeEnumeration
, значение 0
обозначает SerializationHeaderRecord
, указанное в 2.6.1 SerializationHeaderRecord
:
Запись SerializationHeaderRecord ДОЛЖНА быть первой записью в двоичной сериализации. Эта запись имеет основной и вспомогательный вариант формата, а также идентификаторы верхнего объекта и заголовков.
Состоит из:
- RecordTypeEnum (1 байт)
- RootId (4 байта)
- HeaderId (4 байта)
- MajorVersion (4 байта)
- MinorVersion (4 байта)
С этим знанием мы можем интерпретировать запись, содержащую 17 байтов:
00
представляет RecordTypeEnumeration
, что в нашем случае SerializationHeaderRecord
.
01 00 00 00
представляет RootId
Если ни запись BinaryMethodCall, ни запись BinaryMethodReturn отсутствуют в потоке сериализации, значение этого поля ДОЛЖНО содержать ObjectId записи Class, Array или BinaryObjectString, содержащейся в потоке сериализации.
Так что в нашем случае это должно быть ObjectId
со значением 1
(потому что данные сериализуются с использованием порядка байтов), которое мы надеемся увидеть снова; -)
FF FF FF FF
представляет HeaderId
01 00 00 00
представляет собой MajorVersion
00 00 00 00
представляет собой MinorVersion
BinaryLibrary:
Как указано, каждая запись должна начинаться с RecordTypeEnumeration
. Поскольку последняя запись завершена, мы должны предположить, что начинается новая.
Давайте интерпретируем следующий байт:
Как мы видим, в нашем примере за SerializationHeaderRecord
следует запись BinaryLibrary
:
Запись BinaryLibrary связывает идентификатор INT32 (как указано в разделе [MS-DTYP] 2.2.22) с именем библиотеки. Это позволяет другим записям ссылаться на имя библиотеки с помощью идентификатора. Этот подход уменьшает размер провода, когда есть несколько записей, которые ссылаются на одно и то же имя библиотеки.
Состоит из:
- 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 является наиболее подробной из записей класса. Он содержит метаданные об Участниках, включая имена и Типы Удаленного взаимодействия Участников. Он также содержит идентификатор библиотеки, который ссылается на имя библиотеки класса.
Состоит из:
- RecordTypeEnum (1 байт)
- ClassInfo (переменное число байтов)
- MemberTypeInfo (переменное число байтов)
- LibraryId (4 байта)
ClassInfo:
Как указано в 2.3.1.1 ClassInfo
запись состоит из:
- ObjectId (4 байта)
- Имя (переменное число байтов (что опять-таки
LengthPrefixedString
))
- MemberCount (4 байта)
- MemberNames (это последовательность
LengthPrefixedString
, в которой количество элементов ДОЛЖНО быть равно значению, указанному в поле MemberCount
.)
Вернуться к необработанным данным, шаг за шагом:
01 00 00 00
представляет ObjectId
. Мы уже видели этот, он был указан как RootId
в SerializationHeaderRecord
.
0F 53 74 61 63 6B 4F 76 65 72 46 6C 6F 77 2E 41
представляет Name
класса, который представлен с помощью LengthPrefixedString
. Как уже упоминалось, в нашем примере длина строки определяется 1 байтом, поэтому первый байт 0F
указывает, что 15 байтов должны быть прочитаны и декодированы с использованием UTF-8. Результат выглядит примерно так: 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
.
MemberTypeInfo:
После ClassInfo
следует MemberTypeInfo
.
Раздел 2.3.1.2 - MemberTypeInfo
утверждает, что структура содержит:
- BinaryTypeEnums (переменной длины)
Последовательность значений BinaryTypeEnumeration, представляющая типы элементов, которые передаются. Массив ДОЛЖЕН:
Содержат то же количество элементов, что и поле MemberNames структуры ClassInfo.
Заказывается так, чтобы BinaryTypeEnumeration соответствовал имени члена в поле MemberNames структуры ClassInfo.
- 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
, и с удивлением заметить, что мы можем ожидать Int32
, который представлен 4 байтами, как указано в некоторых другой документ об основных типах данных.
LibraryId:
После MemerTypeInfo
следует LibraryId
, он представлен 4 байтами:
02 00 00 00
представляет LibraryId
, что составляет 2.
Значения:
Как указано в 2.3 Class Records
:
Значения членов класса ДОЛЖНЫ быть сериализованы в виде записей, следующих за этой записью, как указано в разделе 2.7. Порядок записей ДОЛЖЕН соответствовать порядку MemberNames, указанному в структуре ClassInfo (раздел 2.3.1.1).
Вот почему мы можем теперь ожидать значения членов.
Давайте посмотрим на последние несколько байтов:
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
:
MessageEnd:
Наконец, последний байт 0B
представляет запись MessageEnd
.