В своем подходе к обучению я применяю довольно простую схему - у меня есть репозиторий, и каждый раз, когда я сталкиваюсь с новой для себя технологией - я иду туда, создаю папку-подпапку, в ней .cs файл и решаю какую-то проблему, применяя изученное. Если теоретическая составляющая темы слишком обширна, чтобы запихнуть ее в голову и комментарии к коду, я пишу конспекты в .md формат.
Недавно я актуализировал конспект по сериализации, и мне пришла идея - почему бы не вытесать из этого статью.
В этой статье мы пройдемся по тому как сериализация работает, основным форматам и языкам разметки, а также объединим знания об использовании .NET System.Text.Json
в формат напоминалки. Сразу же отмечу два дисклеймера:
Это личная база знаний, над которой была проведена большая редакторская работа, я старался оформить данные в формат напоминалки, к которой можно вернуться в любое время, поэтому местами могут встретиться грубые упрощения и не-академическая терминология.
Мы довольно подробно поговорим об XML и SOAP, однако работы с классом
XmlSerializer
вы здесь не увидите. Причины можно найти в данном видеофрагменте. А если серьезно - мне просто не довелось применить данный вид сериализации на практике, но знание основ XML не навредит. ОBinaryFormatter
иISerializable
речи идти вовсе не будет, так как грядущий .NET 9 захлопнет крышку этого гроба окончательно.
Примечание: Хабр не очень дружит с переносом строки внутри блоков кода, для удобства чтения раздутых XML и комментариев - используйте Shift+Колесико мыши. Возможно, отоспавшись, я оформлю переносы вручную.
Оглавление:
Сериализация
Сериализация - процесс преобразования объекта среды выполнения в форму, пригодную для дальнейшей транспортировки. Выражаясь простым языком, это процесс записи данных объекта в памяти, то есть класса или структуры, в такую форму, которую можно передать по сети, сохранить на диске, использовать между процессами и так далее. Десериализация, соотвественно, обратный процесс восстановления состояния объекта из формата транспортировки.
Обычно это текстовые форматы вроде JSON или языки разметки YAML, XML, SOAP и прочие. Например, бинарные сериализаторы пишут непосредственно байты в стрим назначения, хотя, если углубиться в тему - всё перечисленное также проходит процесс кодировки (обычно используя UTF-8), и уже парсеры, разрабатываемые авторами форматов, восстанавливают состояние благодаря строгим протоколам оформления данных.
К слову, язык разметки отличается от формата текста
тем, что язык разметки это более комплексный свод правил для хранения и представления данных, использующий теги, синтаксические правила, определения новых сущностей и т.д.. Текстовые форматы же более легковесны, просты в написании/чтении и ограничиваются малым количеством контрольных сущностей.
Форматы и языки разметки
JSON
JSON (JavaScript Object Notation) — это легковесный текстовый формат для представления структурированных данных в виде пар ключ-значение, композитных объектов, состоящих из этих пар, а также JSON-массивов. JSON изначально был основан на синтаксисе JavaScript, но является языконезависимым и поддерживается большинством современных языков программирования.
Представление данных в формате JSON:
-
Простые классы и структуры состоят из JSON-свойств (или JSON Value). Свойство выглядит как пара
"name":value
, или, если вам так привычнее,"key":value
.public class SomeData { public float pi { get; set; } = 3.14f; public bool b { get; set; } = false; public char c { get; set; } = 'c'; public object? o { get; set; } = null; public string s { get; set; } = "Some Text"; }
{ "pi": 3.14, "b": false, "c": "c", "o": null, "s": "Some Text" }
-
Композитные объекты (JSON Object) заключаются в фигурные скобки {}, внутри которых содержатся свойства, которые также могут представлять собой объекты. Размер такого дерева вложенных объектов называется глубиной сериализации.
К слову, когда мы сериализуем сам корневой объект, он также начинается с фигурных скобок:public class SomeData { public float pi { get; set; } = 3.14f; public bool b { get; set; } = false; public char c { get; set; } = 'c'; public Vector2 ComplexStruct { get; set; } = new(1.5f, 2.7f); }
{ // Начало SomeData "pi": 3.14, "b": false, "c": "c", "ComplexStruct": { // Начало Vector2 "X": 1.5, "Y": 2.7 } // Конец Vector2 } // Конец SomeData
-
Коллекции. Стоит отметить, что разные реализации фреймворков сериализуют коллекции, соответственно, по разному. Массивы, листы, очереди и прочие сериализуются в виде обычного JsonArray.
А вот сериализация, например, словаря, будет выглядеть как объект, свойства которого являются парами"key":value
.
Синтаксис Json массивов, в данном случае сериализуется массив строк:"someValues": [ "first", "second", "third" ]
Логические единицы, то есть простые свойства, объекты, массивы и элементы внутри них разделяются запятыми. Когда мы видим упоминание "trailing commas" где-либо, это означает обработку хвостовых запятых, за которыми не следует еще один элемент, иногда это может вызывать ошибки.
Для сериализации специфических типов, например, словарей, ключом которых выступает композитный объект, может потребоваться предоставить свою реализацию алгоритмов конвертации и разрешения типов.
XML
XML (расширяемый язык разметки) — это язык разметки, предназначенный для хранения и передачи данных в формате, удобном для обработки компьютером. Синтаксис XML основывается на тегах, определяемых пользователем. XML также допускает атрибуты, экранирование (escape characters), комментарии, пространства имен и валидацию при помощи схем. Правильно составленный XML документ называют well-formed.
Общий синтаксис XML для C# объекта будет выглядеть так:
-
Простые свойства(элементы), имя указывается в открывающемся и закрывающемся теге, значение указывается между ними. Также,
тег может быть самозакрывающимся, используя синтаксис<tag/>
.public class SomeData { public float pi { get; set; } = 3.14f; public bool b { get; set; } = false; public char c { get; set; } = 'c'; public string s { get; set; } = "Some Text"; }
<?xml version="1.0" encoding="UTF-8"?> <!--Этого мы коснемся позже--> <SomeData xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"> <pi>3.14</pi> <b>false</b> <c>c</c> <s>Some Text</s> </SomeData>
-
Вы могли обратить внимание на атрибуты вроде
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
. Значение некоторых мы рассмотрим позже. Говоря о самих атрибутах - они являются частью XML тегов, синтаксически выглядят какattribute="value"
, не разделяются запятыми, если атрибутов несколько. Атрибуты это такие же данные, записываемые пользователем или программой, общепринятой практикой считается размещение в них метаданных, то есть "информации об информации", вторая "информация" в данном случае это значение XML-элемента, например:<Order time="8/18/2010 4:32:00"> <!--Метаданные о времени заказа--> <ID>114</ID> </Order>
-
Композитные объекты работают по тем же правилам.
<?xml version="1.0" encoding="UTF-8"?> <SomeData xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"> <pi>3.14</pi> <b>false</b> <c>с</c> <ComplexStruct> <!--Композитный объект. Кстати, это комментарий. --> <X>1.5</X> <Y>2.7</Y> </ComplexStruct> </SomeData>
-
Коллекции не имеют какого-либо специфического синтаксиса. Однако, можем заметить, что теги элементов внутри массивов используют тип элемента в качестве имени, хотя это поведение можно переопределить разными конфигурациями.
<?xml version="1.0" encoding="UTF-8"?> <SomeData xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"> <values> <string>first</string> <string>second</string> <string>third</string> </values> </SomeData>
-
XML поддерживает escape sequences (экранирование) для зарезервированных символов
< > & ' "
. К слову, единственные места, где можно использовать эти символы напрямую это комментарии и блоки CDATA.Экранируемые символы
< — представляет символ < (открывающая угловая скобка). > — представляет символ > (закрывающая угловая скобка). & — представляет символ & (амперсанд). " — представляет символ " (двойная кавычка). ' — представляет символ ' (одинарная кавычка).
-
Отличие CDATA от комментария заключается в том, что комментарий не является частью XML документа и игнорируется парсерами, тогда как CDATA (character data) является частью данных XML, внутри которой текст располагается "сырым" (это можно сравнить с raw string в C#).
<?xml version="1.0" encoding="UTF-8"?> <SomeData> <SomeElement> <!--Внутри этого блока я могу использовать <>&"", но комментарии не являются "полезной" частью XML документа.--> <![CDATA[ CDATA - character data, т.е. данные, которы не требуют экранирования. Я могу использовать <>&", а также это часть XML документа.]]> </SomeElement> </SomeData>
Мы уже могли обратить внимание, что в начале XML документа содержится пролог <?xml version="1.0" encoding="UTF-8"?>
, описывающий версию xml и кодировку. Когда вы видите тег со знаками ?
, это означает, что данный тег является не представлением данных, а инструкцией обработки (Processing Instruction - PI
), подобные инструкции зачастую требуются принимающей XML документ стороне, например веб серверу.
Далее идет открывающий тег объекта, в котором указаны пространства имен через атрибут xmlns:namespace_name
. Пространства имен нужны чтобы не допустить конфликта имён, а также использовать предопределенные элементы и атрибуты. Сами по себе пространства имен обычно являются URI, хотя могут быть и обычной строкой вроде foo
. Большинство xml-парсеров знают о тех определениях, что предоставляет какой либо из стандартных (не-пользовательских) неймспейсов, поэтому никакого процесса запроса к веб серверу не происходит, под стандартным имеется ввиду, например, http://www.w3.org/2001/XMLSchema-instance
, содержащий атрибуты вроде nil
(для обозначения может ли значение быть нулевым) или type
(для ограничения по типу) или schemaLocation
для указания пути к XSD для текущего XML документа. При этом пройдя по самому URL мы получим просто информационную страницу с ссылками. И опять же, повторяясь снова, все пространства имен, атрибуты и элементы - всего лишь текстовые данные, с которыми уже оперируют парсеры лексеры и валидаторы, что возможно благодаря детерменированной структуре XML.
XML Схемы
XSD (XML Schema Definition) — это язык для описания структуры, содержимого и семантики XML-документов. XSD определяет правила и ограничения, которым должны соответствовать XML-документы, чтобы считаться допустимыми (валидными) по отношению к данной схеме и использует синтаксис XML. Основной задачей схемы является валидация XML документов. Например схема может передаваться какому-то сервису, который затем по ней будет валидировать приходящие XML, или же при ручном заполнении XML файла при наличии схемы можно избежать ошибок. Обращаю внимание, что ссылка на схему и ее наличие не является обязательной частью XML.
Хочу отметить, что данная тема довольно сложна и на ее полный разбор потребовалась бы отдельная статья. Пройдемся по
Основам XSD:
-
XSD начинается с импорта xsd неймспейса(
xmlns:xs
в примере ниже), указания корневого тегаschema
из этого неймспейса, а также необходимых пользователю атрибутов<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
Сделаем посложнее
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="http://www.alexfitbie.com" xmlns:ftb="http://www.alexfitbie.com" elementFormDefault="unqualified">
здесь, например, мы указали, что создаем свой неймспейс, который будет использован XML документом позже, затем подключили этот неймспейс в схему(
xmlns:ftb
), а атрибутelementFormDefault
указывает на то, что целевой XML не обязан указывать неймспейс для объявления атрибутов и элементов из схемы (приqualified
в XML мы должны были бы указывать наш неймспейс перед всеми атрибутами и элементами). -
Далее мы формируем структуру будущего XML используя элементы и атрибуты из неймспейса
xs
. Мы можем указать требуемый элемент, его имя, типы которые в него можно поместить, комплексный это объект или простой и так далее.<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> <xs:element name="Person"> <!--Корневой элемент--> <xs:complexType> <!--Комплексный тип, может содержать другие элементы, атрибуты--> <xs:sequence> <!--Элементы в XML будут идти в указанном порядке--> <xs:element name="Name" type="xs:string"></xs:element> <!--Имя xml элемента, тип значения--> <xs:element name="ID" type="xs:int"></xs:element> <xs:element name = "BirthDate" type="xs:date"></xs:element> </xs:sequence> <xs:attribute name="ID" type="xs:int" use="required"/> <!--атрибуты, которые должны(здесь required) быть у элемента размещаются после sequence--> </xs:complexType> </xs:element> </xs:schema>
Мы можем предопределить типы для повторного использования внутри нашей схемы, объявив
complexType
на уровне схемы (то есть под корневым<xs:schema>
). Здесь мы определяемStudent
, который затем будет использован в массивеStudents
. К слову, для создания массива элементов свободного размера мы используем атрибутыminOccurs="0" maxOccurs="unbounded"
.
А внутри студента мы также используем коллекцию из пользовательских типовGrade
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="School"> <!--Корневой элемент будущего XML-->
<xs:complexType>
<xs:all> <!--All - элементы в любом порядке(в противовес sequence). Но в данном случае у нас только массив студентов-->
<xs:element name="Students"> <!--Массив-->
<xs:complexType>
<xs:sequence>
<xs:element name="Student" type="Student" minOccurs="0" maxOccurs="unbounded"/> <!--Здесь мы ограничиваем элементы массива по типу Student(он ниже), а также указываем что этот элемент может встречаться от 0 до неограниченного кол-ва раз, тем самым создавая коллекцию-->
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:all>
</xs:complexType>
</xs:element>
<xs:complexType name="Student"> <!--Объявление типа Student, complexType под корнем схемы-->
<xs:sequence>
<xs:element name="Name" type="xs:string"/>
<xs:element name="Grades">
<xs:complexType>
<xs:sequence>
<xs:element name="Grade" type="Grade" minOccurs="1" maxOccurs="30"/> <!--Массив элементов типа Grade(он ниже), с размером от 1 до 30 элементов-->
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:sequence>
</xs:complexType>
<xs:complexType name="Grade"> <!--Также объявление типа Grade-->
<xs:sequence>
<xs:element name="Subject" type="xs:string"/>
<xs:element name="Value" type="xs:float"/>
</xs:sequence>
</xs:complexType>
</xs:schema>
Итоговый XML, составленный по нашей схеме выглядел бы так:
<School
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="School.xsd">
<Students>
<Student ID="11">
<Name>Jimmy McGill</Name>
<Grades>
<Grade>
<Subject>Math</Subject>
<Value>5.6</Value>
</Grade>
</Grades>
</Student>
<Student ID="01">
<Name>Pit Ritt</Name>
<Grades>
<Grade>
<Subject>History</Subject>
<Value>8.7</Value>
</Grade>
<Grade>
<Subject>Math</Subject>
<Value>7.8</Value>
</Grade>
</Grades>
</Student>
</Students>
</School>
Помимо XSD существует DTD (Document Type Definition), служащий также для определения структуры XML. Но он считается устаревшим, не использует синтаксис XML и обладает куда меньшими возможностями. Пример синтаксиса:
<!DOCTYPE note [
<!ELEMENT note (to,from,heading,body)>
<!ELEMENT to (#PCDATA)>
<!ELEMENT from (#PCDATA)>
<!ELEMENT heading (#PCDATA)>
<!ELEMENT body (#PCDATA)>
]>
SOAP
SOAP (Simple Object Access Protocol) — это протокол обмена сообщениями, используемый для передачи данных между компьютерами. SOAP основывается на XML и определяет строгие правила обмена данными между веб-сервисами и клиентами. Протокол включает описание сообщений, как и каким образом они должны быть переданы, а также стандартные вызовы и ответы.
То есть, SOAP сообщения представляют данные, передаваемые, например, в теле HTTP запроса/ответа. По правде говоря, с SOAP я не работал, протокол можно отнести к категории устаревших и используемых в основном в гигантских легаси проектах, вроде банковских систем и прочих Госуслуг, современные сервисы чаще полагаются на обмен данными через форматы вроде JSON или YAML.
Пример SOAP-сообщения выглядит следующим образом:
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ex="http://example.com/">
<soapenv:Header/>
<soapenv:Body>
<ex:GetStudentDetailsRequest>
<ex:StudentID>12345</ex:StudentID>
</ex:GetStudentDetailsRequest>
</soapenv:Body>
</soapenv:Envelope>
Объяснение элементов:
<soapenv:Envelope>
— это корневой элемент SOAP-сообщения. Он определяет начало и конец сообщения.xmlns:soapenv
задает пространство имен, связанное с протоколом SOAP.<soapenv:Header>
— необязательный элемент, используемый для передачи метаинформации, например, аутентификации. В данном примере он пуст.<soapenv:Body>
— обязательный элемент, содержащий основное содержимое сообщения. Внутри Body находятся данные, которые клиент передает на сервер или получает от него.<ex:GetStudentDetailsRequest>
— это пользовательский элемент, который является частью основного содержимого сообщения. Он включает в себя информацию, которая нужна для выполнения конкретного запроса (в данном случае, запрос деталей студента по его ID).<ex:StudentID>
— это элемент, содержащий конкретные данные запроса (в данном случае ID студента).
YAML
YAML (Yet Another Markup Language YAML Ain't Markup Language) — это текстовый формат("бывший" язык разметки) для сериализации данных, предназначенный для удобочитаемого представления структурированных данных. YAML используется для хранения конфигурационных файлов, передачи данных между программами и различных других задач. YAML не является форматом "из коробки" для .NET, хотя существует сторонняя библиотека YAML.NET.
Общий синтаксис YAML:
Yaml начинается с первой строки
---
и заканчивается строкой...
(опционально, например несколько yaml в одном файле).-
Простые свойства хранятся в паре
key:value
--- pi: 3.14 b: false c: c o: null s: Some Text
-
Для образования композитных типов и обозначения вложенности используются пробелы (не табуляция!):
--- pi: 3.14 b: false c: c ComplexStruct: # Вложенные члены X и Y X: 1.5 Y: 2.7
-
Коллекции используют или inline синтаксис
Values: {a, b, c}
, или синтаксис новой строки с отступом, пробелом и тире, особенно полезным для обозначения композитных элементов массива.Students: # Array - Name: Pam Beasley # Element 1 Age: 10 AverageGrade: 3.9 - Name: Ryan Howard # Element 2 Age: 11 AverageGrade: 4.2
Некоторые структуры данных могут потребовать своих алгоритмов сериализации. Например словари, также как и в JSON(в/из которого yaml легко конвертируется), также могут выглядить как композитный объект
Teachers:
MichaelScott: {Pam Beasley, Ryan Howard} # string key, string[] value, "" опционально
JanGofrey: {"Alex Swanson"}
Кроме этого, присутствует явный синтаксис пары ключ-значение (Complex Mapping Key):
TeachersComplexMappingKey:
? Name: MichaelScott # Явный ключ начинается с ?
Age: 41
Subject: Math
: # Явное значение ключа начинается с :, в данном случае значение это массив
- Name: Pam Beasley
Age: 10
AverageGrade: 3.9
- Name: Ryan Howard
Age: 11
AverageGrade: 4.4
Технологии сериализации
Мы переходим к практическим заметкам. Отмечу, что практически вся информация была взята и переварена из документации Microsoft, где довольно подробно описан каждый раздел и приведены примеры кода.
System.Text.Json
На сегодняшний день самой удобной библиотекой для сериализации в/из JSON является System.Text.Json. Большая часть работы с ним заключаются в использовании статических методов JsonSerializer
, конфигурации при помощи экземпляра JsonSerializerOptions
, передаваемого в методы JsonSerializer
, использовании атрибутов, реализации кастомных обработчиков и конвертеров объектов.
Основы System.Text.Json
Для сериализации/десериализации используется JsonSerializer.Serialize<T>()
, JsonSerializer.Deserialize<T>()
и их async
собратья. Сериализация возможна как в/из Stream
, так и в обычную строку или Utf8Json Reader/Writer
. Методы обладают большим количеством перегрузок для разных нужд.
Также важнейшим (но при этом необязательным) этапом сериализации является создание экземпляра JsonSerializerOptions
, передаваемого в вышеупомянутые методы для конфигурации всего процесса, поэтому каждый раз, когда вы видите упоминание JsonSerializerOptions
в данном материале, я имею ввиду конструкцию вроде:
JsonSerializerOptions options = new()
{ // В 99% случаев мы конфигурируем опции через синтаксис инициализатора
WriteIndented = true,
IncludeFields = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
// И прочие свойства JsonSerializerOptions, что я указываю после точки
};
string serialized = JsonSerializer.Serialize<MyData>(myobj, options);
Кастомизация политики имен и самих имен в выходном JSON происходит при помощи JsonSerializerOptions.PropertyNamingPolicy
и атрибута [JsonPropertyName("")]
перед свойством/полем.
По умолчанию JSONSerializer
сериализует только все публичные свойства (если их тип поддерживает сериализацию). Это поведение можно конфигурировать, игнорируя свойства при помощи [JsonIgnore]
(с возможностью указания условия игнорирования) или при конфигурировании JsonSerializerOptions.DefaultIgnoreCondition
(а также JsonSerializerOptions.IgnoreReadOnlyProperties
). Также возможно включение в Json непубличных свойств и полей через атрибут JsonInclude
/ JsonSerializerOptions.IncludeFields
.
Если в JSON присутствуют элементы, которые нельзя смаппить ни с одним свойством в целевом типе, мы можем создать Dictionary<string, JsonElement>
и пометить его атрибутом [JsonExtensionData]
, куда будут писаться "потерявшиеся" JSON-свойства, для прочтения которых мы пользуемся типом из DOM модели JsonElement
, его мы коснемся позже.
Поддерживаемые типы коллекций перечислены здесь. Говоря в общих чертах, для сериализации поддерживается все, что реализует IEnumerable
(где оно просто перебирается и пишется в JSON). Для десериализации тип должен реализовывать один из перечисленных в статье интерфейсов, это общие интерфейсы коллекций от ICollection<>
до IQueue<>
, предоставляющие интерфейс для добавления элементов. Нюансы есть у Dictionary<K,V>
и Stack<T>
, в случае стека из-за его семантики при десериализации значения будут идти задом наперед (то есть сериализованный стек 3 2 1 0
будет десериализован в 0 1 2 3
). В случае же словаря, список ключей ограничен простыми сериализуемыми типами (иначе говоря теми, которые в качестве полей/свойств сериализуются как "name":value
), поскольку словарь сериализуется как JSON Object, а не Array, т.е. если мы взглянем на представление словаря в JSON:
{ // Dictionary<string, int> выглядит так
"First": 1,
"Second": 2,
"Third": 3
}
то увидим, что string
ключ пишется как имя Json свойства, а int значение - соответственно, как значение.
А для сериализации ключей более сложного типа, может потребоваться реализовать свой JsonConverter
, на который мы взглянем позже.
Иммутабельные типы и заполнение
С десериализацией в изменяемые типы с публичными get
set
свойстами все просто, если очень грубо упростить - JSON находит член объекта, имя которого соответствует имени JSON-свойства, и пишет в него данные (на самом деле при первом обращении генерируются метаданные в режиме рефлексии, но это сейчас не важно).
Однако возможна и десериализация в иммутабельные типы, вернее сказать в их get
-only/readonly
данные, путем использования параметризованного конструктора. Если конструктор не один, или присутствует конструктор без параметров, конструктор для десериализации должен быть помечен [JsonConstructor]
. Все имена параметров конструктора должны соответствовать именам сериализуемых полей/свойств, без учета регистра. Например, имя в конструкторе должно быть simpleNumber
, а имя поля/свойства в объекте должно быть SimpleNumber/SIMPLENUMBER/simpleNumber
, при этом [JsonPropertyName]
ни на что в этой реализации не влияет. Также учтите, что тип передаваемых в конструктор аргументов должен совпадать с типом соответствующих свойств объекта.
public class ReadonlyData
{
[JsonInclude]
public readonly int ID;
public string Name { get; }
public DateOnly BirthDate { get; init; }
public IReadOnlyCollection<int> Numbers { get; } // IReadOnlyCollection чтобы одурачить вас. Это не относится к теме.
[JsonConstructor]
public ReadonlyData(int id, string name, DateOnly birthDate, IReadOnlyCollection<int> numbers)
{
ID = id;
Name = name;
BirthDate = birthDate;
Numbers = numbers;
}
}
Помимо десериализации в иммутабельные типы, JsonSerializer
также может заполнять инициализированные значения. В обычной ситуации сериализатор для каждого json-свойства создает новый объект, затем присваивая ссылку на него c#-свойству. Однако, если какое либо c#-свойство/поле уже инициализированно, например
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] // На уровне класса
class A
{
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] // или тут
public List<int> Numbers { get; } = [1, 2, 3];
}
то, для того, чтобы "заполнить" данный лист, а не пересоздавать его, мы можем использовать атрибут JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)
. Сериализатор через get
возьмет ссылку на лист, и заполнит его десериализуемыми значениями через Add()
. К слову, данный атрибут помогает при работе с иммутабельными get-only/readonly свойствами ссылочного типа, однако для value-типов(struct
) обязательно должен быть указан set
(поскольку вместо ссылки на объект в хипе сериализатор получит копию структуры через get
, заполнит ее и должен будет записать обратно через set
).
Обработка ссылок. $id и $ref
Сериализуемые объекты могут оказаться сложными по своей структуре и указывать друг на друга. Более того, один и тот же объект может встречаться несколько раз в сериализуемых данных. Например, предположим у нас есть класс сотрудника, имеющего свойство, указывающего на его начальника:
public class Employee
{
public string Name { get; set; }
public Employee? Boss { get; set; }
}
и предположим у нас есть класс Corporate
, содежащий ссылку на директора и полный лист сотрудников (куда директор также входит). При создании компании мы создаем граф сотрудников, где у CEO Boss = null
, а у двух менеджеров Boss = CEO
и так далее.
public class Corporate
{
public Employee CEO { get; set; }
public List<Employee> Employees { get; set; } // Сюда мы пишем сотрудников, указывающих на свое начальство, и само начальство
}
Если у нас будет 1 директор и 2 сотрудника, указывающих на директора, то при обычной десериализации мы получим 4 объекта директора: 1 в свойство Company.CEO
, 1 в листе Company.Employees
и еще по одному у каждого сотрудника в свойстве Employee.Boss
:
{
"CEO": { // CEO 1
"Name": "Jim Root",
"Boss": null
},
"Employees":
[
{
"Name": "Jim Root", // CEO 2. Директор также находится в листе сотрудников.
"Boss": null
},
{
"Name": "Jason Tward",
"Boss": {
"Name": "Jim Root", // CEO 3
"Boss": null
}
},
{
"Name": "Alex Stein",
"Boss": {
"Name": "Jim Root", // CEO 4. Итого, десериализуя, мы получим 4! одинаковых объекта
"Boss": null
}
}
]
}
Однако, мы можем передать в конфигурацию JsonSerializerOptions
определенный в .NET обработчик ссылок ReferenceHandler.Preserve
:
JsonSerializerOptions options = new()
{
// Preserve НЕ работает с readonly полями и свойствами и НЕ работает с иммутабельными типами.
ReferenceHandler = ReferenceHandler.Preserve,
};
После этого каждому объекту в выходном JSON будет добавлено мета-свойство $id
, являющееся целым числом. Это будет что-то вроде ключа для каждой записи в Json, в свою очередь объекты (в нашем случае сотрудники), ссылающиеся на директора, вместо полной(повторной) сериализации директора разместят в "Boss"
мета-свойство $ref
с ключом $id
:
{
"CEO": {
"$id": "1", // ключ для указания на нашего директора
"Name": "Jim Root",
"Boss": null
},
"Employees":
{
"$id": "2", // У каждой сериализуемой записи будет $id, в том числе у листа. Но эти ключи нас не интересуют.
"$values":
[
{
"$ref": "1" // Ссылка на CEO, поскольку он также в листе сотрудников
},
{
"$id": "3",
"Name": "Jason Tward",
"Boss": {
"$ref": "1" // Ссылка на CEO
}
},
{
"$id": "4",
"Name": "Alex Stein",
"Boss": {
"$ref": "1" // Еще одна
}
}
]
}
}
Работает это довольно простым образом, внутри JsonReferenceHandler
находится ссылка на класс-стратегию JsonReferenceResolver
. Внутри же него находится словарь, сериализатор читает JSON директора, находит мета-свойство $id
, десериализует объект, помещает в упомянутый словарь директора под ключом, равным его $id
, и затем, натыкаясь на $ref
в сотрудниках, он уже получает ИЗ словаря объект директора по указанному в $ref
ключу и присваивает ссылку на этот объект в десериализуемое поле сотрудника Boss
.
К слову, именно из-за порядка чтения, крайне важно чтобы объекты НА которые ссылалаются шли раньше чем объекты которые ссылаются, однако мы можем применить атрибут [JsonPropertyOrder]
для детерменирования порядка. Совет: при отстуствии сторонних конфигураций, поля, включенные в сериализацию через, например, [JsonInclude]
, всегда идут позже свойств.
Полиморфизм
Полиморфизм - одна из проблем сериализации. Если имеется ссылка базового типа, указывающая на самом деле на объект дочернего, то десериализовать исходное состояние объекта без дополнительных инструментов будет невозможно, поскольку мы знаем лишь о базовом типе, какой тип объекта был фактически сериализован, остается неизвестным. Для таких ситуаций существует атрибут [JsonDerivedType(typeof(DerivedTypeName))]
. Он указывается перед базовым классом, в его аргументах мы перечисляем дочерние типы. То есть, мы как бы говорим сериализатору "Если ты десериализуешь объект в ссылку этого типа, то, возможно, ты работаешь с объектом одного из перечисленных в его атрибуте дочерних типов". Атрибут может быть использован несколько раз, если унаследованных типов, которые требуют полиморфической десериализации, несколько.
На всякий случай я уточню, при сериализации сериализатор опирается на объект в памяти. Любой ваш объект, сериализуемый через ссылку типа object
, будет сериализован как ваш объект, поскольку сериализатор внутри опирается на рефлексию.С десериализацией все интереснее, поскольку ему этот самый объект нужно создать, а значит опираться можно только на аргумент типа JsonSerializer.Deserialize<T>()
. Для поддержки полиморфизма в JSON-представление сериализуемого объекта добавляется мета-свойство $type
. Оно содержит дискриминатор(ключ) типа, экземпляр которого нужно создать. К слову атрибут [JsonDerivedType(typeof(DerivedTypeName), typeDiscriminator)]
принимает аргумент typeDiscriminator
, в который мы можем передать int
или string
, для ручной конфигурации, иначе будет использован счетчик int 0..n для каждого дочернего типа в атрибутах.
[JsonDerivedType(typeof(Developer), "developer")] // "developer" - наш дискриминатор
[JsonPolymorphic(UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToNearestAncestor)] // Об этом позже
public class Employee
{
// ...
}
Таким образом, выходной JSON получит новую запись:
{
"$type": "developer",
// Другие данные
}
Сопоставив значение из мета-свойства $type
и один из дискриминаторов в атрибуте JsonDerivedType
- сериализатор создаст экземпляр указанного типа и десериализует данные в него.
К слову, при полиморфической десериализации и сериализации в качестве аргумента типа JsonSerializer.Serialize<T>()
/JsonSerializer.Deserialize<T>
мы должны указывать базовый тип, то есть тип, помеченный атрибутами [JsonDerivedType]
.
Также выше в примере C# кода был упомянут атрибут [JsonPolymorphic]
. Он служит для дополнительной конфигурации полиморфической десериализации, например в примере используется [JsonPolymorphic(UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToNearestAncestor)]
, говорящий о том, что если будет обнаружен неизвестный тип, не указанный в [JsonDerivedType]
, то JSON десериализует объект в экземпляр ближайшего известного(указанного) предка.
JSON Document Object Model
JSON DOM (Document Object Model) — это модель представления структуры JSON-документа в памяти в виде объекта или дерева объектов, с которыми можно работать программно. Мы можем десериализовать любой объект в такую структуру, с которой можно работать не имея конкретного типа.
Основными типами для работы с DOM являются JsonNode
и JsonDocument
. Первый допускает работу с данными и их изменение, второй - иммутабелен. JsonNode
основывается на синтаксисе индексатора
{ // Исходник
"ID": 1,
"Name": "Jim Carry",
"Sales":
[
1,2,3,4,5
]
}
JsonNode node = JsonNode.Parse(stream)!;
JsonNode idJsonValue = node["ID"]!;
int ID = idJsonValue.Deserialize<int>();
Console.WriteLine(ID);
и представляет данные в виде 3 сущностей: JsonValue
, т.е. примитивное ключ-значение свойство вроде "id":1
, JsonObject
, являющийся композитным объектом (заключен в фигурные скобки), и JsonArray
, представляющий массивы. Все три типа наследуются от JsonNode
, что позволяет также использовать его интерферфейс и в частности индексатор и идти по дереву объектов вглубь.
JsonDocument
- иммутабельная реализация для работы с DOM. Также по сути является графом объектов из спарсенного Json и позволяет обращаться к вложенным свойствам, перебирать их и так далее. Работа с JsonDocument происходит через тип JsonElement
и начинается с обращения к JsonDocument.RootElement
:
{ // Исходник
"ID": 1,
"Name": "Jim Carry",
"Sales":
[
1,2,3,4,5
]
}
using JsonDocument document = JsonDocument.Parse(stream); // IDisposable, поэтому using
JsonElement root = document.RootElement; // Корневой элемент
foreach (var obj in root.EnumerateObject()) // Перебираем все записи в корневом объекте
{
Console.WriteLine(obj);
}
JsonElement sales = root.GetProperty("Sales"); // Получаем массив в корне, перебираем элементы массива.
foreach (JsonElement sale in sales.EnumerateArray())
{
Console.Write(sale.GetInt32() + ", ");
}
UTF8 Json Writer/Reader
Utf8JsonWriter
/ Utf8JsonReader
- типы, обеспечивающие запись/чтение JSON на самом низком уровне. Вся сериализация и десериализация сводится к использованию их. Вкратце примеры их работы:
Utf8JsonWriter
предоставляет API для записи шаг-за-шагом объектов, свойств, массивов, довольно прост в использовании, издалека похож на обычный StreamWriter
. Если вы возьмете лист бумаги, ручку, сядете десериализовывать объект вручную и будете проговаривать каждое ваше действие - вы превратитесь в Utf8JsonWriter
:
using MemoryStream ms = new(); // Создадим стрим, куда будем писать
using Utf8JsonWriter writer = new(ms, options); // Writer тоже IDisposable
writer.WriteStartObject(); // Начинаем писать сам сериализуемый объект
writer.WriteStartObject("Employee"); // Начинаем писать Json-объект. Employee здесь имя свойства в выходном Json, т.е. "Employee":{value}
// Совет: кодируем UTF-16 .NET строку в UTF-8 через JsonEncodedText для производительности своими руками.
JsonEncodedText name = JsonEncodedText.Encode("Jim Carry");
writer.WriteString("Name", name); // На Json выходе получаем: "Name": "Jim Carry"
writer.WriteNull("Null"); // "Null": null
writer.WriteNumber("ID", 10); // "ID": 10
writer.WriteStartArray("Values"); // Начинаем писать массив внутри Json объекта
writer.WriteNumberValue(10);
writer.WriteNumberValue(20);
writer.WriteEndArray(); // Закрываем массив
writer.WriteEndObject(); // Закрываем Json объект Employee
writer.WriteEndObject(); // Закрываем сам сериализуемый объект
writer.Flush(); // Записываем оставшееся в буфере JsonWriter'a в целевой поток ms
Utf8JsonReader
- куда более оптимизирован, является ref struct
, читает Json payload(содержимое) по токенам. Токеном может быть начало объекта, имя свойства, значение свойства, конец объекта, начало массива и т.д.
JsonReaderOptions options = new() // Опции reader'a для обработки комментариев и "лишних" замыкающих запятых
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
ReadOnlySpan<byte> jsonBytes = Encoding.UTF8.GetBytes(json); // Быстренько (и неоптимизированно) получим байты
Utf8JsonReader reader = new(jsonBytes, options);
while (reader.Read()) // Пока буфер не кончился
{
switch (reader.TokenType) // Reader читает все байты одного токена, после чего мы проверяем что это за токен
{
case JsonTokenType.StartArray: // Если это начало массива, следующий токен будет элементом массива
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) // Пока не дошли до конца массива
{
Console.Write(reader.GetInt32() + ", "); // Читаем данные элемента массива и пишем их в консоль
}
}
}
Реализация JsonConverter
Если тип по каким то причинам требует кастомных правил сериализации, то решением может быть реализация своего JsonConverter<T>
, передаваемого в коллекцию JsonSerializerOptions.Converters
. Конвертеры делятся на 2 типа: Basic (JsonConverter<T>
) и Factory (JsonConverterFactory
, создающий экземпляры basic JsonConverter<T>
).
С обычным все просто, он проверяет, может ли обработать переданный тип (сериализатор передает конвертерам все сериализуемые свойства начиная с корневого объекта, где каждый проверяет через CanConvert(Type typeToConvert)
, может ли он конвертировать объекты данного типа). Если он может конвертировать данный тип, вызывается переопределенные вами void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options);
или T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options);
. Как мы можем видеть, конвертеру передаются Utf8Json Writer/Reader, через которые вы пишете/читаете объекты, как оговаривалось ранее.
Реализация Basic Converter
public class Employee // Тип для сериализации
{
public string Name { get; set; }
public int ID { get; }
public readonly DateTime dateOfBirth;
public Employee(string name, int iD, DateTime dateOfBirth)
{
Name = name;
ID = iD;
this.dateOfBirth = dateOfBirth;
}
}
public class EmployeeJsonConverter : JsonConverter<Employee>
{
public override void Write(Utf8JsonWriter writer, Employee value, JsonSerializerOptions options)
{
// Алгоритм записи супер примитивный, давайте просто запишем все свойства и поля объекта через дефис
string data = $"{value.Name} - {value.ID} - {value.dateOfBirth.ToString(CultureInfo.InvariantCulture)}";
writer.WriteStringValue(data);
}
public override Employee? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// Создаем переменные для будущей передачи в конструктор
string Name = string.Empty;
int ID = 0;
DateTime dateOfBirth = DateTime.MinValue;
// В нашем алгоритме записи мы просто пишем 3 поля объекта через дефис. Поэтому читаем 1 строку.
string? data = reader.GetString();
if (data != null)
{
// Делим данные в JSON
var splitted = data.Split('-', StringSplitOptions.TrimEntries);
// И парсим их
Name = splitted[0];
ID = int.Parse(splitted[1]);
dateOfBirth = DateTime.Parse(splitted[2], CultureInfo.InvariantCulture);
}
return new Employee(Name, ID, dateOfBirth);
}
}
С фабричными конвертерами сложнее. Основная их цель - создавать basic converter'ы, упомянутые ранее, а значит фабричные конвертеры мы используем тогда, когда конвертации требует какой-то неопределенный тип, который мы не можем реализовать вручную. Самый распространенный случай - незакрытые generic типы. Например у нас есть некоторый класс DictionaryKey
, который используется в качестве ключа в словаре. Поскольку это композитный тип с полями и свойствами, использовать его "из коробки" в качестве ключа не выйдет. А значит нам нужно создать конвертер для Dictionary<DictionaryKey,>
, второй generic аргумент после запятой не указан, что означает открытый generic тип, благодаря чему нам не придется писать отдельный basic конвертер для каждого типа значения, что мы планируем использовать в словаре.
Убедившись, что fabrick converter имеет дело с нужным типом, он создает экземпляр уже basic converter(также написанного нами), обычно используя System.Reflection.Activator
(что является обычной техникой создания экземпляров в рантайме). Итого, для работы с фабричным конвертерам нам также потребуется определить обычный конвертер, который знает как читать/писать наш DictionaryKey
.
public override bool CanConvert(Type typeToConvert)
{
return typeToConvert.IsGenericType && // Если тип generic
typeToConvert.GetGenericTypeDefinition() == typeof(Dictionary<,>) && // И словарь
typeToConvert.GetGenericArguments()[0] == typeof(DictionaryKey); // И первый (известный) аргумент это DictionaryKey, значит я могу создать для вас экземпляр обычного конвертера.
}
public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options)
{
var genArgs = typeToConvert.GetGenericArguments(); // Получаем все gen аргументы словаря
Type valueType = genArgs[1]; // Второй аргумент, в данном случае тип значения в словаре, о котором мы ничего не знаем
// Создаем экземпляр заранее определенного Basic конвертера, в данном случае JsonConverter<TValue>, типизируя его вторым generic аргументом, то есть valueType. Почему типизируем его вторым аргументом - потому что об аргументе значения словаря, т.е. DictionaryKey, он и так знает, в этом есть цель его существования. Описанный конвертер вы можете найти ниже.
return
(JsonConverter)Activator.CreateInstance(typeof(DictionaryKeyJsonConverter<>).MakeGenericType(valueType), // Типизируем конвертер типом значения словаря
BindingFlags.Instance | BindingFlags.Public,
binder: null,
args: [options], // Конструктор конвертера ожидает JsonSerializerOptions
culture: null)!;
}
Реализация порождаемого фабрикой Basic конвертера для DictionaryKey
Представим, что у нас есть незамысловатый класс DictionaryKey
. Его конвертацию для сериализации в ключ словаря мы сделаем крайне дилетантской: просто разделим дефисами значение полей. Разумеется, в полевых условиях к ToString()
никто не прибегает.
public class DictionaryKey
{
// Поля вместо свойств просто потому что. Не играет роли.
[JsonInclude]
private int first;
[JsonInclude]
private float second;
[JsonInclude]
private string name;
[JsonConstructor]
public DictionaryKey(int first, float second, string name)
{
this.first = first;
this.second = second;
this.name = name;
}
public override string ToString()
{
return $"{first}-{second}-{name}";
}
}
Теперь реализуем конвертер, который порождает вышеописанная фабрика через Activator
.
public class DictionaryKeyJsonConverter<TValue> : JsonConverter<Dictionary<DictionaryKey, TValue>>
{
private JsonConverter<TValue> valueConverter; // Конвертер для TValue, тип значения нашего словаря.
public DictionaryKeyJsonConverter(JsonSerializerOptions options)
{
valueConverter = (JsonConverter<TValue>)options.GetConverter(typeof(TValue)); // Который мы получаем из конфигурации сериализатора. Если в конфигурацию не был передан конвертер для данного типа, используется конвертация по умолчанию, с которой мы имели дело все это время.
}
public override void Write(Utf8JsonWriter writer, Dictionary<DictionaryKey, TValue> dict, JsonSerializerOptions options)
{
writer.WriteStartObject(); // Начинаем писать объект, который на самом деле является словарем.
foreach ((DictionaryKey key, TValue value) in dict)
{
string propertyName = key.ToString()!; // Наш ключ для наблюдателя будет выглядеть как имя json-свойства. То есть "key": value, где key это DictionaryKey, умело уместивший свои данные в строку через ToString(), для простоты примера.
// Соблюдая политику наименования, которая возможно была передана в JsonSerializerOptions, записываем наш DictionaryKey как имя свойства.
writer.WritePropertyName(options.PropertyNamingPolicy?.ConvertName(propertyName) ?? propertyName);
valueConverter.Write(writer, value, options); // А значение записываем через конвертер нашего TValue типа, полученный в конструкторе.
}
writer.WriteEndObject(); // Словарь записан как объект.
}
public override Dictionary<DictionaryKey, TValue>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// Наш словарь - JSON объект, а значит начинается с этого токена.
if (reader.TokenType != JsonTokenType.StartObject)
{
throw new JsonException();
}
Dictionary<DictionaryKey, TValue> result = new();
while(reader.Read())
{
if (reader.TokenType == JsonTokenType.EndObject) // Закончили читать словарь
{
return result;
}
// Запись в словаре всегда начинается с PropertyName, в которое мы умело запихали DictionaryKey
if (reader.TokenType != JsonTokenType.PropertyName)
{
throw new JsonException();
}
string propertyName = reader.GetString()!; // Читаем имя свойства
// И, предположим, мы ToString() реализовали путем склеивания всех свойств и полей DictionaryKey через дефисы. Расклеиваем обратно.
string[] data = propertyName.Split('-', StringSplitOptions.RemoveEmptyEntries);
// Предположим DictionaryKey состоит из 3 свойств int,float,string.
DictionaryKey key = new(int.Parse(data[0]), float.Parse(data[1]), data[2]);
// Reader читает токенами, а значит вызывав Read мы переместим его указатель с токена имени свойства(DictionaryKey здесь) на его значение, которое также является TValue для нашей пары <DictionaryKey, TValue>
reader.Read();
// Делегируем чтение конвертеру TValue
TValue value = valueConverter.Read(ref reader, typeof(TValue), options)!;
result.Add(key, value); // Добавляем пару в словарь
}
throw new JsonException(); // Вас здесь не должно быть, вы пропустили JsonTokenType.EndObject, делающий return.
}
}
Контракты
Для каждого сериализуемого типа .NET необходимо то, что называется контрактом. Контракт определяет стоит ли включать поля, как записывать имена свойств в JSON, какие свойства игнорировать, какой конвертер использовать для какого свойства и т.д.. Обычно мы кастомизируем контракты используя JSON атрибуты, передавая сериализатору экземпляр JsonSerializerOptions
, создавая свои конвертеры и т.д.. Однако, существует также опция кастомизации контрактов на более высоком уровне.
Для кастомизации конракта мы можем обратиться к JsonSerializerOptions.TypeInfoResolver
и инициализировать либо своей реализацией, либо экземпляром предопределенного типа DefaultJsonTypeInfoResolver
. В чем удобство второго подхода - нам не нужно наследоваться и реализовывать сложную логику, как это было с конвертерами, поскольку работа с JsonTypeInfoResolver
заключается в передаче делегатов в коллекцию Modifiers
.
Эта коллекция принимает любого делегата с сигнатурой Action<JsonTypeInfo>
, то есть мы можем передавать туда методы (хуки в своей сути, перехватывающие процесс сериализации), которые ничего не возвращают и принимают объект JsonTypeInfo
. Под этим объектом скрывается каждая сущность, которую сериализатор планирует включать в JSON, от корневого объекта до вложенных.
Если вкратце, JsonTypeInfo
немного похож на MemberInfo
из рефлексии, через него мы можем получить список его собственных свойств и полей, через него мы можем определить, будет ли сериализовываться тип как Json-объект/простое свойство/массив, обратиться к его C# типу, получить доступ к конвертерам, если такие определены, и так далее.
Простые примеры кастомизации контрактов:
public static void IgnorePasswords(JsonTypeInfo typeInfo)
{
for (int i = 0; i < typeInfo.Properties.Count; i++) // Перебираем все свойства объекта
{
if (typeInfo.Properties[i].PropertyType == typeof(Password)) // Если любое свойство является паролем ( какой-то наш класс)
{
typeInfo.Properties.RemoveAt(i); // То удаляем его из списка свойств. То есть объекты типа Password не будут включены в выходной JSON.
}
}
}
Или, например, мы можем использовать рефлексию внутри модификатора контракта, чтобы включить все поля в сериализацию(но, конечно, переданный сериализатору JsonSerializerOptions.IncludeFields
был бы куда эффективнее)
public static void IncludeFieldsModifier(JsonTypeInfo typeInfo)
{
if (typeInfo.Kind is not JsonTypeInfoKind.Object) // Если это не композитный JSON-объект, игнорируем его, массивы и примитивные пары "key":value не обладают полями
{
return;
}
// Вытаскиваем все поля, обращаясь к уже .NET типу сериализуемого typeInfo через свойство Type
foreach (var fieldInfo in typeInfo.Type.GetFields(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic))
{
// Создаем новый JsonPropertyInfo. Это информация о JSON-свойстве, используя которую сериализатор запишет "key":value пару.
JsonPropertyInfo jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo(fieldInfo.FieldType, fieldInfo.Name);
// Нам нужно указать на логику чтения и записи значения новоиспеченного JSON-свойства, в данном случае мы передаем методы рефлексии для чтения/записи FieldInfo, поскольку по сигнатуре они подходят под делегаты Get/Set
jsonPropertyInfo.Get = fieldInfo.GetValue; // Передаем в делегат Get метод для получения значения
jsonPropertyInfo.Set = fieldInfo.SetValue; // Передаем в делегат Set метод для установки значения
typeInfo.Properties.Add(jsonPropertyInfo); // Добавляем JSON-свойство к JSON-свойствам нашего Json-объекта
}
}
После создания наших контрактов, мы передаем их в конфигурацию:
JsonSerializerOptions options = new()
{
TypeInfoResolver = new DefaultJsonTypeInfoResolver()
{
Modifiers = { IncludeFieldsModifier } // Передаем наш метод в лист делегатов, которые будут вызываться.
}
};
Режимы сериализации
Обычно JsonSerializer
работает в режиме Reflection. При первой сериализации типа создаются метаданные (контракты), описывающие как этот тип должен сериализовываться, какие свойства и поля, их атрибуты и тд.. Этот подход удовлетворяет большинство потребностей, однако в некоторых случаях, когда особенно важна оптимизация, мы можем захотеть избежать сбора метаданных в рантайме.
Поэтому у сериализатора есть второй режим работы: SourceGeneration.
Он также делится на 2 подрежима: Matadata-based и Serialization Optimization.
Metadata-based позволяет сгенерировать файлы исходного кода, содержащие метаданные, описывающие сериализуемые типы, которые затем будут интегрированы и скомпилированы вместе с вашим кодом.
Serialization-optimization - довольно свежий режим, позволяет заменить использование JsonSerializer на Utf8JsonWriter/Reader, что очень повышает производительность кода.
Однако у этих режимов есть недостатки: большинство фич сериализатора перестают работать, например разрешение ссылок, десериализации иммутабельных типов, атрибутов для заполнения инициализированных объектов и пр..
В остальном, данная тема невероятно огромная и погоня за оптимизацией зачастую не стоит свеч, поэтому я просто оставлю ссылку на документацию.
Заключение
Надеюсь, данная статья останется в ваших закладках и принесет пользу. Пиши конспекты, пей чай, пеки булки.
amironov
Про JSON Shema забыли написать.
Fitbie Автор
Согласен, но думаю про схемы и в общем валидацию форматов сериализации можно будет написать отдельный материал.