В своем подходе к обучению я применяю довольно простую схему - у меня есть репозиторий, и каждый раз, когда я сталкиваюсь с новой для себя технологией - я иду туда, создаю папку-подпапку, в ней .cs файл и решаю какую-то проблему, применяя изученное. Если теоретическая составляющая темы слишком обширна, чтобы запихнуть ее в голову и комментарии к коду, я пишу конспекты в .md формат.
Недавно я актуализировал конспект по сериализации, и мне пришла идея - почему бы не вытесать из этого статью.

В этой статье мы пройдемся по тому как сериализация работает, основным форматам и языкам разметки, а также объединим знания об использовании .NET System.Text.Json в формат напоминалки. Сразу же отмечу два дисклеймера:

  1. Это личная база знаний, над которой была проведена большая редакторская работа, я старался оформить данные в формат напоминалки, к которой можно вернуться в любое время, поэтому местами могут встретиться грубые упрощения и не-академическая терминология.

  2. Мы довольно подробно поговорим об XML и SOAP, однако работы с классом XmlSerializer вы здесь не увидите. Причины можно найти в данном видеофрагменте. А если серьезно - мне просто не довелось применить данный вид сериализации на практике, но знание основ XML не навредит. О BinaryFormatter и ISerializable речи идти вовсе не будет, так как грядущий .NET 9 захлопнет крышку этого гроба окончательно.

Примечание: Хабр не очень дружит с переносом строки внутри блоков кода, для удобства чтения раздутых XML и комментариев - используйте Shift+Колесико мыши. Возможно, отоспавшись, я оформлю переносы вручную.

Оглавление:

  1. Форматы и языки разметки

  2. System.Text.Json

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

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

Обычно это текстовые форматы вроде 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.

    Экранируемые символы
    &lt; — представляет символ < (открывающая угловая скобка).
    &gt; — представляет символ > (закрывающая угловая скобка).
    &amp; — представляет символ & (амперсанд).
    &quot; — представляет символ " (двойная кавычка).
    &apos; — представляет символ ' (одинарная кавычка).
    
  • Отличие 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, что очень повышает производительность кода.

Однако у этих режимов есть недостатки: большинство фич сериализатора перестают работать, например разрешение ссылок, десериализации иммутабельных типов, атрибутов для заполнения инициализированных объектов и пр..

В остальном, данная тема невероятно огромная и погоня за оптимизацией зачастую не стоит свеч, поэтому я просто оставлю ссылку на документацию.

Заключение

Надеюсь, данная статья останется в ваших закладках и принесет пользу. Пиши конспекты, пей чай, пеки булки.

Комментарии (2)


  1. amironov
    02.09.2024 06:36

    Про JSON Shema забыли написать.


    1. Fitbie Автор
      02.09.2024 06:36

      Согласен, но думаю про схемы и в общем валидацию форматов сериализации можно будет написать отдельный материал.