Как анализировать содержимое потока двоичной сериализации?
Я использую двоичную сериализацию (BinaryFormatter) в качестве временного механизма для хранения информации о состоянии в файле для относительно сложной (игровой) структуры объекта; файлы выходят намного больше, чем я ожидаю, и моя структура данных включает в себя рекурсивные ссылки - поэтому мне интересно, действительно ли BinaryFormatter хранит несколько копий одних и тех же объектов или мое основное количество объектов и значений должно быть "arithmentic - это не совсем базовый уровень, или откуда исходит чрезмерный размер.
При поиске переполнения стека мне удалось найти спецификацию для формата двоичного удаленного взаимодействия Microsoft: http://msdn.microsoft.com/en-us/library/cc236844(PROT.10).aspx
Чего я не могу найти, так это какого-либо существующего средства просмотра, которое позволяет вам "заглянуть" в содержимое выходного файла бинарного форматера - получить количество объектов и общее количество байтов для различных типов объектов в файле и т. Д.;
Я чувствую, что это, должно быть, моя "гугл-фу" подвела меня (что мало у меня есть) - кто-нибудь может помочь? Это должно быть сделано раньше, верно?
ОБНОВЛЕНИЕ: я не мог найти это и не получил ответов, поэтому я собрал что-то относительно быстрое (ссылка на загружаемый проект ниже); Я могу подтвердить, что BinaryFormatter не хранит несколько копий одного и того же объекта, но он выводит довольно много метаданных в поток. Если вам нужно эффективное хранилище, создайте свои собственные методы сериализации.
4 ответа
Потому что это может заинтересовать кого-то, кого я решил сделать в этой статье о том, как выглядит двоичный формат сериализованных объектов.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 ID (как указано в разделе [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
значения (потому что у нас было 2 члена в MemberNames
).
Опять же, вернемся к необработанным данным полного 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
запись.
Василий прав в том, что в конечном итоге мне нужно будет реализовать собственный процесс форматирования / сериализации, чтобы лучше справляться с управлением версиями и выводить намного более компактный поток (до сжатия).
Однако я хотел понять, что происходит в потоке, поэтому я написал (относительно) быстрый класс, который делает то, что я хотел:
- анализирует свой путь в потоке, собирая коллекции имен объектов, количества и размеров
- после этого выводит краткое изложение того, что он нашел - классы, количество и общие размеры в потоке
Для меня недостаточно полезно размещать его где-то видимым, как codeproject, поэтому я просто поместил проект в zip-файл на моем веб-сайте: http://www.architectshack.com/BinarySerializationAnalysis.ashx
В моем конкретном случае выясняется, что проблема была двоякой:
- BinaryFormatter ОЧЕНЬ многословен (это известно, я просто не осознавал степень)
- У меня были проблемы в классе, оказалось, что я храню объекты, которые мне не нужны
Надеюсь, это поможет кому-то в какой-то момент!
Обновление: Ян Райт связался со мной с проблемой с исходным кодом, где он падал, когда исходные объекты содержали "десятичные" значения. Теперь это исправлено, и я воспользовался случаем, чтобы переместить код на GitHub и дать ему (разрешительную, BSD) лицензию.
Наше приложение оперирует массивными данными. Это может занять до 1-2 ГБ оперативной памяти, как ваша игра. Мы столкнулись с той же проблемой "хранения нескольких копий одних и тех же объектов". Также двоичная сериализация хранит слишком много метаданных. Когда он был впервые реализован, сериализованный файл занял около 1-2 ГБ. В настоящее время мне удалось уменьшить значение - 50-100 МБ. Что мы сделали.
Краткий ответ - не используйте двоичную сериализацию.Net, создайте свой собственный механизм двоичной сериализации. У нас есть собственный класс BinaryFormatter и интерфейс ISerializable (с двумя методами Serialize, Deserialize).
Один и тот же объект не должен быть сериализован более одного раза. Мы сохраняем его уникальный идентификатор и восстанавливаем объект из кеша.
Я могу поделиться кодом, если вы спросите.
РЕДАКТИРОВАТЬ: Кажется, вы правы. Посмотрите следующий код - это доказывает, что я был неправ.
[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" из примера кода, вы увидите, что в нем много метаданных. Вот почему я рекомендовал вам создать собственный механизм сериализации.
Извините за то, что сочинял.
Возможно, вы могли бы запустить вашу программу в режиме отладки и попробовать добавить контрольную точку.
Если это невозможно из-за размера игры или других зависимостей, вы всегда можете написать простое / маленькое приложение, содержащее код десериализации и посмотреть там режим отладки.