JavaRush /Курси /Go SELF /json.Encoder/json.Decoder: потокова робота з JSON

json.Encoder/json.Decoder: потокова робота з JSON

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

1. Навіщо потрібні Encoder/Decoder

Якщо ви вже працювали з json.Marshal, то, мабуть, знайоме таке відчуття: «я отримую []byte, а далі роблю з ними що завгодно». І це правда… аж до моменту, коли вам потрібно обробити великий ввід, попрацювати зі stdin, писати вивід у stdout або, що найприкріше, перестати ганяти дані по колу: «рядок → байти → рядок → байти». У цей момент раптом хочеться читати й писати JSON напряму, без зайвих проміжних копій.

Головна думка така: Marshal/Unmarshal — це про «вмістити все в памʼять», а Encoder/Decoder — про роботу поверх потоку.

Уявіть, що на вхід надходить не один акуратний JSON, а багато невеликих об’єктів підряд, наприклад задачі по одному на рядок. Варіант «прочитати все цілком у []byte» змусить вас або збирати величезний буфер, або вигадувати ручний розбір. А json.Decoder уміє читати значення по одному.

Коротке нагадування: io.Reader і io.Writer як «вхід» і «вихід»

Із io.Reader/io.Writer легко заплутатися, якщо сприймати їх як «щось абстрактне з підручника». Але на практиці все дуже просто:

  • io.Reader — це те, звідки можна читати байти
  • io.Writer — це те, куди можна писати байти

Файл, мережа, пам’ять, stdin/stdout — усе це можна звести до одного інтерфейсу.

Дуже корисний ефект полягає в тому, що якщо ваш код приймає io.Reader, ви можете «підсунути» йому і os.Stdin, і strings.NewReader(...), і bytes.Buffer. Тобто ви зможете тестувати й налагоджувати логіку без файлів і без зовнішнього світу.

У цій лекції ми розвиватимемо навчальний застосунок із задачами — умовний «менеджер задач». Ми додамо можливості «експортувати задачі в JSON» і «імпортувати задачі з JSON» так, щоб вони працювали з будь-яким джерелом або приймачем даних, а не лише з файлами чи рядками.

2. json.Encoder: пишемо JSON прямо в потік

json.NewEncoder(w) і Encoder.Encode(v)

Коли ви пишете JSON у потік, хочеться, щоб усе було максимально прозоро: беремо io.Writer, створюємо енкодер і кажемо: «Encode ось це значення». З погляду API це майже як Marshal, тільки замість []byte результат одразу потрапляє в Writer.

Є дві деталі, які новачки зазвичай помічають не одразу:

  • Encode записує одне JSON‑значення за один виклик: об’єкт, масив, число, рядок — будь-яке валідне JSON‑значення.
  • Encode зазвичай додає переведення рядка \n наприкінці. Це зручно для формату «по одному об’єкту на рядок».

Почнімо з малого: навчімося записувати одну задачу.

package main

import (
	"encoding/json"
	"io"
)

type Task struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

func writeTask(w io.Writer, t Task) error {
	enc := json.NewEncoder(w)
	return enc.Encode(t)
}

Зверніть увагу: жодного []byte, жодного string(...) і жодних зворотних перетворень. Ми просто пишемо в w, а що саме буде w — файл, stdout чи буфер — вирішує код, який викликає.

Encoder.SetIndent для читабельного JSON

У реальному застосунку вам інколи потрібні два режими: «машинний» JSON без зайвих пробілів, тобто компактний, і «людський» JSON для налагодження або експорту користувачу — охайний, з відступами. У Encoder є метод SetIndent(prefix, indent), який налаштовує форматування.

Важливо вловити стиль: форматування — це налаштування енкодера, а не окрема «чарівна функція десь поруч». Ми створюємо енкодер, налаштовуємо його, а вже потім пишемо.

package main

import (
	"encoding/json"
	"io"
)

func writePrettyTask(w io.Writer, t Task) error {
	enc := json.NewEncoder(w)
	enc.SetIndent("", "  ")
	return enc.Encode(t)
}

Так JSON буде довшим, зате його можна читати очима без болю.

Експорт задач: масивом і потоком

Якщо ви експортуєте список задач, у вас є два робочі варіанти:

  • експортувати весь список одним JSON‑масивом
  • експортувати потік об’єктів — тобто кілька Encode підряд

Ось як це може виглядати в шарі taskio.

package taskio

import (
	"encoding/json"
	"io"
)

type Task struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

func WriteTasksAsArray(w io.Writer, tasks []Task) error {
	enc := json.NewEncoder(w)
	enc.SetIndent("", "  ")
	return enc.Encode(tasks)
}

func WriteTasksAsStream(w io.Writer, tasks []Task) error {
	enc := json.NewEncoder(w)
	for _, t := range tasks {
		if err := enc.Encode(t); err != nil {
			return err
		}
	}
	return nil
}

3. json.Decoder: читаємо JSON із потоку

json.NewDecoder(r) і Decoder.Decode(&v)

Декодер — це друга половина цієї пари. Він читає з io.Reader і заповнює вашу змінну. Тут найважливіше не забути те, що ми вже не раз повторювали: декодування потребує вказівника, тому що функція має записати результат.

Ще один момент: Decode теж працює «по одному значенню». Викликали один раз — прочитали одне JSON‑значення. Викликали вдруге — спробували прочитати наступне. Так і з’являється можливість читати потік значень.

package main

import (
	"encoding/json"
	"io"
)

func readTask(r io.Reader) (Task, error) {
	dec := json.NewDecoder(r)

	var t Task
	if err := dec.Decode(&t); err != nil {
		return Task{}, err
	}
	return t, nil
}

Якщо ви випадково напишете dec.Decode(t) без &, компілятор або середовище виконання швидко пояснять, що ви намагаєтеся заповнити копію. Це як наливати чай у фотографію горнятка: горнятко гарне, але чай опиниться на столі.

Потік JSON‑значень: читаємо до io.EOF

Потік значень зазвичай виглядає так:

{"id":1,"title":"Прочитати книгу про Go","done":false}
{"id":2,"title":"Купити молоко","done":true}
{"id":3,"title":"Спати","done":false}

Кожен рядок — валідний JSON‑об’єкт. Увесь файл цілком — невалідний JSON‑документ як єдине ціле, бо JSON не дозволяє трьох об’єктів підряд без обгортки. Але для Decoder це нормально, тому що він читає значення по одному.

Патерн читання завжди один і той самий: запускаємо цикл, викликаємо Decode(&t) і виходимо по io.EOF.

package main

import (
	"encoding/json"
	"io"
)

func readTaskStream(r io.Reader) ([]Task, error) {
	dec := json.NewDecoder(r)

	var out []Task
	for {
		var t Task
		err := dec.Decode(&t)
		if err == io.EOF {
			break
		}
		if err != nil {
			return nil, err
		}
		out = append(out, t)
	}
	return out, nil
}

Цей код простий, але дуже потужний: він однаково працює і з strings.NewReader(...), і зі stdin, і з мережевим з’єднанням, і з файлом.

Щоб візуально вкласти це в голові, можна уявити потік так:

flowchart TD
    A["Decoder.Decode(&t)"] -->|успіх| B["додати t до out"]
    B --> A
    A -->|err == io.EOF| C[кінець потоку: return out]
    A -->|err != nil| D[помилка: return nil, err]

JSON‑масив: читаємо як одне значення

JSON‑масив виглядає так:

[
  {"id":1,"title":"Прочитати книгу про Go","done":false},
  {"id":2,"title":"Купити молоко","done":true}
]

Це одне валідне JSON‑повідомлення, і його зручно надсилати або зберігати як «експорт цілком». Тут не потрібен цикл по Decode. Ви робите один Decode(&tasks) — і все.

package main

import (
	"encoding/json"
	"io"
)

func readTaskArray(r io.Reader) ([]Task, error) {
	dec := json.NewDecoder(r)

	var tasks []Task
	if err := dec.Decode(&tasks); err != nil {
		return nil, err
	}
	return tasks, nil
}

4. Два протоколи даних: потік і масив

Що таке «одне JSON‑значення»

Фраза «одне JSON‑значення» звучить так, ніби йдеться про щось езотеричне, але сенс дуже простий і буденний. У JSON значення — це не лише об’єкт {...}. JSON‑значення може бути:

  • об’єктом {...}
  • масивом [...]
  • рядком "hi"
  • числом 123
  • булевим true або false
  • null

Чому це важливо? Тому що різниця між масивом і потоком об’єктів для людини часто виглядає однаково — «там багато задач». А для Decoder це два різні протоколи:

  • якщо вхід — масив, то це одне значення, і його читають одним Decode(&tasks)
  • якщо вхід — кілька об’єктів підряд, то це кілька значень, і їх читають циклом Decode до io.EOF

Для закріплення тримайте в голові таку думку: Decode не читає все, що схоже на JSON, а читає рівно одне значення й зупиняється.

Порівняння форматів

Вибір між масивом і потоком значень — це не питання «правильно» чи «неправильно», а питання протоколу та зручності. Масив зручний, коли ви працюєте з усім одразу. Потік зручний, коли даних багато або їх хочеться обробляти в міру надходження.

Критерій Потік значень ({...}\n{...}\n...) Масив ([{...},{...}])
Валідність як «один JSON‑документ» Ні (це послідовність JSON‑значень) Так
Читання Цикл Decode до io.EOF Один Decode у []Task
Запис Зазвичай багато Encode підряд Один Encode для []Task
«Великі дані» Зручніше: можна читати й обробляти по одному Часто потрібно тримати весь список у пам’яті
«Просто подивитися очима» Нормально, особливо по рядках, але це не масив Дуже зручно: стандартний формат списків

Найважливіше зафіксувати ось що: ви маєте наперед домовитися, який формат очікує ваш код. Якщо ваш імпорт очікує масив, а ви підсовуєте потік об’єктів, Decode чесно прочитає перший об’єкт… а потім здивується, що «масив» раптом закінчився не так, як слід.

5. Інтеграція в застосунок: експорт/імпорт через io.Writer/io.Reader

Коли ми додаємо нову можливість у навчальний застосунок, хочеться, щоб вона була не розрізненим прикладом, а маленькою деталлю конструктора. Тому ми й оформлюємо шар taskio, який уміє записувати та читати задачі в обох форматах.

З читанням у форматі масиву все просто:

package taskio

import (
	"encoding/json"
	"io"
)

func ReadTasksFromArray(r io.Reader) ([]Task, error) {
	dec := json.NewDecoder(r)

	var tasks []Task
	if err := dec.Decode(&tasks); err != nil {
		return nil, err
	}
	return tasks, nil
}

І читання з потоку значень — наш цикл до io.EOF:

package taskio

import (
	"encoding/json"
	"io"
)

func ReadTasksFromStream(r io.Reader) ([]Task, error) {
	dec := json.NewDecoder(r)

	var out []Task
	for {
		var t Task
		err := dec.Decode(&t)
		if err == io.EOF {
			return out, nil
		}
		if err != nil {
			return nil, err
		}
		out = append(out, t)
	}
}

Тепер це можна швидко показати з main, не прив’язуючись до файлів: використаємо bytes.Buffer як io.Writer для пам’яті та strings.NewReader як io.Reader для рядка.

package main

import (
	"bytes"
	"fmt"

	"example.com/app/taskio"
)

func main() {
	tasks := []taskio.Task{{ID: 1, Title: "Вивчити Encoder", Done: false}}

	var buf bytes.Buffer
	_ = taskio.WriteTasksAsArray(&buf, tasks)

	fmt.Print(buf.String()) // друкуємо JSON, який накопичили в памʼяті
}

Зверніть увагу на красу цього підходу: уся логіка JSON живе в taskio, а main просто обирає, куди писати й звідки читати. Це дуже зручно, коли пізніше з’являться CLI‑команди для експорту та імпорту або мережеві запити: шар JSON не доведеться переписувати.

6. Типові помилки під час роботи з json.Encoder/json.Decoder

Помилка № 1: намагатися Decode без вказівника (dec.Decode(t)).
Decode має записати дані в змінну, а записувати можна лише за адресою. Тому майже завжди форма така: dec.Decode(&x). Якщо забути &, ви або не скомпілюєтеся, або отримаєте поведінку, від якої сумно навіть котові, який випадково пройшовся по клавіатурі.

Помилка № 2: переплутати формат входу — чекати масив, а отримати потік об’єктів або навпаки.
Якщо код написаний під масив, він робить один Decode(&tasks) і очікує [...]. А потік — це {...}\n{...}, тобто кілька значень, які читаються циклом. Тут немає універсального вгадування: формат треба фіксувати як частину контракту, наприклад «експорт завжди масивом», «лог завжди потоком».

Помилка № 3: завершувати читання потоку «за даними», а не за io.EOF.
Іноді трапляється наївна ідея: «якщо прийшов Task{} (нульові значення), значить це кінець». Ні: нульові значення — це цілком валідна задача, наприклад порожній title із некоректного вводу. Кінець потоку в Go позначається io.EOF, і його потрібно перевіряти явно.

Помилка № 4: не враховувати, що Encode додає переведення рядка.
Encoder.Encode(v) зазвичай завершує запис \n. Це не помилка і не «зайвий символ», а зручність для формату «по одному значенню на рядок». Якщо ви порівнюєте вивід як рядки в тестах або вручну, враховуйте це переведення рядка, інакше буде відчуття, що JSON «чомусь із зайвою порожнечею».

Помилка № 5: перевикористовувати один глобальний Encoder/Decoder «на всі випадки життя».
Енкодер і декодер прив’язані до конкретного потоку (io.Writer/io.Reader). Робити їх глобальними — означає прив’язати весь код до одного джерела даних і отримати проблеми, коли паралельно з’являться інші потоки, навіть якщо це просто різні буфери в тестах. Набагато здоровіше створювати enc := json.NewEncoder(w) поруч із тим місцем, де ви справді пишете.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ