JavaRush /Курси /Go SELF /DisallowUnknownFields і суворий розбір JSON

DisallowUnknownFields і суворий розбір JSON

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

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-підходу до помилок.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ