1. Декораторы в I/O: зачем они нужны
Когда вы только начинаете программировать, кажется, что любой новый «эффект» в коде делается через переписывание логики: «хочу ограничить чтение — напишу свой Read», «хочу логировать входные данные — снова напишу свой Read», «хочу писать в два места — значит, придётся дублировать вызовы». В Go есть гораздо более спокойный путь: взять готовый io.Reader или io.Writer и “обернуть” его в другой объект, который добавит поведение, не меняя основной код.
В мире I/O такую обёртку часто называют декоратором. Идея простая: у нас есть поток (stream), и мы строим из стандартных «кирпичиков» мини-конвейер обработки.
Представьте воду в трубе. io.Reader — это труба, из которой мы можем «черпать» данные. io.Writer — это труба, в которую мы «льём» данные. Декораторы — это насадки: ограничитель потока, тройник, который отводит часть воды в отдельную банку, или разветвитель, который ливает сразу в два ведра. И всё это без сварки новой трубы из подручных кастрюль.
io.LimitReader: не больше N байт
Когда мы читаем данные из потока, мы не всегда доверяем источнику. Иногда источник хороший, но мы хотим «пробник» (preview) — прочитать только первые байты. Иногда источник подозрительный (пользовательский ввод, файл, сеть), и мы хотим защититься от ситуации «а я пришлю тебе 10 гигабайт, и ты будешь читать, пока не состаришься». io.LimitReader — это стандартный способ в Go сказать: «читай, но только до лимита».
Сигнатура выглядит так:
func LimitReader(r Reader, n int64) Reader
Важно понять философию: LimitReader не читает сам. Он возвращает новый Reader, который будет вести себя так, как будто в исходном потоке данных ровно n байт. Когда лимит исчерпан, дальше вы получите io.EOF, даже если в исходном потоке ещё тонна данных.
Мини-пример: читаем строку, но только первые 5 байт
Сейчас мы используем память (bytes.Buffer), чтобы не отвлекаться на файлы и сеть.
package main
import (
"bytes"
"fmt"
"io"
)
func main() {
src := bytes.NewBufferString("abcdefg")
r := io.LimitReader(src, 5)
buf := make([]byte, 8)
n, err := r.Read(buf)
fmt.Printf("n=%d data=%q err=%v\n", n, buf[:n], err) // n=5 data="abcde" err=<nil>
}
Здесь важно, что buf размером 8, но Read вернул только 5 байт — лимит сработал.
Что полезно запомнить про LimitReader
LimitReader — это «полицейский на входе». Он не мешает данным существовать, он просто не даёт вам их прочитать через этот конкретный интерфейс. Поэтому при отладке иногда возникает ощущение «почему данные пропали?» — а это вы сами поставили ограничитель и забыли.
2. io.TeeReader: читаем и параллельно копируем
У io.TeeReader очень «жизненная» задача: иногда хочется, чтобы чтение из потока автоматически оставляло след — например, в логах, в буфере для отладки или в дополнительном выходе «на сторону». При этом мы не хотим переписывать цикл чтения и вручную делать w.Write(buf[:n]) в каждом месте. TeeReader как раз про это: он возвращает Reader, который при каждом чтении копирует прочитанные байты в Writer.
Сигнатура:
func TeeReader(r Reader, w Writer) Reader
Звучит почти магически, но магии нет: вы читаете из TeeReader, а он внутри делает две вещи — получает байты из r и пишет эти байты в w.
И вот тут начинается важный нюанс: если запись в w сломалась, то ошибка проявится как ошибка чтения. С точки зрения вызывающего кода это логично: «я хотел прочитать поток, но чтение сопровождается побочным действием, и оно не удалось».
Мини-пример: читаем кусками и собираем «копию для отладки»
package main
import (
"bytes"
"fmt"
"io"
"strings"
)
func main() {
src := strings.NewReader("go-go-go")
var debugCopy bytes.Buffer
r := io.TeeReader(src, &debugCopy)
buf := make([]byte, 2)
n, _ := r.Read(buf)
fmt.Printf("chunk=%q\n", buf[:n]) // chunk="go"
fmt.Printf("debug=%q\n", debugCopy.String()) // debug="go"
}
Да, мы прочитали только один чанк — и «копия» содержит только то, что реально прочитали. TeeReader ничего не «дочитывает» сам.
Где это реально полезно
На практике TeeReader часто применяют как «подслушку» при обработке данных. Например, вы парсите входной поток и хотите сохранить первые N байт, чтобы в случае ошибки показать понятное сообщение или записать диагностическую информацию. Это хорошо сочетается с идеей Go «ошибки — значения»: мы не пытаемся всё спрятать, мы стараемся при ошибке иметь полезный контекст. Эта философия в Go вообще часто всплывает вокруг I/O и обработки ошибок.
4. io.MultiWriter: запись сразу в несколько мест
Иногда задача симметричная: не «читать и копировать», а «писать сразу в несколько приёмников». Самый понятный пример — одновременно писать в консоль и в буфер (для тестов/сохранения результата/дальнейшей обработки). Или писать в два разных буфера. Или писать в лог и в основной выход. Для этого есть io.MultiWriter.
Сигнатура:
func MultiWriter(writers ...Writer) Writer
Получается новый Writer, который на каждый Write(p) вызывает Write(p) у каждого вложенного writer’а.
Здесь тоже есть важная честность: это не транзакция. Если один writer успел записать, а второй сломался, то «откатить» запись у первого невозможно. Поэтому MultiWriter — это скорее удобный разветвитель, чем банковская операция «всё или ничего».
Мини-пример: пишем в два буфера
package main
import (
"bytes"
"fmt"
"io"
)
func main() {
var a bytes.Buffer
var b bytes.Buffer
w := io.MultiWriter(&a, &b)
_, _ = w.Write([]byte("Hi"))
fmt.Println(a.String(), b.String()) // Hi Hi
}
Если вам в этот момент подумалось: «Ого, можно сделать “двойную запись” без копипасты» — да, именно так. И это отличная новость для вашего будущего: копипаста — как сахар, сначала вкусно, потом больно.
5. Конвейеры: комбинируем LimitReader, TeeReader, MultiWriter
Когда вы увидели эти три штуки по отдельности, возникает естественный вопрос: «а можно собрать их вместе?» Можно — и это как раз стиль Go: небольшие простые кирпичи, которые комбинируются в понятные конструкции.
Важно помнить, что порядок обёрток меняет смысл. Если вы ограничили поток, а потом сделали tee, то в «копию» попадёт только ограниченная часть. Если вы сделали tee, а потом ограничили уже результирующий reader, то запись в «копию» всё равно будет происходить только при чтении — то есть тоже ограниченно, но конвейер будет устроен иначе.
Небольшая схема того, как это выглядит в голове:
flowchart LR
A["Источник данных (io.Reader)"] --> B["io.LimitReader (лимит)"]
B --> C["io.TeeReader (копия в Writer)"]
C --> D["Ваш код читает кусками"]
C --> E["Отладочная копия (io.Writer)"]
В реальном коде это обычно выглядит как одна строка, но понимание важно держать именно «конвейерное».
Шпаргалка: кто что делает и когда выбирать
Чтобы не держать всё в голове как кашу из терминов, полезно иметь маленькую карту:
| Инструмент | Принимает | Возвращает | Идея | Типичный кейс |
|---|---|---|---|---|
|
|
|
Ограничить чтение n байт | превью, защита от слишком большого ввода |
|
|
|
Читаем и параллельно копируем прочитанное | диагностика, логирование входа, «сохранить то, что обработали» |
|
|
|
Пишем одновременно в несколько мест | stdout + буфер, stdout + лог, 2 буфера |
Тут нет «лучше/хуже» — это разные насадки на трубу.
6. Декораторы в учебном приложении
Сейчас у нас есть хорошая возможность сделать шаг в сторону «приложения, которое растёт». Пусть у нас есть простая модель задач (мини todo). До этого мы могли печатать задачи через fmt.Println прямо в консоль. Сегодня мы сделаем взрослее: научимся формировать отчёт и отправлять его в любой io.Writer, а дальше подключим декораторы.
Модель задачи и функция записи одной строки
package main
import (
"fmt"
"io"
)
type Task struct {
ID int
Title string
Done bool
}
func writeTaskLine(w io.Writer, t Task) error {
_, err := fmt.Fprintf(w, "%d\t%v\t%s\n", t.ID, t.Done, t.Title)
return err
}
Обратите внимание на идею: writeTaskLine ничего не знает про os.Stdout. Он просто умеет писать в любой io.Writer.
Запись списка задач в поток
package main
import "io"
func writeTasks(w io.Writer, tasks []Task) error {
for _, t := range tasks {
if err := writeTaskLine(w, t); err != nil {
return err
}
}
return nil
}
Здесь нет никакой магии, но это фундамент: как только ваш код принимает io.Writer, вы можете подключить что угодно — память, консоль, «двойной выход», логирование и так далее.
io.MultiWriter: одновременно в консоль и в буфер
Теперь сделаем так, чтобы отчёт одновременно печатался пользователю и сохранялся в памяти (например, чтобы потом показать превью или прогнать тест).
package main
import (
"bytes"
"io"
"os"
)
func main() {
tasks := []Task{
{ID: 1, Title: "прочитать про io.Reader", Done: true},
{ID: 2, Title: "не забыть проверить err", Done: false},
}
var copyBuf bytes.Buffer
w := io.MultiWriter(os.Stdout, ©Buf)
_ = writeTasks(w, tasks)
}
Да, здесь мы «проглотили» ошибку через _ = . В реальном коде мы так делать не хотим (и в типичных ошибках ниже я вас за это поругаю), но для фокусировки на MultiWriter пример компактный.
io.LimitReader: показываем только первые байты отчёта
Представим, что отчёт большой, а нам нужен «кусочек» (preview). Сделаем отчёт в буфер, а потом прочитаем только ограниченную часть.
package main
import (
"bytes"
"fmt"
"io"
)
func main() {
var report bytes.Buffer
_ = writeTasks(&report, []Task{{ID: 1, Title: "big report", Done: false}})
r := io.LimitReader(&report, 10)
buf := make([]byte, 64)
n, err := r.Read(buf)
fmt.Printf("preview=%q err=%v\n", buf[:n], err)
}
Ключевой момент: мы читаем только buf[:n]. И да, чтение из bytes.Buffer «съедает» данные — это нормально, потому что мы относимся к нему как к потоку.
io.TeeReader: читаем превью и сохраняем «что реально показывали»
Иногда хочется не просто ограничить чтение, а ещё и сохранить то, что показывали пользователю, чтобы, например, при ошибке сказать: «я успел обработать вот это».
package main
import (
"bytes"
"fmt"
"io"
"strings"
)
func main() {
src := strings.NewReader("task report line 1\ntask report line 2\n")
var shown bytes.Buffer
r := io.TeeReader(io.LimitReader(src, 12), &shown)
buf := make([]byte, 16)
n, _ := r.Read(buf)
fmt.Printf("shownNow=%q\n", buf[:n]) // shownNow="task report "
fmt.Printf("storedCopy=%q\n", shown.String()) // storedCopy="task report "
}
Здесь мы собрали маленький конвейер: сначала ограничили поток 12 байтами, потом сделали tee в буфер shown. В результате shown содержит ровно то, что реально «протекло» через чтение.
7. Типичные ошибки при работе с LimitReader, TeeReader, MultiWriter
Когда вы начинаете собирать I/O-конвейеры, мозг сначала радуется («как Lego!»), а потом пытается перепутать детальки местами. Это нормально: новые абстракции всегда дают пару лёгких шишек. Важно научиться замечать симптомы и быстро возвращаться к правилам контрактов Reader/Writer: читать кусками, учитывать n, проверять ошибки и помнить, что обёртки делают побочные эффекты.
Ошибка №1: ожидать, что io.TeeReader «сам всё прочитает и скопирует».
TeeReader вообще не “двигает данные” без вашего чтения. Он копирует ровно то, что вы реально прочитали из возвращённого reader’а. Если вы один раз сделали Read на 5 байт, то и в копии будет максимум 5 байт, даже если в источнике мегабайты.
Ошибка №2: игнорировать ошибки чтения из TeeReader.
Самая неприятная деталь TeeReader в том, что ошибка записи в w проявляется как ошибка чтения. Издалека это выглядит странно: «я же читаю, причём тут запись?» — а запись там часть контракта tee. Если вы игнорируете err, вы можете потерять сигнал о том, что «копия» не сохранилась.
Ошибка №3: думать, что io.MultiWriter — это “всем или никому”.
MultiWriter может успеть записать в первый writer и упасть на втором. В результате у вас получится «в одном месте данные есть, в другом нет». Это не баг, это природа операции. Поэтому MultiWriter хорош для логов/вывода, но плох, если вам нужна строгая атомарность.
Ошибка №4: забыть, что io.LimitReader меняет поведение конца потока.
После того как лимит исчерпан, вы получите io.EOF как будто поток закончился. Иногда разработчик воспринимает это как «данные реально закончились», а потом удивляется, почему в исходном источнике ещё что-то было. Источник был, но через ограничитель вы его больше не увидите.
Ошибка №5: потерять смысл из-за неправильного порядка обёрток.
Если вы сначала делаете TeeReader, а потом LimitReader, и ждёте, что в копии будут только первые n байт, вы, скорее всего, получите именно первые n байт — но по причине «читали только n», а не потому что tee что-то ограничил. С обёртками полезно проговаривать словами: «что именно ограничиваем?» и «что именно копируем?» — и только потом писать код.