1. Навіщо взагалі потрібна «суворість» під час розбору JSON
Коли ви тільки починаєте писати програми, дуже хочеться, щоб компʼютер був добрим і пробачав помилки. Але з даними все навпаки: що поблажливішим є парсер, то більше шансів, що він мовчки проковтне проблему — і ви отримаєте баг, який проявиться через три тижні, у пʼятницю ввечері, та ще й чомусь лише на машині колеги.
За замовчуванням encoding/json у Go доволі поблажливий: він намагається заповнити все, що може, і не здіймає галас через зайві поля. Це зручно для сумісності форматів, але небезпечно, коли ви очікуєте чітко фіксований контракт. У цій лекції ми навчимося вмикати «режим педанта»: «не знаю поля — отже, помилка».
Як encoding/json зіставляє поля struct і поля JSON
Перш ніж вмикати суворий режим, важливо розуміти, що саме вважається «відомим» і «невідомим». encoding/json працює за дуже простою схемою: він бачить JSON-обʼєкт {...}, дивиться на ключі й намагається знайти відповідне поле в struct. Якщо поле експортоване (з великої літери) й підходить за імʼям або тегом — записує значення.
Наприклад, ось невеликий DTO — структура, у яку ми читаємо JSON. Поки що зробімо просту модель завдання для нашого навчального застосунку (умовний todo/task-tracker), щоб приклади були пов’язані між лекціями:
package main
type TaskDTO struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
Важливий момент: json:"title" — це контракт. Якщо в JSON прийде ключ "title", він потрапить у Title. Якщо прийде "Title" або "taskTitle" — це вже інша історія, і часто саме тут ховаються сюрпризи.
2. Проблема «зайвих полів»: коли помилка є, але її ніхто не помітив
Тепер підходимо до головного болю. Уявіть: користувач — або ваш же код, але тиждень тому — надсилає JSON, де в одному місці помилка: "titel" замість "title". Людина очікує, що програма скаже: «Гей, поле неправильне». А програма за замовчуванням часто робить вигляд, що все нормально: просто ігнорує невідомий ключ, а Title лишається порожнім рядком.
Ось демонстрація мовчазної поведінки — спеціально без суворого режиму:
package main
import (
"encoding/json"
"fmt"
)
func main() {
data := []byte(`{"id": 1, "titel": "Buy milk", "done": false}`)
var t TaskDTO
_ = json.Unmarshal(data, &t)
fmt.Printf("%+v\n", t) // {ID:1 Title: Done:false}
}
Тут «усе успішно», а завдання раптом без назви. І далі ви можете годинами шукати, чому Title порожній, хоча корінь проблеми — помилка у вхідних даних.
Така поведінка взагалі не унікальна для JSON: ідея «заповнюємо лише збіжні поля, решту ігноруємо» трапляється й в інших кодеках. Наприклад, в описах бінарного формату gob підкреслюється зіставлення полів за імʼям і те, що поля, для яких немає відповідника, ігноруються.
3. Decoder.DisallowUnknownFields(): вмикаємо суворий режим
Щоб перестати пробачати зайві поля під час читання в struct, Go дає нам дуже прямий механізм: Decoder.DisallowUnknownFields(). Вмикається він не глобально в пакеті, а в конкретному декодері, тобто прямо там, де ви читаєте вхід.
Схема проста: створюємо json.NewDecoder(r), вмикаємо суворий режим, викликаємо Decode(&v).
package main
import (
"encoding/json"
"fmt"
"strings"
)
func main() {
r := strings.NewReader(`{"id": 1, "titel": "Buy milk", "done": false}`)
dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
var t TaskDTO
err := dec.Decode(&t)
fmt.Println(err) // json: unknown field "titel"
}
І ось це вже схоже на доросле життя: ми не отримали порожній Title, ми отримали зрозумілу помилку. Так, це неприємно користувачеві, який помилився, але це чесно. І головне — це економить ваш час.
Кілька тонких моментів, які варто проговорити вголос, щоб не було магії.
По-перше, суворий режим стосується саме декодування в struct. Якщо ви декодуєте в map[string]any, то «невідомих полів» не існує — мапа готова прийняти будь-які ключі.
По-друге, суворий режим допомагає ловити друкарські помилки й неочікувані дані, але не замінює перевірку обов’язкових полів. Якщо поле "title" відсутнє, суворий режим не поскаржиться — він просто залишить Title у zero value (порожній рядок), а вже ваша валідація має сказати: «title обов’язковий».
4. Чому суворий режим працює не завжди
Після ввімкнення DisallowUnknownFields хочеться повірити, що тепер усе безпечно і JSON нас не обдурить. Але важливо розуміти межі.
Якщо ви робите ось так:
package main
import (
"encoding/json"
"fmt"
"strings"
)
func main() {
r := strings.NewReader(`{"id": 1, "titel": "Buy milk", "done": false}`)
dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
var m map[string]any
err := dec.Decode(&m)
fmt.Println(err) // <nil>
fmt.Println(m["titel"]) // Buy milk
}
Помилки немає — тому що ми не просили декодер зіставляти ключі з полями структури. Для map будь-яке поле «відоме».
Це не баг, а просто інше призначення: map[string]any використовують, коли формат справді динамічний або ви пишете універсальний проксі чи прошарок. Але якщо у вас фіксований контракт входу, краще читати в struct — і тоді суворий режим починає працювати за призначенням.
5. Ще один рівень суворості: один JSON і без хвоста
Суворість — це не лише про «невідомі поля». Є ще одна класична проблема: вхід містить не один JSON-документ, а два підряд або JSON + хвіст сміття. Наприклад:
{"id":1,"title":"Buy milk","done":false} ну привіт
або навіть так:
{"id":1,"title":"Buy milk","done":false}
{"id":2,"title":"Pay bills","done":true}
Decode читає одне JSON-значення за виклик. І якщо ви зробили один Decode, він не зобов’язаний автоматично перевіряти, що далі потік завершився. Тому в суворому контракті часто застосовують прийом: після першого Decode зробити ще один Decode і переконатися, що він повернув io.EOF.
Зробімо маленький повторно використовуваний хелпер decodeOneStrict. Він одразу робить дві «суворості»: забороняє невідомі поля й забороняє зайві дані після JSON.
package main
import (
"encoding/json"
"fmt"
"io"
)
func decodeOneStrict(r io.Reader, v any) error {
dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return err
}
// Перевіряємо, що далі немає другого JSON-значення.
if err := dec.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return fmt.Errorf("зайве JSON-значення")
}
return fmt.Errorf("залишкові дані: %w", err)
}
return nil
}
Зверніть увагу: ми декодуємо «в нікуди» через &struct{}{} — порожню структуру без полів. Нам не важливе значення, нам важлива поведінка декодера. Якщо там другий JSON — другий Decode поверне nil. Якщо там сміття, яке не починається з JSON, буде помилка, а не io.EOF. Якщо все чисто — буде io.EOF.
6. Вбудовуємо суворий розбір у застосунок
Щоб не залишати тему надто абстрактною, вбудуймо це в логіку нашого навчального застосунку «таск-трекер». Поки що без CLI-прапорців і без HTTP: просто читаємо одне завдання з io.Reader. Це знадобиться пізніше і для читання з файла, і для читання зі stdin, і для тестів через strings.NewReader.
Зробімо функцію ReadTaskStrict, яка строго читає одне завдання й повертає структуру.
package main
import (
"fmt"
"io"
)
func ReadTaskStrict(r io.Reader) (TaskDTO, error) {
var t TaskDTO
if err := decodeOneStrict(r, &t); err != nil {
return TaskDTO{}, fmt.Errorf("прочитати завдання: %w", err)
}
return t, nil
}
Тут ми додали контекст помилки через fmt.Errorf("...": %w, err). Це хороша звичка: коли помилка підніметься вище, ви розумітимете, на якому кроці вона виникла.
До речі, у encoding/json є типізовані помилки (наприклад, *json.SyntaxError містить зсув Offset), і ідея «помилка — це значення, у якому можуть бути корисні поля» активно використовується в Go. Це стане особливо приємним, коли ви почнете діагностувати «синтаксична помилка на байті N».
Перевірмо, як це виглядає в міні-main:
package main
import (
"fmt"
"strings"
)
func main() {
input := `{"id": 1, "titel": "Buy milk", "done": false}`
t, err := ReadTaskStrict(strings.NewReader(input))
fmt.Println(t) // {0 false}
fmt.Println(err) // прочитати завдання: json: unknown field "titel"
}
Помилку видно, контекст "прочитати завдання" теж видно. Для користувача — або для вашого майбутнього «я» — це набагато краще, ніж тихий нуль.
7. Корисні нюанси суворого декодування
Таблиця: рівні суворості під час декодування
Коли ви проєктуєте введення, корисно заздалегідь вирішити: ви хочете бути суворими чи дружніми до майбутніх змін. Іноді формат має бути розширюваним, і тоді зайві поля краще ігнорувати. А інколи ви обробляєте команди або конфіги, де друкарська помилка — це критична помилка. Тож корисно тримати в голові, які «ручки суворості» в нас узагалі є.
| Підхід | Що ловимо | Що не ловимо | Де доречно |
|---|---|---|---|
| Звичайний Decode/Unmarshal | Поганий JSON-синтаксис, невідповідність типів | Друкарські помилки в назвах полів, другий JSON у потоці | Гнучкий, еволюційний формат, сумісність |
| DisallowUnknownFields() | Невідомі поля під час декодування в struct | Хвіст після JSON, другий JSON-документ | Фіксований контракт (конфіг, API-запит) |
| DisallowUnknownFields + перевірка io.EOF | І невідомі поля, і хвіст / другий JSON | Відсутні обов’язкові поля (потрібна валідація) | Суворе введення, імпорт, критичні операції |
Де саме має стояти сувора перевірка
Щоб не переплутати «шари» застосунку, корисно намалювати просту схему. Суворий розбір — це частина етапу decode, а не бізнес-логіки. Бізнес-логіка має отримувати вже нормальні структури й думати про сенс, а не про синтаксис.
flowchart TD
A[io.Reader: файл / stdin / рядок] --> B[json.Decoder + DisallowUnknownFields]
B --> C["Decode(&dto) + перевірка EOF"]
C --> D[Післявальна перевірка полів]
D --> E[Бізнес-логіка: створити / оновити завдання]
Ця схема хороша тим, що в ній видно: суворий режим не замінює валідацію, він доповнює її. Спочатку ми гарантуємо контракт структури, потім гарантуємо контракт сенсу.
8. Типові помилки під час суворого JSON-розбору
Помилка № 1: увімкнули DisallowUnknownFields, але декодуємо в map[string]any і чекаємо, що це спрацює.
Це часта пастка, бо зовні здається: «я ж увімкнув суворий режим». Але невідомі поля — це поняття лише відносно структури. Якщо приймач — map, то всі поля «відомі», і перевіряти нічого. Якщо контракт фіксований — декодуйте в struct.
Помилка № 2: перевіряють невідомі поля, але забувають перевірити зайві дані після JSON.
У потоковій моделі Decode читає одне значення. Якщо вхід містить два JSON підряд, перший Decode відпрацює успішно, і ви отримаєте напівправду: перший об’єкт зчитали, а все, що далі було сміттям, — проігнорували. Лікується шаблоном «другий Decode має повернути io.EOF».
Помилка № 3: плутають zero value і відсутність поля.
Навіть у суворому режимі відсутність поля "done" — не помилка: Done просто буде false. Так само відсутність "title" залишить порожній рядок. Якщо поле має бути обов’язковим, це розв’язується окремою валідацією, а не DisallowUnknownFields.
Помилка № 4: повертають голу помилку без контексту й потім не розуміють, де вона виникла.
Коли у вас у програмі кілька місць, де читається JSON — конфіг, імпорт, мережеве введення, — помилка виду json: unknown field "titel" без контексту змушує вас гадати: «Це де?». Звичка додавати контекст через fmt.Errorf("decode task: %w", err) різко спрощує життя. І сама ідея «помилка може зберігати деталі, наприклад зсув у JSON» — частина Go-підходу до помилок.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ