1. Wprowadzenie
Krótko: binarny format serializacji zamienia obiekt w sekwencję bajtów, które maksymalnie kompaktowo kodują jego strukturę i wartości. Wyobraź sobie, że nie opisujesz obiektu słowami (jak w JSON czy XML), tylko zapisujesz każdy jego bit dokładnie tak, jak jest przechowywany w pamięci.
W formacie tekstowym dane to jak list do kumpla po rosyjsku (każdy znak zrozumiały dla człowieka). W formacie binarnym to bardziej morsowanie, gdzie każda kropka i kreska są zapisane maksymalnie krótko i „odczytać ręcznie” tego raczej nie dasz rady.
Schemat: porównanie formatów
| Format | Czy czytelny dla człowieka | Rozmiar pliku | Szybkość (zapis/odczyt) | Kompatybilność |
|---|---|---|---|---|
| XML/JSON | Tak | Duży | Wolniejsze | Dobra |
| Binarny | Nie | Mały | Bardzo szybko | Ograniczona |
Jak działa binarna serializacja w .NET?
W ekosystemie .NET historycznie głównym narzędziem do binarnej serializacji był BinaryFormatter. Ale wraz z rozwojem platformy uznano go za niebezpieczny i usunięto z .NET 9. Obecnie standardem są inne sposoby: BinaryWriter/BinaryReader, a dla złożonych obiektów — zewnętrzne biblioteki (np. protobuf-net).
Krótko o starym (krótki rys historyczny)
BinaryFormatter potrafił wziąć dowolną klasę oznaczoną atrybutem [Serializable] i zamienić ją w bajty, a przy deserializacji odtworzyć strukturę obiektu. Brzmi magicznie, ale w tym tkwi mnóstwo problemów (o tym niżej).
Nowoczesne narzędzia
Dla typów prymitywnych i prostych struktur wygodnie jest używać klas BinaryWriter i BinaryReader. Dla złożonych obiektów — biblioteki zewnętrzne (np. protobuf-net, MessagePack-CSharp itp.).
2. Serializacja prymitywów za pomocą BinaryWriter
Dokończmy pracę nad naszym ćwiczeniowym appem. Na przykład chcemy zapisywać ustawienia użytkownika (imię użytkownika, ilość punktów, czas logowania) do pliku binarnego.
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)
{
// Otwieramy plik do zapisu
using var stream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None);
using var writer = new BinaryWriter(stream);
// Zapisujemy dane po częściach. Najpierw string, potem liczba, potem data
writer.Write(profile.Name ?? string.Empty); // string
writer.Write(profile.Score); // int
writer.Write(profile.LoginTime.ToBinary()); // data konwertowana na "long"
}
Teraz przykład odczytu:
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 };
}
Cechy prymitywnej binarnej serializacji
Za pomocą BinaryWriter serializujemy każde pole osobno. To solidny i przewidywalny sposób: jeśli zmieni się struktura danych, widać to w kodzie.
3. Problemy klasycznej binarnej serializacji
Spójrzmy teraz na drugą stronę medalu. Dlaczego Microsoft tak mocno odradzał używanie BinaryFormatter i ostatecznie je zablokował?
Kruchość formatu (Schema Evolution Hell)
Dane binarne są silnie związane ze strukturą klasy. Zmieniłeś klasę (zmieniłeś nazwę pola, dodałeś nowe, usunąłeś stare) — stare pliki binarne stają się nieczytelne. Zmiana kolejności pól też napsuje.
Ilustracja:
// Wczoraj
public class Profile
{
public string Name;
public int Score;
}
// Dzisiaj
public class Profile
{
public string Name;
public double Rating; // Dodano nowe pole
public int Score;
}
Odczyt starego pliku wywoła błąd albo odczyta pola z „pomieszanymi” danymi. W przeciwieństwie do JSON-a czy XML-a, gdzie brakujące elementy można pominąć, format binarny się nie dostosowuje — to jak betonowa ścieżka: wystarczy małe przesunięcie i jazda na rowerze kończy się upadkiem.
Podatności przy deserializacji
Największy problem BinaryFormatter to potencjalne podatności. Jeśli Twój soft deserializuje binarne dane pochodzące z niezaufanego źródła (np. od użytkownika z Internetu), atakujący może podłożyć złośliwy „obiekt”. W przeszłości prowadziło to nawet do zdalnego wykonania kodu na maszynie ofiary.
Krosplatformowość i kompatybilność
Binarny serializer jest ściśle zależny od wewnętrznego przedstawienia danych w .NET oraz wersji runtime, kompilatora i architektury (np. x64/ARM). Jeśli serializujesz na Windows i próbujesz deserializować na Linux — niespodzianki murowane! Nawet między wersjami .NET mogą wystąpić niezgodności.
Niewygoda diagnostyki
Przy problemach z formatami tekstowymi możesz otworzyć plik, popatrzeć i zgadnąć, co poszło nie tak. Plik binarny to tajemnica na siedem pieczęci. Wszystko, co widzisz, to bezsensowny strumień bajtów. Analizowanie takiego pliku to frajda dla masochistów.
4. Binarna serializacja złożonych obiektów
Referencje
BinaryFormatter potrafił zapamiętywać powiązania między obiektami (np. jeśli dwa pola wskazują na ten sam obiekt), ale BinaryWriter i większość bibliotek zewnętrznych tej magii nie mają. Zwykle serializacja idzie po prostu „zagnieżdżenie obiektu w obiekcie i zapisanie ich po kolei”.
Cykliczne referencje
Serializacja obiektów z cyklicznymi odwołaniami (np. u „mamy” pole Child, a u „dziecka” pole Parent wskazujące z powrotem) albo rzuci wyjątek, albo skończy się nieskończoną pętlą.
Przykład:
public class Node
{
public Node? Next { get; set; }
public Node? Prev { get; set; }
}
Próba serializacji takiego obiektu „naiwna” skończy się zapętleniem.
5. Binarna serializacja i przenośność
Każdy binarny format (zwłaszcza własny) to format „tylko dla swoich”. Jeśli zamierzasz wymieniać dane z innymi programami lub przechowywać je „na lata” — wybieraj otwarte standardy: JSON, XML albo ProtoBuf.
Kiedy binarna serializacja ma sens?
- Jeśli dane żyją w ramach jednej aplikacji i są zapisywane „na krótko”.
- Jeśli ważna jest szybkość i kompaktowość (np. dla dużych logów lub wymiany między serwisami w tej samej ekosystemie).
- Jeśli bardzo ściśle kontrolujesz obie strony: i serializację, i deserializację.
Alternatywy: protobuf, MessagePack i inne
- protobuf-net: port Google Protocol Buffers dla .NET, nadaje się do krosplatformowej wymiany i kompatybilności.
- MessagePack-CSharp: szybka implementacja MessagePack dla .NET.
W odróżnieniu od „surowego” BinaryWriter, te biblioteki oferują schematy, wspierają ewolucję formatu, krosplatformowość i bezpieczeństwo. Używaj ich, jeśli planujesz jakąkolwiek kompatybilność z innymi systemami.
6. „Ręczna” binarna serializacja
Jeśli mimo wszystko musisz zapisywać binarne dane (np. w aplikacjach, gdzie liczy się wydajność), używaj BinaryWriter/BinaryReader — i zawsze jawnie koduj kolejność i typ danych.
Wskazówki:
- Zawsze zapisuj dane w tej samej kolejności, w jakiej planujesz je czytać.
- Przy zmianie struktury pliku utrzymuj numer wersji lub zapisuj „magiczny nagłówek” (Magic Header).
- Dodawaj długość stringów/tablic przed zapisaniem samych danych.
- Dokumentuj strukturę pliku: inaczej za rok sam nie zrozumiesz własnego formatu.
Przykład: wersjonowanie
// Zapisujemy numer wersji formatu na pierwszym miejscu
writer.Write((byte)1); // Wersja 1
writer.Write(profile.Name ?? "");
writer.Write(profile.Score);
writer.Write(profile.LoginTime.ToBinary());
/*
Pozwala przy zmianie formatu w przyszłości dodać warunki odczytu
*/
7. Typowe błędy przy pracy z binarną serializacją
Zapisaliście pola w jednej kolejności, a przy odczycie zamieniliście je miejscami. W efekcie wartości „przesuwają się”: string jest czytany jako int, int jako data itd.
Zapisaliście 10 obiektów, a czytacie 11. Przepływ się łamie: pojawi się wyjątek o osiągnięciu końca pliku.
Zmodyfikowaliście strukturę klasy, a stare pliki binarne stały się nieczytelne — tracicie całą historię danych.
Zapomnieliście obsłużyć wyjątki przy odczycie ważnego pliku — aplikacja pada przy pierwszej awarii na dysku (np. EndOfStreamException).
Próbujecie wymieniać pliki binarne między różnymi językami bez jawnego formatu — w 99% przypadków to gwarantowane cierpienie.
Deserializujecie dane otrzymane po sieci od nieznanych użytkowników — witajcie, podatności! Nigdy nie używajcie BinaryFormatter; walidujcie wejście i używajcie bezpiecznych formatów.
GO TO FULL VERSION