JavaRush /Курси /Go SELF /Помилки JSON: як створювати корисні повідомлення

Помилки JSON: як створювати корисні повідомлення

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

1. Чому «просто вивести err» не допомагає

Коли тільки починаєш писати програми, дуже хочеться зробити так: «якщо сталася помилка — надрукувати err і все». Це природно: програма принаймні не мовчить. Але доволі швидко виявляється, що сирий текст помилки часто або надто технічний (invalid character '}' looking for beginning of object key string), або надто загальний (unexpected EOF), або взагалі ні про що (cannot unmarshal number into Go struct field ...).

У Go важливо памʼятати ідею errors are values — помилки не магічні закляття, а звичайні значення, які можна аналізувати й покращувати. Тому хороша стратегія виглядає так: усередині програми ми зберігаємо причину максимально точно, щоб код міг її розпізнати, а на межі застосунку (CLI, імпорт, конфігурація) перетворюємо її на зрозуміле людині повідомлення. Це схоже на переклад: усередині команди ви говорите «мовою розробників», а користувачеві — нормальною мовою.

2. Контекст операції та обгортання помилок

Уявіть, що ви отримуєте помилку unexpected EOF. Звідки вона? З файла? Із stdin? Чи читали ми tasks.json? Або config.json? Чи це обрізаний шматок даних посередині?

Ось тут і зʼявляється контекст операції: коли помилка підіймається вгору стеком, кожен шар додає, що саме він робив. У Go це зазвичай роблять через fmt.Errorf("...: %w", err). Ключовий момент — саме "%w": так ми обгортаємо помилку, зберігаючи початкову причину всередині ланцюжка, щоб її можна було розпізнати через errors.Is/errors.As.

Мініприклад: читаємо JSON задач і додаємо контекст:

package main

import "fmt"

func loadTasksJSON() error {
	err := fmt.Errorf("unexpected EOF") // уявімо, що це повернув json.Decoder
	return fmt.Errorf("читання файлу задач: %w", err)
}

Якщо роздрукувати err.Error(), людина побачить читання файлу задач: unexpected EOF. Це вже краще: стало зрозуміло, де сталася помилка.

Але тут є важлива тонкість: обгортаючи помилку, ви робите її частиною контракту. Якщо ви віддаєте назовні помилку з чужого пакета (наприклад, sql.ErrNoRows або внутрішню структуру помилок бази), ви ніби обіцяєте користувачам вашого коду, що ця причина там буде завжди. У блогах про Go окремо наголошують: wrapping — це рішення на рівні API, а не просто «гарний текст».

Практичне правило для нашого рівня: якщо помилка є «деталлю реалізації» і ви не хочете виносити її назовні, можна додати контекст через "%v" — не через "%w". Але на межі введення JSON причина найчастіше корисна, бо вона пояснює, що саме не так із вхідними даними.

"%w" і "%v": чому це рішення про контракт

Дуже поширена помилка новачка — думати, що "%w" потрібен просто «щоб було гарно». Насправді "%w" — це не про красу, а про те, щоб код вище у стеку міг сказати: «ага, всередині є *json.SyntaxError» і витягнути деталі через errors.As.

Go 1.13 формалізував wrapping як стандарт: "%w" зберігає ланцюжок причин, а errors.Is/errors.As бачать увесь цей ланцюжок. Це зручно, але вимагає дисципліни: якщо ви обгортаєте чужу помилку й віддаєте назовні, ви тим самим обіцяєте, що цю причину можна буде розпізнати.

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

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

3. Повідомлення для користувача та причина для діагностики

У прикладному коді майже завжди є дві правди про помилку. Перша правда — коротке людське повідомлення: «у файлі задач пошкоджений JSON». Друга правда — технічна причина: «SyntaxError at offset 183».

Класичний прийом зі світу Go — мати структуру помилки, де окремо зберігається те, що показуємо користувачеві, і те, що лишаємо для логів та діагностики. У старих, але дуже показових прикладах Go-коду це роблять через appError з полями Message (для користувача) і Error (для логів і діагностики).

Ми зробимо схожу ідею, тільки простіше й без HTTP.

Мінімальна «користувацька» помилка з unwrap

package main

type InputError struct {
	Msg string
	Err error
}

func (e *InputError) Error() string { return e.Msg }
func (e *InputError) Unwrap() error { return e.Err }

Зверніть увагу на стиль: Error() повертає коротке повідомлення, яке не соромно показати людині. А Unwrap() зберігає причину для errors.As/errors.Is.

Маленький «перекладач» помилок JSON

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

Почнемо з простого каркаса. Він спеціально короткий, щоб ви могли переписати його самі без відчуття, що читаєте чужу дипломну роботу.

package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
)

func wrapJSONInput(err error) error {
	if errors.Is(err, io.EOF) {
		return &InputError{Msg: "очікувався JSON, але введення порожнє", Err: err}
	}

	var se *json.SyntaxError
	if errors.As(err, &se) {
		msg := fmt.Sprintf("помилка синтаксису JSON, позиція %d", se.Offset)
		return &InputError{Msg: msg, Err: err}
	}

	return &InputError{Msg: "не вдалося прочитати JSON", Err: err}
}

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

4. Типізовані помилки encoding/json

Помилки encoding/json — не просто рядки. Деякі з них мають конкретні типи, і це справжній подарунок: можна дістати структуровані деталі й зробити повідомлення розумнішим.

Найкорисніші на практиці:

  • *json.SyntaxError — синтаксис JSON зламаний. Важливе поле — Offset (позиція в байтах). Ідея з Offset і тим, що його можна використати для точнішого повідомлення (наприклад, рядок/колонка), зустрічається навіть в офіційних матеріалах про обробку помилок.
  • *json.UnmarshalTypeError — JSON валідний, але тип не збігся з вашим struct (наприклад, "id": "abc" замість числа). Там часто є назва поля та інформація про те, що очікувалося.

Скелет розпізнавання через errors.As виглядає так:

package main

import (
	"encoding/json"
	"errors"
)

func isSyntax(err error) bool {
	var se *json.SyntaxError
	return errors.As(err, &se)
}

А от так можна дістати деталі:

package main

import (
	"encoding/json"
	"errors"
	"fmt"
)

func syntaxOffset(err error) (int64, bool) {
	var se *json.SyntaxError
	if !errors.As(err, &se) {
		return 0, false
	}
	return se.Offset, true
}

Тут важлива звичка: ми не порівнюємо текст err.Error(), а дивимося на тип. Це надійніше й не ламається, коли змінюються формулювання помилок.

5. Offset, рядок і колонка

Одне — сказати «позиція 183», інше — «рядок 12, колонка 5». Людині рядок і колонка зрозуміліші. Але є нюанс: Offset — це позиція в байтах, а щоб порахувати рядок і колонку, потрібно мати вихідний текст цілком.

Якщо ви читаєте невеликий конфіг або невеликий JSON-файл, можна дозволити собі прочитати все в памʼять, наприклад через io.ReadAll, саме заради хорошої діагностики. Це нормальний компроміс: зручність користувача часто важливіша за мікрооптимізації.

Мініфункція для line/col за байтовим зрізом:

package main

func lineCol(data []byte, offset int64) (line, col int) {
	line, col = 1, 1
	for i := int64(0); i < offset-1 && i < int64(len(data)); i++ {
		if data[i] == '\n' {
			line, col = line+1, 1
		} else {
			col++
		}
	}
	return line, col
}

Так, це не враховує руни й UTF-8 «по-справжньому», але для діагностики JSON за байтами цього зазвичай достатньо: помилки encoding/json теж рахують зміщення в байтах.

6. Вбудовуємо це в застосунок: суворий decode TaskFile

Тепер давайте привʼяжемо все до нашого навчального застосунку — простого менеджера задач. Нехай у нас є формат «файл задач», де ми зберігаємо версію та список задач.

package main

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

type TaskFile struct {
	Version int    `json:"version"`
	Tasks   []Task `json:"tasks"`
}

Ми читаємо один JSON-документ у строгому режимі й у разі помилки додаємо контекст операції, а потім на межі перетворюємо її на *InputError.

Спочатку — суворий decode і контекст:

package main

import (
	"encoding/json"
	"fmt"
	"io"
)

func decodeTaskFile(r io.Reader) (TaskFile, error) {
	dec := json.NewDecoder(r)
	dec.DisallowUnknownFields()

	var tf TaskFile
	if err := dec.Decode(&tf); err != nil {
		return TaskFile{}, fmt.Errorf("розбір файлу задач: %w", err)
	}
	return tf, nil
}

Тепер — «межа застосунку»: умовний main, де ми вирішуємо, що показувати користувачеві.

package main

import (
	"fmt"
	"os"
)

func main() {
	_, err := decodeTaskFile(os.Stdin)
	if err != nil {
		err = wrapJSONInput(err)
		fmt.Fprintln(os.Stderr, "Помилка:", err) // повідомлення для користувача
		os.Exit(1)
	}
	fmt.Println("OK") // усе гаразд
}

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

7. Типові помилки під час оформлення JSON-помилок

Помилка №1: повертати «голу» помилку без контексту.
Якщо ви просто повертаєте err, то на верхньому рівні бачите unexpected EOF і починаєте грати в детектива: EOF де, чому, на якому кроці? Набагато краще додавати контекст на кожному шарі: "розбір файлу задач", "читання конфігурації", "unmarshal versioned envelope". Так рядок помилки стає історією дій, а не загадкою.

Помилка №2: використовувати "%v" замість "%w" там, де ви хочете розпізнавати причину.
Фраза "decode task file: …" однаково виглядатиме і з "%v", і з "%w", і це підступно. Але лише "%w" збереже причину так, щоб вище можна було зробити errors.As(err, &se) і дістати Offset у *json.SyntaxError. Wrapping — це не косметика, а механізм.

Помилка №3: класифікувати помилки за err.Error() і підрядками, коли є тип.
Рядки помилок можуть змінюватися, локалізуватися, доповнюватися. Якщо ви можете розпізнати *json.SyntaxError або *json.UnmarshalTypeError через errors.As, робіть так — це набагато надійніше. І сама ідея «помилка — значення, його можна аналізувати» в Go вважається фундаментальною.

Помилка №4: показувати користувачеві надто технічні деталі.
Повідомлення cannot unmarshal number into Go struct field Task.id of type string — дуже інформативне для розробника, але для користувача це шум і цілком легальна причина почати ненавидіти вашу програму. Краще показувати «поле id має бути числом», а технічну причину зберігати всередині Unwrap(), щоб ви могли дістати її під час налагодження.

Помилка №5: не виділяти випадок порожнього введення (io.EOF).
io.EOF часто означає «введення порожнє», а не «у нас щось зламалося». Якщо ваша функція за контрактом очікує JSON, порожнє введення — це зрозуміла помилка користувача або файлу. Якщо цей кейс не виділити, користувач отримає дивне EOF і не зрозуміє, що сталося.

1
Опитування
JSON (продвинутий), рівень 46, лекція 4
Недоступний
JSON (продвинутий)
JSON (продвинутий)
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ