45
0
0
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники
Назад

Сериализуй то, сериализуй это... Выбираем формат для данных в файле

Время чтения 28 минут
Нет времени читать?
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники
45
0
0
Нет времени читать?
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники

Признайтесь: вы ведь тоже по умолчанию сохраняете всё в JSON? Я точно так делал. Нужно сохранить настройки — JSON. Экспорт данных для перегона между системами — ну конечно JSON. А потом в один прекрасный день оказалось, что файл экспорта весит 800 МБ и парсится 12 секунд…

Меня зовут Дмитрий Бахтенков, и я ведущий .NET-разработчик. В этой статье мы разберем шесть форматов сериализации в .NET: JSON, XML, YAML, BinaryFormatter, MessagePack и Protobuf. Для каждого я покажу, как подключить и использовать в коде, прогоню бенчмарк и расскажу о подводных камнях по безопасности. 

Сериализуй то, сериализуй это... Выбираем формат для данных в файле

Знакомимся с форматами

Прежде чем сравнивать, давайте разберемся с каждым форматом по отдельности. Для всех примеров будем использовать одну и ту же модель данных:


public class UserProfile
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public DateTime BirthDate { get; set; }
    public UserRole Role { get; set; }
}
 
public enum UserRole
{
    User,
    Admin,
    Moderator
}

JSON

JSON (JavaScript Object Notation) — пожалуй, самый популярный текстовый формат сериализации на сегодня. Человекочитаемый, компактнее XML, поддерживается буквально везде.

В .NET у нас два основных сериализатора: встроенный System.Text.Json и сторонний Newtonsoft.Json (он же Json.NET). Давайте разберемся, когда какой выбирать:

System.Text.Json встроен в .NET, начиная с .NET Core 3.0. Работает быстрее, аллоцирует меньше памяти, и его активно развивает команда Microsoft. Для новых проектов я рекомендую начинать именно с него.

Newtonsoft.Json — ветеран .NET-экосистемы, появился задолго до System.Text.Json. Поддерживает больше сценариев из коробки: полиморфную сериализацию, кастомные конвертеры с более гибким API, работу с dynamic. Если нужна максимальная гибкость или вы работаете с легаси-кодом, этот сериализатор по-прежнему актуален.

Попробуем сериализовать наш объект в файл с помощью System.Text.Json:


using System.Text.Json;
 
var user = new UserProfile
{
    Id = 1,
    Name = "Дмитрий",
    Email = "dmitry@example.com",
    BirthDate = new DateTime(1990, 5, 15),
    Role = UserRole.Admin
};
 
var options = new JsonSerializerOptions
{
    WriteIndented = true, // для человекочитаемого вывода
    Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping // чтобы кириллица не экранировалась
};
 
// Сериализация в файл
await using var stream = File.Create("user.json");
await JsonSerializer.SerializeAsync(stream, user, options);

 

На выходе получим:


{
  "Id": 1,
  "Name": "Дмитрий",
  "Email": "dmitry@example.com",
  "BirthDate": "1990-05-15T00:00:00",
  "Role": 1
}

Обратите внимание: enum сериализовался как число. Если хотите строковое значение, добавьте JsonStringEnumConverter в опции

XML

XML (eXtensible Markup Language) — формат, который многие считают устаревшим, но он до сих пор живет и здравствует. Конфигурации .csproj, SOAP-сервисы, интеграции с легаси-системами — XML повсюду.

В .NET есть два основных XML-сериализатора:

XmlSerializer — классический вариант, работает с публичными свойствами и требует конструктор без параметров. Простой, предсказуемый, знакомый всем.

DataContractSerializer — более гибкий, работает с атрибутами [DataContract] / [DataMember], поддерживает приватные поля и более сложные графы объектов. Используется в WCF.

Для нашего примера возьмем XmlSerializer: он проще и для файловой сериализации подходит отлично:


using System.Xml.Serialization;
 
var serializer = new XmlSerializer(typeof(UserProfile));
 
// Сериализация в файл
using var writer = new StreamWriter("user.xml");
serializer.Serialize(writer, user);
 
// Десериализация из файла
using var reader = new StreamReader("user.xml");
var deserialized = (UserProfile)serializer.Deserialize(reader)!;

 

Результат:


<?xml version="1.0" encoding="utf-8"?>
<UserProfile xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
             xmlns:xsd="http://www.w3.org/2001/XMLSchema">
  <Id>1</Id>
  <Name>Дмитрий</Name>
  <Email>dmitry@example.com</Email>
  <BirthDate>1990-05-15T00:00:00</BirthDate>
  <Role>Admin</Role>
</UserProfile>

 

Видно, что XML значительно многословнее JSON: пространства имен, закрывающие теги, объявление XML. Для одного объекта разница небольшая, но когда у вас коллекция из тысяч элементов, работать с этим становится сложнее.

YAML

YAML — формат, который любят за читаемость. Docker Compose, Kubernetes-манифесты, конфиги GitHub Actions, настройки Jekyll — YAML повсюду в мире DevOps.

В .NET для работы с YAML есть библиотека YamlDotNet. Подключаем через NuGet:

dotnet add package YamlDotNet

 

Пример сериализации:


using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
 
var serializer = new SerializerBuilder()
    .WithNamingConvention(CamelCaseNamingConvention.Instance)
    .Build();
 
var yaml = serializer.Serialize(user);
await File.WriteAllTextAsync("user.yaml", yaml);

 

Результат:

Выглядит аккуратно, правда? Но у YAML есть несколько неприятных сюрпризов, о которых стоит знать:

Norway Problem. В YAML 1.1 строка NO интерпретируется как bool false. Представьте, что у вас есть коды стран, и код Норвегии (NO) вдруг превращается в false. Та же история с YES, ON, OFF. В YAML 1.2 это исправили, но далеко не все парсеры используют новую версию.

Неявная типизация. Строка 1.0 может стать числом float, строка 2024-01-01 — датой. Это особенно опасно при обмене данными между разными языками: Python-парсер может интерпретировать значение иначе, чем C#-парсер.

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

BinaryFormatter

BinaryFormatter — встроенный бинарный сериализатор, который был в .NET с самого начала. Работал просто: помечаешь класс атрибутом [Serializable], вызываешь Serialize/Deserialize, и он через рефлексию обходит весь граф объектов, включая приватные поля.

Звучит удобно. Так почему же Microsoft его убил?

Проблема в том, как BinaryFormatter работает под капотом. Сериализованные данные содержат полную информацию о типах: имя сборки, имя класса, все поля. При десериализации BinaryFormatter создает объекты указанных типов и заполняет их поля. Если атакующий подсунет вредоносный payload с нужными типами, при десериализации выполнится произвольный код. Через эту уязвимость ломали Microsoft Exchange Server, SharePoint, Azure DevOps.

Microsoft долго боролся с последствиями, но в итоге решил, что формат невозможно сделать безопасным. Вот хронология:

  1. .NET 5 — атрибут [Obsolete] с предупреждением SYSLIB0011.
  2. .NET 7 — предупреждение стало ошибкой компиляции.
  3. .NET 8 — NotSupportedException по умолчанию при вызове.
  4. .NET 9 — полное удаление реализации из поставки.

Показывать пример кода с BinaryFormatter я не буду: незачем учить тому, что нельзя использовать. Вместо этого запомним: если вы встретили BinaryFormatter в легаси-коде — это повод для миграции на System.Text.Json, MessagePack или Protobuf.

Полезные ссылки:

MessagePack

MessagePack — бинарный формат сериализации, который часто описывают как «бинарный JSON». Он schema-less (не требует описания схемы), компактный и быстрый.

Немного истории. MessagePack создал японский разработчик Садаюки Фурухаси (Sadayuki Furuhashi) в 2008 году. Что интересно: изначально формат разрабатывался не для сетевого взаимодействия, а для распределенной файловой системы. Позже Фурухаси создал Fluentd — популярный коллектор логов, и MessagePack стал его основой.

В .NET для работы с MessagePack есть библиотека MessagePack-CSharp. Подключаем:

dotnet add package MessagePack

 

Модель нужно пометить атрибутами:


using MessagePack;
 
[MessagePackObject]
public class UserProfile
{
    [Key(0)] public int Id { get; set; }
    [Key(1)] public string Name { get; set; } = string.Empty;
    [Key(2)] public string Email { get; set; } = string.Empty;
    [Key(3)] public DateTime BirthDate { get; set; }
    [Key(4)] public UserRole Role { get; set; }
}

 

Атрибут [Key(N)] задает порядковый номер поля. Именно по этим номерам (а не по именам свойств) MessagePack находит данные при десериализации. Это одна из причин компактности: в бинарном представлении хранятся числовые индексы, а не строковые имена полей.


// Сериализация в файл
var bytes = MessagePackSerializer.Serialize(user);
await File.WriteAllBytesAsync("user.msgpack", bytes);
 
// Десериализация из файла
var data = await File.ReadAllBytesAsync("user.msgpack");
var deserialized = MessagePackSerializer.Deserialize<UserProfile>(data);

 

Библиотека также поддерживает встроенное LZ4-сжатие — можно сжимать данные прямо при сериализации без дополнительных шагов:


var options = MessagePackSerializerOptions.Standard.WithCompression(
    MessagePackCompression.Lz4BlockArray);
 
var compressed = MessagePackSerializer.Serialize(user, options);

 

Как мы видим, файл в формате MessagePack гораздо меньше, чем в остальных форматах:

Где MessagePack используют для сохранения в файлы на практике:

  • Игровые сейвы в Unity. Unity официально рекомендует MessagePack как формат для сохранений.
  • Кеш на диск (Pinterest). Сжатие 64-битных ID до 5 байт.

Protocol Buffers (Protobuf)

Protocol Buffers (Protobuf) — бинарный формат сериализации от Google. В отличие от MessagePack, Protobuf — schema-first: структура данных описывается заранее, и сериализатор строго ей следует.

Разработка Protobuf внутри Google началась в 2001 году (Proto1). В 2008-м формат стал open source (Proto2), а в 2016-м вышла третья версия (Proto3). Сегодня Protobuf — основа gRPC и множества внутренних систем Google.

Для .NET самый популярный вариант — библиотека protobuf-net. Она позволяет работать с Protobuf через атрибуты, без .proto-файлов:

dotnet add package protobuf-net


using ProtoBuf;
 
[ProtoContract]
public class UserProfile
{
    [ProtoMember(1)] public int Id { get; set; }
    [ProtoMember(2)] public string Name { get; set; } = string.Empty;
    [ProtoMember(3)] public string Email { get; set; } = string.Empty;
    [ProtoMember(4)] public DateTime BirthDate { get; set; }
    [ProtoMember(5)] public UserRole Role { get; set; }
}

// Сериализация в файл
using var file = File.Create("user.proto.bin");
Serializer.Serialize(file, user);
 
// Десериализация из файла
using var input = File.OpenRead("user.proto.bin");
var deserialized = Serializer.Deserialize<UserProfile>(input);

 

Одна из главных сильных сторон Protobuf — версионирование контрактов. Вы можете добавлять новые поля (с новыми номерами тегов), и старые данные продолжат корректно десериализоваться. Старые клиенты просто проигнорируют неизвестные теги. Для файлов, которые будут жить долго (архивы, конфиги, сейвы), это критически важно.

Где Protobuf используют для сохранения в файлы:

  • Архивное хранение в Google — Protobuf используется не только для gRPC, но и для хранения данных на диске (документация Google так и говорит: Archival storage of data).
  • TFRecord в TensorFlow — формат хранения ML-датасетов (изображения + метки) внутри Protobuf.
  • Игровые сейвы — protobuf-unity

Полезные ссылки:

Кто быстрее?

С теорией разобрались, теперь давайте замерим. Я написал бенчмарк на BenchmarkDotNet под .NET 8 (LTS) с двумя сценариями:

  1. Маленький DTO — один объект UserProfile (строки, число, DateTime, enum).
  2. Коллекция — List<UserProfile>; из 10 000 таких объектов.

Что измеряем:

  • Скорость сериализации.
  • Скорость десериализации.
  • Размер выходного файла.
  • Аллокации (через [MemoryDiagnoser]).

Участники:

  • System.Text.Json
  • Newtonsoft.Json
  • XmlSerializer
  • YamlDotNet
  • MessagePack-CSharp
  • protobuf-net

 

Результаты: коллекция из 10 000 объектов

Что бросается в глаза

Бинарные форматы (MessagePack, Protobuf) ожидаемо лидируют — и по скорости, и по размеру файла. 

Newtonsoft.Json предсказуемо медленнее System.Text.Json и аллоцирует значительно больше.

YamlDotNet — аутсайдер по производительности. Аллокации в десятки раз больше, чем у остальных. 

Уязвимости при десериализации

А теперь поговорим о безопасности. Каждый раз, когда вы десериализуете данные из внешнего источника (файла, HTTP-запроса, очереди сообщений), вы потенциально выполняете чужие инструкции.

JSON/Newtonsoft: TypeNameHandling

Начнем с самого распространенного: Newtonsoft.Json и его настройка TypeNameHandling.

Если вы включите TypeNameHandling.All (или Auto, Objects), Newtonsoft начнет записывать в JSON специальное поле $type с полным именем .NET-типа. При десериализации библиотека создаст именно этот тип. Казалось бы, удобно для полиморфизма. Но проблема в том, что атакующий может указать в $type любой тип из загруженных сборок.

Вот как выглядит вредоносный payload:


{
  "$type": "System.IO.FileInfo, System.IO.FileSystem",
  "fileName": "rce-test.txt"
}

 

Это упрощенный пример. На практике через инструмент ysoserial.net можно собрать цепочку объектов (gadget chain), которая при десериализации выполнит произвольную команду на сервере. Атакующий подставляет тип с сеттером, который вызывает Process.Start(), и готово — RCE (Remote Code Execution).

Реальные случаи: CVE-2026-26208 в ADB Explorer. Приложение читает App.txt с небезопасной настройкой, и можно подменить этот файл для выполнения произвольного кода.

Как защититься:

  • Не используйте TypeNameHandling.All / Auto / Objects. Никогда.
  • Если полиморфизм нужен, настройте SerializationBinder, который разрешает только конкретные типы.
  • Или перейдите на System.Text.Json: он не поддерживает $type по дизайну и безопасен в этом отношении.

XML: XXE (XML External Entities)

У XML есть своя классическая уязвимость — XXE (XML External Entities). Она не связана с десериализацией как таковой, но напрямую касается обработки XML-данных.

XML поддерживает конструкцию <!DOCTYPE> с определением внешних сущностей (<!ENTITY>). Парсер может подставить содержимое внешнего ресурса прямо в документ. Атакующий отправляет XML такого вида:


<?xml version="1.0"?>
<!DOCTYPE foo [
  <!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<UserProfile>
  <Name>&xxe;</Name>
</UserProfile>

 

Если парсер обрабатывает DTD, он прочитает файл /etc/passwd (или любой другой файл на сервере) и подставит его содержимое в поле Name. Это позволяет читать произвольные файлы, что может быть недопустимо.

Как защититься:

В .NET нужно правильно настроить XmlReaderSettings:


var settings = new XmlReaderSettings
{
    DtdProcessing = DtdProcessing.Prohibit, // запрещаем обработку DTD
    XmlResolver = null                       // отключаем резолвинг внешних ресурсов
};
 
using var reader = XmlReader.Create(stream, settings);
var serializer = new XmlSerializer(typeof(UserProfile));
var result = (UserProfile)serializer.Deserialize(reader)!;

 

Начиная с .NET Core / .NET 5+, XmlReader по умолчанию не обрабатывает DTD, но явно указать DtdProcessing.Prohibit — хорошая практика. Если вы работаете с .NET Framework, там дефолты менее безопасные и настройка обязательна.

BinaryFormatter: десериализация = исполнение кода

Мы уже говорили, что Microsoft убил BinaryFormatter, и вот конкретная причина.

При десериализации BinaryFormatter восстанавливает граф объектов из бинарных данных. Payload содержит полные имена типов, и десериализатор создает экземпляры этих типов, вызывая конструкторы и заполняя поля. Атакующий собирает цепочку объектов, в которой создание и инициализация определенных типов приводят к выполнению произвольного кода.

Инструмент ysoserial.net содержит десятки готовых gadget chains для .NET. Достаточно скормить вредоносный payload приложению, которое вызывает BinaryFormatter.Deserialize, — и всё, RCE. Один из примеров — CVE-2019-1306 в Azure DevOps.

Проблема в том, что payload управляет тем, какие объекты создаются. В JSON-сериализаторах (без TypeNameHandling) десериализатор создает только тот тип, который вы указали в коде. В BinaryFormatter тип определяется данными.

После многих лет борьбы с последствиями Microsoft признал, что BinaryFormatter невозможно сделать безопасным, и принял решение об удалении.

YAML: десериализация произвольных типов

YAML поддерживает теги типов — специальные маркеры, которые указывают парсеру, какой объект создать:


user: !tag:MyApp.MaliciousType
  command: "rm -rf /"

 

Если десериализатор резолвит произвольные типы по тегам из данных, атакующий может подсунуть вредоносный объект. C этим связана уязвимость CVE-2018-1000210, в которой через теги можно было выполнить RCE.

Как защититься:

  • Используйте DeserializerBuilder без регистрации произвольных тегов.
  • Десериализуйте в конкретные, заранее известные типы.
  • Не используйте «динамическую» десериализацию для внешних данных.

 

MessagePack/Protobuf: почему безопаснее по дизайну

Бинарные форматы MessagePack и Protobuf значительно безопаснее при десериализации. Причина: в них нет механизма полиморфной десериализации — данные не содержат информации о типах .NET, а десериализатор создает только те объекты, которые описаны в вашем коде.

Protobuf особенно строг: schema-first подход означает, что сериализатор и десериализатор работают только с полями, описанными в контракте. Неизвестные поля просто пропускаются.

В MessagePack-CSharp существует режим Typeless. Он делает ровно то, за что мы ругали BinaryFormatter: записывает имена типов в сериализованные данные и создает их при десериализации. Используете Typeless с внешними данными — получаете потенциальную RCE.

 

Общая оценка

Какой формат выбрать

Заключение

Что можно вынести из этой статьи:

  • JSON — отличный дефолт для человекочитаемых данных. System.Text.Json закрывает большинство задач и делает это быстро.
  • XML жив, но для новых проектов выбирайте его, только если того требует интеграция.
  • YAML хорош для конфигов, но для сериализации данных в файлы у него слишком много подводных камней и слабая производительность.
  • BinaryFormatter мертв. Если встретите в легаси, мигрируйте.
  • MessagePack и Protobuf выбирайте, если важны скорость, компактность и безопасность. MessagePack проще начать (schema-less), Protobuf надежнее для долгоживущих данных (версионирование).

И главное — помните о безопасности. Десериализация из недоверенных источников требует осторожности с любым форматом. Не включайте TypeNameHandling в Newtonsoft, не забывайте о DtdProcessing.Prohibit в XML и держитесь подальше от Typeless-режимов.

Спасибо за прочтение статьи! Кроме того, я веду телеграм-канал Flexible Coding, где рассказываю о своем опыте в программировании.

Комментарии0
Тоже интересно
Комментировать
Поделиться
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники