JavaRush /Курси /Go SELF /Wrapping‑стратегія: що залишаємо для логів

Wrapping‑стратегія: що залишаємо для логів

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

1. Чому «показувати користувачу err.Error()» — погана звичка

Коли ви пишете CLI‑застосунок, дуже легко почати жити за принципом: якщо впало — друкуємо err і все. У маленьких навчальних програмах це навіть виглядає чесно: «Ну от помилка — чого ще треба?». Але в реальному світі у помилки є щонайменше три аудиторії, і в кожної — свої очікування.

Користувач хоче коротке й зрозуміле пояснення: що саме він зробив не так або чому програма не змогла виконати дію. Скрипти й автоматизація очікують стабільної поведінки: зазвичай це exit code і передбачуваний формат помилок. А розробник хоче подробиць: який файл, яка операція, яка першопричина, і бажано так, щоб це було видно в логах.

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

flowchart TD
    U[Користувач] -->|читає| MSG[Коротке повідомлення в stderr]
    S[Скрипт/CI] -->|читає| CODE[Exit code]
    D[Розробник] -->|читає| LOG[Логи/діагностика]

    ERR[Error у Go] --> MSG
    ERR --> CODE
    ERR --> LOG

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

2. %w і %v: зовні схоже, але контракт різний

На рівні синтаксису різниця між %w і %v у fmt.Errorf виглядає майже смішно: одна літера, а драматургії — як у серіалі на вісім сезонів. Але з погляду архітектури це справді важливо: %w робить першопричину доступною для програмного аналізу через errors.Is/errors.As, а %v перетворює її на текст і «відрізає шлях назад».

Коли ви обгортаєте помилку з %w, ви ніби кажете коду, який вас викликає: «Я дозволяю тобі зазирнути всередину й ухвалити рішення на основі причини». Це зручно, але небезпечно: якщо всередині у вас sql.ErrNoRows, *os.PathError або щось зі сторонньої бібліотеки, то зовнішній код може почати на це спиратися. І далі змінити бібліотеку буде майже так само «приємно», як переїхати на іншу планету без коробок. У документації Go та статтях про найкращі практики цю думку формулюють прямо: wrapping робить конкретну помилку частиною API, і якщо ви не готові підтримувати цю поведінку, краще не обгортати.

Міні‑приклад на пальцях:

package main

import (
	"errors"
	"fmt"
)

var ErrNotFound = errors.New("not found")

func loadTask(id int) error {
	// %w: першопричину збережено
	return fmt.Errorf("load task %d: %w", id, ErrNotFound)
}

А ось «сплющений» варіант:

package main

import (
	"errors"
	"fmt"
)

var ErrNotFound = errors.New("not found")

func loadTaskHidden(id int) error {
	// %v: першопричина стала просто текстом, Is/As її не побачать
	return fmt.Errorf("load task %d: %v", id, ErrNotFound)
}

Обидва варіанти друкуються для людини майже однаково. Різниця проявиться тоді, коли ви захочете зробити errors.Is(err, ErrNotFound) або витягнути типізовану помилку через errors.As. І ось тут ми підходимо до стратегічного питання: кому й навіщо ми хочемо дозволити зазирнути всередину помилки?

3. Де додавати контекст: шари застосунку та поле Op

Коли ви читаєте чужі логи, швидко розумієте просту річ: «помилка читання» без контексту — це не помилка, а загадка. У хорошій системі помилка майже завжди несе контекст операції, щоб було видно, на якому кроці все зламалося: «load tasks», «parse args», «write output», «open data file».

Кумедно, але новачки зазвичай роблять навпаки: або не додають контекст узагалі, або додають його так старанно, що виходить «read: read: read: read: permission denied». Тому корисно мислити не рядком, а структурою: у нас є шари (команда → логіка застосунку → сховище → ОС), і кожен шар може додати невеликий смисловий ярлик, але не має перетворювати помилку на літературний твір.

Схема шарів нашого навчального CLI‑застосунку, умовно tasker:

flowchart LR
    CMD[cmd/tasker: команда] --> APP[app: usecase]
    APP --> ST[storage: читання/запис]
    ST --> OS[os/fs/json: системні причини]

Нехай у нас уже є AppError (з минулої лекції) з полями Kind, Op, Err. Тоді базова ідея така: Op описує, що саме ми робили на цьому рівні, а Err зберігає першопричину (можливо, вже з додатковим wrapping).

Ось короткий, практичний приклад конструктора, щоб не писати одне й те саме вручну:

package apperr

type Kind int

const (
	KindInternal Kind = iota
	KindValidation
	KindNotFound
	KindIO
)

type AppError struct {
	Kind Kind
	Op   string
	Err  error
}

func (e *AppError) Error() string {
	if e == nil {
		return "<nil>"
	}
	if e.Err == nil {
		return e.Op
	}
	if e.Op == "" {
		return e.Err.Error()
	}
	return e.Op + ": " + e.Err.Error()
}

func (e *AppError) Unwrap() error { return e.Err }

Тут важливо, що Unwrap() повертає Err, а отже errors.Is/errors.As зможуть ходити ланцюжком причин.

4. Коли зберігати першопричину, а коли ховати деталі

У wrapping‑стратегії є простий критерій: якщо код, що викликає, може й має ухвалити рішення на основі причини — зберігаємо причину як причину (wrap через %w або Unwrap). Якщо ж причина — внутрішня деталь реалізації, яка не має «протікати» назовні, її краще перетворити на текст або замінити на більш стабільний доменний зміст.

Корисно тримати в голові таку маленьку таблицю: це не «істина в останній інстанції», але як старт — дуже непогано.

Ситуація Що сталося Wrap першопричину? Чому
Користувач передав шлях до файла, і ми не змогли його відкрити permission denied, file not found зазвичай так причина стосується зовнішнього світу користувача
Ми використовуємо внутрішню БД або формат, і це деталь реалізації sql.ErrNoRows, json.SyntaxError з внутрішнього файла зазвичай ні (або лише для логів) інакше зовнішній код почне залежати від наших внутрішніх деталей
Помилка валідації аргументів «limit має бути > 0» зазвичай не обов’язково рішення ухвалюються за KindValidation, а причина другорядна
Неочікувана помилка «щось зламалося» так, але користувачу її показують обережно нам потрібне розслідування, але користувачу не потрібен дамп

Зверніть увагу: «wrap чи не wrap» — це не про те, чи хочу я бачити причину в тексті. Текст і так можна зібрати. Це про те, чи робимо ми першопричину частиною програмного контракту. Якщо ви повертаєте помилку, що обгортає, наприклад, sql.ErrNoRows, зовнішній код може почати писати errors.Is(err, sql.ErrNoRows), і тоді ви вже зобов’язані підтримувати цю поведінку, інакше «зламаєте клієнтів».

За тією ж логікою: якщо os.Open повернув *os.PathError, а цей тип — деталь реалізації вашої функції, то краще не віддавати його назовні як unwrap‑ланцюжок, а «перепакувати» без %w.

5. Повідомлення для користувача і діагностика — різні рішення

Зараз буде важлива вправа на мислення: уявіть, що помилка — це валіза. Усередині валізи лежить купа речей: причини, контекст, вкладені помилки. Користувач — це людина на стійці реєстрації, яка питає: «Що у вас сталося?». Розробник — це співробітник служби безпеки, якому потрібно відкрити валізу й зрозуміти, що всередині.

Якщо ви відповідаєте користувачу «ось уся валіза, тримайте», він отримає купу незрозумілих деталей. Якщо ви відповідаєте «нічого не знаю» — ви позбавляєте себе розслідування. Тому нормальний патерн для CLI: повідомлення для користувача будується за змістом (Kind, інколи Op), а першопричина лишається всередині помилки для логів.

Міні‑ескіз функції, яка формує «людське» повідомлення (ми не робимо тут ідеальний UX‑словник — нам важливий принцип):

package apperr

import (
	"errors"
)

func UserMessage(err error) string {
	if err == nil {
		return ""
	}

	var ae *AppError
	if errors.As(err, &ae) {
		switch ae.Kind {
		case KindValidation:
			return "invalid input"
		case KindNotFound:
			return "not found"
		case KindIO:
			return "i/o error"
		default:
			return "internal error"
		}
	}

	return "internal error"
}

Зауважте: ми не друкуємо ae.Err.Error() користувачу. Це не тому, що ми «жадібні», а тому, що це нестабільно й часто занадто технічно. Зате в логах ми можемо спокійно зберегти ae.Err і навіть увесь ланцюжок помилок, щоб розробник міг зрозуміти, що сталося.

І тут wrapping‑стратегія починає працювати: ми можемо зберігати першопричину через %w, не показуючи її користувачу напряму. Тобто «wrap для діагностики» і «show for UX» — це різні рішення, і їх корисно ухвалювати окремо.

6. Практика на CLI tasker: різні політики wrapping

Уявімо, що наш застосунок зберігає задачі у файлі (це типова й зрозуміла модель для навчального CLI). Нехай шлях до файла ми отримуємо з конфігурації або прапорця, але саме існування файла — уже питання I/O, а отже ідеальний приклад для wrapping‑стратегії.

Почнемо з функції завантаження задач. Спростимо: читаємо файл цілком і парсимо JSON (ми тут не обговорюємо streaming‑JSON і складні формати — нам важливі помилки).

package storage

import (
	"encoding/json"
	"os"
)

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

func LoadTasks(path string) ([]Task, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}

	var tasks []Task
	if err := json.Unmarshal(data, &tasks); err != nil {
		return nil, err
	}
	return tasks, nil
}

Цей код «технічно працює», але він нічого не каже про зміст. Далі в нас два варіанти політики wrapping.

Варіант A: шлях — частина контракту, отже причини ОС можна зберігати

Якщо шлях до файла задає користувач, то помилки на кшталт permission denied або no such file належать до його світу. У такому разі можна зберегти першопричину, щоб верхній шар міг, наприклад, відрізнити «файла не існує» і «немає прав». Тоді storage‑шар може обгортати з %w, додаючи зрозумілий Op.

package storage

import (
	"fmt"
	"os"
)

func LoadTasks(path string) ([]Task, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("read tasks file %s: %w", path, err)
	}
	// ...
	return nil, nil
}

Тепер errors.Is з os.ErrNotExist або os.ErrPermission (через відповідні помилки fs) теоретично можуть спрацювати на верхньому рівні. Це нормально лише якщо ви готові вважати це частиною поведінки вашої програми.

Варіант B: внутрішній файл — деталь реалізації, отже не «протікаємо» помилками ОС

Якщо файл суворо внутрішній (наприклад, завжди ~/.tasker/tasks.json), і ви не хочете, щоб хтось зовні ухвалював рішення на основі *os.PathError, то можна «сплющити» системну помилку в текст для зовнішнього шару, але зберегти її для логів усередині вашого AppError.

package storage

import (
	"encoding/json"
	"fmt"
	"os"

	"example/tasker/apperr"
)

func LoadTasks(path string) ([]Task, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, &apperr.AppError{
			Kind: apperr.KindIO,
			Op:   "load tasks",
			// Першопричина є, але рішення про її «публічність» — ваше.
			Err: fmt.Errorf("read file: %v", err), // %v: не даємо unwrap назовні
		}
	}

	var tasks []Task
	if err := json.Unmarshal(data, &tasks); err != nil {
		return nil, &apperr.AppError{
			Kind: apperr.KindInternal,
			Op:   "decode tasks",
			Err:  err, // можна залишити як є або теж контролювати
		}
	}

	return tasks, nil
}

Зверніть увагу на тонкість: тут ми «сплющили» системну помилку (%v), щоб зовнішній код не робив errors.Is за типами на кшталт *os.PathError і не залежав від деталей. Ця логіка збігається з рекомендацією не обгортати внутрішні помилки, якщо ви не хочете фіксувати їх як частину API.

А водночас у нас усе ще є структурний контекст KindIO і Op, і верхній шар зможе ухвалити рішення рівня «це I/O» без танців навколо тексту помилки.

Як уникнути «read: read: read»: по одному унікальному уточненню для кожного шару

Коли починаєш акуратно wrap’ити помилки, майже неминуче настає момент, коли лог виглядає так, ніби програма застрягла у слові read і не може з нього вибратися. Це нормальна хвороба росту: ви чесно додаєте контекст на кожному кроці, але контекст повторюється.

Тут допомагає дисципліна: кожен шар додає лише той контекст, який є унікальним для нього. У storage‑шарі Op буде «load tasks». У app‑шарі — «add task» або «list tasks». У cmd‑шарі — «parse args» або «run command». І якщо контекст не додає нової інформації, краще не додавати його взагалі.

Міні‑приклад в app‑шарі, де ми не змінюємо зміст помилки, а лише додаємо, на якому рівні це сталося:

package app

import (
	"fmt"

	"example/tasker/apperr"
	"example/tasker/storage"
)

type Service struct {
	Path string
}

func (s Service) List() ([]storage.Task, error) {
	tasks, err := storage.LoadTasks(s.Path)
	if err != nil {
		// Додаємо контекст рівня usecase, зберігаючи причину:
		return nil, &apperr.AppError{
			Kind: apperr.KindOf(err), // припустімо, KindOf уже є
			Op:   "list tasks",
			Err:  fmt.Errorf("%w", err), // зберігаємо ланцюжок причин
		}
	}
	return tasks, nil
}

Зверніть увагу: якщо storage.LoadTasks уже повернув AppError{Kind: KindIO, Op: "load tasks"}, то app‑шар додасть Op: "list tasks". У підсумку для логів вийде хороший ланцюжок «list tasks: load tasks: read file: permission denied». Для користувача ж ви зможете показати щось на кшталт «i/o error» або більш дружнє формулювання — але це вже окреме рішення, яке не зобов’язане дорівнювати err.Error().

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

Помилка №1: використовувати %w «скрізь за звичкою», не розуміючи, що це публічний контракт.
Найнебезпечніша частина %w у тому, що він здається «просто правильнішим за %v». Але %w — це не про красу тексту, а про те, що ви дозволяєте зовнішньому коду аналізувати першопричину через errors.Is/errors.As. У результаті ваш внутрішній вибір бібліотеки або формату стає зобов’язанням. Це як пообіцяти «ніколи не міняти двері», бо в когось підійшов ключ.

Помилка №2: «сплющити» помилку занадто рано й втратити діагностику.
Іноді розробник робить fmt.Errorf("something failed: %v", err) десь глибоко внизу, а потім угорі мріє відрізнити not found від permission denied. Мрія не здійсниться, бо ви самі відрізали можливість errors.Is/errors.As. Якщо ви хочете і контроль, і акуратний UX — зберігайте першопричину, але окремо вирішуйте, що друкувати користувачу.

Помилка №3: змішувати Op і повідомлення для користувача в один рядок.
Op добрий, коли він короткий і технічний: «load tasks», «parse args», «write output». Якщо туди запхати «Будь ласка, вкажіть коректний шлях до файла, інакше…», це перетворюється на непередбачувану мішанину для логів і ламає ідею стабільного UX. Користувацький текст має будуватися окремою функцією, а Op має допомагати розробнику.

Помилка №4: дублювати однаковий контекст на кожному рівні й отримувати «read: read: read».
Коли контекст повторюється, він перестає бути контекстом і стає шумом. Якщо storage‑шар уже сказав «load tasks», то app‑шару немає сенсу писати «load tasks» ще раз. Додавайте лише той шматок змісту, який є унікальним для рівня, інакше логи будуть довгими, а інформації в них не побільшає.

Помилка №5: показувати користувачу «сирий» err.Error() з нижніх шарів.
Нижні шари часто містять системні деталі: шляхи, внутрішні формати, дивні формулювання стандартної бібліотеки. Для діагностики це корисно, але для користувача — майже завжди дратівливо й інколи небезпечно. Набагато надійніше друкувати коротке повідомлення за Kind, а деталі залишати в логах (із Op і першопричиною всередині помилки).

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