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

Я использую двоичную сериализацию (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:

Поэтому, если мы посмотрим на полученные данные, мы можем начать интерпретацию первого байта:

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



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 запись.

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

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

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

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

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

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

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

Другие вопросы по тегам