JavaRush /Курси /Go SELF /JSON‑вивід у CLI — форматування та детермінізм

JSON‑вивід у CLI — форматування та детермінізм

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

1. Навіщо CLI потрібен JSON‑вивід

Якщо таблиця виглядає красиво, виникає цілком природне запитання: «Навіщо тоді ще JSON?». На практиці все просто: таблиця — для очей, JSON — для інструментів. Таблицю приємно читати в терміналі, але її незручно парсити в bash‑скриптах, CI, моніторингу та пайпах. JSON, навпаки, машина читає легко, а людині він теж цілком зручний — особливо якщо ввімкнути форматування.

У світі CLI JSON‑режим — це не просто додаткова можливість для галочки, а спосіб перетворити вивід на стабільний контракт. Наприклад, користувач може зробити так:

  • вивести список задач у JSON,
  • потім відфільтрувати його зовнішнім інструментом,
  • потім перетворити на звіт.

І якщо ваш JSON «плаває» від запуску до запуску, то такі сценарії починають ламатися… інколи. А «ламається інколи» — це улюблений режим багів: ви наче нічого не змінювали, але воно вже не працює.

Щоб було простіше тримати всю картину в голові, корисно мислити так: команда готує дані, а рендер перетворює їх на форматований вивід.

flowchart LR
    A["дані: []Task"] --> B[рендер]
    B --> C[stdout: таблиця]
    B --> D[stdout: JSON]

2. Контракт JSON‑виводу

Коли ми говоримо «JSON‑вивід», то зазвичай маємо на увазі дуже конкретне правило: команда має друкувати в stdout рівно одне JSON‑значення — масив або об’єкт — і не домішувати туди зайвого тексту. У JSON‑режимі не можна писати "OK", "Total: 5", "Done!" та інші привітні фрази, бо будь-який зайвий текст ламає парсинг.

Це правило звучить нудно, але воно рятує від сюрпризів. Адже користувач може робити так:

tasker list --format=json | jq '.items[] | select(.done == false)'

Якщо ви раптом додасте рядок "found 10 tasks" перед JSON — jq скаже «я не розумію» і матиме рацію. Таблиця може бути балакучою, JSON має бути мовчазним і передбачуваним.

Окремо важливо пам’ятати про stderr. Якщо сталася помилка, наприклад не вдалося прочитати файл бази, у JSON‑режимі ви не «вставляєте» помилку в JSON, якщо не проєктували протокол саме так. Ви виводите помилку в stderr і повертаєте ненульовий exit code. Це продовжує нашу попередню домовленість щодо stdout/stderr і загальний Go‑підхід: помилки — це значення, їх треба явно повертати й обробляти.

3. struct замість map[string]any

Коли студент уперше хоче віддати JSON, рука тягнеться до map[string]any: «зараз додам полів — і поїхали». Це працює… але лише до того моменту, коли ви захочете стабільності, читаності та передбачуваності.

У struct є три практичні переваги.

По‑перше, struct — це явний контракт: набір полів відомий, типи відомі, теги відомі. IDE допомагає, компілятор допомагає — ви рідше помиляєтеся.

По‑друге, серіалізація структури в Go зазвичай передбачуваніша щодо порядку полів. Для нас це важливо через детермінізм тексту та golden‑тести.

По‑третє, у encoding/json є офіційні точки розширення: інтерфейси Marshaler і Unmarshaler, якщо вам раптом знадобиться тонке налаштування серіалізації. Сьогодні ми глибоко в кастомний маршалінг не занурюємося, але корисно знати, що «офіційний шлях» існує — це не хаки й не магія.

Тому в навчальному застосунку — нашому CLI‑менеджері задач — ми робитимемо JSON‑відповідь через структуру‑обгортку на кшталт:

  • об’єкт { items: [...], total: N } для списку,
  • а всередині items — задачі у вигляді DTO, тобто структур «під формат».

4. json.Marshal та json.Encoder

У Go є два популярні способи отримати JSON. Перший — json.Marshal(v) і потім out.Write(bytes). Другий — json.NewEncoder(out).Encode(v). Обидва способи нормальні, але для CLI частіше зручніший Encoder, бо він одразу пише в io.Writer і повертає помилку.

Порівняємо їх у невеликій таблиці — без філософії, суто за відчуттями розробника, який не хоче страждати:

Підхід Як виглядає Що зручно Де можна спіткнутися
json.Marshal + Write
«Спочатку байти, потім друк» Зручно, якщо вам треба байти далі обробляти Легко забути \n, легко забути обробити помилку запису
json.Encoder.Encode
«Одразу пишемо в потік» Чудово лягає на io.Writer, простіше для великих даних Треба пам’ятати про налаштування енкодера — відступи, екранування

Оскільки ми вже домовилися, що рендери мають приймати io.Writer і повертати error, Encoder підходить ідеально: це буквально «потоковий рендер». І це красиво узгоджується з філософією Go про явну обробку помилок.

Міні‑приклад: «байти вручну»

package main

import (
	"encoding/json"
	"io"
)

func renderJSONViaMarshal(out io.Writer, v any) error {
	b, err := json.Marshal(v)
	if err != nil {
		return err
	}
	_, err = out.Write(append(b, '\n'))
	return err
}

Тут усе нормально, але зверніть увагу: ми вручну додали \n. Якщо забути, вивід буде без завершального нового рядка. Начебто дрібниця, але вона впливає на детермінізм тексту й дратує термінал.

Міні‑приклад: «пишемо енкодером»

package main

import (
	"encoding/json"
	"io"
)

func renderJSONViaEncoder(out io.Writer, v any) error {
	enc := json.NewEncoder(out)
	return enc.Encode(v)
}

Коду менше, і він краще передає намір: «я кодую й одразу пишу».

5. Читабельний JSON: SetIndent і SetEscapeHTML

За замовчуванням JSON виходить «в один рядок». Для машин це нормально, але людині такий JSON читати важче: він перетворюється на «простирадло» — термін технічний, не образливий.

Коли CLI працює в інтерактивному режимі, форматований JSON — це нормальна турбота про користувача. У Go це робиться через Encoder.SetIndent(prefix, indent).

package main

import (
	"encoding/json"
	"io"
)

func renderPrettyJSON(out io.Writer, v any) error {
	enc := json.NewEncoder(out)
	enc.SetIndent("", "  ")
	return enc.Encode(v)
}

Два пробіли — типовий компроміс: достатньо красиво, не надто широко.

Ще один нюанс — HTML‑екранування. JSON‑енкодер у Go за замовчуванням екранує деякі символи (<, >, &), щоб JSON був безпечнішим, коли його вбудовують у HTML. Для CLI це зазвичай не потрібно і інколи навіть заважає читаності, якщо у вас у задачі раптом з’явиться «Fix ». Налаштування є в Encoder як SetEscapeHTML(false).

package main

import (
	"encoding/json"
	"io"
)

func renderPrettyJSONNoEscape(out io.Writer, v any) error {
	enc := json.NewEncoder(out)
	enc.SetIndent("", "  ")
	enc.SetEscapeHTML(false)
	return enc.Encode(v)
}

Тут важливо не плутати: ми не робимо програму небезпечною, ми просто робимо вивід більш очікуваним саме для термінала. У браузер цей JSON не вставляють — ми ж зараз пишемо CLI, а не фронтенд.

6. Детермінізм JSON‑виводу

Слово «детермінізм» звучить як заклинання з книжки з алгоритмів, але в CLI воно означає просту річ: якщо дані однакові, вивід має збігатися байт у байт, включно з переводом рядка в кінці. Це важливо для скриптів, снапшот‑тестів, порівняння виводів у CI та просто для спокою.

У JSON‑виводі на практиці найчастіше ламають детермінізм три речі.

Перша — порядок елементів масиву. JSON‑масив ([...]) упорядкований. Отже, якщо у вас задачі йдуть у різному порядку, JSON буде різним. Тому список задач має потрапляти в рендер уже в правильному порядку. Ми це фіксували на рівні пайплайна виводу, а сортування розглядаємо окремо. Тут наша позиція проста: рендер не сортує, він друкує те, що йому передали.

Друга — map. Порядок обходу map у Go не гарантується, і це свідома властивість мови: не можна будувати контракт виводу на випадковому порядку ключів. Тому якщо ви віддаєте назовні JSON‑об’єкт через map[string]any, ви майже гарантовано отримаєте нестабільний вивід. Набагато спокійніше віддавати структуру.

Третя — «випадкові» поля. Наприклад, ви додали в JSON поле "generated_at" з поточним часом. Формально дані ті самі, а текст завжди різний. Іноді так і треба, але тоді це усвідомлене рішення: ви самі прибрали детермінізм. У навчальному застосунку ми так робити не будемо: вивід команди list не має залежати від поточної секунди.

До речі, раз уже ми заговорили про JSON і помилки: пакет encoding/json повертає типізовані помилки, наприклад *json.SyntaxError при розборі некоректного JSON. Це гарний приклад того, що в Go помилки — це теж структуровані дані, а не лише рядок. Сьогодні ми більше про вивід, але запам’ятайте цю ідею: помилки теж можуть бути «контрактом».

7. Вбудовування JSON‑рендера в застосунок

TaskDTO та ListResponse

Тепер зберемо все в коді нашого навчального CLI‑застосунку — умовно назвемо його tasker. Уявімо, що доменна модель задачі в нас така: мінімальна й без зайвого.

package main

import "time"

type Task struct {
	ID        int
	Title     string
	Done      bool
	CreatedAt time.Time
}

Якщо ми зараз почнемо друкувати Task як є, то швидко зіткнемося з двома проблемами. По‑перше, доменна модель може змінюватися з власних причин, а формат виводу — зі своїх. По‑друге, time.Time у JSON виглядає нормально, але вам може захотітися контролювати поля, наприклад друкувати "created_at" в UTC або лише дату. Тому зробимо DTO — структуру під формат.

package main

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

Зверніть увагу: CreatedAt став рядком. Це не тому, що time поганий, а тому, що в CLI‑контракті вам часто хочеться явно вирішити, який саме текст ви віддаєте назовні. Якщо завтра ви захочете змінити формат дати — зробите це в одному місці.

Тепер обгортка відповіді:

package main

type ListResponse struct {
	Items []TaskDTO `json:"items"`
	Total int       `json:"total"`
}

І невелика функція перетворення — вона навмисно коротка й пряма:

package main

import "time"

func toTaskDTO(t Task) TaskDTO {
	return TaskDTO{
		ID:        t.ID,
		Title:     t.Title,
		Done:      t.Done,
		CreatedAt: t.CreatedAt.UTC().Format(time.RFC3339),
	}
}

Чому UTC()? Тому що це простий спосіб прибрати залежність від локальної часової зони машини. Якщо один розробник запустить CLI в UTC‑8, а інший — в UTC+3, то вивід часу в локальній зоні може відрізнятися. У навчальному застосунку нам важливіша стабільність.

renderJSON(out, tasks) як «чистий рендер»

Тепер пишемо рендер. Він має робити три речі: зібрати DTO, налаштувати енкодер, записати результат. Він не має фільтрувати, сортувати, читати прапорці чи ходити у файли. Лише друк підготовлених даних.

package main

import (
	"encoding/json"
	"io"
)

func renderJSON(out io.Writer, tasks []Task) error {
	items := make([]TaskDTO, 0, len(tasks))
	for _, t := range tasks {
		items = append(items, toTaskDTO(t))
	}

	resp := ListResponse{Items: items, Total: len(items)}

	enc := json.NewEncoder(out)
	enc.SetIndent("", "  ")
	enc.SetEscapeHTML(false)
	return enc.Encode(resp)
}

Тут є кілька важливих дрібниць, які насправді формують контракт.

По‑перше, ми завжди повертаємо total як len(items). Це детерміновано й не залежить від зовнішніх обставин. По‑друге, Encode пише один JSON‑об’єкт і завершує його переводом рядка — це зручно для термінала та пайпів. По‑третє, ми не використовуємо map, тому порядок полів в об’єкті більш передбачуваний, а головне — структура контракту явна.

Якщо хочеться побачити, як це виглядатиме, можна уявити такий вивід:

{
  "items": [
    {
      "id": 1,
      "title": "купити молоко",
      "done": false,
      "created_at": "2026-01-16T00:00:00Z"
    }
  ],
  "total": 1
}

Порядок елементів: зона відповідальності підготовки даних

Іноді дуже хочеться «для зручності» засунути сортування прямо в renderJSON. Здається логічним: «раз я друкую список, то й відсортую». Але це пастка архітектури: рендер починає ухвалювати рішення, які належать до бізнес‑логіки.

Правильніше тримати дисципліну: у вас є крок підготовки даних — пайплайн, а в рендера лишається тільки форматування. Тоді ви зможете гарантувати, що таблиця й JSON показують елементи в одному й тому самому порядку, бо вони отримують один і той самий підготовлений список.

Ця дисципліна особливо корисна, коли ви робите два режими виводу (table/json). Якщо порядок визначається у двох місцях, то одного дня ви отримаєте ситуацію «в таблиці одне, у JSON — інше», і це максимально дратуватиме користувачів. Причому і людей, і скрипти — у терміналі зазвичай дістається всім.

8. Типові помилки

Помилка № 1: «JSON‑режим» друкує зайві рядки.
Дуже часта проблема: ви зробили JSON, а потім десь у коді лишився fmt.Println("OK") або «Total tasks: 10». Для ока це дрібниця, але для парсера — катастрофа. Найкращі ліки — дисципліна stdout/stderr: JSON у stdout, будь-які повідомлення — у stderr, і єдина точка рендера.

Помилка № 2: JSON будується через map[string]any, і порядок ключів починає плавати.
На маленьких прикладах може здаватися, що все й так стабільно, але це ілюзія. Щойно ви запускаєте програму багато разів або змінюєте оточення, порядок ключів може почати змінюватися. Для JSON як формату це не помилка — семантично об’єкт не впорядкований, — але для тестів і порівняння виводів це біль. У CLI майже завжди вигідніше struct із json‑тегами.

Помилка № 3: ігноруються помилки кодування або запису.
Іноді пишуть так: json.NewEncoder(out).Encode(v) — і не перевіряють помилку. А потім дивуються, чому в пайпі інколи порожньо. Помилка запису — реальна річ: stdout може бути закритий, диск може закінчитися, пайп може обірватися. У Go заведено повертати error і обробляти його нагорі, бо помилки — це значення.

Помилка № 4: time.Time друкується «як вийде» і залежить від локальної часової зони машини.
Якщо ви серіалізуєте час у локальній зоні, два запуски на різних машинах можуть дати різний результат. Це особливо помітно, коли один розробник у UTC, інший — у PST, а CI взагалі живе своїм життям. Якщо ви хочете стабільний контракт — нормалізуйте час, часто через UTC, і використовуйте явний формат рядка.

Помилка № 5: рендер починає робити фільтрацію, сортування або читання прапорців.
Це ламає межі відповідальності: у вас «форматування» раптом перетворюється на «бізнес‑логіку». У підсумку код важче тестувати й легше випадково розсинхронізувати table та JSON‑режими. Тримайте рендер чистим: він має отримувати готові дані й перетворювати їх на текст, без ухвалення рішень «що показувати».

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