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 | Что это значит | Что делать |
|---|---|---|---|
|
|
прочитали кусок данных | обработать p[:n], продолжать |
|
|
поток закончился | завершить чтение |
|
|
прочитали последний кусок и сразу конец | обработать p[:n], потом завершить |
|
другая ошибка | данные прочитались, но дальше проблема | обработать p[:n], потом вернуть/логировать ошибку |
|
другая ошибка | ничего не прочитали, сразу ошибка | вернуть/логировать ошибку |
Откуда вообще берётся вариант 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 параметрами — и вы внезапно начнёте писать код, который легко переносить и проверять.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ