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 і повертає помилку.
Порівняємо їх у невеликій таблиці — без філософії, суто за відчуттями розробника, який не хоче страждати:
| Підхід | Як виглядає | Що зручно | Де можна спіткнутися |
|---|---|---|---|
|
«Спочатку байти, потім друк» | Зручно, якщо вам треба байти далі обробляти | Легко забути \n, легко забути обробити помилку запису |
|
«Одразу пишемо в потік» | Чудово лягає на 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
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‑режими. Тримайте рендер чистим: він має отримувати готові дані й перетворювати їх на текст, без ухвалення рішень «що показувати».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ