1. Введение
Если коротко: бинарный формат сериализации превращает объект в последовательность байтов, которые максимально компактно кодируют его структуру и значения. Представьте, что вы не просто описываете объект словами (как в JSON или XML), а записываете каждый его бит точно так, как хранится в памяти.
В текстовом формате данные — как письмо другу на русском языке (каждый символ — понятно человеку). В бинарном формате — это скорее морзянка, где каждая точка и тире записаны максимально коротко, и прочитать "вручную" это невозможно.
Схема: сравнение форматов
| Формат | Читаем человеком | Объем файла | Скорость (запись/чтение) | Совместимость |
|---|---|---|---|---|
| XML/JSON | Да | Большой | Медленнее | Хорошая |
| Бинарный | Нет | Малый | Очень быстро | Ограничена |
Как работает бинарная сериализация в .NET?
В .NET-экосистеме исторически был главный инструмент для бинарной сериализации — класс BinaryFormatter. Но с развитием платформы он был признан небезопасным и исключён из .NET 9. Сейчас стандартом стали другие способы: BinaryWriter/BinaryReader, а для сложных объектов — сторонние библиотеки (например, protobuf-net).
Коротко о старом (исторический экскурс)
BinaryFormatter умел брать любой помеченный атрибутом [Serializable] класс и превращать его в байты, а при десериализации восстанавливать структуру объекта. Звучит магически, но в этом кроется масса проблем (об этом ниже).
Современные средства
Для примитивных типов и простых структур удобно использовать классы BinaryWriter и BinaryReader. Для сложных объектов — сторонние библиотеки (например, protobuf-net, MessagePack-CSharp и др.).
2. Сериализация примитивов с помощью BinaryWriter
Давайте продолжим дорабатывать наше учебное приложение. Например, мы хотим записывать пользовательские настройки (имя пользователя, количество очков, время входа) в бинарный файл.
public class UserProfile
{
public string Name { get; set; }
public int Score { get; set; }
public DateTime LoginTime { get; set; }
}
public static Task SaveUserProfile(UserProfile profile, string filePath)
{
// Открываем файл для записи
using var stream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None);
using var writer = new BinaryWriter(stream);
// Записываем данные по частям. Сначала строку, затем число, затем дату
writer.Write(profile.Name ?? string.Empty); // строка
writer.Write(profile.Score); // целое число
writer.Write(profile.LoginTime.ToBinary()); // дата приводится к "long"
}
Теперь пример чтения:
public static Task<UserProfile> LoadUserProfile(string filePath)
{
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new BinaryReader(stream);
string name = reader.ReadString();
int score = reader.ReadInt32();
long dateData = reader.ReadInt64();
DateTime loginTime = DateTime.FromBinary(dateData);
return new UserProfile { Name = name, Score = score, LoginTime = loginTime };
}
Особенности примитивной бинарной сериализации
С помощью BinaryWriter мы сериализуем каждое свойство по отдельности. Это надёжный и предсказуемый способ: если структура данных меняется, мы видим это в коде.
3. Проблемы классической бинарной сериализации
Теперь посмотрим на обратную сторону медали. Почему, собственно, Microsoft так агрессивно осуждала BinaryFormatter и даже запретила его использование?
Хрупкость формата (Schema Evolution Hell)
Бинарные данные жёстко завязаны на структуру класса. Изменили класс (переименовали поле, добавили новое, удалили старое) — старые бинарные файлы стали нечитаемыми. Изменили порядок свойств — тоже проблема.
Иллюстрация:
// Вчера
public class Profile
{
public string Name;
public int Score;
}
// Сегодня
public class Profile
{
public string Name;
public double Rating; // Добавилось новое поле
public int Score;
}
Чтение старого файла вызовет либо ошибку, либо прочитает поля с "смешанными" данными. В отличие от JSON или XML, где отсутствующие элементы можно пропустить, бинарный формат не подстраивается под изменения — он как плотная бетонная дорожка: слегка отошёл вбок — и сразу "падение велосипеда".
Уязвимости десериализации
Самая большая проблема BinaryFormatter — потенциальная уязвимость. Если ваш софт десериализует бинарные данные, полученные из ненадёжного источника (например, от пользователя из интернета), злоумышленник может подложить вредоносный "объект". В прошлом это приводило даже к удалённому выполнению произвольного кода на машине жертвы.
Кроссплатформенность и совместимость
Бинарный сериализатор жёстко завязан на внутреннее представление данных в .NET и текущей версии среды выполнения, компилятора и архитектуры (например, x64/ARM). Если вы сериализуете на Windows и пробуете десериализовать на Linux — сюрпризы обеспечены! Даже между версиями .NET могут возникнуть несовместимости.
Неудобство диагностики
При возникновении проблем с текстовыми форматами можно открыть файл, посмотреть на содержимое и догадаться, что пошло не так. Бинарный файл — это загадка семи печатей. Всё, что видят ваши глаза, — бессмысленный поток байтов. "Анализировать" такой файл — удовольствие на любителя.
4. Бинарная сериализация сложных объектов
Ссылки
BinaryFormatter умел запоминать связи между объектами (например, если два свойства ссылаются на один и тот же объект), но у BinaryWriter и большинства сторонних библиотек такой магии нет. Обычно сериализация происходит по принципу "вложил один объект в другой и записал их по очереди".
Циклические ссылки
Сериализация объектов с зацикленными ссылками (например, у "мамы" свойство Child, а у "ребёнка" — свойство Parent, указывающее на родителя) либо вызывает ошибку, либо приводит к бесконечному циклу.
Пример:
public class Node
{
public Node? Next { get; set; }
public Node? Prev { get; set; }
}
Попытка сериализовать этот объект "по наивному" приведёт к зацикливанию.
5. Бинарная сериализация и переносимость
Любой бинарный формат (особенно самописный) — это формат "только для своих". Если вы собираетесь обмениваться данными с другими программами или сохранять их "на века" — выбирайте открытые стандарты: JSON, XML или ProtoBuf.
Когда бинарная сериализация оправдана?
- Если данные живут в рамках одного приложения и сохраняются "на короткий срок".
- Если важна скорость и компактность (например, для больших логов или обмена между сервисами внутри одной экосистемы).
- Если вы очень чётко контролируете обе стороны: и сериализацию, и десериализацию.
Альтернативы: protobuf, MessagePack и другие
- protobuf-net: порт Google Protocol Buffers для .NET, подходит для кроссплатформенных обменов и совместимости.
- MessagePack-CSharp: быстрая реализация MessagePack для .NET.
В отличие от "сырого" BinaryWriter, эти библиотеки реализуют схемы, поддерживают эволюцию формата, кроссплатформенность и безопасность. Используйте их, если планируете хоть какую-то совместимость с другими системами.
6. "Ручная" бинарная сериализация
Если вам всё-таки нужно записывать бинарные данные (например, для приложений, где важна производительность), используйте BinaryWriter/BinaryReader — и всегда явно кодируйте порядок и тип данных.
Советы:
- Всегда записывайте данные в одном и том же порядке, в каком планируете их читать.
- При изменении структуры файла поддерживайте номер версии или пишите "магический заголовок" (Magic Header).
- Добавляйте длину строк/массивов перед записью самих данных.
- Документируйте структуру файла: иначе через год вы сами не поймёте свой формат.
Пример: версионирование
// Сохраняем номер версии формата на первом месте
writer.Write((byte)1); // Версия 1
writer.Write(profile.Name ?? "");
writer.Write(profile.Score);
writer.Write(profile.LoginTime.ToBinary());
/*
Позволяет при изменении формата впоследствии добавить условия чтения
*/
7. Типичные ошибки при работе с бинарной сериализацией
Закодировали поля в одном порядке, при чтении поменяли местами. В результате значения "съезжают": строка читается как int, int — как дата и т.д.
Записали 10 объектов, а читаете 11. Нарушается поток: возникнет исключение о достижении конца файла.
Изменили структуру класса, а старые бинарные файлы читать невозможно — теряется вся история данных.
Забыли обработать исключения при чтении важного файла — приложение падает при первом сбое на диске (например, EndOfStreamException).
Пытаются обмениваться бинарными файлами между разными языками программирования без явного формата — в 99% случаев это гарантированная боль.
Десериализуют данные, полученные по сети от незнакомых пользователей — привет, уязвимости! Никогда не используйте BinaryFormatter; валидируйте вход и используйте безопасные форматы.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ