JavaRush/Курсы/Go SELF/Декораторы потоков — io.LimitReader, io.TeeReader, io.Mul...

Декораторы потоков — io.LimitReader, io.TeeReader, io.MultiWriter

26 уровень, 2 лекция
Открыта

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)"]

В реальном коде это обычно выглядит как одна строка, но понимание важно держать именно «конвейерное».

Шпаргалка: кто что делает и когда выбирать

Чтобы не держать всё в голове как кашу из терминов, полезно иметь маленькую карту:

Инструмент Принимает Возвращает Идея Типичный кейс
io.LimitReader(r, n)
io.Reader
io.Reader
Ограничить чтение n байт превью, защита от слишком большого ввода
io.TeeReader(r, w)
io.Reader, io.Writer
io.Reader
Читаем и параллельно копируем прочитанное диагностика, логирование входа, «сохранить то, что обработали»
io.MultiWriter(w1, w2, ...)
много io.Writer
io.Writer
Пишем одновременно в несколько мест 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, &copyBuf)

	_ = 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 что-то ограничил. С обёртками полезно проговаривать словами: «что именно ограничиваем?» и «что именно копируем?» — и только потом писать код.

1
Задача
Go SELF,  26 уровень,  2 лекция
Недоступна
Превью файла
Превью файла
1
Задача
Go SELF,  26 уровень,  2 лекция
Недоступна
Отладочная подслушка
Отладочная подслушка
1
Задача
Go SELF,  26 уровень,  2 лекция
Недоступна
Двойная запись
Двойная запись
1
Задача
Go SELF,  26 уровень,  2 лекция
Недоступна
Конвейер логов
Конвейер логов
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны авторизоваться
У этой страницы еще нет ни одного комментария