JavaRush /Курси /Go SELF /io.Reader та io.Writer: основа введення/виведення і тесто...

io.Reader та io.Writer: основа введення/виведення і тестованості

Go SELF
Рівень 26 , Лекція 0
Відкрита

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 Що це означає Що робити
>0
nil
прочитали шматок даних обробити p[:n], продовжувати
0
io.EOF
потік закінчився завершити читання
>0
io.EOF
прочитали останній шматок і одразу дійшли до кінця обробити p[:n], потім завершити
>0
інша помилка дані прочитано, але далі виникла проблема обробити p[:n], а потім повернути або залогувати помилку
0
інша помилка нічого не прочитали, одразу помилка повернути або залогувати помилку

Як узагалі виникає варіант 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 як параметри — і ви раптом почнете писати код, який легко переносити й перевіряти.

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