JavaRush /Курсы /Go SELF /Унификация ошибок CLI — фиксируем финальную модель

Унификация ошибок CLI — фиксируем финальную модель

Go SELF
55 уровень , 0 лекция
Открыта

1. Почему строки недостаточно для контракта CLI

Когда мы пишем первые маленькие консольные программы, очень хочется сделать так: «если что-то пошло не так — напечатаем err.Error() и закончим». Это выглядит логично и даже работает… до тех пор, пока программа не вырастает хотя бы до трёх команд и одного файла на диске. Тогда внезапно выясняется, что в CLI у ошибки есть минимум три аудитории: пользователь, который хочет коротко понять «что исправить», скрипт, который хочет код завершения, и разработчик, который хочет понять первопричину.

Проблема строки в том, что строка — штука «скользкая». Сегодня вы написали return fmt.Errorf("bad id"), завтра поменяли на return fmt.Errorf("invalid id"), и внезапно чей-то скрипт, который делал strings.Contains(err.Error()(), "bad id"), сломался. Да, так делать не нужно, но люди так делают — особенно мы сами, если очень устали и очень «надо к релизу».

Поэтому главный принцип сегодняшней лекции: логика приложения не должна зависеть от текста ошибки. Текст — это то, что читают глазами. А код должен принимать решения по структуре: по типу ошибки или хотя бы по её категории.

2. Kind: минимальная таксономия ошибок для CLI

Чтобы ошибки стали управляемыми, нам нужно договориться о «паспорте» ошибки: к какой категории по смыслу она относится. В Go для этого часто вводят поле Kind (или Code, или Class) — но мы назовём именно Kind, потому что это слово меньше конфликтует с «exit code» и «HTTP code».

Смысл простой: Kind — это не текст. Это перечисление, по которому мы потом будем принимать решения: какой exit code вернуть, как писать в лог, что показать пользователю.

Давайте зафиксируем маленький набор категорий, чтобы не устроить «энциклопедию ошибок» на 48 страниц:

Kind Что означает Примеры в CLI
KindValidation
Неверный ввод / аргументы / конфигурация запуска не передали -title, -id не число
KindNotFound
Ресурс не найден задача с таким id отсутствует
KindIO
Ошибка ввода-вывода не удалось прочитать файл, нет прав
KindInternal
Всё неожиданное баг, несовместимый формат, «так не должно быть»

Теперь — код. Мы хотим enum-подобное перечисление на iota, чтобы не плодить «магические строки» и опечатки.

package apperr

type Kind int

const (
	KindInternal Kind = iota
	KindValidation
	KindNotFound
	KindIO
)

Да, KindInternal первым — это намеренно. Пусть «неизвестная ошибка» по умолчанию считается internal. В мире ошибок это как шлем: может быть, не модно, но голова целее.

3. AppError: единая форма ошибки для всего приложения

Теперь самое важное: мы хотим не просто Kind, а единый тип ошибки, который будет «официальной валютой» нашего приложения. Назовём его AppError.

Здесь важно не переборщить: новички часто делают AppError на 12 полей, включая «время», «команду», «пользователя», «номер сборки», «фазу луны»… и потом никто этим не пользуется. Нам нужен минимализм, который реально будет работать.

Пусть AppError содержит:

  • Kind — категория по смыслу;
  • Op — короткий контекст операции (что мы пытались сделать);
  • Err — исходная причина (внутренняя ошибка), чтобы диагностика не терялась.
package apperr

type AppError struct {
	Kind Kind
	Op   string
	Err  error
}

Теперь реализуем Error() string. Важно: это не пользовательское сообщение, а просто читаемая строка, полезная для логов и отладки. Часто её делают как op + ": " + err.

package apperr

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

И, наконец, ключевой элемент: Unwrap() error. Он позволяет стандартным инструментам Go видеть «цепочку причин» ошибки.

package apperr

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

Почему это важно прямо сейчас, даже если вы «ещё не используете errors.Is/errors.As»? Потому что вы почти наверняка начнёте — хотя бы для распознавания *AppError на границе приложения. Плюс, стандартная библиотека и вообще Go-экосистема уже построены вокруг идеи unwrap-цепочки.

4. Op: технический контекст, а не сообщение пользователю

Когда вы впервые видите поле Op, рука тянется написать туда что-то вроде: "Ой, всё сломалось, попробуйте ещё раз". Но Op — это не UX, а технический контекст: «какая операция выполнялась, когда произошла ошибка».

Представьте, что вы чините баг по логам. Вам нужно не «Ой», а конкретика: "load storage", "parse id", "write output", "read config". Это похоже на ярлык на коробке в переезде: «кухня/тарелки», а не «что-то хрупкое, не бейте».

Хороший Op обычно:

  • короткий;
  • стабильный (не меняется каждую неделю);
  • безопасный для логов (без секретов и персональных данных).

Плохой Op обычно:

  • слишком длинный;
  • меняется вместе с текстом сообщения пользователю;
  • содержит путь к файлу с токенами, «чтобы было удобнее» (а потом токен утёк в лог…).

5. Конструкторы AppError: единый стиль и меньше шума

Если мы везде будем писать руками &AppError{Kind: ..., Op: ..., Err: ...}, через неделю вы увидите 15 вариаций одного и того же. Кто-то забудет Kind, кто-то перепутает Op и Err, кто-то положит nil в Err и получит странный текст.

Поэтому сделаем маленькие конструкторы.

package apperr

func New(kind Kind, op string, cause error) *AppError {
	return &AppError{Kind: kind, Op: op, Err: cause}
}

func Validation(op string, cause error) *AppError {
	return New(KindValidation, op, cause)
}

func NotFound(op string, cause error) *AppError {
	return New(KindNotFound, op, cause)
}

func IO(op string, cause error) *AppError {
	return New(KindIO, op, cause)
}

func Internal(op string, cause error) *AppError {
	return New(KindInternal, op, cause)
}

Теперь стиль единый: увидели apperr.Validation(...) — сразу понятно, что это ошибка ввода. Увидели apperr.IO(...) — понятно, что упали на файловой операции.

И главное: вы перестаёте «придумывать формат ошибок» каждый раз заново. Программа становится более предсказуемой.

6. Как извлекать Kind из error: KindOf

Поскольку функции у нас обычно возвращают тип error (интерфейс), нам нужен простой способ понять: а что за ошибка пришла? Это *AppError или вообще что-то другое?

В Go для этого используют errors.As: он проходит по цепочке unwrap и пытается найти ошибку нужного типа.

package apperr

import "errors"

func KindOf(err error) Kind {
	var ae *AppError
	if errors.As(err, &ae) {
		return ae.Kind
	}
	return KindInternal
}

Обратите внимание на &ae: errors.As принимает адрес переменной, чтобы записать туда найденную ошибку нужного типа. Это тот же принцип, что и у fmt.Scan(&x): «вот место, куда положить результат».

С точки зрения контракта это очень удобно: если ошибка не нашей формы, мы всё равно считаем её internal (потому что приложение не смогло классифицировать её «по правилам»).

7. Примеры в CLI: validation и I/O в одном стиле

Чтобы это не осталось «теорией про красивые структуры», давайте сделаем маленький кусок кода для типичного CLI: парсим id задачи из строки. Здесь очень легко сделать ошибку «пользователь виноват» (validation), а не «программа виновата» (internal).

package todo

import (
	"errors"
	"strconv"

	"example/apperr"
)

func ParseID(raw string) (int, error) {
	if raw == "" {
		return 0, apperr.Validation("parse id", errors.New("id is required"))
	}

	id, err := strconv.Atoi(raw)
	if err != nil {
		return 0, apperr.Validation("parse id", errors.New("id must be integer"))
	}
	return id, nil
}

Здесь мы намеренно не «светим» пользователю внутренности strconv.Atoi (там будет текст вроде "invalid syntax"). Валидация — это место, где мы хотим давать стабильное и понятное описание ошибки. А технические детали (если они нужны) можно будет сохранить через wrapping — но это уже следующий шаг в общей стратегии.

Теперь пример с I/O: читаем файл хранилища задач (условно tasks.json или tasks.csv — формат сейчас не важен).

package storage

import (
	"os"

	"example/apperr"
)

func Load(path string) ([]byte, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, apperr.IO("read storage file", err)
	}
	return data, nil
}

Смысл: файл не прочитался — это I/O. Здесь полезно сохранить реальную причину ("permission denied", "no such file or directory"), потому что это диагностика, а не «ошибка ввода аргумента».

8. Правила и путь ошибки в CLI

Почему Unwrap — не опциональная деталь

На уровне новичка может показаться: «Ну мы же и так храним Err, зачем ещё Unwrap()?» А затем вы захотите сделать две совершенно обычные вещи.

Первая — распознавать ошибки по цепочке. Например, если внутри Err лежит os.ErrNotExist, то вы хотите это аккуратно обработать. Для этого стандартный путь — errors.Is, который умеет проходить unwrap-цепочку.

Вторая — извлекать типизированную ошибку. Например, вы захотите найти *AppError внутри более общей ошибки. Для этого errors.As делает тот же обход unwrap-цепочки.

Именно поэтому Go 1.13 «узаконил» соглашение про Unwrap() и добавил инструменты для работы с цепочкой причин. А %w в fmt.Errorf существует как раз для того, чтобы ошибка, созданная форматированием, не теряла свою внутреннюю причину и могла быть корректно «развёрнута».

Мы сегодня не строим всю стратегию wrapping (где, что и как оборачивать) — но фундамент (Unwrap) закладываем сразу, чтобы дальше не пришлось делать болезненный рефакторинг.

Схема: как ошибка проходит через слои

Чтобы держать в голове общую картину, полезно мысленно видеть, как ошибка идёт по слоям приложения: снизу вверх она «обрастает смыслом», а на границе превращается в поведение процесса.

flowchart TD
    A[Низкий уровень: os/strconv/json] -->|err| B[Наш код: storage/todo/app]
    B -->|AppError Kind+Op+Err| C[Команда CLI: обработчик subcommand]
    C -->|error| D[Граница приложения]
    D --> E[stderr: короткое сообщение]
    D --> F[лог: детали + контекст]
    D --> G[exit code: 0/1/2]

Сейчас мы делаем только один кусок этой схемы: фиксируем, что “наш код” возвращает ошибки в едином формате AppError. Всё остальное (как печатать, как логировать, как вычислять коды) станет проще именно потому, что «сырьё» стало одинаковым.

Жёсткое правило модуля: что «нормально» возвращать наружу

В любом проекте очень полезно зафиксировать одно правило, которое потом можно проверять ревью и тестами (а иногда — просто собственными глазами):

Идеальная договорённость для CLI-слоя звучит так: функции прикладного уровня возвращают error, но внутри этого error мы ожидаем либо nil, либо *apperr.AppError (возможно, обёрнутую дополнительным контекстом, но всё равно с возможностью errors.As достать *AppError).

Чтобы «подстраховаться», удобно иметь функцию, которая превращает «что угодно» в *AppError (как минимум KindInternal). Это особенно полезно на границе команды, где вы собираете результат работы.

package apperr

import "errors"

func Ensure(err error) *AppError {
	if err == nil {
		return nil
	}
	var ae *AppError
	if errors.As(err, &ae) {
		return ae
	}
	return Internal("unknown error", err)
}

Обратите внимание: это не «магия», а просто страховка от разнородных ошибок, которые могут прилететь из стандартной библиотеки или из кода, который пока не переведён на новый стиль.

9. Типичные ошибки при унификации ошибок CLI

Ошибка №1: определять класс ошибки через strings.Contains(err.Error()(), "...").
Такой код выглядит как «быстрая победа», но он ломается от любого изменения текста, от локализации, от добавления контекста, от wrapping-а. В итоге вы получаете систему, где изменение формулировки в одном месте может поменять exit code в другом. Гораздо стабильнее один раз присвоить ошибке Kind и дальше работать по нему.

Ошибка №2: превращать Kind в огромный справочник на 30 значений.
Чем больше категорий вы заводите «про запас», тем быстрее они начинают пересекаться: «а KindConfig — это validation или internal?», «а KindPermission — это IO или validation?». На уровне CLI почти всегда хватает небольшого набора вроде validation/notfound/io/internal, а детализацию можно добавлять только когда она реально нужна контракту.

Ошибка №3: смешивать пользовательское сообщение и технический контекст в Op.
Op должен помогать отладке и логам, а не быть «текстом для stderr». Если вы положите туда UX-формулировки, они начнут меняться, и всё снова станет нестабильным. Для пользователя лучше строить отдельное сообщение (позже мы централизуем это на границе CLI), а в Op оставить короткую «метку операции».

Ошибка №4: забыть реализовать Unwrap() и тем самым сломать цепочку причин.
Без Unwrap() вы лишаете себя нормальной работы errors.Is/errors.As по цепочке. Это особенно неприятно, когда вы вроде бы всё оборачиваете и добавляете контекст, но на границе уже не можете достать *AppError и корректно классифицировать ошибку. Конвенция unwrap-цепочки в Go появилась именно для того, чтобы такие сценарии работали предсказуемо.

Ошибка №5: возвращать наружу сырой error из стандартной библиотеки без классификации.
os.ReadFile и strconv.Atoi честно возвращают ошибки — но они не знают, что у вас CLI и что вам нужен exit code 2 для неверного ввода. Если вы не «поднимете смысл» ошибки в виде Kind, то на верхнем уровне вы будете гадать: это пользователь ошибся или приложение? Унификация как раз про то, чтобы не гадать, а знать.

1
Задача
Go SELF, 55 уровень, 0 лекция
Недоступна
Паспорт ошибок
Паспорт ошибок
1
Задача
Go SELF, 55 уровень, 0 лекция
Недоступна
Единая обёртка
Единая обёртка
1
Задача
Go SELF, 55 уровень, 0 лекция
Недоступна
Детектор причины
Детектор причины
1
Задача
Go SELF, 55 уровень, 0 лекция
Недоступна
Пограничный фильтр
Пограничный фильтр
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ