Сериализация объектов в C#

Добавлен , опубликован
В этой статье будет подробно разобрана сериализация/десериализация объектов, ее предназначение, форматы и случаи, где какой формат сериализации использовать.
В наше время нередко приходится сталкиваться с такими ситуациями, когда на запоминающем устройстве нужно сохранить необходимую информацию, где оная может принимать вид некоторых структур данных. Структуры данных могут быть представлены как в виде простых объектов с парой параметров, так и сложных в виде многочисленных иерархий объектов. При становлении вопроса о сохранении этих данных, у вас либо какая-то агрессия и зубы скрипят, либо вы вспоминаете то, что здесь было изложено и, по выполнении задачи, продолжаете радоваться жизни.
С этой проблемой призван справится механизм сериализации, где сериализация еть процесс преобразования какой-либо сущности в поток байтов. После преобразования мы можем этот поток байтов или записать на диск в необходимом формате или сохранить его временно в памяти. А при необходимости можно выполнить обратный процесс - десериализацию, то есть, получить из потока байтов ранее сохраненный объект и привести в изначальный вид.
Перечислю несколько распространенных форматов сериализации/десериализации, где каждый из перечисленных имеет свои преимущества.
Форматы и проведенный бенчмарк:
  • Xml-сериализация
| + При больших объемах данных* быстрее всех сериализуется и десериализуется

  • Json-сериализация
| + при малых объемах данных* быстрее всех сериализуется и десериализуется
| + исходный файл меньше весит при больших и малых объемах данных
Идеально подходит для кратковременной сериализации объектов Unity.

  • Бинарная-сериализация
| + нечитабельное содержимое, позволяющее скрыть информацию от сторонних глаз
| + поддерживает больше типов для де/сериализации
Подходит к долговременной сериализации объектов Unity, где подразумевается сокрытие данных от "взломщиков".

| большим объемом считается количество более 1500 объектов
| малым объемом считается количество менее 500 объектов

XML сериализация

XML сериализация сериализует только публичные поля и свойства
XML сериализация должна должна на этапе компиляции располагать информацией о типах, которые сериализует
Сериализуемые объекты должны иметь беспараметрический конструктор
Свойства с модификатором readonly не сериализуются
Для начала напишем класс-шаблон для сведений об игровом состоянии. Выглядеть он будет следующим образом:
        public class GameState
        {
            public int Money { get; set; }
            public int Lives { get; set; }
        }
И метод для сериализации наших данных.
            GameState state = new GameState() // создаем объект с данными, базируясь на классе-шаблоне
            {
                Money = 1488,
                Lives = 228
            };

            XmlSerializer serializer = new XmlSerializer(typeof(GameState)); // создаем сериализатор и сообщаем ему о том, какой тип сериализуем
            using (TextWriter writer = new StreamWriter(@"C:\Users\Msey\Desktop\GameState.xml")) // если вкратце, то здесь мы создаем модуль, позволяющий записывать символы по указанной директории
            {
                serializer.Serialize(writer, state); // сериализуем данные
            }
Выходные данные будут выглядеть следующим образом:
<?xml version="1.0" encoding="utf-8"?>
<GameState xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
  <Money>1488</Money>
  <Lives>228</Lives>
</GameState>
Теперь отредактируем наш класс-шаблон так, чтобы убедиться, что приватные поля не сериализуются и что с непараметрическим конструктором все работает в полной мере.
        public class GameState
        {
            public int money;
            public int lives;
            private int weed;

            public GameState()
            {
                money = 1111;
                lives = 2222;
                weed  = 3333; // не будет сериализоваться, тк поле weed с модификатором доступа private
            }
        }
Результат очевиден:
<?xml version="1.0" encoding="utf-8"?>
<GameState xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
  <money>1111</money>
  <lives>2222</lives>
</GameState>
Если мы заменим пустой конструктор на конструктор с параметрами, то в процессе выполнения программы мы получим ошибку выполнения на этапе создания сериализатора:
            public GameState(int i =1111, int j =2222) // так нельзя - конструктор с параметрами
            {
                money = i;
                lives = j;
            }



            public GameState(int i , int j) // так тоже нельзя - конструктор продолжает быть параметрическим (Ваш кэп ©)
            {
                money = i;
                lives = j;
            }
Рассмотрим распространенные атрибуты XML-сериализации:
  • [XmlElement]: поле будет сериализовано в качестве элемента
  • [XmlAttribute]: поле будет сериализовано в качестве атрибута
  • [XmlIgnore]: поле будет пропущено во время сериализации
  • [XmlRoot]: задает корневой элемент при сериализации
Рассмотрим атрибут XmlElement:
        public class GameState
        {
            [XmlElement("no_money")] 
            public int money; // независимо от того, как называется поле, атрибут XmlElement указывает, что представляет элемент строкой ниже и сериализует/десериализует его под именем, указанном в этом атрибуте
            [XmlElement("no_lives")]
            public int lives;

            public GameState()
            {
                money = 1111;
                lives = 2222;
            }
        }
Результат:
<?xml version="1.0" encoding="utf-8"?>
<GameState xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
  <no_money>1111</no_money>
  <no_lives>2222</no_lives>
</GameState>
Также атрибут [XmlElement("no_money")] можно записать в виде [XmlElement(ElementName ="no_money")]. Отличие заключается в том, что в атрибуте [XmlElement(ElementName ="no_money")] можно записать несколько параметров:
[XmlElement(ElementName ="no_money", Namespace = "nmspc")]
Здесь мы добавили пространство имен, и тогда данный элемент в файле принимает следующий вид:
<no_money xmlns="nmspc">1111</no_money>
Теперь обратим внимание на поведение при аттрибуте XmlAttribute:
        public class GameState
        {
            [XmlAttribute("MoneyAttribute")]
            public int money;

            [XmlAttribute("LivesAttribute")]
            public int lives;

            public GameState()
            {
                money = 2222;
                lives = 2222;
            }
        }
Результат:
<?xml version="1.0" encoding="utf-8"?>
<GameState xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" MoneyAttribute="2222" LivesAttribute="2222" />
Аттрибут XmlIgnore позволяет игнорировать поле во время сериализации. Заменим класс:
        public class GameState
        {
            [XmlIgnore]
            public int money;
            [XmlIgnore]
            public int lives;

            public GameState()
            {
                money = 1111;
                lives = 2222;
            }
        }
И наблюдаем результат:
<?xml version="1.0" encoding="utf-8"?>
<GameState xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" />
В сгенерированном XML файле отсутствуют элементы.
Атрибут XmlRoot применяется в качестве указания корневого каталога его содержимого. Корневыми элементами могут быть структуры, перечисления, классы и интерфейсы.
        [XmlRoot("Root_GameState")]
        public class GameState
        {
            public int money;
            public int lives;

            public GameState()
            {
                money = 1111;
                lives = 2222;
            }
        }
Результат:
<?xml version="1.0" encoding="utf-8"?>
<Root_GameState xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
  <money>1111</money>
  <lives>2222</lives>
</Root_GameState>
Десериализация для класса GameState не слишком сильно отличается от сериализации:
            GameState state;

            XmlSerializer deserializer = new XmlSerializer(typeof(GameState));

            using (TextReader reader = new StreamReader(@"C:\Users\Msey\Desktop\GameState.xml"))
            {
                state = (GameState) deserializer.Deserialize(reader);
            }

Json сериализация

Сериализация в json имеет схожий с XML процесс сериализации:
            GameState state = new GameState();

            JsonSerializer serializer = new JsonSerializer();

            using (StreamWriter sw = new StreamWriter(@"C:\Users\Msey\Desktop\GameState.json"))
            using (JsonWriter writer = new JsonTextWriter(sw))
            {
                serializer.Serialize(writer, state);
            }
Перечислю, пожалуй, основные атрибуты для сериализации в json, которые вам могут пригодиться в дальнейшем:
  • [JsonObjectAttribute] - атрибут, который используется для задания поведения класса при сериализации
  • [JsonPropertyAttribute] - атрибут, который используется для задания поведения свойств и полей при сериализации
  • [JsonIgnore] - атрибут, позволяющий игнорировать поле или свойство при сериализации
Десериализуем теперь json обратно в объект:
            GameState state;

            JsonSerializer serializer = new JsonSerializer();

            using (StreamReader sw = new StreamReader(@"C:\Users\Msey\Desktop\GameState.json"))
            using (JsonReader writer = new JsonTextReader(sw))
            {
                state = (GameState) serializer.Deserialize(writer);               
            }

Бинарная сериализация

Процесс бинарной сериализации выглядит так:
            GameState state = new GameState();

            BinaryFormatter formatter = new BinaryFormatter();

            using (FileStream stream = new FileStream(@"C:\Users\Msey\Desktop\GameState.dat", FileMode.Create))
            {
                formatter.Serialize(stream, state);
            }
Однако стоит заметить, что конструктор BinaryFormatter не принимает тип сериализуемого объекта, а это значит, что на этапе компиляции formatter не будет ничего знать о нашем классе (типе) с игровым состоянием и в процессе выполнения выдаст ошибку, поэтому добавим к нему атрибут [Serializable]:
        [Serializable]
        public class GameState
        {
            public int money;
            public int lives;

            public GameState()
            {
                money = 1111;
                lives = 2222;
            }
        }
В случае с десериализацией можно пренебречь атрибутом [Serializable], однако я рекомендую этот атрибут использовать всегда для сериализуемых объектов как для читаемости, так и "кросссериализуемости".
Процесс десериализации:
            GameState state;

            BinaryFormatter formatter = new BinaryFormatter();

            using (FileStream stream = new FileStream("C:\Users\Msey\Desktop\GameState.dat", FileMode.Open))
            {
                state = (GameState)formatter.Deserialize(stream);
            }
Статья будет дополняться.
`
ОЖИДАНИЕ РЕКЛАМЫ...
29
Если честно, то сравнение сериализации представлено так-себе. 1.5к объектов не показатель. Самый важный показатель сериализации не рассмотрен.
29
1.5к объектов не показатель
Msey
более 1500
Самый важный показатель сериализации не рассмотрен
Ну так поведай нам.
29
Ну так поведай нам.
Обратная-совместимость данных. Что делать, когда ты уже в продакшене, а надо расширить или урезать данные, сериализованные в формате X
29
Все это медленнее (за исключением бинарной сериализации) и менее читаемо чем просто ручная запись файлов в плейн-текст формате уровня:
game_state
money 999.99
lives 12
Текст разбивается по пробелам и лайнбрейкам и ручками пишется recursive descent parser (простейшая вещь с которой справится и школьник). Подобный формат легко править и читать даже без наличия продвинутого редактора, он нормально будет смотреться в гит диффах. При желании легчайшим образом добавляется собственное версионирование, к примеру:
game_state
money 999.99 Since 1.2
lives 12
Одумайтесь. Жсон и хмл совершенно нечитаемые форматы при больших объемах древовидных данных.
24
Doc, Согласен. Но на самом деле сильно зависит от юзкейсов. Например, собрать прототип на готовой JSON сериализации будет быстрее, если своей либы еще нет готовой - запилить потом другой алгоритм сериализации никто не мешает, когда понадобится и будет дополнительно время на это.
Сам пользуюсь JSON-ом для хранения статичных данных в своем основном проекте т.к. руки не дошли пилить кастомную сериализацию, а анрил из коробки понимает превращение таблиц данных в JSON и обратно и, соответственно, в наличии визуальный редактор и другие плюшки, которые иначе пришлось бы пилить вручную либо с нуля либо допиливать к существующим инструментам. Правда руками я их в итоге трогаю довольно редко, а ингейм сохранения всеравно в бинарниках.
9
json рулит: по структуре проще xml, а сама структура более понятная чем прямой текст. Про читаемость - открыл в Notepad++ и поехали, он определяет скобки как блок данных, все нормально читается.
29
Я согласен что если есть зависимость от сторонних тулз - можно и подстроиться под формат. В основном правда очень часто единственной тулзой является сам движок игры. И свой сериалайз + парсинг пишется очень и очень быстро, особенно в хайлевел языках, где работа со строками уже налажена. К примеру взгляните на:
здесь функция split_into_words_and_quoted_pieces из 60 строк это весь токенайзер. Далее парсинг совершенно тривиален и добавлять новые части к нему ничего не стоит.
он определяет скобки как блок данных, все нормально читается.
Это ничего не дает, в формате куча визуального мусора и он слишком прост чтобы в него сериализовать более сложные данные, например список из объектов разных под-типов.
Когда в жсоне один линейный список он читается нормально. Как только данные усложняются формат становится не сильно более понятным чем бинарный.
9
Как только данные усложняются формат становится не сильно более понятным чем бинарный.
Как и твой приведенный, разве нет? Визуальный мусор есть, но сам формат простой. У меня не было случаев, когда не разобрался при просмотре тестового сохранения или я не писал ничего сложного.
Мои причины использования json очень простые: она почти ничем не хуже твоего метода; часто можно не писать парсер, а использовать работающую либу; этот формат известен всем в отличие от своего персонального.
Но твою позицию со своим форматом пониманию.
14
Не мог пройти мимо, не ответив на заблуждения вышеотписавшихся.
  1. сериализация вручную пилится в редких случаях, я за свои 14 лет опыта программирования пришел в выводу что юзать готовые решения - лучше, в 1ю очередь изза их сторонней поддержки и правки багов, плюс функицонала больше чем вы сами навелосипедите
  2. основная задача при сериализации с любыми решениями хоть готовыми хоть вашими - описать данные, пишите C# классы, и когда структуры классов и иераррхии вложенности усложняются - как раз выгоднее писать нормальные C# классы и отдать на откуп системе их перевод в текст JSON/XML или даж бинарный, а не самому писать как каждый подтип будет преобразовываться
  3. нет проблем сохранять полиморфные объекты в JSON, юзайте JsonDotNet с опцией чтобы писало $type при сериализации и соответсвенно нужный подкласс инстанциируется при десериализации
  4. при текстовом формате сериализации, что JSON что XML, не столь важен выбор, когда речь о читаемости итоговых сериализованных файлов - это редкое действие, к тому же открываете в текст редакторе, и при сериализации включаете PrettyPrint чтобы на 1 строке был 1 атрибут, а не все в 1 строку
9
Перечитал комментарии. На данный момент мое дилетантское мнение таково, что json дает хорошую читаемость в простейших случаях + всякие библиотеки, чтоб не писать самому. Во всех остальных случаях, думается, своя сериализация будет лучшим решением, потому что:
  1. Вы понимаете, как работает ваша сериализация. И если будете ловить какие-то баги, то понимаете, как ее исправить, чего не скажешь о какой-то либе, начинку который вы не знаете.
  2. По производительности как конечное решение своя кастомная сериализация всегда лучше.
14
Вы понимаете, как работает ваша сериализация. И если будете ловить какие-то баги, то понимаете, как ее исправить, чего не скажешь о какой-то либе, начинку который вы не знаете
только в готовых решениях багов как правило почти нет, или очень редкие, и при желании можно выбрать либу-конкурентов без этих багов если их править не будут.. а свой велосипед надо самому поддерживать. в том числе и баги фиксить, и функционал добавлять - всякие подтипы полиморфные в списке (как Doc указывал, а я сказал что они уже реализованы) много чего еще из-коробки есть
  1. производительность там где нужна - можно юзать protobuf или иже с ними бинарные.. текстовая десериализация в/из json не будет самописная быстрее чем готовые либы json, за счет чего? рефлексию если убрать даже - есть генераторы кода, которые без рефлексии компилят, и при этом будет чуть быстрей, но руками такое городить - долго, я имею ввиду свой генератор кода, где будет вида
output.writeTag("health", health);
output.writeHierarchyOfObject("target", target); где target спец подтип, и в него тоже вручную сериалайзить
так вот руками написанное такое всмысле без генератора - будет с рефлекскией - и медленно, а без рефлексии нужен свой велосипед - жесть будет
.. про генератор я имел ввиду что по CLASS декларации само сгенерит код того вида выше с writeTag которые руками не надо писать - и будет быстрее чем любой самописный елосипед, просто это доп требование - если надо - найдете сериалайзер даже json такой
\ protobuf - уже юзает такие же генераторы, без рефлексии
29
Намеки на то, сколько времени занимает подобное дело - оверэстимейшн. Написание текстовой сериализации для объекта занимает минуты.
24
Alex_Hell,
Не знаю как у вас в шарпах, а на джаве я пользовался встроенной в среду разработки возможностью выполнять перед компиляцией проекта код, получающий информацию об аннотациях в коде проекта - кастомный сериализатор-десериализатор с генерацией кода писался очень быстро на основе этого.
Чтобы оставить комментарий, пожалуйста, войдите на сайт.