Сериализация данных в языке C#

Принципиальные подходы к сериализации информации

В процессе работы программы, её переменные хранятся в произвольных участках оперативной памяти компьютера. Значимые типы, а так же данные массивов и строк занимают непрерывные участки, в то время как составные ссылочные типы данных (например, классы, содержащие в качестве полей другие классы) представляют собой сложную древовидную структуру.

Сериализацией называется процесс трансформации значения произвольного типа данных в непрерывную последовательность байт (byte[]). Этот процесс должен происходить без потери информации, чтобы полученный массив байт мог бы быть десериализован обратно в значение исходного типа.

Наиболее распространёнными сценариями, в которых требуется применять сериализацию являются:

Существующие алгоритмы сериализации/десериализации можно разделить на две группы:

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

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

Недостатки обоих подходов к сериализации вытекают из их преимуществ. Текстовая сериализация является избыточной, и, как следствие, требует большего объёма памяти при хранении и вычислительных ресурсов при обработке. Двоичная же сериализация является неуниверсальной, и зачастую требует использования одного и того же языка программирования на сериализующей и десериализующей стороне.

Понятие о механизме рефлексии

В прошлой работе мы рассмотрели наиболее примитивный подход к сериализации базовых типов данных: чисел и строк. Не смотря на свою простоту, он не подходит для работы со сложными типами данных, т.к. требует написания большого количества рутинного (сл. boilerplate) кода. Рассмотрим следующий класс:

class Книга
{
    public string Название;
    public List<string> Авторы;
    public int КоличествоСтраниц;
    public string Издательство;
}

Объём кода, необходимого для сериализации объекта типа Книга, будет расти по мере добавления новых полей в класс. Вместе с тем, изменения кода сериализации должны производиться параллельно с изменениями кода десериализации. В предыдущей работе было показано, что единственная ошибка в порядке десериализации приводит к некорректной десериализации всех последующих данных.

Можно представить более удобный и безопасный способ сериализации. Допустим, что язык C# предоставляет программисту метод .Fields(), который возвращает коллекцию полей соответствующего класса. Используя этот метод, можно было бы написать универсальный код для сериализации объектов класса Книга:

class Книга
{
    public string Название;
    public List<string> Авторы;
    public int КоличествоСтраниц;
    public string Издательство;

    public byte[] Serialize()
    {
        foreach (Field f in Fields())
        {
            byte[] serialized = {};
            if (f.Type == "int")
                serialized = serialized.Concat(BitConverter.GetBytes(f.Value)).ToArray();
            if (f.Type == "string")
                serialized = serialized.Concat(Encoding.Unicode.GetBytes(f.Value)).ToArray();
            // ...
            return serialized;
        }
    }
}

В примере выше метод .Fields() возвращает список полей класса Книга, а воображаемый класс Field содержит информацию о каждом отдельном поле. Например, для первого поля класса Книга, объект Field f должен был бы содержать значение "string" в f.Type и непосредственно название книги в f.Value.

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

if (f.Type == "double")
  // ...

Механизм, позволяющий коду “взглянуть на себя” называется рефлексией, и в действительности присутствует в языке C#. Более того, решая задачи сериализации, программисту, в большинстве случаев, даже не требуется напрямую использовать этот механизм, как и реализовывать метод .Serialize() из примера выше. Вместо этого, дело сводится к вызову встроенного метода .Serialize(), соответствующего выбранному алгоритму сериализации

Сериализация JSON

Синтаксис нотации JSON чрезвычайно прост, что и обуславливает его популярность среди различных языков программирования. Приведём пример того, как будет выглядеть сериализованный объект класса Книга:

{
  "Название": "C# для чайников",
  "Авторы": ["Мюллер Джон Поль", "Семпф Билл"],
  "КоличествоСтраниц": 608,
  "Издательство": "Диалектика"
}

Объект JSON обрамляется символами { и } и состоит из произвольного числа полей. Каждое поле имеет вид ключ: значение, где ключ - произвольная строка. Значениями в JSON являются:

Наконец, рассмотрим код, сериализующий и десериализующий JSON в C#. Необходимые для этого классы находятся в пространстве имён System.Text.Json:

using System.Text.Json;
using System.Text.Json.Serialization;

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

class Книга
{
    public string Название {get; set; }
    public List<string> Авторы {get; set; }
    public int КоличествоСтраниц {get; set; }
    public string Издательство {get; set; }
}

Теперь произведём сериализацию и десериализацию:

// Создадим объект, который хотим сериализовать
Книга к = new Книга();
к.Название = "C# для чайников";
к.Авторы = new List<string>({"Мюллер Джон Поль", "Семпф Билл"});
к.КоличествоСтраниц = 608;
к.Издательство = "Диалектика";

// Сериализуем объект в JSON-строку
string serializedStr = JsonSerializer.Serialize(к);
// Сериализуем объект сразу в массив байт
byte[] serializedBytes = JsonSerializer.SerializeToUtf8Bytes(к);

// Десериализуем JSON из строки
Книга к2 = JsonSerializer.Deserialize<Книга>(serializedStr);
// Десериализуем JSON из массива байт
Книга к3 = JsonSerializer.Deserialize<Книга>(serializedbytes);

Отметим, что метод .Deserialize<T>() параметризуется типом T, в который производится десериализация. Разумеется, этот тип должен совпадать с типом переменной, которая была передана в .Serialize().

Контрольные вопросы

  1. Виды алгоритмов сериализации, их преимущества и недостатки.
  2. В чём фундаментальная проблема “ручного” подхода к сериализации, описанного в предыдущей лабораторной работе?
  3. Что такое рефлексия в языках программирования и зачем она нужна?
  4. Как будет выглядеть результат сериализации в JSON объекта типа Dictionary<string, int>?

Задание на лабораторную работу

Модифицируйте клиентское и серверное приложения, разработанные в предыдущей лабораторной работе, таким образом, чтобы операции сериализации и десериализации производились при помощи JsonSerializer. Убедитесь, что после модификации приложения продолжают функционировать корректно. В результате выполнения этой работы, объём кода обоих проектов должен уменьшиться.