Оптимизация сериализации в .NET - Как это работает (полный вариант)

ОГЛАВЛЕНИЕ

Как это работает (полный вариант)

Методы Write(xxx) сохраняют данные, используя нормальный размер для типа, поэтому Int32 всегда занимает 4 байта, а Double всегда занимает 8 байтов и т.д. Методы WriteOptimized(xxx) используют другой метод сохранения, который зависит не только от типа, но также и от его значения. Поэтому Int32, который меньше 128, может быть сохранен в единственный байт (с помощью 7-битного кодирования), но значения Int32WriteOptimized(). 268,435,456 или больше, или отрицательные числа нельзя сохранить с помощью этого метода (иначе они бы заняли 5 байтов, поэтому это нельзя было бы считать оптимизацией), но если вы хотите сохранить значение, например число элементов в списке, которое никогда не будет отрицательным и никогда не достигнет предела, то в этом случае можно использовать метод

DateTime – это другой тип, который имеет метод оптимизации. Его ограничения в том, что он не может оптимизировать значение DateTime с большей точностью, чем до миллисекунд, но в общем случае, когда дата сохраняется без времени, она займет не больше 3 байтов в потоке. (DateTime с hh:mm, не имеющий секунд, займет 5 байтов – все еще намного лучше, чем 8 байтов, занимаемые методом Write(DateTime).) Метод WriteObject() использует перечисление SerializedType, чтобы описать тип объекта, следующего в потоке. Перечисление определяется как byte, что дает нам 256 возможных значений. Каждый базовый тип принимает одно из этих значений, но 256 – это довольно много, поэтому ряд из них следует применить для 'кодирования' известных значений в их типе. Так что каждый числовой тип также имеет версию 'ноль' (некоторые имеют  также версию 'минус один'), что позволяет задавать тип и значение с помощью всего лишь одного байта. Это дает возможность сериализовать в компактном виде объекты, содержащие много данных, тип которых неизвестен во время компиляции.

Так как строки используются в значительной степени, нужно обеспечить, чтобы они всегда были оптимизированы (строки не имеют перегруженного метода WriteOptimized(string), потому что они всегда оптимизированы!). С этой целью мы выделили 128 из 256 значений для использования строками – фактически списками строк. Это позволяет записать любую строку using с помощью метки списка строк (состоящего из byte плюс оптимизированный Int32), чтобы убедиться, что значение данной строки записано один и только один раз – если строка встречается несколько раз, то только метка списка строк сохраняется несколько раз. Если мы сделаем доступными 128 списков строк, каждый из которых имеет мало строк, вместо одного списка строк, содержащего много строк, то метка строки будет занимать 1 байт для первых 16,256 уникальных строк, соответственно можно занять 2 байта для последующих 2,080,768 уникальных строк! Этого должно быть достаточно в любом случае! Особое внимание было уделено генерации нового списка, как только текущий список достигнет 127 в размере, чтобы воспользоваться преимуществами метки строки меньшего размера – как только все 128 возможных списков созданы, им назначаются строки методом циклического перебора. Строки помечаются, только если они длиннее двух символов - Null, Empty, 'Y', 'N', или один пробел имеют свои собственные SerializedTypeCode, а другие 1-символьные строки займут 2 байта (1 для типа, и 1 для самого символа).

Другое большое преимущество использования меток строки в том, что во время десериализации в памяти сохраняется только один экземпляр данной строки. Хотя сериализатор .NET делает то же самое для ссылки на ту же самую строку, он не делает этого в случае, когда ссылки разные, но значение одинаковое. Это часто происходит во время чтения таблиц баз данных, содержащих одинаковое строковое значение в столбце – потому что они поступают через DataReader в различные моменты, они имеют одинаковое значение, но разные ссылки, и сериализуются много раз. Это не имеет значения для SerializationWriter – он использует Hashtable для идентификации одинаковых строк независимо от их ссылок. Поэтому десериализованный граф обычно использует память более эффективно, чем граф перед сериализацией.

Массивом объектов также уделяется особое внимание, поскольку они широко используются при работе с базами данных, как часть DataTable или классы сущностей. Их содержимое записывается с помощью метода WriteObject(), чтобы сохранить тип и/или оптимизированное значение, но для них есть специальные методы оптимизации, такие как поиск последовательностей нулей или DBNull.Value. Где выявляется последовательность, там записывается SerializationType, идентифицирующий последовательность (1 байт), за которым следует длина последовательности (обычно 1 байт). Также есть перегруженный метод, названный WriteOptimized(object[], object[]), который принимает два объекта-массива одинаковой длины, такие, какие вы можете найти в измененном DataRow или в измененной сущности; первый object[]записывается, как описано выше, но значения во втором списке сравниваются с их эквивалентами в первом, и там, где значения идентичны, это обозначается специальным типом SerializationType, таким образом,сокращая размер пары одинаковых значений до одного байта независимо от их обычного размера при хранении.

Во время разработки SerializationWriter мы столкнулись с необходимостью сериализации объекта (фабрики класса), который должен использоваться многими сущностями в коллекции. Хотя эта фабрика класса не имеет собственных данных, сериализовать нужно только ее тип, но было бы полезно убедиться, что каждая сущность использует одну и ту же фабрику класса во время десериализации. Для этого мы снабжаем метками каждый объект: использование WriteTokenizedObject (object) помещает метку в поток, чтобы представлять этот объект, а сам объект будет сериализован позже, после того как метки строки будут сериализованы. Мы также добавили перегрузку этого метода.

Для обеспечения большей компактности, если ваш объект может быть восстановлен с помощью конструктора без параметров, используйте перегрузку WriteTokenizedObject(object, true): он сохранит имя типа как строку, и SerializationReader восстановит его, используя Activator.GetInstance.

Есть одно свойство, доступное для настройки: SerializationWriter.OptimizeForSize (которое по умолчанию равняется true) управляет тем, должен ли WriteObjectMethod() использовать оптимизацию сериализации там, где возможно. Поскольку он должен проверять, находится ли значение в пределах параметров, необходимых для оптимизации, то сериализация занимает немного больше времени. Установка свойства в false позволит обойти эти проверки и использовать быстрый и простой метод. В реальности эти проверки не будут заметны для маленьких наборов данных и займут только несколько дополнительных миллисекунд для больших наборов данных (десятки мегабайт), поэтому обычно данное свойство можно не менять. Помеченные данные записываются в поток только после завершения сериализации. Метод ToArray реализует это путем добавления всех таблиц строк и записи смещения данных этой таблицы в первые 4 байта потока. Таким образом, десериализатор может прочитать первые 4 байта, переустановить указатель потока на таблицу данных и прочитать их непосредственно. Положение потока затем изменяется обратно на байт 5, и десериализация происходит в таком порядке, в каком данные были сериализованы. Все оптимизации полностью документированы в коде, особенно требования к оптимизациям, которые вы должны видеть как пояснение действия.