Перевод одной из статей Бена Джонсона из серии "Go Walkthrough" по более углублённому изучению стандартной библиотеки в контексте реальных задач. Go является языком программирования, хорошо приспособленным для работы с байтами. Будь у вас списки байт, потоки байт или просто отдельные байты, в Go легко с ними работать. Это примитивы, на которых мы строим наши абстракции и сервисы. Пакет io является одним из самых фундаментальных во всей стандартной библиотеке. Он предоставляет набор интерфейсов и вспомогательных функций для работы с потоками байтов. Этот пост является одним из серии статей по более углублённому разбору стандартной библиотеки. Несмотря на то, что стандартная документация предоставляет массу полезной информации, в контексте реальных задач может быть непросто разобраться, что и когда использовать. Эта серия статей направлена на то, чтобы показать использование пакетов стандартной библиотеки в контексте реальных приложений. При работе с байтами, есть две фундаментальные операции: чтение и запись. Давайте сначала взглянём на чтение байтов. Простейшая конструкция для чтения байтов из потока это интерфейс Reader: Этот интерфейс многократно реализован в стандартной библиотеке для вообще всего — от сетевых соединений, до файлов и до врапперов для слайсов в памяти. Reader принимает на вход буфер, p, как параметр для метода Read(), чтобы не нужно было выделять память. Если бы Read() возвращал новый слайс, вместо того, чтобы принимать его как аргумент, ридеру пришлось бы выделять память при каждом вызове Read(). Это была бы катастрофа для сборщика мусора. Одна из проблем с интерфейсом Reader в том, что с ним идёт набор довольно витиеватых правил. Во-первых, он возвращает ошибку io.EOF при нормальном ходе дел, просто если поток данных завершился. Это может запутывать новичков. Во-вторых, нет гарантии, что ваш буфер будет заполнен целиком. Если вы передали 8-байтовый слайс, по факту вы можете прочитать от 0 до 8 байт. Обработка чтения по частям можем быть непростой и легко подвержена ошибкам. К счастью, у нас есть немало вспомогательных функций для решения этих задач. Представим, что у вас есть протокол, который нужно распарсить и вы хотите прочесть 8-байтовое uint64 значение из ридера. В этом случае предпочтительней использовать io.ReadFull(), так как вы точно знаете, сколько хотите прочесть:  Эта функция проверяет, что буфер полностью заполнен перед тем, как вернуть значение. Если размер полученных данных отличается от размера буфера, то вы получите ошибку io.ErrUnexpectedEOF. Эта простая гарантия упрощает код довольно сильно. Чтобы прочесть 8 байт, достаточно сделать так: Есть также довольно много более высокоуровневых парсеров вроде binary.Read(), которые умеют парсить определённые типы. Мы познакомимся ближе с ними в следующих постах о других пакетах. Ещё одна чуть реже используемая вспомогательная функция это ReadAtLeast(): Эта функция записывает доступные для чтения данные в ваш буфер, но не менее указанного количества байт. Я не нашел надобности в этой функции лично для себя, но легко могу представить её пользу для случаев, когда вы хотите уменьшить количество вызовов Read() и буферизировать дополнительные данные. Нередко вы можете встретить ситуацию, где вам необходимо объединить несколько ридеров вместе. Это легко сделать с помощью MultiReader: Например, вы хотите отправить HTTP ответ, в котором заголовок читается из памяти, а содержимое тела ответа — из файла. Многие люди сначала прочитают файл в буфер в памяти перед отправкой, но это медленно и может требовать много памяти. Вот более простой подход: MultiReader даёт возможность http.Post() использовать оба ридера как один. Один из моментов, которые вам могут встретиться при работе с ридерами это то, что если данные были вычитаны, их нельзя прочитать ещё раз. Например, ваше приложение не смогло распарсить тело HTTP запроса, но вы не можете его проанализировать, потому что парсер уже прочёл данные, их больше нет в ридере. TeeReader является тут хорошим решением — он позволяет сохранять вычитанные данные, при этом не мешая процессу чтения. Эта функция создаёт новый ридер-обёртку вокруг вашего ридера r. Любая операция чтения из нового ридера будет также записывать данные в w. Этот райтер(writer) может представлять собой всё что угодно — от буфера в памяти, до лог файла и до потока стандартных ошибок STDERR. Например, вы можете захватывать ошибочные запросы следующим образом: Впрочем, тут важно быть внимательными с размерами вычитанного тела ответа, чтобы не израсходовать память. Поскольку потоки никак не ограничены по размеру, иногда чтение из них может привести к проблемам с памятью или местом на диске. Типичный пример это хендлер, осуществляющий загрузку файла. Обычно существуют лимиты на максимальный размер загружаемого файла, чтобы не переполнять диск, но может быть утомительно имплементировать их вручную. LimitReader даёт нам эту функциональность, предоставляя обёртку вокруг ридера, который ограничивает количество байт, доступных для вычитки. Один из моментов при работе с LimitReader-ом это то, что он не скажет вам, если r вычитал больше, чем n. Он просто вернёт io.EOF, как только вычитает n байт. Как вариант, можно выставить лимит в n+1 и потом проверить, прочитали ли вы больше, чем n байт в конце. Теперь, после того как мы познакомились с чтением байтов из потоков, давайте посмотрим, как их записывать в потоки. Интерфейс Writer это, по сути, инвертированный Reader. Мы указываем набор байтов, которые нужно записать в поток: В общем случае, запись байтов это более простая операция, чем чтение. С ридерами сложность в том, чтобы правильно работать с частичными и не полными чтениями, но при частичной или неполной записи, мы просто получаем ошибку. Иногда вам нужно отправить данные сразу в несколько writer-ов. Например, в лог файл и в STDERR. Это похоже на TeeReader, только мы хотим дублировать запись, а не чтение. В этом случае нам подойдёт MultiWriter: Имя может немного сбивать толку, потому что это не совсем writer-версия MultiReader. Если MultiReader объединяет несколько ридеров в один, то MultiWriter возвращает writer, который дублирует записи во все writer-ы. Я активно использую MultiWriter в unit-тестах, где я хочу убедиться, что сервисы пишут в лог корректно: Использование MultiWriter позволяет мне проверить содержимое buf и при этом видеть полный вывод логов в терминале для отладки. Теперь, когда мы разобрались и с чтением, и с записью байт, логично разобраться, как мы можем объединять эти две операции вместе и копировать данные между ними. Самый простой способ скопировать из ридера во writer это использовать функцию Copy(): Эта функция использует буфер в 32 КБ, чтобы прочитать из src и записать в dst. Если случится ошибка, отличная от io.EOF, копирование остановится и вернётся ошибка. Одна из проблем с Copy() заключается в том, что у вас нет способа гарантировать максимальное количество скопированных байт. Например, вы хотите скопировать лог файл до его текущего размера. Если же лог продолжит расти во время копирования, вы получите больше байт, чем ожидалось. В этом случае можно использовать функцию CopyN(), которая скопирует не больше указанного количества: Ещё один важный момент с Copy() заключается в том, что при каждом копировании выделяется буфер в 32КБ. Если вам нужно делать много операций копирования, вы можете переиспользовать уже выделенный буфер и использовать CopyBuffer(): Накладные расходы на Copy() на самом деле очень малы, поэтому я лично не использую CopyBuffer(). Чтобы избежать использования промежуточного буфера, типы данных могут имплементировать специальные интерфейсы для чтения и записи в них напрямую. Если они имплементированы для типа, функция Copy() не будет использовать буфер, а будет использовать эти специальные методы. Если тип имплементирует интерфейс WriterTo, то он может записывать данные напрямую: Я использовал его в функции BoltDB Tx.WriteTo(), которая позволяет пользователям создавать снапшот базы данных из транзакции. С другой стороны, интерфейс ReaderFrom позволяет типу напрямую читать данные из ридера: Иногда вы оказываетесь в ситуации, когда у вас есть функция, принимающая Reader, но у вас есть только Writer. Возможно, вы хотите динамически записать данные в HTTP запрос, но http.NewRequest() принимает только Reader. Вы можете инвертировать райтер, используя io.Pipe(): Тут вы получаете новый ридер и writer. Любая запись в PipeWriter переправится в PipeReader. Я редко использовал эту функцию, но exec.Cmd использует её для реализации Stdin, Stdout и Stderr пайпов, которые могут быть очень полезны при работе c запускаемыми программами. Всё хорошее подходит к концу, и работа с потоками не исключение. Интерфейс Closer предоставляет общий способ закрывать потоки: Тут особо не о чем писать, интерфейс этот очень простой, но я стараюсь всегда возвращать ошибку в моих Close() методах, чтобы мои типы реализовывали этот интерфейс, если потребуется. Closer не всегда используется напрямую, он чаще идёт в сочетании с другими интерфейсами, такими как ReadCloser, WriteCloser и ReadWriteCloser. Потоки обычно представляют собой постоянно появляющиеся данные от начала до конца, но бывают исключения. Файл, к примеру, может быть потоком, но при этом вы также можете произвольно перемещаться к любой позиции внутри файла. Интерфейс Seeker предоставляет возможность перемещаться внутри потока: Есть три способа прыгать на нужную позицию: переход от текущей позиции, переход с начала потока и переход с конца. Вы указываете этот способ аргументом whence. Аргумент offset указывает на сколько байт переместиться. Перемещение по потоку может быть полезным, если вы используете блоки фиксированного размера или если ваш файл содержит индекс со смещениями. Иногда данные находятся в заголовке и логично использовать переход с начала потока, но иногда данные находятся в хвосте и удобнее перемещаться с конца. Чтение и запись порциями могут быть утомительны, если всё что вам нужно это один байт или руна (rune). В Go для этого есть интерфейсы, которые облегчают жизнь. Интерфейсы ByteReader и ByteWriter предоставляют простые методы для чтения и записи одного байта: Заметьте, что тут нет параметра для количества байт, это всегда будет 0 или 1. Если байт не был прочитан или записан, возвращается ошибка. Также есть есть ByteScanner интерфейс, позволяющий удобно работать с буферизированными ридерами для байт: Этот интерфейс позволяет вернуть байт обратно в поток. Это бывает удобно, например при написании LL(1) парсеров, так как позволяет заглядывать на байт вперёд. Если вы парсите Unicode данные, то вы должны работать с рунами вместо индивидуальных байт. В этом случае вы должны использовать интерфейсы RuneReader и RuneScanner: Потоки байт важны для многих Go программ. Это интерфейсы для всего, от сетевых соединений до файлов на диске и до пользовательского ввода с клавиатуры. Пакет io предоставляет основные примитивы для работы со всем этим. Мы посмотрели на чтение, запись и копирование байт, а также на оптимизацию этих операций под конкретные задачи. Эти примитивы могут выглядеть просто, но они являются базовыми строительными блоками для приложений, активно работающих с данными. Пожалуйста, изучите внимательно пакет io и используйте его интерфейсы в своих программах. Также я буду рад, если вы поделитесь своими интересными способами использования пакета io, равно как и любым советам по тому, как можно улучшить эту серию статей.