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

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

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

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

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


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

Ответ 1

Потому что это может быть интересно для кого-то, я решил сделать это сообщение о . Как выглядит двоичный формат сериализованных .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 в качестве значений.



Примеры данных результата:

Если мы посмотрим на сериализованный результат в шестнадцатеричном редакторе, мы получим что-то вроде этого:

Example result data



Давайте интерпретировать данные результата примера:

В соответствии с вышеупомянутой спецификацией (вот прямая ссылка на PDF: [MS-NRBF].pdf) каждая запись в потоке идентифицируется RecordTypeEnumeration. Раздел 2.1.2.1 RecordTypeNumeration гласит:

Это перечисление идентифицирует тип записи. Каждая запись (кроме MemberPrimitiveUnTyped) начинается с перечисления типа записи. Размер перечисления - один BYTE.



SerializationHeaderRecord:

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

SerializationHeaderRecord_RecordTypeEnumeration

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

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

Он состоит из:

  • RecordTypeEnum (1 байт)
  • RootId (4 байта)
  • HeaderId (4 байта)
  • MajorVersion (4 байта)
  • MinorVersion (4 байта)



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

SerializationHeaderRecord_Complete

00 представляет RecordTypeEnumeration, который SerializationHeaderRecord в нашем случае.

01 00 00 00 представляет RootId

Если в потоке сериализации не присутствует ни BinaryMethodCall, ни BinaryMethodReturn, значение этого поля ДОЛЖНО содержать ObjectId записи Class, Array или BinaryObjectString, содержащейся в потоке сериализации.

Итак, в нашем случае это должно быть ObjectId со значением 1 (потому что данные сериализуются с использованием little-endian), которые мы будем надеяться увидеть снова; -)

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 байт)
  • LibraryId (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 (decimal 66) сообщает нам, что нам нужно прочитать следующие 66 байтов и интерпретировать их как LibraryName.

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



ClassWithMembersAndTypes:

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

ClassWithMembersAndTypesRecord_RecordTypeEnumeration

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

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

Он состоит из:

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



ClassInfo:

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

  • ObjectId (4 байта)
  • Имя (переменное число байтов (опять-таки LengthPrefixedString))
  • MemberCount (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.

Имя второго члена: <Т411 >

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.

  • ДополнительноInfos (переменная по длине), в зависимости от BinaryTpeEnum дополнительная информация может быть или не быть.

| BinaryTypeEnum | AdditionalInfos |
|----------------+--------------------------|
| Primitive | PrimitiveTypeEnumeration |
| String | None |

Поэтому, учитывая это, мы почти там... Мы ожидаем 2 BinaryTypeEnumeration значений (потому что в MemberNames было 2 члена).



Снова вернемся к исходным данным полной записи MemberTypeInfo:

ClassWithMembersAndTypesRecord_MemberTypeInfo

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

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



LibraryId:

После 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 байт)
  • ObjectId (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:

MessageEnd_RecordTypeEnumeration

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

Ответ 2

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

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

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

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

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

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

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


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

Ответ 3

Наше приложение использует массивные данные. Он может занимать до 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" из примера кода, вы увидите, что у него много метаданных. Поэтому я рекомендовал вам создать собственный механизм сериализации.

Извините за то, что вы созрели.

Ответ 4

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

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