Чтение и запись бинарных файлов в C#

Для работы с бинарными файлами в C# можно использовать как обычный FileStream, так и более удобные для этих целей классы, а именно — BinaryWriter и BinaryReader. Эти классы, содержат достаточно большое количество перегруженных версий методов, позволяющих записывать и считывать различные данные различных типов в бинарные файлы.

Запись бинарных файлов в C# (класс BinaryWriter)

Для создания экземпляра класса BinaryWriter мы можем воспользоваться одним из следующих конструкторов:

BinaryWriter() Инициализирует новый экземпляр класса BinaryWriter, который осуществляет запись в поток.
BinaryWriter(Stream) Инициализирует новый экземпляр класса BinaryWriter на основании указанного потока с использованием кодировки UTF-8.
BinaryWriter(Stream, Encoding) Инициализирует новый экземпляр класса BinaryWriter на основе указанного потока и кодировки символов.
BinaryWriter(Stream, Encoding, Boolean) Инициализирует новый экземпляр класса BinaryWriter на основании указанного потока и кодировки символов, а также при необходимости оставляет поток открытым.

Для работы с бинарными данными у класса BinaryWriter определен ряд методов, которые имеют перегруженные версии:

Метод Описание
Flush() Очищает все буферы текущего модуля записи и вызывает немедленную запись всех буферизованных данных на базовое устройство.
Seek(Int32, SeekOrigin) Задает позицию в текущем потоке. См. работу аналогичного метода у FileStream.
Write() Записывает значение различных типов в поток. Определены методы для записи чисел, символов, строк, значений boolean и т.д.
Write7BitEncodedInt(Int32) Записывает 32-разрядное целое число в сжатом формате.

Так как мы рассматриваем работу с бинарными файлами, то запишем в файл, например, объект такого класса:

public class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
    public double Weight { get; set; }
}

Бинарные файлы, обычно, имеют строго определенную структуру, благодаря чему, мы можем получать доступ к определенной части данных и считывать или записывать их. В нашем классе «тонким» местом является имя. У разных людей длина имени различна — от одного символа до десятков символов и более. Соответственно, чтобы мы могли точно устанавливать позицию в файле для чтения данных, записывать этот файл мы должны строго по определенному формату. Для этого договоримся, что длина имени человека не должна превышать 100 символов, а если, друг, имя превысит 100 символов то оно будет обрезаться.

Таким образом мы получаем следующий формат бинарного файла:

  1. первые 100 байт файла отведены для имени (Name)
  2. далее 4 байта для возраста (Age)
  3. далее 8 байт для веса (Weight)

Итого, после записи одного объекта в файл его размер должен стать ровно 100+4+8 = 112 байт. Вот как может выглядеть запись объекта в файл в C#:

class Program
 {

     static byte[] CopyBytes(string value, int bufferSize)
     {
         byte[] result = new byte[bufferSize];
         byte[] buffer = Encoding.Default.GetBytes(value);
         Array.Copy(buffer, result, buffer.Length < bufferSize ? buffer.Length : bufferSize);
         return result;
     }

     static void Main(string[] args)
     {
         Person person = new()
         {
             Name = "Иван Иванович Иванов",
             Age = 45,
             Weight = 70.5
         };

         string fileName = @"c:\CSharp Output\TextFile.dat";
         using FileStream fs = new FileStream(fileName, FileMode.OpenOrCreate);
         using BinaryWriter writer = new BinaryWriter(fs);
         byte[] nameBuffer = CopyBytes(person.Name, 100);
         writer.Write(nameBuffer);
         writer.Write(person.Age);
         writer.Write(person.Weight);
     }
 }

Вспомогательная функция CopyBytes создает массив ровно на 100 байт. После записи мы получим файл размером 112 байт. Так как нам известен формат файла, то мы можем таким образом записать в файл, например, не один, а 15 объектов и, при необходимости, получить доступ к любому из объектов.

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

string fileName = @"c:\CSharp Output\TextFile.dat";
using FileStream fs = new FileStream(fileName, FileMode.OpenOrCreate);
using BinaryWriter writer = new BinaryWriter(fs, Encoding.UTF8, false);
writer.Write(person.Name);
writer.Write(person.Age);
writer.Write(person.Weight);

Будет занимать не 112 байт, как ранее, а всего 51, но, при этом мы уже не сможем точно позиционировать курсор на необходимом нам объекте, если будем записывать в файл несколько объектов одного класса.

Чтение бинарных файлов в C# (класс BinaryReader)

Для чтения бинарного файла мы можем использовать класс BinaryReader, которые, как и предыдущий класс BinaryWriter содержит ряд специальных методов, но только для чтения данных из файла. Конструкторы класса BinaryReader — точная копия конструкторов BinaryWriter:

BinaryReader(Stream) Инициализирует новый экземпляр класса BinaryReader на основании указанного потока с использованием кодировки UTF-8.
BinaryReader(Stream, Encoding) Инициализирует новый экземпляр класса BinaryReader на основе указанного потока и кодировки символов.
BinaryReader(Stream, Encoding, Boolean) Инициализирует новый экземпляр класса BinaryReader на основании указанного потока и кодировки символов, а также при необходимости оставляет поток открытым.

Основные методы для чтения бинарных данных представлены в таблице ниже:

Метод Описание
FillBuffer(Int32) Заполняет внутренний буфер указанным количеством байтов, которые были cчитаны из потока.
PeekChar() Возвращает следующий доступный для чтения символ, не перемещая позицию байта или символа вперед.
Read() Выполняет чтение знаков из базового потока и перемещает текущую позицию в потоке вперед в соответствии с используемым значением Encoding и конкретным знаком в потоке, чтение которого выполняется в настоящий момент.
Read(Byte[], Int32, Int32) Считывает указанное количество байтов из потока, начиная с заданной точки в массиве байтов.
Read(Char[], Int32, Int32) Считывает указанное количество символов из потока, начиная с заданной точки в массиве символов.
Read7BitEncodedInt() Считывает 32-разрядное целое число в сжатом формате.
ReadBoolean() Считывает значение Boolean из текущего потока и перемещает текущую позицию в потоке на один байт вперед.
ReadByte() Считывает из текущего потока следующий байт и перемещает текущую позицию в потоке на один байт вперед.
ReadBytes(Int32) Считывает указанное количество байтов из текущего потока в массив байтов и перемещает текущую позицию на это количество байтов.
ReadChar() Считывает следующий знак из текущего потока и изменяет текущую позицию в потоке в соответствии с используемым значением Encoding и конкретным знаком в потоке, чтение которого выполняется в настоящий момент.
ReadChars(Int32) Считывает указанное количество символов из текущего потока, возвращает данные в массив символов и перемещает текущую позицию в соответствии с используемой Encoding и определенным символом, считываемым из потока.
ReadDecimal() Считывает десятичное значение из текущего потока и перемещает текущую позицию в потоке на шестнадцать байтов вперед.
ReadDouble() Считывает число с плавающей запятой длиной 8 байт из текущего потока и перемещает текущую позицию в потоке на восемь байт вперед.
ReadInt16() Считывает целое число со знаком длиной 2 байта из текущего потока и перемещает текущую позицию в потоке на два байта вперед.
ReadInt32() Считывает целое число со знаком длиной 4 байта из текущего потока и перемещает текущую позицию в потоке на четыре байта вперед.
ReadInt64() Считывает целое число со знаком длиной 8 байта из текущего потока и перемещает текущую позицию в потоке на восемь байтов вперед.
ReadSByte() Считывает из текущего потока байт со знаком и перемещает текущую позицию в потоке на один байт вперед.
ReadSingle() Считывает число с плавающей запятой длиной 4 байта из текущего потока и перемещает текущую позицию в потоке на четыре байта вперед.
ReadString() Считывает строку из текущего потока. Строка предваряется значением длины строки, которое закодировано как целое число блоками по семь битов.
ReadUInt16() Считывает целое число без знака длиной 2 байта в формате с прямым порядком байтов из текущего потока и перемещает текущую позицию в потоке на два байта вперед.
ReadUInt32() Считывает целое число без знака длиной 4 байта из текущего потока и перемещает текущую позицию в потоке на четыре байта вперед.
ReadUInt64() Считывает целое число без знака длиной 8 байт из текущего потока и перемещает текущую позицию в потоке на восемь байтов вперед.

Чтобы прочитать наш файл, созданный без использования буфера на 100 байт, можно использовать следующий код:

using BinaryReader reader = new(fs, Encoding.UTF8, false);
Person person2 = new();
person.Name = reader.ReadString();
person.Age = reader.ReadInt32();
person.Weight = reader.ReadDouble();
//выводим полученные данный в консоль
Console.WriteLine(person.Name);
Console.WriteLine(person.Age);
Console.WriteLine(person.Weight);

Если же мы будем придерживаться заданного формата и записывать имя как массив байт, то чтение очередного объекта из файла можно произвести следующим образом:

class Program
 {
     static string ReadString(byte[] buffer)
     {
         int i = buffer.Length - 1;
         while (buffer[i] == 0)
             --i;
         byte[] data = new byte[i + 1];
         Array.Copy(buffer, data, i + 1);
         return Encoding.Default.GetString(data);
     }

     static void Main(string[] args)
     {
         byte[] readBuf = new byte[100];

         using BinaryReader reader = new(fs, Encoding.UTF8, false);
         reader.Read(readBuf, 0, 100); //читаем массив байт, содержащих имя

         Person person2 = new();
         person.Name = ReadString(readBuf); //считываем из массива имя
         person.Age = reader.ReadInt32();
         person.Weight = reader.ReadDouble();
     }
 }

Так как при записи массива с именем в файл все «пустые» элементы массива «забиваются» нулями, то здесь метод ReadString() определяет последний значащий (не равный нулю) символ в переданном ему массиве и создает из полученного массива строку в заданной кодировке.

Произвольный доступ к объектам в бинарном файле

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

using System.Text;

namespace HelloWorld
{
    public class Person
    {
        public string Name { get; set; }
        public int Age { get; set; }
        public double Weight { get; set; }
    }
    class Program
    {

        static byte[] CopyBytes(string value, int bufferSize)
        {
            byte[] result = new byte[bufferSize];
            byte[] buffer = Encoding.Default.GetBytes(value);
            Array.Copy(buffer, result, buffer.Length < bufferSize ? buffer.Length : bufferSize);
            return result;
        }

        static string ReadString(byte[] buffer)
        {
            int i = buffer.Length - 1;
            while (buffer[i] == 0)
                --i;
            byte[] data = new byte[i + 1];
            Array.Copy(buffer, data, i + 1);
            return Encoding.Default.GetString(data);
        }


        static void WriteObjectToFile(BinaryWriter writer, Person data)
        {
            byte[] nameBuffer = CopyBytes(data.Name, 100);
            writer.Write(nameBuffer);
            writer.Write(data.Age);
            writer.Write(data.Weight);
        }


        static Person ReadObjectFromFile(BinaryReader reader, int num)
        {
            //каждый объект занимает 112 байт - определяем на какую позицию переместить курсор в потоке
            reader.BaseStream.Position = 112 * (num - 1);
            byte[] readBuf = new byte[100];
            reader.Read(readBuf, 0, 100); //читаем массив байт, содержищих имя
            Person person = new()
            {
                Name = ReadString(readBuf), //считываем из массива имя
                Age = reader.ReadInt32(),
                Weight = reader.ReadDouble()
            };
            return person;

        }

        static void Main(string[] args)
        {
            string fileName = @"c:\CSharp Output\TextFile.dat";
            //создаем массив объектов
            Person[] people = new Person[]
            {
                new() { Name = "Иван Иванович Иванов", Age = 45, Weight = 70.5},
                new() { Name = "Петров Петр Петрович", Age = 37, Weight = 65},
                new() { Name = "Сидорова Анна Михайловна", Age = 30, Weight = 60}
            };

            //записываем все объекты в файл
            using FileStream fs = new FileStream(fileName, FileMode.OpenOrCreate);
            {
                using BinaryWriter writer = new BinaryWriter(fs, Encoding.UTF8, false);
                {
                    foreach (Person data in people)
                    {
                        WriteObjectToFile(writer, data);
                    }
                }

            }

            using FileStream fs_read = new FileStream(fileName, FileMode.OpenOrCreate);
            {
                using BinaryReader reader = new(fs_read, Encoding.UTF8, false);
                {
                    long count = fs_read.Length / 112;
                    Console.WriteLine($"Количество объектов в файле: {count}");
                    Console.WriteLine("Введите номер объекта, который необходимо прочитать");
                    int num = int.Parse(Console.ReadLine());
                    if (num > count)
                        throw new Exception("Задан номер больше, чем есть объектов в файле");

                    Person person = ReadObjectFromFile(reader, num);

                    Console.WriteLine("----Полученный объект----");
                    Console.WriteLine(person.Name);
                    Console.WriteLine(person.Age);
                    Console.WriteLine(person.Weight);
                }
            }
        }
    }
}

Рассмотрим это пример подробно. Во-первых, здесь мы записываем в файл массив из трех объектов типа Person. Запись производится в методе WriteObjectToFile:

static void WriteObjectToFile(BinaryWriter writer, Person data)
 {
     byte[] nameBuffer = CopyBytes(data.Name, 100);
     writer.Write(nameBuffer);
     writer.Write(data.Age);
     writer.Write(data.Weight);
 }

Код метода CopyBytes был представлен выше. После того, как файл записан, а все ресурсы, используемые объектом BinaryWriter и потоком FileStream освобождены, мы создаем объект типа BinaryReader для чтения файла и первым делом определяем количество объектов в файле:

using FileStream fs_read = new FileStream(fileName, FileMode.OpenOrCreate);
{
    using BinaryReader reader = new(fs_read, Encoding.UTF8, false);
    {
        long count = fs_read.Length / 112;
        Console.WriteLine($"Количество объектов в файле: {count}");
        Console.WriteLine("Введите номер объекта, который необходимо прочитать");
        ....
    }

Так как размер, занимаемый одним объектом нам точно известен (112 байт), то рассчитать количество объектов в файле не составляет никакого труда. Далее, мы просим пользователя ввести номер объекта который необходимо прочитать. Если номер оказывается допустимым (он менее или равен количеству объектов в файле), то вызывается метод чтения конкретного объекта из файла — ReadObjectFromFile:

static Person ReadObjectFromFile(BinaryReader reader, int num)
 {
     //каждый объект занимает 112 байт - определяем на какую позицию переместить курсор в потоке
     reader.BaseStream.Position = 112 * (num - 1);
     byte[] readBuf = new byte[100];
     reader.Read(readBuf, 0, 100); //читаем массив байт, содержащих имя
     Person person = new()
     {
         Name = ReadString(readBuf), //считываем из массива имя
         Age = reader.ReadInt32(),
         Weight = reader.ReadDouble()
     };
     return person;
 }

здесь мы устанавливаем курсор в потоке на необходимую позицию и последовательно считываем все поля объекта. После того, как объект прочитан — выводим его пользователю в консоль. Результат может быть, например, таким:

Количество объектов в файле: 3
Введите номер объекта, который необходимо прочитать
3
—-Полученный объект—-
Сидорова Анна Михайловна
30
60

Представленный выше пример, конечно, можно улучшить, определить более экономичный, с точки зрения потребления места на диске, формат и так далее. Здесь же я просто привел один из вариантов того, как можно осуществить произвольный доступ к объектам, записанным в бинарном файле, используя BinaryReader.

Итого

Для доступа к данным в бинарных файлах удобно использовать классы BinaryWriter и BinaryReader. Эти классы содержат специальные методы, позволяющие без дополнительных преобразований записывать и считывать строки, числа и другие данные из файлов. Используя заданный формат бинарного файла можно организовать произвольный доступ к различным частям бинарного файла для считывания/записи данных.

Подписаться
Уведомить о
guest
0 Комментарий
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии