Чтобы прочитать небольшой файл в Go, используйте os.ReadFile, а для записи целиком вызовите os.WriteFile. Большой файл можно обрабатывать по строкам через bufio.Scanner или копировать через io.Copy, не собирая всё содержимое в памяти. В каждом случае нужно проверить ошибки, а при самостоятельном открытии файла ещё и закрыть его.
Разберём эти варианты на учебной задаче: прочитать список заданий, пропустить пустые строки и сохранить оставшиеся в отдельный файл. Сначала посмотрим на отдельные операции, затем соберём обработчик. Примеры проверены на Go 1.26.9, Linux amd64.
Как выбрать способ работы с файлом
Пакет os открывает файлы и предоставляет операции чтения и записи. Пакет bufio добавляет буферизацию и разбор строк. Пакет io содержит общие интерфейсы и операции над потоками данных.
| Задача | Что использовать | Что учитывать |
|---|---|---|
| Прочитать файл целиком | os.ReadFile | Содержимое помещается в память |
| Записать готовые данные | os.WriteFile | Существующий файл обрезается |
| Добавить данные в конец | os.OpenFile с O_APPEND | Проверять запись и закрытие |
| Обработать строки | bufio.Scanner | Есть ограничение размера токена |
| Читать строки с другим контролем ошибок | bufio.Reader | Самостоятельно учитывать разделитель и конец файла |
| Скопировать поток без преобразования | io.Copy | Выходной файл может остаться частичным при ошибке |
Выбирайте способ по тому, какие данные нужны программе одновременно. Для небольшого конфигурационного файла удобно получить один срез байтов. Для журнала на несколько гигабайт обычно достаточно очередной строки. Если содержимое вообще не нужно разбирать, построчный цикл только усложнит копирование.
Размер файла сам по себе не задаёт универсальную границу между способами. Учитывайте доступную память процесса, число одновременных операций и дальнейшую обработку. Несколько параллельных загрузок создают другую нагрузку, чем одно чтение того же файла при запуске программы.
Чтение файла целиком через os.ReadFile
Полная программа читает tasks.txt из текущего рабочего каталога и выводит содержимое:
package main
import (
"fmt"
"os"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
data, err := os.ReadFile("tasks.txt")
if err != nil {
return fmt.Errorf("прочитать задания: %w", err)
}
fmt.Print(string(data))
return nil
}
ReadFile возвращает []byte и ошибку. При успешном чтении ошибка равна nil: отдельно обрабатывать io.EOF не требуется. Функция сама открывает и закрывает файл. Вызывать Close в этом примере негде и не нужно.
Преобразование string(data) удобно для вывода текста. Сам файл при этом не становится «текстовым» для Go. ReadFile возвращает байты без проверки кодировки. Если дальше нужен разбор JSON, можно передать эти байты декодеру, не создавая строку только ради промежуточного шага.
Относительный путь отсчитывается от рабочего каталога процесса. Это может быть каталог, из которого запущена команда, или каталог, заданный менеджером сервисов. Расположение исходника и исполняемого файла само по себе эту базу не определяет. Если программа не находит существующий файл, сначала проверьте рабочий каталог через os.Getwd и переданный путь.
Перед чтением пользовательского файла задайте допустимый размер входа. Не считайте файл небольшим только потому, что таким был тестовый образец. Например, импорт пользовательских данных должен иметь ограничение входа, а журнал лучше читать последовательно.
Запись файла через os.WriteFile
Следующие примеры содержат функции. Их можно поместить в пакет приложения; используемые пакеты перечислены перед каждым фрагментом.
Для функции записи нужен импорт os:
func writeTasks(path string, data []byte) error {
return os.WriteFile(path, data, 0o600)
}
Если файла нет, WriteFile создаёт его. Если он существует, функция обрезает его перед записью. Запись короткого нового содержимого не оставляет хвост от старого. Добавления в конец здесь нет.
Число 0o600 задаёт права при создании: в Unix чтение и запись разрешены владельцу. Фактические права дополнительно ограничиваются маской umask. Для уже существующего файла WriteFile сохраняет прежние права. Значение третьего аргумента не исправит права ранее созданного общедоступного файла.
Родительский каталог должен существовать. Для пути out/tasks.txt сначала создайте out, если это входит в задачу приложения. Подойдёт os.MkdirAll("out", 0o700) с проверкой ошибки. Не создавайте каталоги вслепую, когда опечатка в пути должна считаться ошибкой конфигурации.
У WriteFile есть ещё одно ограничение: запись не является транзакцией. При сбое старое содержимое уже может быть потеряно, а новое сохранено лишь частично. Для временного отчёта это иногда допустимо. Для единственного экземпляра конфигурации нужен другой порядок обновления.
Если старое содержимое нельзя потерять при неудачной записи
Обычно новое содержимое сначала пишут во временный файл в том же каталоге. Затем проверяют запись, при необходимых гарантиях вызывают Sync, закрывают файл и заменяют целевой через Rename. Неудачно созданный временный файл удаляют.
Этот порядок требует требований к платформе и восстановлению после сбоя. Документация os.Rename отдельно предупреждает: вне Unix операция не гарантирует атомарность даже внутри одного каталога. Атомарная замена имени также не равна гарантии сохранения данных после отключения питания. На Unix для такой гарантии может понадобиться синхронизация каталога; детали зависят от файловой системы.
Поэтому ниже обычная запись используется только для результата, который можно построить заново. Обещания безопасной замены конфигурации у этих функций нет.
Добавление данных в конец через os.OpenFile
Чтобы дописать строку, откройте файл с флагами O_WRONLY | O_CREATE | O_APPEND. Для функции нужны импорты errors, fmt и os:
func appendTask(path, task string) (err error) {
f, err := os.OpenFile(path,
os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600)
if err != nil {
return fmt.Errorf("открыть список: %w", err)
}
defer func() {
err = errors.Join(err, f.Close())
}()
_, err = f.WriteString(task + "\n")
if err != nil {
return fmt.Errorf("добавить задание: %w", err)
}
return nil
}
O_WRONLY разрешает запись, O_CREATE создаёт отсутствующий файл, а O_APPEND направляет записи в конец. Флаг O_TRUNC здесь не нужен: он обрезал бы существующее содержимое при открытии.
Функция добавляет перевод строки после задания. Если старый файл не оканчивается переводом строки, новое задание приклеится к его последней строке. Условие этого примера: файл уже содержит завершённые строки, а аргумент task не содержит собственных переводов строки. Для произвольного пользовательского текста нужен формат с экранированием или отдельная проверка.
defer установлен после проверки открытия. Именованный результат err позволяет добавить ошибку закрытия к результату функции. errors.Join сохраняет обе ошибки, если не удались и запись, и закрытие. Поведение именованных результатов разобрано в статье про defer в Go.
Этот пример предполагает одного писателя. Сам по себе O_APPEND не превращает произвольную последовательность операций разных процессов в согласованную запись бизнес-события. Если список изменяют несколько участников, отдельно определите синхронизацию и формат данных.
Построчное чтение через bufio.Scanner
Для текстового списка удобен Scanner. По умолчанию он разбивает вход на строки и убирает окончания \n и \r\n. Последняя непустая строка возвращается и без завершающего перевода строки.
Функции ниже нужны импорты bufio, fmt и os:
func forEachLine(path string, handle func(string) error) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("открыть список: %w", err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 4096), 1024*1024)
lineNumber := 0
for scanner.Scan() {
lineNumber++
if err := handle(scanner.Text()); err != nil {
return fmt.Errorf("строка %d: %w", lineNumber, err)
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("прочитать список: %w", err)
}
return nil
}
os.Open открывает файл для чтения. Каждый успешный Scan подготавливает очередную строку, а Text возвращает её как строку Go. Пустая строка тоже передаётся обработчику. Отсутствие данных после последнего перевода строки не создаёт дополнительной строки.
После цикла всегда проверяйте scanner.Err(). Возврат false из Scan означает и нормальный конец файла, и остановку из-за ошибки. Если убрать проверку, обработка слишком длинной строки может выглядеть как успешное завершение короткого файла.
Функция принимает обработчик, поэтому обход и полезное действие разделены. Можно считать строки, проверять задания или отправлять их дальше. Ошибка обработчика останавливает обход и получает номер строки. В тестах такой результат проще связать с конкретными входными данными.
Здесь ошибка закрытия входного файла не возвращается: ошибки чтения проверены, новых данных файл не принимает. Это явное упрощение примера. В функциях записи ошибку Close мы сохраняем, поскольку нельзя сообщать об успешном сохранении, проигнорировав неудачное завершение работы с файлом.
Как работает ограничение Scanner
Стандартный предел буфера Scanner равен 64 * 1024 байт. Это не обещание прочитать строку ровно из 64 КиБ: буферу может понадобиться место и для перевода строки. Ограничение действует на отдельную запись вместе со служебным запасом. Общий размер файла может быть больше.
В примере начальный буфер занимает 4096 байт, а допустимый рост ограничен 1 МиБ. Вызов Buffer должен произойти до первого Scan. Такой выбор подходит, если формат приложения заведомо ограничивает длину записи. Это не универсальное значение для любого журнала.
Если длина строки превышает предел, сканирование прекращается с ошибкой. Нельзя просто увеличить буфер после ошибки и продолжить с того же места: Scanner мог уже прочитать данные дальше последнего возвращённого токена. Выберите ограничение до начала обработки или используйте другой способ чтения.
Ещё одна граница касается scanner.Bytes(). Возвращённый срез может ссылаться на буфер, который следующий вызов Scan перезапишет. Обработать байты сразу можно; для сохранения в коллекции нужна копия. В примере используется Text, поэтому обработчик получает строку без такой зависимости от следующего сканирования.
Когда вместо Scanner нужен bufio.Reader
bufio.Reader удобен, если приложение должно само решать, что делать с полученными данными и ошибкой чтения. Метод ReadString('\n') возвращает строку вместе с разделителем. Если разделителя нет до конца файла, он возвращает последние данные и io.EOF одновременно.
Функции нужны импорты bufio, errors и io:
func readLines(r io.Reader, handle func(string) error) error {
reader := bufio.NewReader(r)
for {
line, err := reader.ReadString('\n')
if len(line) > 0 {
if handleErr := handle(line); handleErr != nil {
return handleErr
}
}
if err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
}
}
Сначала функция передаёт полученные данные обработчику и только затем разбирает ошибку. Иначе последняя строка без \n потерялась бы. При другой ошибке чтения обработчик тоже может получить неполную строку. Контракт этого примера разрешает обработку такого фрагмента; если вам нужны только завершённые записи, измените эту политику явно.
В отличие от предыдущей функции, handle получает окончания строк. Пустая строка файла представлена строкой "\n". Учитывайте это при подсчёте символов или повторной записи: бездумное добавление ещё одного \n создаст лишние пустые строки.
У ReadString нет стандартного предела токена Scanner, но вся текущая строка всё равно собирается в памяти. Для потенциально бесконечной строки такой вариант не решает проблему памяти. Если нужно строго ограничивать выделения, рассматривайте чтение фрагментами через ReadSlice с обработкой bufio.ErrBufferFull или блоками через Read.
Обычный Read также может вернуть байты вместе с ошибкой. Сначала обрабатывают n > 0, затем проверяют err. Это правило интерфейса io.Reader, а не особенность файлов. Подробности проверки и оборачивания ошибок есть в статье про обработку ошибок в Go.
Копирование большого файла через io.Copy
Когда данные не нужно преобразовывать, используйте io.Copy. Функция читает источник до конца и записывает данные в приёмник. При успехе она возвращает число скопированных байтов и nil, без отдельной ошибки io.EOF.
Нужны импорты errors, fmt, io и os:
func copyFile(src, dst string) (err error) {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("открыть источник: %w", err)
}
defer in.Close()
out, err := os.OpenFile(dst,
os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return fmt.Errorf("создать копию: %w", err)
}
defer func() {
err = errors.Join(err, out.Close())
}()
_, err = io.Copy(out, in)
return err
}
O_EXCL вместе с O_CREATE требует создания нового файла. Если назначение уже существует, открытие завершится ошибкой. Так функция не обрежет случайно источник, если ей передали тот же путь или другой путь к тому же существующему файлу.
При ошибке копирования назначение может остаться частичным. Функция не удаляет его и не заменяет старую версию. Вызывающий код должен решить, сохранить ли файл для диагностики или удалить. Копируются только данные: права исходника, времена и другие метаданные здесь не переносятся.
В коде нет чтения всего файла в один срез. Однако не стоит приписывать io.Copy один фиксированный размер буфера: она может использовать методы WriteTo источника или ReadFrom приёмника. Для выбора реализации достаточно знать, что эта функция предназначена для передачи потока без предварительной загрузки всего содержимого вашим кодом.
Сохраняем непустые строки с буферизацией
Соберём обработчик списка. Он принимает входной поток, пропускает строки, состоящие из пробелов, и пишет остальные строки с окончанием \n. Пробелы внутри непустой строки сохраняются. Вход и выход открывает вызывающий код.
Для функции нужны импорты bufio, fmt, io и strings:
func filterTasks(dst io.Writer, src io.Reader) error {
scanner := bufio.NewScanner(src)
scanner.Buffer(make([]byte, 4096), 1024*1024)
writer := bufio.NewWriter(dst)
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == "" {
continue
}
if _, err := fmt.Fprintln(writer, line); err != nil {
return fmt.Errorf("записать задание: %w", err)
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("прочитать задания: %w", err)
}
if err := writer.Flush(); err != nil {
return fmt.Errorf("сбросить буфер: %w", err)
}
return nil
}
Буферизированный писатель накапливает небольшие записи и передаёт их нижележащему io.Writer порциями. Поэтому успешный Fprintln ещё не означает, что данные дошли даже до файлового объекта. Финальный Flush передаёт оставшиеся данные и возвращает возможную ошибку.
При ошибке чтения функция возвращается без финального сброса. Уже переданная часть результата может сохраниться, а остаток в буфере потеряться. Такой выход считается неудачным целиком. Не используйте полученный файл как готовый список, если функция вернула ошибку.
Функция принимает интерфейсы, поэтому её можно проверить без диска. Передайте strings.NewReader на вход и bytes.Buffer на выход. Проверьте пустой ввод, пустые строки, \r\n, последнюю строку без перевода строки и превышение лимита. Отдельно передайте писатель, возвращающий ошибку: короткий ввод может обнаружить её только при Flush. Приёмы организации таких проверок разобраны в статье про тестирование в Go.
Соединяем обработчик с файлами
Для этой функции нужны импорты errors, fmt и os, а также объявление filterTasks выше:
func filterFile(src, dst string) (err error) {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("открыть задания: %w", err)
}
defer in.Close()
out, err := os.OpenFile(dst,
os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return fmt.Errorf("создать результат: %w", err)
}
defer func() {
err = errors.Join(err, out.Close())
}()
return filterTasks(out, in)
}
Вызывайте filterFile("tasks.txt", "filtered.txt") и проверяйте результат. Выходной путь должен быть новым, родительский каталог должен существовать. Это защищает исходные данные от случайного обрезания при ошибке в аргументах.
Каждая функция закрывает только те ресурсы, которые сама открыла. filterTasks управляет своими буферами и возвращает ошибки обработки. filterFile владеет открытыми файлами и закрывает их. Обработчик не должен закрывать переданный поток: тот же код можно использовать с памятью, сетью или частью более длинного конвейера.
Чем различаются Flush, Close и Sync
Flush у bufio.Writer отправляет данные из буфера Go нижележащему писателю. Если это файл, закрытие самого файла не вызывает Flush у отдельного объекта bufio.Writer. Потерянный сброс нельзя исправить последующим Close.
Close освобождает файловый ресурс и может вернуть ошибку. Ставьте отложенное закрытие сразу после успешного открытия. В длинном цикле удобнее вынести обработку одного файла в функцию: тогда defer срабатывает после каждого файла, а не после завершения всего обхода.
Sync запрашивает сохранение содержимого файла в устойчивое хранилище. Он нужен, если этого требует контракт приложения, и не заменяет сброс буфера Go. При буферизированной записи порядок такой: проверить Flush, затем при необходимости Sync, затем Close. Гарантии восстановления после сбоя проектируются отдельно от простого закрытия дескриптора.
Итог
Для небольшого файла начните с os.ReadFile и os.WriteFile. Если нужно дописать данные, выберите OpenFile с O_APPEND. Для последовательного разбора строк используйте Scanner с заранее выбранным ограничением и обязательной проверкой Err.
Большой файл не обязательно загружать целиком: обрабатывайте строки по одной или передавайте поток через io.Copy. Сохраняйте различие между ошибкой обработки и нормальным концом файла. При записи проверяйте буфер и закрытие, а при необходимости безопасной замены файла отдельно задайте требования к атомарности и сохранности после сбоя.
Теги: