1. Giới thiệu
Chắc bạn đã quen với luồng (Stream), nó là một abstraction cho việc đọc hoặc ghi dữ liệu tuần tự. Mình từng dùng FileStream để truy cập file ở cấp độ byte, và cũng đã dùng StreamReader và StreamWriter để làm việc với dữ liệu text cho tiện, thực ra bên dưới nó vẫn dùng FileStream và tự xử lý encoding.
Nhưng nếu bạn cần lưu vào file không chỉ là text, mà là dữ liệu có kiểu rõ ràng: số nguyên (int), số thực (double, float), giá trị boolean (bool), ngày tháng (DateTime) hoặc thậm chí là struct tự định nghĩa? Đúng là bạn có thể convert hết sang string rồi ghi bằng StreamWriter, sau đó lại parse ngược khi đọc. Nhưng cách này có mấy nhược điểm to đùng:
- Lưu trữ không hiệu quả: Số 12345 nếu ghi dạng text thì tốn 5 byte (ký tự). Dạng nhị phân int chỉ tốn 4 byte thôi. Dữ liệu lớn thì khác biệt càng rõ.
- Hiệu năng: Việc convert số sang string rồi lại parse ngược lại — tốn CPU vô ích.
- Độ chính xác dữ liệu: Convert số thực sang text rồi lại parse có thể làm mất chính xác do làm tròn.
- Khó parse: Tự tách text để lấy từng kiểu dữ liệu (ví dụ "123,45 TRUE 2024-06-21") làm code phức tạp và dễ lỗi.
Để giải quyết mấy vấn đề này, có các class chuyên dụng là BinaryReader và BinaryWriter. Hai class này là adapter chuyên biệt, hoạt động trên bất kỳ Stream nào (thường là FileStream) và cung cấp method tiện để đọc/ghi kiểu dữ liệu nguyên thủy của C# ở dạng nhị phân. Nó tự lo việc convert byte sang kiểu cụ thể và ngược lại, giúp làm việc với file nhị phân có cấu trúc dễ hơn nhiều.
Ý chính: BinaryReader và BinaryWriter không phải là stream độc lập. Nó tăng cường chức năng của Stream hiện có, thêm method để làm việc với kiểu C# thay vì byte thô.
2. Ghi dữ liệu với BinaryWriter
BinaryWriter có loạt method Write(), overload cho từng kiểu dữ liệu nguyên thủy của C#. Khi gọi, BinaryWriter sẽ convert giá trị sang dạng nhị phân (chuỗi byte) và ghi vào Stream gốc.
Ví dụ: Lưu cài đặt game
Giả sử bạn muốn lưu cài đặt game: mức âm lượng (float), level hiện tại (int), nhạc bật không (bool) và độ khó đã chọn (string).
string filePath = "settings.bin";
// 1. Tạo FileStream để ghi
using FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write);
// 2. Tạo BinaryWriter trên FileStream, chỉ định encoding cho string (nếu có)
using BinaryWriter writer = new BinaryWriter(fs, Encoding.UTF8);
// 3. Ghi các kiểu dữ liệu khác nhau
writer.Write(0.75f); // float (4 byte)
writer.Write(15); // int (4 byte)
writer.Write(true); // bool (1 byte)
writer.Write("Easy"); // string (prefix độ dài + byte)
Console.WriteLine($"Cài đặt đã lưu vào '{filePath}'.");
Phân tích ví dụ:
- Tạo FileStream với FileMode.Create, tức là tạo file mới hoặc ghi đè file cũ.
- Sau đó tạo BinaryWriter, truyền vào fs. Lưu ý, BinaryWriter mặc định sẽ đóng luôn stream gốc (fs) khi gọi Dispose() (tự động khi dùng using).
- Các method writer.Write() rất dễ hiểu: Write(float), Write(int), Write(bool), Write(string). Nó tự biết cần ghi bao nhiêu byte cho từng kiểu và ghi thế nào.
- Với string, BinaryWriter tự động thêm prefix độ dài trước byte của string. Nhờ đó BinaryReader biết chính xác cần đọc bao nhiêu byte để lấy lại string.
- Nếu bạn mở settings.bin bằng editor text sẽ thấy "rác", vì đây là file nhị phân. Muốn xem nội dung phải dùng HEX editor.
3. Đọc dữ liệu với BinaryReader
BinaryReader có các method ReadXxx() (ví dụ ReadInt32(), ReadBoolean(), ReadString()), nó sẽ đọc đúng số byte từ Stream gốc và convert thành kiểu dữ liệu C# tương ứng.
Ví dụ: Tải cài đặt game
Giờ mình sẽ đọc lại cài đặt từ file settings.bin vừa tạo ở trên.
string filePath = "settings.bin";
// 1. Tạo FileStream để đọc
using FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read);
// 2. Tạo BinaryReader trên FileStream, dùng đúng encoding như khi ghi
using BinaryReader reader = new BinaryReader(fs, Encoding.UTF8);
// 3. Đọc dữ liệu THEO ĐÚNG THỨ TỰ đã ghi
float volume = reader.ReadSingle(); // float
int level = reader.ReadInt32(); // int
bool isMusicOn = reader.ReadBoolean(); // bool
string difficulty = reader.ReadString(); // string
Console.WriteLine($"Cài đặt đã tải từ '{filePath}':");
Console.WriteLine($"- Âm lượng: {volume:P0}"); // Format kiểu phần trăm (dùng string format)
Console.WriteLine($"- Level người chơi: {level}");
Console.WriteLine($"- Nhạc bật: {isMusicOn}");
Console.WriteLine($"- Độ khó: {difficulty}");
Phân tích ví dụ:
- Mở FileStream ở chế độ FileMode.Open để đọc.
- Tạo BinaryReader trên fs, chỉ định đúng encoding như khi ghi.
- Cực kỳ quan trọng: Thứ tự gọi reader.ReadXxx() phải CHÍNH XÁC như thứ tự đã ghi bằng BinaryWriter. Nếu bạn đọc string ở chỗ đã ghi int, sẽ bị lỗi EndOfStreamException (nếu string dài hơn) hoặc đọc sai dữ liệu.
- Các method ReadXxx() tự động đọc đúng số byte và convert sang kiểu cần. ReadString() dùng prefix độ dài đã ghi bởi BinaryWriter để biết cần đọc bao nhiêu byte cho string.
4. Lưu ý quan trọng và best practice
Thứ tự nghiêm ngặt:
Đây là luật số 1. BinaryReader và BinaryWriter không lưu metadata về kiểu; nó chỉ biết mỗi kiểu chiếm bao nhiêu byte. Bạn phải đảm bảo thứ tự khớp nhau.
Quản lý resource (using):
Như hầu hết các class .NET làm việc với resource hệ thống (file, kết nối mạng...), cả BinaryReader và BinaryWriter đều implement IDisposable. Luôn bọc chúng trong using block — đảm bảo Dispose() được gọi tự động kể cả khi lỗi. Như vậy tránh rò rỉ và file sẽ được đóng đúng cách.
À, mặc định BinaryWriter và BinaryReader cũng sẽ gọi Dispose() cho stream gốc (ví dụ FileStream), nên nó cũng được đóng luôn.
using FileStream fs = new FileStream("data.bin", FileMode.OpenOrCreate);
using BinaryWriter writer = new BinaryWriter(fs);
// ... làm việc
Encoding cho string:
Để làm việc với string truyền vào BinaryWriter.Write(string) và đọc bằng BinaryReader.ReadString() cho chuẩn, luôn chỉ định cùng một encoding trong constructor của chúng (ví dụ Encoding.UTF8). Nếu không sẽ lỗi với ký tự ngoài ASCII.
Xử lý exception:
File IO luôn có thể bị lỗi ngoài ý muốn (file không tồn tại, không đủ quyền, đầy ổ...). Luôn bọc code dùng FileStream và BinaryReader/BinaryWriter trong try-catch để code ổn định.
BaseStream và vị trí:
Bạn có thể truy cập stream gốc qua property BaseStream (ví dụ reader.BaseStream hoặc writer.BaseStream). Rất tiện nếu muốn biết vị trí hiện tại (BaseStream.Position) hoặc seek file (BaseStream.Seek()).
// Ví dụ dùng BaseStream.Position
using FileStream fs = new FileStream("data.bin", FileMode.OpenOrCreate);
using BinaryWriter writer = new BinaryWriter(fs);
writer.Write(123);
Console.WriteLine($"Vị trí hiện tại trong stream: {writer.BaseStream.Position}"); // In ra 4 (kích thước int)
writer.Write("Hello");
Console.WriteLine($"Vị trí hiện tại trong stream: {writer.BaseStream.Position}"); // In ra 4 + (1+5) = 10
⚠️ Method Write(string) sẽ ghi trước độ dài string dạng số nguyên 7-bit, sau đó mới đến byte của string. Nên tổng kích thước không phải lúc nào cũng là 1 + độ dài string đâu nhé.
GO TO FULL VERSION