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; перевіряйте вхідні дані й користуйтеся безпечними форматами.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ