Розбираємося в Go: пакет io

Переклад однієї з статей Бена Джонсона з серії "Go Walkthrough" за більш поглибленого вивчення стандартної бібліотеки в контексті реальних завдань.
Go є мовою програмування, добре пристосованим для роботи з байтами. Будь у вас списки байт, потоки байт або просто окремі байти, Go легко з ними працювати. Це примітиви, на яких ми будуємо наші абстракції і сервіси.
Пакет io є одним з найбільш фундаментальних у всій стандартній бібліотеці. Він надає набір інтерфейсів і допоміжних функцій для роботи з потоками байтів.
Цей пост є одним із серії статей з більш поглибленого розбору стандартної бібліотеки. Незважаючи на те, що стандартна документація надає масу корисної інформації, в контексті реальних завдань може бути непросто розібратися, що і коли використовувати. Ця серія статей спрямована на те, щоб показати використання пакетів стандартної бібліотеки в контексті реальних додатків.
Читання байтів
При роботі з байтами, є дві основні операції: читання і запис. Давайте спочатку поглянемо на читання байтів.
Інтерфейс Reader
Найпростіша конструкція для читання байтів з потоку це інтерфейс Reader:
type Reader interface {
Read(p []byte) (int n, err error)
}

Цей інтерфейс багаторазово реалізований в стандартній бібліотеці для взагалі всього — відмережевих з'єднань, файли доврапперов для слайсів в пам'яті.
Reader приймає на вхід буфер, p, як параметр методу Read(), щоб не потрібно було виділяти пам'ять. Якби Read() повертав новий слайс, замість того, щоб приймати його як аргумент, рідер довелося б виділяти пам'ять при кожному виклику Read(). Це була б катастрофа для складальника сміття.
Одна з проблем з інтерфейсом Reader в тому, що з ним іде набір досить різноманітних правил. По-перше, він повертає помилку io.EOF при нормальному ході справ, просто якщо потік даних завершився. Це може заплутувати новачків. По-друге, немає гарантії, що ваш буфер буде заповнений повністю. Якщо ви передали 8-байтовий слайс, за фактом ви можете прочитати від 0 до 8 байт. Обробка читання по частинах можемо бути непростою та легко схильна до помилок. На щастя, у нас є чимало допоміжних функцій для рішення цих завдань.
Покращуємо гарантії читання
Уявімо, що у вас є протокол, який потрібно розпарсити і ви хочете прочитати 8-байтове uint64 значення з рідера. В цьому випадку краще використовувати io.ReadFull(), так як ви точно знаєте, скільки хочете прочитати:
func ReadFull(r Reader, buf []byte) (int n, err error)

Ця функція перевіряє, чи повністю заповнений буфер перед тим, як повернути значення. Якщо розмір отриманих даних відрізняється від розміру буфера, то ви отримаєте помилку io.ErrUnexpectedEOF. Ця проста гарантія спрощує код досить сильно. Щоб прочитати 8 байт, досить зробити так:
buf := make([]byte, 8)
if _, err := io.ReadFull(r, buf); err != nil {
return err
}

Є також досить багато більш високорівневих парсерів зразок binary.Read(), які вміють парсити певні типи. Ми познайомимося ближче з ними в наступних постах про інших пакетах.
Ще одна трохи рідше використовується допоміжна функція це ReadAtLeast():
func ReadAtLeast(r Reader, buf []byte, min int) (int n, err error)

Ця функція записує доступні для читання дані в буфер, але не менше вказаної кількості байт. Я не знайшов потреби в цій функції особисто для себе, але легко можу уявити її користь для випадків, коли ви хочете зменшити кількість викликів Read() і буферізіровать додаткові дані.
Об'єднання потоків
Нерідко ви можете зустріти ситуацію, де вам необхідно об'єднати кілька рідерів разом. Це легко зробити за допомогою MultiReader:
func MultiReader(readers ...Reader) Reader

Наприклад, ви хочете відправити HTTP відповідь, в якому заголовок читається з пам'яті, а вміст тіла відповіді з файлу. Багато людей спочатку прочитають файл у буфер в пам'яті перед відправкою, але це повільно і може вимагати багато пам'яті.
Ось більш простий підхід:
r := io.MultiReader(
bytes.NewReader([]byte("...my header...")),
myFile,
)
http.Post("http://example.com", "application/octet-stream", r)

MultiReader дає можливість http.Post() використовувати обидва рідера як один.
Дублювання потоків
Один з моментів, які вам можуть зустрітися при роботі з рідерами це те, що якщо дані були вичитані, їх не можна прочитати ще раз. Наприклад, ваш додаток не змогло розпарсити тіло HTTP запиту, але ви не можете його проаналізувати, тому що парсер вже прочитав дані, їх більше немає в рідері.
TeeReader є тут хорошим рішенням — він дозволяє зберігати вичитані дані, при цьому не заважаючи процесу читання.
func TeeReader(r Reader, w Writer) Reader

Ця функція створює новий рідер-обгортку навколо вашого рідера r. Будь-яка операція читання з нового рідера буде також записувати дані в w. Цей райтер(writer) може являти собою все що завгодно — від буфера пам'яті, до лог файлу та до потоку стандартних помилок STDERR.
Наприклад, ви можете захоплювати помилкові запити наступним чином:
var buf bytes.Buffer
body := io.TeeReader(req.Body &buf)

// ... process body ...

if err != nil {
// inspect buf
return err
}

Втім, тут важливо бути уважними з розмірами вычитанного тіла відповіді, щоб не витратити пам'ять.
Обмеження довжини потоку
Оскільки потоки ніяк не обмежені за розміром, іноді читання з них може привести до проблем з пам'яттю або місцем на диску. Типовий приклад це хендлер, що здійснює завантаження файлу. Зазвичай існують обмеження на максимальний розмір завантажуваного файлу, щоб не переповнювати диск, але може бути утомливо імплементувати їх вручну.
LimitReader дає нам цю функціональність, надаючи обгортку навколо рідера, який обмежує кількість байт, доступних для вичитки.
func LimitReader(r Reader, n int64) Reader

Один з моментів при роботі з LimitReader-му це те, що він не скаже вам, якщо r вичитав більше, ніж n. Він просто поверне io.EOF, як тільки віднімає n байт. Як варіант, можна виставити ліміт n+1 і потім перевірити, чи прочитали ви більше, ніж n байт в кінці.
Запис байтів
Тепер, після того як ми познайомилися з читанням байтів з потоків, давайте подивимося, як їх записувати в потоки.
Інтерфейс Writer
Інтерфейс Writer це, по суті, інвертований Reader. Ми вказуємо набір байтів, які потрібно записати в потік:
type Writer interface {
Write(p []byte) (int n, err error)
}

У загальному випадку, запис байтів це більш проста операція, ніж читання. З рідерами складність в тому, щоб правильно працювати з частковими і не повними читаннями, але при часткової або неповної запису, ми просто отримуємо помилку.
Дублювання запису
Іноді вам потрібно відправити дані відразу в кілька writer-ів. Наприклад, в лог файл і STDERR. Це схоже на TeeReader, тільки ми хочемо дублювати запис, а не читання.
В цьому випадку нам підійде MultiWriter:
func MultiWriter(writers ...Writer) Writer

Ім'я може трохи збивати користі, тому що це не зовсім writer-версія MultiReader. Якщо MultiReader об'єднує кілька рідерів в один, то MultiWriter повертає writer, який дублює запису в усі writer-и.
Я активно використовую MultiWriter в unit-тестах, де я хочу переконатися, що сервіси пишуть в лог коректно:
type MyService struct {
LogOuput io.Writer
}
...
var buf bytes.Buffer
var s MyService
s.LogOutput = io.MultiWriter(&buf, os.Stderr)

Використання MultiWriter дозволяє мені перевірити вміст buf і при цьому бачити повний висновок логів в терміналі для налагодження.
Копіювання байт
Тепер, коли ми розібралися і з читанням, і з записом байт, логічно розібратися, як ми можемо об'єднувати ці дві операції разом і копіювати дані між ними.
Об'єднуючи readers & writers
найпростіший спосіб скопіювати з рідера під writer це використовувати функцію Copy():
func Copy(dst Writer, src Reader) (written int64, err error)

Ця функція використовує буфер в 32 КБ, щоб прочитати з src і записати в dst. Якщо трапиться помилка, відмінна від io.EOF, копіювання зупиниться і повернеться помилка.
Одна з проблем з Copy() полягає в тому, що у вас немає способу гарантувати максимальну кількість скопійованих байт. Наприклад, ви хочете скопіювати лог файл до його поточного розміру. Якщо ж лог продовжить зростати під час копіювання, ви отримаєте більше даних, ніж очікувалося. В цьому випадку можна використовувати функцію CopyN(), яка скопіює не більше вказаної кількості:
func CopyN(dst Writer, src Reader, n int64) (written int64, err error)

Ще один важливий момент з Copy() полягає в тому, що при кожному копіюванні виділяється буфер в 32КБ. Якщо вам треба робити багато операцій копіювання, ви можете переиспользовать вже виділений буфер і використовувати CopyBuffer():
func CopyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error)

Накладні витрати на Copy() насправді дуже малі, тому я особисто не використовую CopyBuffer().
Оптимізуємо копіювання
Щоб уникнути використання проміжного буфера, типи даних можуть імплементувати спеціальні інтерфейси для читання і запису на них безпосередньо. Якщо вони імплементовані для типу, функція Copy() не буде використовувати буфер, а буде використовувати ці спеціальні методи.
Якщо тип імплементує інтерфейс WriterTo, то він може записувати дані безпосередньо:
type WriterTo interface {
WriteTo(w Writer) (n int64, err error)
}

Я використав його функції BoltDB Tx.WriteTo(), яка дозволяє користувачам створювати снапшот бази даних транзакції.
З іншого боку, інтерфейс ReaderFrom дозволяє типу напряму читати дані з рідера:
type ReaderFrom interface {
ReadFrom(r Reader) (n int64, err error)
}

Адаптація рідерів і райтерів
Іноді ви опиняєтеся в ситуації, коли у вас є функція, що приймає Reader, але у вас є тільки Writer. Можливо, ви хочете динамічно записати дані в HTTP запит, але http.NewRequest() приймає тільки Reader.
Ви можете інвертувати райтер, використовуючи io.Pipe():
func Pipe() (*PipeReader, *PipeWriter)

Тут ви отримуєте новий рідер і writer. Будь-який запис в PipeWriter переправится в PipeReader.
Я рідко використовував цю функцію, але exec.Cmd використовує її для реалізації Stdin, Stdout і Stderr пайпов, які можуть бути дуже корисні при роботі c запускаються програмами.
Закриття потоків
Все добре підходить до кінця, і робота з потоками не виняток. Інтерфейс Closer надає загальний спосіб закривати потоки:
type Closer interface {
Close() error
}

Тут особливо не про що писати, цей інтерфейс дуже простий, але я намагаюся завжди повертати помилку в моїх Close() методи, щоб мої типи реалізовували цей інтерфейс, якщо буде потрібно. Closer не завжди використовується безпосередньо, він частіше йде в поєднанні з іншими інтерфейсами, такими як ReadCloser, WriteCloser і ReadWriteCloser.
Навігація по потокам
Потоки зазвичай представляють собою постійно з'являються дані від початку до кінця, але бувають винятки. Файл, наприклад, може бути потоком, але при цьому ви також можете довільно переміщатися до будь-якої позиції всередині файлу.
Інтерфейс Seeker надає можливість переміщатися всередині потоку:
type Seeker interface {
Seek(offset int64, whence int) (int64, error)
}

Є три способи стрибати на потрібну позицію: перехід від поточної позиції, перехід з початку потоку і перехід з кінця. Ви вказуєте цей спосіб аргументом whence. Аргумент offset вказує на скільки байт переміститися.
Переміщення по потоку може бути корисним, якщо ви використовуєте блоки фіксованого розміру або якщо ваш файл містить індекс зі зсувами. Іноді дані знаходяться в заголовку і логічно використовувати перехід з початку потоку, але іноді дані знаходяться в хвості і зручніше пересуватися з кінця.
Оптимізація під типи даних
Читання і запис порціями можуть бути утомливі, якщо все що вам потрібно це один байт або руна (rune). В Go для цього є інтерфейси, які полегшують життя.
Робота з індивідуальними байтами
Інтерфейси ByteReader і ByteWriter надають прості методи для читання і запису одного байта:
type ByteReader interface {
ReadByte() (c byte, err error)
}
type ByteWriter interface {
WriteByte(c byte) error
}

Зауважте, що тут немає параметра для кількості байт, це завжди буде 0 або 1. Якщо байт не був прочитаний чи записаний, повертається помилка.
Також є ByteScanner інтерфейс, що дозволяє зручно працювати з буферизированными рідерами для байт:
type ByteScanner interface {
ByteReader
UnreadByte() error
}

Цей інтерфейс дозволяє повернути байт назад в потік. Це буває зручно, наприклад при написанні LL(1) парсерів, так як дозволяє заглядати на байт вперед.
Робота з індивідуальними рунами
Якщо ви парсите Unicode дані, то ви повинні працювати з рунами замість індивідуальних байт. У цьому випадку ви повинні використовувати інтерфейси RuneReader і RuneScanner:
type RuneReader interface {
ReadRune() (r rune, int size, err error)
}
type RuneScanner interface {
RuneReader
UnreadRune() error
}

Висновок
Потік байт важливі для багатьох Go програм. Це інтерфейси для всього, від мережевих з'єднань до файлів на диску і до користувальницького введення з клавіатури. Пакет io надає основні примітиви для роботи з усім цим.
Ми подивилися на читання, запис і копіювання байт, а також на оптимізацію цих операцій під конкретні завдання. Ці примітиви можуть виглядати просто, але вони є базовими будівельними блоками для додатків, що активно працюють з даними.
будь Ласка, вивчіть уважно пакет io і використовуйте його інтерфейси в своїх програмах. Також я буду радий, якщо ви поділитеся своїми цікавими способами використання пакету io, так само як і будь порад щодо того, як можна поліпшити цю серію статей.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.