JavaRush /Курсы /Go SELF /io.Reader и io.Writer: основа I/O и тестируемости

io.Reader и io.Writer: основа I/O и тестируемости

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

1. Потоковая модель и контракты Reader/Writer

Если вы раньше представляли ввод/вывод как «прочитать файл целиком» или «вывести строку на экран», то сегодня мы чуть расширим картину мира. Большая часть настоящего I/O в программах устроена как поток (stream): данные приходят кусочками, размер заранее неизвестен, а скорость зависит от внешнего мира. Даже если вы работаете «с памятью», удобно мыслить так же.

Представьте, что вы пьёте чай через трубочку. Вы же не требуете от трубочки «дай мне весь чай сразу одним глотком на 2 литра». Вы делаете много маленьких «чтений». Поток в Go — это как раз такая «трубочка» для данных: читаем и пишем порциями.

Схематично (в стиле «всё гениальное — прямоугольники и стрелки»):

flowchart LR
    A[Источник данных<br/>Reader] -->|"Read(p)"| B[Наша логика<br/>обработка]
    B -->|"Write(p)"| C[Приёмник данных<br/>Writer]

И ключевая мысль дня: наша логика должна зависеть от Reader/Writer, а не от того, что там внутри (файл, сеть, память, stdin/stdout).

Интерфейс io.Reader: «дай байты в мой буфер»

Сейчас будет немного «официального» языка, но без него мы начнём путаться в мелочах. io.Reader — это интерфейс стандартной библиотеки, который описывает источник байтов. Не строк, не чисел, не JSON, а именно байтов: «сырья», из которого уже можно собрать что угодно.

У io.Reader есть ровно один метод:

Read(p []byte) (n int, err error)

Смысл такой: вы (как читающая сторона) даёте буфер p, а reader пытается заполнить его данными. Возвращается n — сколько байтов реально записано в p. А ещё возвращается err, который сообщает «всё ли было нормально».

Самое важное правило, которое спасает от тонны багов: после Read можно использовать только p[:n]. Не весь p. Не «ну там же раньше было что-то полезное». Только первые n байтов.

Мини-пример: читаем из строки маленькими кусочками. Да, strings.NewReader — это «читатель из памяти», но он идеально показывает потоковую модель.

package main

import (
	"fmt"
	"io"
	"strings"
)

func main() {
	r := strings.NewReader("hello-go")
	buf := make([]byte, 3)

	for {
		n, err := r.Read(buf)
		if n > 0 {
			fmt.Printf("chunk=%q\n", buf[:n]) // chunk="hel" ...
		}
		if err == io.EOF {
			break
		}
		if err != nil {
			fmt.Println("read error:", err)
			return
		}
	}
}

Интерфейс io.Writer: «возьми байты из моего буфера»

Если Reader — это трубочка «внутрь программы», то Writer — трубочка «наружу». Он описывает приёмник байтов: куда-то можно записать данные (консоль, файл, сеть, буфер в памяти, логгер и так далее).

Контракт у io.Writer тоже предельно короткий:

Write(p []byte) (n int, err error)

Смысл похож, но зеркальный: вы даёте writer’у данные в p, а он сообщает, сколько байтов реально принял (n) и была ли ошибка (err). И тут есть неприятная для новичка штука: Write имеет право записать не всё, то есть вернуть n < len(p).

Почему так? Потому что внешний мир не обязан быть удобным. Канал связи может быть загружен, буфер — переполнен, приёмник — «медленный». Поэтому в серьёзном коде запись часто делается в цикле «дозапиши остаток».

Ещё одна важная мысль: стандартная библиотека Go очень активно строит I/O вокруг маленьких интерфейсов. Например, буферизатор bufio.Writer реализует io.Writer и поддерживает идею «сначала накапливаем записи, потом проверяем ошибку при Flush», потому что так удобнее жить с ошибками при выводе.

Как правильно читать n и err (и что такое io.EOF)

Почти все «странные баги» в I/O у новичков происходят из-за неверной трактовки пары (n, err). Кажется логичным «если err != nil, значит всё плохо и надо срочно выйти». Но в потоковом I/O это иногда ломает данные.

io.EOF — не «ужас-ошибка», а штатный сигнал «конец потока»

io.EOF означает: «данные закончились». Это не авария, а обычный способ корректно завершить чтение. В большинстве циклов чтения EOF — это условие break или return nil.

Комбинации n/err для Read

Вот таблица, которую полезно держать в голове (это не «зубрёжка», это карта местности):

n err Что это значит Что делать
>0
nil
прочитали кусок данных обработать p[:n], продолжать
0
io.EOF
поток закончился завершить чтение
>0
io.EOF
прочитали последний кусок и сразу конец обработать p[:n], потом завершить
>0
другая ошибка данные прочитались, но дальше проблема обработать p[:n], потом вернуть/логировать ошибку
0
другая ошибка ничего не прочитали, сразу ошибка вернуть/логировать ошибку

Откуда вообще берётся вариант n > 0 и err != nil? Это не «плохая реализация», это разрешённая часть контракта. Внутри стандартной библиотеки тоже встречаются реализации, где сначала отдаётся полезная порция данных, а затем сообщается об ошибке или конце потока.

Мини-демонстрация «нормального странного» reader’а, который возвращает данные и ошибку вместе:

package main

import (
	"fmt"
	"io"
)

type glitchyReader struct{ done bool }

func (r *glitchyReader) Read(p []byte) (int, error) {
	if r.done {
		return 0, io.EOF
	}
	r.done = true

	copy(p, "OK")
	return 2, fmt.Errorf("network glitch")
}

func main() {
	var r glitchyReader
	buf := make([]byte, 8)

	n, err := r.Read(buf)
	fmt.Printf("n=%d data=%q err=%v\n", n, buf[:n], err)
	// n=2 data="OK" err=network glitch
}

Здесь мораль простая: сначала обрабатываем данные при n > 0, потом решаем, что делать с err.

Частичная запись в Write: «дозапиши, пожалуйста»

Для Write ситуация похожа: n может быть меньше len(p), и это означает «записали только часть». Если вам нужно гарантированно записать всё, делайте «write-all» цикл.

package main

import "io"

func writeAll(w io.Writer, p []byte) error {
	for len(p) > 0 {
		n, err := w.Write(p)
		if err != nil {
			return err
		}
		p = p[n:]
	}
	return nil
}

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

2. Почему Reader/Writer помогают тестируемости

Когда мы говорим «тестируемость», не обязательно сразу представлять go test, мок-генераторы и прочую взрослую жизнь. Тестируемость на базовом уровне — это когда вашу логику можно проверить без шаманства: не трогая файлы, сеть, реальные stdin/stdout, и не заставляя человека «вручную вводить 100 строк».

И вот тут io.Reader и io.Writer — настоящие супергерои без плаща. Если ваша функция принимает io.Reader, вы можете подсунуть ей хоть файл, хоть строку из памяти. Если функция пишет в io.Writer, вы можете направить вывод хоть в консоль, хоть в буфер в памяти и сравнить результат.

Сравните два подхода по ощущению (и по будущей боли).

Плохой (жёстко привязан к консоли, «как протестировать — не знаю, но верю»):

package main

import "fmt"

func PrintHelloBad() {
	fmt.Println("hello") // всегда в stdout
}

Хороший (можно писать куда угодно):

package main

import (
	"fmt"
	"io"
)

func PrintHello(w io.Writer) {
	fmt.Fprintln(w, "hello")
}

Разница не в «красоте». Разница в том, что второй вариант можно проверить, подставив буфер, а первый — только глазами (или сложными трюками со stdout).

3. Пример: TaskBook

Сейчас соберём маленький учебный кусочек приложения, который будет расти вместе с курсом. Пусть это будет простая «книжка задач» (TaskBook): список задач можно вывести в любой io.Writer, а загрузить — из любого io.Reader. Сегодня без файлов и без JSON — только поток и контракт.

Модель данных

package main

type Task struct {
	ID    int
	Title string
	Done  bool
}

Пишем задачи в io.Writer

Формат сделаем простым и «сканируемым»: одна строка = одна задача.

Пример строки:

1 buy_milk false

Код записи:

package main

import (
	"fmt"
	"io"
)

func WriteTasks(w io.Writer, tasks []Task) error {
	for _, t := range tasks {
		_, err := fmt.Fprintf(w, "%d %s %t\n", t.ID, t.Title, t.Done)
		if err != nil {
			return err
		}
	}
	return nil
}

Обратите внимание на стиль: функция не печатает сама в stdout, не «знает», куда пишет. Она умеет только одно: «возьми writer и запиши туда задачи».

Читаем задачи из io.Reader

Для чтения воспользуемся fmt.Fscan, который вы уже видели раньше в теме про ввод. Это удобно, потому что Fscan умеет читать из любого io.Reader, а не только из stdin.

package main

import (
	"fmt"
	"io"
)

func ReadTasks(r io.Reader) ([]Task, error) {
	var tasks []Task

	for {
		var t Task
		_, err := fmt.Fscan(r, &t.ID, &t.Title, &t.Done)
		if err == io.EOF {
			return tasks, nil
		}
		if err != nil {
			return nil, err
		}
		tasks = append(tasks, t)
	}
}

Тут важно, что io.EOF — это «всё, закончили», а не «сломалось».

Собираем всё в main и проверяем через буфер

Здесь мы используем strings.NewReader как вход (это Reader), и bytes.Buffer как выход (это Writer). Да, подробно bytes.Buffer будет следующим шагом дня, но как «контейнер для проверки» он слишком удобен, чтобы его игнорировать.

package main

import (
	"bytes"
	"fmt"
	"strings"
)

func main() {
	input := "1 buy_milk false\n2 learn_go true\n"
	r := strings.NewReader(input)

	tasks, err := ReadTasks(r)
	if err != nil {
		fmt.Println("read error:", err)
		return
	}

	var out bytes.Buffer
	if err := WriteTasks(&out, tasks); err != nil {
		fmt.Println("write error:", err)
		return
	}

	fmt.Print(out.String())
	// 1 buy_milk false
	// 2 learn_go true
}

Смысл этого примера не в том, что «мы прочитали из строки» (в реальности потом это может быть файл или сеть). Смысл в том, что логика чтения/записи задач вообще не зависит от источника и приёмника. А это значит: проверять, переносить, переиспользовать и комбинировать такой код намного проще.

4. Типичные ошибки при работе с io.Reader/io.Writer

Ошибка №1: использовать весь буфер после Read, а не buf[:n].
Это очень коварно: «вроде работает», но начинает печатать мусор, дублировать хвосты или смешивать данные из разных чтений. После Read корректны только первые n байтов (buf[:n]), остальная часть буфера — старые данные, которые там могли остаться.

Ошибка №2: воспринимать io.EOF как «настоящую ошибку».
Если вы пишете if err != nil { return err } и не выделяете err == io.EOF, ваша функция будет «падать» в самом обычном сценарии: когда поток закончился. EOF — штатный сигнал завершения чтения. В цикле чтения это обычно означает «выйти».

Ошибка №3: делать return err до обработки данных при n > 0.
Комбинация «данные + ошибка» выглядит странно, но она допустима контрактом. Если вы сразу выходите при err != nil, вы теряете уже полученные байты. Правило простое: сначала обработать p[:n], потом разбираться с ошибкой.

Ошибка №4: считать, что один Write записывает всё.
На реальных writer’ах (сеть, некоторые обёртки, цепочки writer’ов) запись может быть частичной. Если вам нужно записать всё целиком, пишите цикл дозаписи, ориентируясь на n, или используйте готовые инструменты, когда вы уже точно понимаете контракт.

Ошибка №5: привязывать бизнес‑логику к fmt.Println и fmt.Scan.
Такой код приятно писать первые полчаса, но потом он становится «непроверяемым» и плохо переиспользуемым: нельзя направить вывод в строку, нельзя прочитать данные из памяти, нельзя аккуратно встроить в другую систему. Принимайте io.Reader/io.Writer параметрами — и вы внезапно начнёте писать код, который легко переносить и проверять.

1
Задача
Go SELF, 26 уровень, 0 лекция
Недоступна
Привет в чат
Привет в чат
1
Задача
Go SELF, 26 уровень, 0 лекция
Недоступна
Счётчик трафика
Счётчик трафика
1
Задача
Go SELF, 26 уровень, 0 лекция
Недоступна
Фильтр пробелов
Фильтр пробелов
1
Задача
Go SELF, 26 уровень, 0 лекция
Недоступна
Надёжная запись
Надёжная запись
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ