«Хороший сайт делается для людей, а поисковики приходят следом»

— из принципов редакции SeoLiga.ru
Главная » Статьи » .NET и C# » Работа с файлами и потоками в C#

Работа с файлами и потоками в C#

Почти любое приложение рано или поздно что-то читает с диска или пишет на него: настройки, логи, отчёты, загруженные пользователем документы. В .NET для этого есть богатый набор классов, и новичку легко запутаться, какой из них выбрать. Давайте разложим всё по полочкам: от однострочных помощников до тонкой работы с потоками байтов.

Простые способы: класс File

Если файл небольшой, самый короткий путь — статические методы класса File из пространства имён System.IO. Они читают или пишут всё содержимое за один вызов.

using System.IO;

string text = File.ReadAllText("settings.txt");
string[] lines = File.ReadAllLines("names.txt");
File.WriteAllText("result.txt", "Готово");
File.AppendAllText("log.txt", "Запуск приложения\n");

Удобство налицо, но есть ограничение: содержимое целиком загружается в память. Для файла в несколько мегабайт это нормально, а для файла в несколько гигабайт — катастрофа. Также эти методы по умолчанию используют кодировку UTF-8, что стоит помнить при работе со старыми файлами.

Что такое поток

Поток (Stream) — абстракция последовательности байтов. Неважно, откуда они берутся: из файла, сети, памяти или сжатого архива. Класс Stream задаёт общий набор операций — чтение, запись, перемещение по позиции, закрытие. Благодаря этому код, написанный для одного вида потока, легко переносится на другой.

Главные наследники, которые вам пригодятся:

  • FileStream — работа с файлом на уровне байтов.
  • MemoryStream — поток поверх массива в памяти, удобен для тестов и временных данных.
  • NetworkStream — обмен данными по сети.
  • GZipStream — сжатие и распаковка на лету, оборачивает другой поток.
  • BufferedStream — добавляет буферизацию к любому потоку для ускорения мелких операций.

Читатели и писатели

Читать сырые байты неудобно, когда нужен текст. Для этого есть обёртки: StreamReader и StreamWriter для текста, BinaryReader и BinaryWriter для типизированных двоичных данных. Они берут на себя преобразование байтов в строки и числа.

using (var reader = new StreamReader("big.txt"))
{
    string line;
    while ((line = reader.ReadLine()) != null)
    {
        Console.WriteLine(line);
    }
}

Этот код читает файл построчно и в каждый момент держит в памяти только одну строку. Именно так нужно обрабатывать большие логи и выгрузки.

Зачем нужен using

Файл — внешний ресурс операционной системы. Пока вы его не закрыли, он может быть заблокирован для других программ, а данные записи могут остаться в буфере и не попасть на диск. Конструкция using гарантирует, что метод освобождения ресурса будет вызван, даже если внутри возникло исключение.

Типичная ошибка новичка — открыть поток и забыть закрыть. Тогда при повторном запуске операции вы получите исключение о том, что файл занят другим процессом. Привычка всегда оборачивать потоки в using спасает от большинства таких проблем. В современных версиях языка можно писать короче: using var writer = new StreamWriter(path);, и освобождение произойдёт при выходе из области видимости.

Режимы открытия и доступ

При создании FileStream вы указываете, что делать с файлом и какие права запрашивать. Основные режимы:

  1. Open — открыть существующий, ошибка, если файла нет.
  2. Create — создать новый или затереть старый.
  3. CreateNew — создать, ошибка, если файл уже есть.
  4. OpenOrCreate — открыть, а если нет, создать.
  5. Append — открыть и перейти в конец для дозаписи.

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

Кодировки

Одна из самых болезненных тем. Если прочитать файл в неверной кодировке, вместо русских букв получатся нечитаемые знаки. StreamReader по умолчанию пытается определить кодировку по метке порядка байтов, а при её отсутствии считает файл UTF-8. Старые файлы из Windows часто лежат в однобайтной кодировке для кириллицы, и тогда её нужно указать явно:

var enc = Encoding.GetEncoding("windows-1251");
using (var reader = new StreamReader(path, enc))
{
    string all = reader.ReadToEnd();
}

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

Пути и безопасность

Не склеивайте пути строками вручную. Класс Path содержит методы для объединения, получения имени, расширения и каталога, учитывающие разделители текущей системы. Метод Path.Combine сам вставит нужный слэш. Перед записью проверяйте существование каталога через Directory.Exists и создавайте его при необходимости.

Если путь приходит от пользователя, будьте осторожны. Относительные сегменты вроде двух точек позволяют выйти за пределы разрешённой папки. Нормализуйте путь через Path.GetFullPath и убедитесь, что результат начинается с ожидаемого каталога.

Асинхронный ввод-вывод

Чтение с диска и особенно из сети — медленные операции. Если выполнять их в потоке интерфейса, окно зависнет. Для этого у потоков и читателей есть асинхронные методы, которые используются вместе с async и await:

public async Task<string> LoadAsync(string path)
{
    using (var reader = new StreamReader(path))
    {
        return await reader.ReadToEndAsync();
    }
}

Пока операция выполняется, поток освобождается для другой работы. В серверных приложениях это напрямую влияет на количество обслуживаемых клиентов. Не смешивайте синхронные и асинхронные вызовы без нужды и не блокируйте результат через Wait или Result, иначе легко получить взаимную блокировку.

Типичные ошибки

  • Чтение огромного файла целиком вместо построчной или блочной обработки.
  • Забытый вызов закрытия, из-за чего файл остаётся занят.
  • Игнорирование исключений: файла может не быть, прав может не хватать, диск может быть заполнен.
  • Жёстко заданные пути вроде диска C вместо специальных папок пользователя и данных приложения.
  • Неверная кодировка при обмене файлами между разными системами.

Сжатие и копирование потоков

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

using (var input = File.OpenRead("data.bin"))
using (var output = File.Create("data.bin.gz"))
using (var gzip = new GZipStream(output, CompressionMode.Compress))
{
    input.CopyTo(gzip);
}

Метод CopyTo читает порциями и не требует загружать файл в память целиком. Для асинхронных сценариев есть CopyToAsync. Тот же приём работает для передачи данных между файлом и сетью или между памятью и диском.

Наблюдение за файлами и временные файлы

Иногда приложению нужно реагировать на появление или изменение файлов. Для этого существует класс FileSystemWatcher: вы задаёте каталог и маску, подписываетесь на события создания, изменения и удаления. Учтите, что событие изменения может приходить несколько раз подряд для одной операции, поэтому добавляйте небольшую задержку или фильтрацию дублей.

Для временных данных используйте Path.GetTempFileName или Path.GetTempPath, а не произвольные папки рядом с программой. Не забывайте удалять временные файлы после работы, в том числе в блоке finally.

Заключение

Для мелких задач берите File.ReadAllText и File.WriteAllText, для больших данных — StreamReader и FileStream, для сложных конвейеров — комбинируйте потоки через обёртки. Всегда используйте using, указывайте кодировку явно, когда работаете с чужими файлами, и учитесь обрабатывать исключения ввода-вывода. Эти четыре привычки превращают работу с файлами из источника странных багов в предсказуемую рутину.

ИБ
Ирина Белова
Автор SeoLiga.ru. Пишет о практике: что работает на реальных проектах, а что лучше обойти стороной. Хотите предложить свою статью? Загляните в раздел «Авторам».