🦫 Самоучитель по Go для начинающих. Часть 14. Интерфейсы ввода-вывода. Буферизация. Работа с файлами. Пакеты io, bufio, os
В этой статье рассмотрим основные методы ввода-вывода из пакета io, изучим механизм буферизации и его применение в Go, а также разберем, как работать с файлами с помощью пакета os.
Ввод-вывод. Пакет io
Одним из самых фундаментальных пакетов стандартной библиотеки является io, который предоставляет базовые интерфейсы для операций ввода-вывода. Основное его назначение заключается в обертке функций и методов различных пакетов в общедоступные интерфейсы, абстрагирующие функциональность.
Давайте изучим основные инструменты пакета io для выполнения трех базовых операций: чтения, записи и копирования. Стоит уделить особое внимание сущностям из этих пунктов, так как они используются во многих других библиотеках.
Чтение данных
Чтение данных производится с помощью интерфейса io.Reader
, содержащего единственный метод Read
, который принимает слайс байт, а возвращает количество записанных байт и ошибку в случае некорректного завершения:
При использовании Reader стоит учитывать следующее:
- После завершения потока данных Reader возвращает ошибку
io.EOF
(End Of File), которую следует обработать отдельно. - Reader не гарантирует полное заполнение буфера.
- Даже если метод Read возвращает
n < len(p)
байт, он может использовать весь слайс p во время вызова.
Если заранее известен размер данных для чтения, предпочтительнее использовать функцию io.ReadFull
, которая проверяет заполнение буфера перед возвратом значения и в случае различия размера данных и размера буфера выдает ошибку io.ErrUnexpectedEOF
:
func ReadFull(r Reader, buf []byte) (n int, err error)
Запись данных
Для записи данных используется интерфейс io.Writer
, визуально похожий на io.Reader
:
Writer представляет собой обертку для метода Write
, который записывает len(p)
байт из слайса p в указанный поток данных и возвращает два значения: количество записанных байт n (0 <= n <= len(p))
и возникшую ошибку, которая привела к досрочной остановке записи.
При работе с Writer следует придерживаться следующих правил:
Write
должен возвращать ненулевое значение ошибки, если было прочитаноn < len(p)
байт.Write
не должен изменять данные слайса, даже временно.- Реализации интерфейса
Writer
не должны сохранять источник данныхp
.
Копирование данных
Копирование данных из источника src в dts до появления io.EOF
или произвольной ошибки производится с помощью функции io.Copy
. Она возвращает число скопированных байт и ошибку в случае её возникновения, а при корректном завершении предполагается, что err == nil
:
func Copy(dst Writer, src Reader) (written int64, err error)
Для копирования данных с размером больше заданного числа байт следует использовать функцию io.CopyN
:
func CopyN(dst Writer, src Reader, n int64) (written int64, err error)
Она копирует не больше n
байт из src
в dst
и возвращает количество скопированных байт и ошибку в случае её возникновения. Стоит учитывать, что число записанных байт (written) равняется параметру n
только в том случае, если err
равняется nil
.
Проиллюстрируем применение интерфейса Reade
r и работу функции Copy
в коде:
В первой строке переменной r
с помощью функции strings.NewReader
присваивается указатель на структуру Reader
пакета strings
, которая реализует io.Reader
и еще несколько других интерфейсов:
Далее вызывается функция io.Copy
, принимающая в качестве аргументов os.Stdout
(стандартный поток вывода) и переменную r
интерфейса Reader
. В результате выполнения кода из объекта r
в стандартный поток вывода будет скопирована строка: данные для чтения
.
Буферизованный ввод-вывод в Go. Пакет bufio
Скорость операций ввода-вывода значительно повышается, когда данные накапливаются в специальном буфере для их последующего чтения или записи. Такой подход называется буферизацией. Он позволяет сократить количество системных вызовов и улучшить производительность программы.
В Go для буферизации операций ввода-вывода используется встроенный пакет bufio. Он оборачивает io.Reader
или io.Writer
, создавая новый объект Reader
или Writer
, соответственно, который также реализует интерфейс, но обеспечивает буферизацию и некоторые улучшения ввода-вывода.
Пакет bufio
содержит три основных типа: Reader
, Writer
и Scanner
. Давайте рассмотрим каждый из них подробнее.
bufio.Reader
Тип bufio.Reader
реализует буферизацию для объекта io.Reader
и создается с помощью одной из функций:
func NewReader(rd io.Reader) *Reader
– Reader с буфером стандартного размера (4096 байт).func NewReaderSize(rd io.Reader, size int) *Reader
– Reader с буфером явно задаваемого размера.
Совместно с этими функциями часто используется метод ReadString
, считывающий данные до первого появления разделителя delim
и возвращающий строку длиной до delim
включительно:
func (b *Reader) ReadString(delim byte) (string, error)
В качестве примера напишем код считывания данных из стандартного ввода до первого разделителя, которым будет выступать точка с запятой:
bufio.Writer
Тип bufio.Writer
предоставляет буферизацию для объекта io.Writer
и может быть создан одной из следующих функций:
func NewWriter(w io.Writer) *Writer
– Writer с буфером стандартного размера (4096 байт).func NewReaderSize(rd io.Reader, size int) *Reader
– Writer с буфером явно задаваемого размера.
После окончания записи необходимо вызвать метод Writer.Flush
для перенаправления данных в базовый интерфейс io.Writer
.
Пакет bufio предусматривает три отдельных метода для записи данных типов string, byte и rune:
func (b *Writer) WriteString(s string) (int, error)
– записывает строку и возвращает количество записанных байт. Если оно меньше длины строки, метод вернет ошибку.func (b *Writer) WriteByte(c byte) error
– записывает один байт, возвращает ошибку в случае её возникновения.
Применим изученные функции и методы в программе, которая считывает строковую переменную со стандартного потока os.Stdin
и записывает её в поток os.Stdout
:
bufio.Scanner
Тип bufio.Scanner
предоставляет интерфейс для построчного чтения данных.
Чаще всего он используется в связке с методом Scanner.Scan
, который итерируется по токенам источника данных, определяемым типом SplitFunc:
func (s *Scanner) Scan() booltype SplitFunc func(data []byte, atEOF bool) (advance int, token []byte, err error)
Метод Scanner.Split
устанавливает конкретную функцию разбиения (тип SplitFunc
) для объекта Scanner
. Она может использоваться для сканирования файла по байтам, рунам, строкам и словам, разделенным пробелами. Допускается задание пользовательских функций разбиения:
func (s *Scanner) Split(split SplitFunc)
Применим функции построчного чтения в программе, которая считывает очередную порцию данных и сразу же выводит её на экран:
При запуске кода и ввода сообщения в консоль получим его же на строке ниже:
Работа с файлами. Пакет os
В этом пункте изучим основные методы для работы с файлами в программах на Go.
Открытие и закрытие файла
os.OpenFile
Наиболее общей функцией для открытия файла является os.OpenFile
со следующей сигнатурой:
func OpenFile(name string, flag int, perm FileMode) (*File, error)
Она открывает файл с указанным названием name и специальным флагом, указывающим атрибуты открытия файла. Полный список флагов будет приведен в отдельном пункте.
Если файл не существовал до открытия, и в качестве аргумента os.OpenFile
передан флаг O_CREATE
, то файл будет создан с атрибутом perm типа FileMode
, который задает уровень доступа к файловой системе в виде числа uint32. Например, FileMode
со значением 0666 означает, что файл доступен для чтения и записи.
В случае успешного завершения os.OpenFile
возвращает объект типа *File
, иначе – ошибку типа *PathError
.
os.Open
Оберткой над os.OpenFile
является функция os.Open
, которая открывает файл только для чтения, на что указывает флаг O_RDONLY
:
Типичный код открытия файла в Go выглядит следующим образом:
Обратите внимание: открытый файл должен всегда закрываться по завершении работы программы с помощью метода Close
. Обычно он вызывается отложено, с использованием defer, как это показано в примере выше.
Флаги
Флаги указывают параметры открытия файла, такие как доступ (чтение, запись или все вместе), поведение (открытие, сокращение и т. д.), режим работы (асинхронный, добавление и т. д.). Стоит иметь в виду, что в каждой конкретной операционной системе могут быть реализованы не все флаги. Для Linux, к примеру, список флагов можно посмотреть в man page.
Пакет os предоставляет константы, оборачивающие основные флаги операционной системы:
Создание, переименование и удаление файла
Для создания, удаления и переименования файла используются функции os.Create
, os.Remove
и os.Rename
соответственно.
os.Create
создает или вырезает файл с указанным именем. Если он уже существует, то будет вырезан, иначе – создан с атрибутом 0666 (чтение и запись). В случае успешного завершения функция вернет объект*File
с файловым дескрипторомO_RDWR
, готовый к использованию для ввода-вывода. Если возникнет ошибка, она будет иметь тип*PathError
:
os.Remove
с сигнатуройfunc Remove(name string) error
удаляет файл с указанным названием или возвращает ошибку типа*PathError
.os.Rename
с сигнатуройfunc Rename(oldpath, newpath string) error
переименовывает (перемещает) файл изoldpath
вnewpath
. Если объект с именемnewpath
уже существует и не является директорией, функция заменит этот объект на новый.
Чтение файла
os.ReadFile и io.ReadAll
Если заранее известно название файла, для его чтения можно использовать функцию os.ReadFile
. Она считывает все содержимое и в случае успешного завершения возвращает err == nil
, но не err == EOF
.
Альтернативой для os.ReadFile
является функция io.ReadAll
, считывающая данные из объекта io.Reader
до первой ошибки или EOF.
Здесь и далее в демонстрационном коде будет использоваться единственный файл с именем filename.txt
. Его содержимое будет меняться в зависимости от используемых функций и методов. Изначально в нем содержатся три строки, которые мы прочитаем с помощью функции os.ReadFile
и выведем на экран:
Код для функции io.ReadAll
аналогичен написанному выше, требуется лишь предварительно открыть необходимый файл и передать его в качестве аргумента в io.ReadAll
.
Содержимое файла filename.txt
, выведенное на экран:
Так как две рассматриваемые функции прочитывают файл целиком, их не стоит использовать для открытия больших источников данных, иначе это может привести к переполнению доступной памяти, зависанию программы и другим неприятным последствиям.
bufio.NewScanner
При работе с файлами большого размера следует считывать данные построчно. Этого можно достичь с помощью ранее изученного сканера из пакета bufio:
Вывод будет аналогичен предыдущему пункту.
Метод Read
Метод Read
считывает фиксированное число байт, сохраняет их в слайсе []byte
и возвращает количество прочитанных байт и встреченную ошибку. При достижении конца файла Read
вернет два значения: 0, io.EOF
:
Вывод будет аналогичен предыдущим двум пунктам.
Запись в файл
Как и в случае с чтением, есть несколько способов записи данных в файл. Давайте рассмотрим их реализацию и особенности.
os.WriteFile
Функция os.WriteFile
при необходимости создает файл с заданным атрибутом и записывает туда данные из переданного слайса байт. Если файл существовал, то os.WriteFile
обрежет его перед записью, не меняя разрешения:
В результате файл filename.txt
перезапишется и будет содержать единственную строку: "message from WriteFile".
Функция os.WriteFile
имеет интересную особенность: так как она требует несколько системных вызовов для завершения записи, ошибка в произвольный момент выполнения может привести к тому, что файл окажется в частично записанном состоянии.
Метод Write
Метод Write записывает фиксированное число байт из слайса []byte
и возвращает количество успешно записанных байт и встреченную ошибку. Если это количество меньше длины слайса, вернется ошибка с ненулевым значением:
Содержимое файла filename.txt
после выполнения кода:
Обратите внимание: метод Write требует, чтобы файл был заранее открыт или создан с доступом на запись, иначе вернется ошибка bad file descriptor
.
Метод WriteString
Записи строки в файл производится с помощью метода WriteString
, который является оберткой для Write
:
Метод WriteString
применяется в коде аналогично методу Write
, но вместо слайса байт передается строка.
Добавление данных в файл
Как вы могли заметить, предыдущие инструменты полностью перезаписывали содержимое файла, что может быть недопустимым в некоторых ситуациях. Для добавления некоторых данных сразу после имеющихся следует указать флаг os.O_APPEND
при открытии файла:
Теперь файл filename.txt
содержит следующие данные:
Какой пакет использовать?
Как вы могли убедиться, язык Go предоставляет широкие возможности для обработки данных и взаимодействия с файловой системой. В этой статье мы изучили лишь основные инструменты из трех базовых пакетов: io, bufio и os.
Какой же пакет использовать для каждой конкретной задачи? Универсальных правил здесь нет, но можно принять во внимание следующие рекомендации:
- Для выполнения базовых операций над небольшими файлами допускается использовать инструменты из пакета io, которые под капотом являются абстракциями сущностей пакета os.
- Для обеспечения полноценного доступа к файловой системе следует воспользоваться составляющими пакета os. Это позволит получить контроль над сущностями ОС и избежать непредвиденных ошибок.
- При работе с большим объемом данных, который может быть разделен на отдельные фрагменты, стоит отдать предпочтение пакету bufio, так как он предоставляет возможность считывать и записывать содержимое поэтапно.
Отметим, что помимо io, bufio и os в стандартной библиотеке Go есть множество других пакетов для обработки данных и манипуляций с файловой системой, заточенных под конкретные задачи: encoding/csv для работы с csv файлами, encoding/json для обработки json, path для управления путями и другие. Большая часть из них задействует рассмотренные в этой статье примитивы из io, bufio и os, поэтому изучение дополнительных пакетов не составит у читателей особого труда.
Для знакомства с обработкой JSON рекомендуем прочитать статью "Эффективная работа с JSON в Go".
Задачи
Для отработки изученного материала будет полезно решить две несложные задачи.
Поиск слов
Напишите программу для подсчета количества слов в стандартном потоке ввода и последующего их вывода на экран с символом переноса строки. Ввод-вывод осуществляйте с использованием пакета bufio
.
Пример входных данных
Выходные данные для примера
Подсказка
Подсказка: примените функции и методы из пакета bufio, для разбиения на слова используйте функцию bufio.ScanWords
.
Решение
Сумма заказа
Напишите программу для подсчета общей стоимости заказа. Данные о купленных товарах находятся в файле order.txt
в формате "товар-цена". Размер файла может быть довольно большим, поэтому не стоит считывать его целиком.
Пример данных в файле order.txt
Выходные данные для примера
Решение
Заключение
В этой части самоучителя мы научились эффективно обрабатывать данные и взаимодействовать с операционной системой при помощи трех базовых пакетов: io, bufio и os.
В следующей статье цикла погрузимся в изучение конкурентности в языке Go, разберемся с горутинами, каналами и примитивами синхронизации.
Содержание самоучителя
- Особенности и сфера применения Go, установка, настройка
- Ресурсы для изучения Go с нуля
- Организация кода. Пакеты, импорты, модули. Ввод-вывод текста.
- Переменные. Типы данных и их преобразования. Основные операторы
- Условные конструкции if-else и switch-case. Цикл for. Вложенные и бесконечные циклы
- Функции и аргументы. Области видимости. Рекурсия. Defer
- Массивы и слайсы. Append и сopy. Пакет slices
- Строки, руны, байты. Пакет strings. Хеш-таблица (map)
- Структуры и методы. Интерфейсы. Указатели. Основы ООП
- Наследование, абстракция, полиморфизм, инкапсуляция
- Обработка ошибок. Паника. Восстановление. Логирование
- Обобщенное программирование. Дженерики
- Работа с датой и временем. Пакет time
- Интерфейсы ввода-вывода. Буферизация. Работа с файлами. Пакеты io, bufio, os
- Конкурентность. Горутины. Каналы
- Тестирование кода и его виды. Table-driven подход. Параллельные тесты
- Основы сетевого программирования. Стек TCP/IP. Сокеты. Пакет net