Как анализировать содержимое потока бинарной сериализации?

Я использую двоичную сериализацию (BinaryFormatter) в качестве временного механизма для хранения информации о состоянии в файле относительно сложной (игровой) структуры объекта; файлы выходят намного больше, чем я ожидаю, и моя структура данных включает рекурсивные ссылки, поэтому мне интересно, действительно ли BinaryFormatter хранит несколько копий одних и тех же объектов, или мое базовое "число объектов и значений, которые я должен иметь», арифметика слишком неправильная, или откуда еще берется чрезмерный размер.

При поиске переполнения стека мне удалось найти спецификацию двоичного формата удаленного взаимодействия Microsoft: http://msdn.microsoft.com/en-us/library/cc236844(PROT.10).aspx

Чего я не могу найти, так это какого-либо существующего средства просмотра, которое позволяет вам «заглянуть» в содержимое выходного файла двоичного форматирования - получить количество объектов и общее количество байтов для различных типов объектов в файле и т. д.;

Я чувствую, что это, должно быть, мое "гугл-фу" подводит меня (то немногое, что у меня есть) - кто-нибудь может помочь? Это должно быть уже сделано раньше, верно??


ОБНОВЛЕНИЕ: я не смог найти его и не получил ответов, поэтому я собрал что-то относительно быстро (ссылка на загружаемый проект ниже); Я могу подтвердить, что BinaryFormatter не хранит несколько копий одного и того же объекта, но выводит в поток довольно много метаданных. Если вам нужно эффективное хранилище, создайте собственные методы сериализации.


person Tao    schedule 16.06.2010    source источник


Ответы (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:

Итак, если мы вернемся к полученным данным, мы можем начать интерпретировать первый байт:

SerializationHeaderRecord_RecordTypeEnumeration

Как указано в 2.1.2.1 RecordTypeEnumeration, значение 0 идентифицирует SerializationHeaderRecord, указанное в 2.6.1 SerializationHeaderRecord:

Запись SerializationHeaderRecord ДОЛЖНА быть первой записью в двоичной сериализации. Эта запись имеет основную и дополнительную версии формата и идентификаторы верхнего объекта и заголовков.

Это состоит из:

  • RecordTypeEnum (1 байт)
  • RootId (4 байта)
  • Заголовок (4 байта)
  • Основная версия (4 байта)
  • Младшая версия (4 байта)



Зная это, мы можем интерпретировать запись, содержащую 17 байтов:

SerializationHeaderRecord_Complete

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. Поскольку последняя запись завершена, мы должны считать, что начинается новая.

Давайте интерпретируем следующий байт:

BinaryLibraryRecord_RecordTypeEnumeration

Как видим, в нашем примере за SerializationHeaderRecord следует запись BinaryLibrary:

Запись BinaryLibrary связывает идентификатор INT32 (как указано в разделе 2.2.22 [MS-DTYP]) с именем библиотеки. Это позволяет другим записям ссылаться на имя библиотеки с помощью идентификатора. Этот подход уменьшает размер проводника при наличии нескольких записей, ссылающихся на одно и то же имя библиотеки.

Это состоит из:

  • RecordTypeEnum (1 байт)
  • ID библиотеки (4 байта)
  • LibraryName (переменное количество байтов (то есть LengthPrefixedString))



Как указано в 2.1.1.6 LengthPrefixedString...

LengthPrefixedString представляет строковое значение. Строка имеет префикс длины строки в кодировке UTF-8 в байтах. Длина кодируется в поле переменной длины минимум 1 байт и максимум 5 байт. Чтобы минимизировать размер провода, длина кодируется как поле переменной длины.

В нашем простом примере длина всегда кодируется с помощью 1 byte. Зная это, мы можем продолжить интерпретацию байтов в потоке:

BinaryLibraryRecord_RecordTypeEnumeration_LibraryId

0C представляет RecordTypeEnumeration, который идентифицирует запись BinaryLibrary.

02 00 00 00 представляет LibraryId, которое в нашем случае равно 2.



Теперь LengthPrefixedString следует:

BinaryLibraryRecord_RecordTypeEnumeration_LibraryId_LibraryName

42 представляет информацию о длине LengthPrefixedString, которая содержит LibraryName.

В нашем случае информация о длине 42 (десятичное число 66) говорит нам, что нам нужно прочитать следующие 66 байт и интерпретировать их как LibraryName.

Как уже было сказано, строка имеет кодировку UTF-8, поэтому результат приведенных выше байтов будет примерно таким: _WorkSpace_, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null



Класс с членами и типами:

Опять же, запись завершена, поэтому мы интерпретируем RecordTypeEnumeration следующей записи:

ClassWithMembersAndTypesRecord_RecordTypeEnumeration

05 идентифицирует запись ClassWithMembersAndTypes. Раздел 2.3.2.1 ClassWithMembersAndTypes гласит:

Запись ClassWithMembersAndTypes является самой подробной из записей Class. Он содержит метаданные об участниках, включая имена и типы удаленного взаимодействия участников. Он также содержит идентификатор библиотеки, который ссылается на имя библиотеки класса.

Это состоит из:

  • RecordTypeEnum (1 байт)
  • ClassInfo (переменное количество байтов)
  • MemberTypeInfo (переменное количество байтов)
  • ID библиотеки (4 байта)



Информация о классе:

Как указано в 2.3.1.1 ClassInfo, запись состоит из:

  • Идентификатор объекта (4 байта)
  • Имя (переменное количество байтов (что опять же LengthPrefixedString))
  • Количество участников (4 байта)
  • MemberNames (это последовательность LengthPrefixedString, где количество элементов ДОЛЖНО быть равно значению, указанному в поле MemberCount).



Вернемся к необработанным данным, шаг за шагом:

ClassWithMembersAndTypesRecord_RecordTypeEnumeration_ClassInfo_ObjectId

01 00 00 00 представляет ObjectId. Мы уже видели это, оно было указано как RootId в SerializationHeaderRecord.

ClassWithMembersAndTypesRecord_RecordTypeEnumeration_ClassInfo_ObjectId_Name

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 в качестве имени пространства имен.

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 представляет собой первое MemberName, 1B снова представляет собой длину строки, которая составляет 27 байтов, что приводит к чему-то вроде этого: <SomeString>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 представляет второе 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:

ClassWithMembersAndTypesRecord_MemberTypeInfo

01 представляет BinaryTypeEnumeration первого члена, согласно 2.1.2.2 BinaryTypeEnumeration мы можем ожидать String, и он представлен с использованием LengthPrefixedString.

00 представляет собой BinaryTypeEnumeration второго члена, и опять же, согласно спецификации, это Primitive. Как указано выше, за Primitive следует дополнительная информация, в данном случае PrimitiveTypeEnumeration. Вот почему нам нужно прочитать следующий байт, который равен 08, сопоставить его с таблицей, указанной в 2.1.2.3 PrimitiveTypeEnumeration, и с удивлением заметить, что мы можем ожидать Int32, который представлен 4 байтами, как указано в каком-то другом документе об основных типах данных.



Идентификатор библиотеки:

После MemerTypeInfo следует LibraryId, он представлен 4 байтами:

ClassWithMembersAndTypesRecord_LibraryId

02 00 00 00 представляет LibraryId, равное 2.



Значения:

Как указано в 2.3 Class Records:

Значения элементов класса ДОЛЖНЫ быть сериализованы в виде записей, следующих за этой записью, как указано в разделе 2.7. Порядок записей ДОЛЖЕН соответствовать порядку MemberNames, указанному в структуре ClassInfo (раздел 2.3.1.1).

Вот почему теперь мы можем ожидать значения членов.

Давайте посмотрим на последние несколько байтов:

BinaryObjectStringRecord_RecordTypeEnumeration

06 определяет BinaryObjectString. Он представляет значение нашего свойства SomeString (точнее, <SomeString>k__BackingField).

Согласно 2.5.7 BinaryObjectString содержит:

  • RecordTypeEnum (1 байт)
  • Идентификатор объекта (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

должно быть Value нашего второго члена. 7B в шестнадцатеричном формате равно 123 в десятичном, что, похоже, соответствует коду нашего примера.

Итак, вот полная запись ClassWithMembersAndTypes: ClassWithMembersAndTypesRecord_Complete



Конец сообщения:

MessageEnd_RecordTypeEnumeration

Наконец, последний байт 0B представляет запись MessageEnd.

person Markus Safar    schedule 11.05.2015
comment
Боже, эта информация была для меня на вес золота! БОЛЬШОЕ спасибо Маркусу за всю тяжелую работу! - person Gediminas; 19.11.2015
comment
Жалко, что после всей этой работы в другом посте есть ответ на этот вопрос :) - person Mark; 12.08.2016
comment
Это, вероятно, лучший ответ по теме, который я когда-либо видел в Интернете. Как жаль, что это не было отмечено как решение. - person Dominik Szymański; 30.10.2017
comment
Фиксированный! Извините, этот ответ не появился 2 года назад (почти 5 лет после исходного ответа :)) - person Tao; 22.03.2018
comment
Классное объяснение! - person Klyuch; 02.05.2021

Василий прав в том, что мне в конечном итоге нужно будет реализовать свой собственный процесс форматирования/сериализации, чтобы лучше обрабатывать версии и выводить гораздо более компактный поток (до сжатия).

Однако я хотел понять, что происходит в потоке, поэтому я написал (относительно) быстрый класс, который делает то, что я хотел:

  • анализирует свой путь через поток, создавая коллекции имен объектов, количества и размеров
  • после этого выводит краткую сводку того, что он нашел - классы, количество и общие размеры в потоке

Мне недостаточно удобно размещать его где-нибудь на видном месте, например, codeproject, поэтому я просто закинул проект в zip-файл на свой веб-сайт: http://www.architectshack.com/BinarySerializationAnalysis.ashx

В моем конкретном случае оказывается, что проблема была двоякой:

  • BinaryFormatter ОЧЕНЬ многословен (это известно, я просто не осознавал степени)
  • У меня действительно были проблемы в моем классе, оказалось, что я хранил объекты, которые мне не нужны

Надеюсь, это поможет кому-то в какой-то момент!


Обновление: Ян Райт связался со мной по поводу проблемы с исходным кодом, когда он аварийно завершал работу, когда исходный объект(ы) содержал "десятичные" значения. Теперь это исправлено, и я воспользовался случаем, чтобы переместить код на GitHub и дать ему (разрешительную, BSD) лицензию.

person Tao    schedule 19.06.2010
comment
Сделайте все свои перечисления байтами (public MyEnumName : byte) — вы сэкономите еще немного места. - person Vasyl Boroviak; 20.06.2010

Наше приложение оперирует массивными данными. Это может занять до 1-2 ГБ ОЗУ, как и ваша игра. Мы столкнулись с той же проблемой «хранения нескольких копий одних и тех же объектов». Также бинарная сериализация хранит слишком много метаданных. Когда это было впервые реализовано, сериализованный файл занимал около 1-2 ГБ. В настоящее время мне удалось уменьшить значение - 50-100 МБ. Что мы сделали.

Короткий ответ: не используйте двоичную сериализацию .Net, создайте свой собственный механизм двоичной сериализации. У нас есть собственный класс BinaryFormatter и интерфейс ISerializable (с двумя методами Serialize, Deserialize).

Один и тот же объект не должен сериализоваться более одного раза. Мы сохраняем его уникальный идентификатор и восстанавливаем объект из кеша.

Я могу поделиться некоторым кодом, если вы спросите.

EDIT: Кажется, вы правы. Смотрите следующий код - он доказывает, что я ошибался.

[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» из примера кода, вы увидите, что в нем много метаданных. Вот почему я рекомендовал вам создать собственный механизм сериализации.

Извините за путаницу.

person Vasyl Boroviak    schedule 16.06.2010
comment
спасибо - в конечном итоге я буду использовать сериализацию XML (вероятно), потому что удобочитаемость для человека очень важна для меня, но на самом деле моя цель/вопрос сейчас заключается в том, как понять, что на самом деле хранит класс BinaryFormatter, чтобы я мог определить, следует ли сосредоточиться при реализации моей собственной сериализации ИЛИ решить какую-либо другую проблему дизайна в самой структуре данных. Я хочу знать, что в файле! :) - person Tao; 16.06.2010
comment
Конечно, BinaryFormatter хранит несколько копий одних и тех же объектов. Я проверил это некоторое время назад. Это действительно так. - person Vasyl Boroviak; 16.06.2010
comment
Справедливо; Я продержусь пару дней, чтобы посмотреть, найдет ли кто-нибудь способ просмотра статистики содержимого потока, как я и предполагал, и в противном случае поищу что-нибудь построить. - person Tao; 16.06.2010
comment
Извините, еще одно замечание по этой теме - насколько я мог судить (в иерархической структуре с обратными ссылками, несколько тысяч объектов различных типов, в сумме сериализованных до 10 МБ), BinaryFormatter НЕ хранит несколько копий одного и того же объекта; Было бы интересно увидеть доказательства обратного... - person Tao; 20.06.2010
comment
Удивительный! Спасибо за экономию времени. Я пришел сюда, так как мне нужно было десериализовать объекты из другого NS. Я создал тот же объект в своей сборке, и с помощью этой записи я смог просто заменить заголовки внешних байтов внутренним фиктивным объектом. getHeaderLen(byte[] b) { var x = 28 + b[22]; var y = x + b[x] + 5; return y + b[y] + 8; - person nullable; 27.03.2020

Возможно, вы могли бы запустить свою программу в режиме отладки и попробовать добавить контрольную точку.

Если это невозможно из-за размера игры или других зависимостей, вы всегда можете создать простое/небольшое приложение, включающее код десериализации, и заглянуть туда из режима отладки.

person Juan Nunez    schedule 16.06.2010
comment
Извините, я не понимаю, что вы имеете в виду... Я могу отладить и посмотреть на полученный десериализованный объект в VS, но это ничего не говорит мне о том, сколько объектов какого типа было в сериализованном потоке, что было продублировано и т. д. (или нет? Я что-то упустил?) - person Tao; 16.06.2010
comment
Я хочу сказать, что если вы можете отлаживать, вы можете увидеть точное состояние потока сериализации, объект, переданный для сериализации, и его содержимое. - person Juan Nunez; 16.06.2010
comment
Извините, если я здесь скучен или упускаю какую-то важную функцию VS, но, поскольку у меня нет доступа к внутренностям BinaryFormatter, добавление точки останова просто позволит увидеть поток, а затем увидеть десериализованный объект; конечно, у меня есть содержимое потока в виде файла, и у меня был объект еще до того, как я его сериализовал, так что это не дает мне ничего полезного. Моя проблема заключается в том, что я не могу исследовать сам объект через VS IDE, я хотел бы понять структуру данных потока и использование пространства внутри него. - person Tao; 16.06.2010
comment
Извините, я действительно неправильно понял ваш вопрос. Я предполагаю, что нет такой важной функции. Виноват. - person Juan Nunez; 16.06.2010