Как анализировать содержимое потока двоичной сериализации? - PullRequest
31 голосов
/ 16 июня 2010

Я использую двоичную сериализацию (BinaryFormatter) в качестве временного механизма для хранения информации о состоянии в файле для относительно сложной (игровой) структуры объекта;файлы выходят на намного * на 1002 * больше, чем я ожидаю, и моя структура данных включает в себя рекурсивные ссылки - поэтому мне интересно, действительно ли BinaryFormatter хранит несколько копий одних и тех же объектов или мое основное "число"«Объекты и значения, которые у меня должны быть», «arithmentic - это не совсем базовый формат, или откуда еще слишком большой размер»http://msdn.microsoft.com/en-us/library/cc236844(PROT.10).aspx

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

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


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

Ответы [ 4 ]

78 голосов
/ 11 мая 2015

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



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

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

Example result data



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

В соответствии с вышеупомянутой спецификацией (вот прямая ссылка на 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 байта)
  • 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 (потому что данные сериализуются с использованием порядка байтов), которое мы надеемся увидеть снова; -)

FF FF FF FF представляет HeaderId

01 00 00 00 представляет собой MajorVersion

00 00 00 00 представляет собой MinorVersion



BinaryLibrary:

Как указано, каждая запись должна начинаться с RecordTypeEnumeration. Поскольку последняя запись завершена, мы должны предположить, что начинается новая.

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

BinaryLibraryRecord_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. Обладая этим знанием, мы можем продолжить интерпретацию байтов в потоке:

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



ClassWithMembersAndTypes:

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

ClassWithMembersAndTypesRecord_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.)



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

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 значения (потому что в MemberNames было 2 члена).



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



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.

7 голосов
/ 19 июня 2010

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

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

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

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

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

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

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


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

5 голосов
/ 16 июня 2010

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

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

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

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

РЕДАКТИРОВАТЬ: Кажется, вы правы.Посмотрите следующий код - это доказывает, что я был неправ.

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

Извините за смех.

0 голосов
/ 16 июня 2010

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

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

...