Почти любое приложение рано или поздно что-то читает с диска или пишет на него: настройки, логи, отчёты, загруженные пользователем документы. В .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 вы указываете, что делать с файлом и какие права запрашивать. Основные режимы:
- Open — открыть существующий, ошибка, если файла нет.
- Create — создать новый или затереть старый.
- CreateNew — создать, ошибка, если файл уже есть.
- OpenOrCreate — открыть, а если нет, создать.
- 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, указывайте кодировку явно, когда работаете с чужими файлами, и учитесь обрабатывать исключения ввода-вывода. Эти четыре привычки превращают работу с файлами из источника странных багов в предсказуемую рутину.