1. Потокова модель і контракти Reader/Writer
Якщо раніше ви уявляли введення/виведення як «прочитати файл повністю» або «вивести рядок на екран», то сьогодні ми трохи розширимо картину. Більшість справжнього I/O в програмах працює як потік (stream): дані надходять шматочками, розмір заздалегідь невідомий, а швидкість залежить від зовнішнього світу. Навіть якщо ви працюєте з пам’яттю, зручно мислити так само.
Уявіть, що ви п’єте чай через трубочку. Ви ж не вимагатимете від трубочки: «Дай мені весь чай одразу — на 2 л». Ви робите багато маленьких «читань». Потік у Go — це саме така «трубочка» для даних: читаємо й пишемо порціями.
Схематично — у стилі «усе геніальне — це прямокутники й стрілки»:
flowchart LR
A[Джерело даних<br/>читач] -->|"Read(p)"| B[Наша логіка<br/>обробка]
B -->|"Write(p)"| C[Приймач даних<br/>записувач]
І ключова думка дня: наша логіка має залежати від Reader/Writer, а не від того, що там усередині: файл, мережа, пам’ять, stdin чи stdout.
Інтерфейс io.Reader: «дай байти в мій буфер»
Зараз буде трохи офіційної мови, але без неї ми швидко заплутаємося в дрібницях. io.Reader — це інтерфейс стандартної бібліотеки, який описує джерело байтів. Не рядків, не чисел, не JSON, а саме байтів — «сировини», з якої вже можна зібрати що завгодно.
У io.Reader є рівно один метод:
Read(p []byte) (n int, err error)
Сенс такий: ви, як сторона, що читає, даєте буфер p, а читач намагається заповнити його даними. Повертається 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("помилка читання:", err)
return
}
}
}
Інтерфейс io.Writer: «візьми байти з мого буфера»
Якщо Reader — це трубочка «всередину програми», то Writer — трубочка «назовні». Він описує приймач байтів: кудись можна записати дані — у консоль, файл, мережу, буфер у пам’яті, логер тощо.
Контракт у io.Writer теж гранично короткий:
Write(p []byte) (n int, err error)
Сенс схожий, але дзеркальний: ви передаєте йому дані, що лежать у 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? Це не «погана реалізація», а дозволена частина контракту. У стандартній бібліотеці теж є реалізації, де спочатку віддається корисна порція даних, а потім повідомляється про помилку або завершення потоку.
Мінідемонстрація «нормального, але трохи дивного» читача, який повертає дані й помилку разом:
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 дані=%q помилка=%v\n", n, buf[:n], err)
// n=2 дані="OK" помилка=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("помилка читання:", err)
return
}
var out bytes.Buffer
if err := WriteTasks(&out, tasks); err != nil {
fmt.Println("помилка запису:", 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 записує все.
На реальних приймачах байтів — мережа, деякі обгортки, ланцюжки приймачів — запис може бути частковим. Якщо вам потрібно записати все цілком, пишіть цикл дозапису, орієнтуючись на n, або використовуйте готові інструменти, коли ви вже точно розумієте контракт.
Помилка № 5: прив’язувати бізнес-логіку до fmt.Println і fmt.Scan.
Такий код приємно писати перші пів години, але потім його важко перевіряти й повторно використовувати: не можна спрямувати виведення в рядок, не можна прочитати дані з пам’яті, не можна акуратно вбудувати в іншу систему. Приймайте io.Reader/io.Writer як параметри — і ви раптом почнете писати код, який легко переносити й перевіряти.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ