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) поруч із тим місцем, де ви справді пишете.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ