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. То есть вы сможете тестировать и отлаживать логику без файлов и без «внешнего мира».

В этой лекции мы будем развивать учебное приложение с задачами (условный «task manager»). Мы добавим функции «экспортировать задачи в 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":"Read Go book","done":false}
{"id":2,"title":"Buy milk","done":true}
{"id":3,"title":"Sleep","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["append(out, t)"]
    B --> A
    A -->|err == io.EOF| C[конец потока: return out]
    A -->|err != nil| D[ошибка: return nil, err]

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

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

[
  {"id":1,"title":"Read Go book","done":false},
  {"id":2,"title":"Buy milk","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. Два протокола данных: поток vs массив

Что такое «одно 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: "Learn 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‑I/O — это io.EOF, и его нужно проверять явно.

Ошибка №4: не понимать, что Encode добавляет перевод строки.
Encoder.Encode(v) обычно заканчивает запись \n. Это не ошибка и не «лишний символ», а удобство для «по одному значению на строку». Если вы сравниваете вывод как строки в тестах или руками — учитывайте этот перевод строки, иначе будет ощущение, что JSON «почему-то с лишней пустотой».

Ошибка №5: переиспользовать один глобальный Encoder/Decoder «на все случаи жизни».
Энкодер и декодер привязаны к конкретному потоку (io.Writer/io.Reader). Делать их глобальными — значит привязать весь код к одному источнику данных и получить проблемы, когда параллельно появятся другие потоки (даже если это просто разные буферы в тестах). Гораздо здоровее создавать enc := json.NewEncoder(w) рядом с тем местом, где вы реально пишете.

1
Задача
Go SELF, 46 уровень, 0 лекция
Недоступна
Отправка задачи
Отправка задачи
1
Задача
Go SELF, 46 уровень, 0 лекция
Недоступна
Красивый экспорт
Красивый экспорт
1
Задача
Go SELF, 46 уровень, 0 лекция
Недоступна
Счётчик потока
Счётчик потока
1
Задача
Go SELF, 46 уровень, 0 лекция
Недоступна
Чистка задач
Чистка задач
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ