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 |
|---|---|---|
|
Неверный ввод / аргументы / конфигурация запуска | не передали -title, -id не число |
|
Ресурс не найден | задача с таким id отсутствует |
|
Ошибка ввода-вывода | не удалось прочитать файл, нет прав |
|
Всё неожиданное | баг, несовместимый формат, «так не должно быть» |
Теперь — код. Мы хотим 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, то на верхнем уровне вы будете гадать: это пользователь ошибся или приложение? Унификация как раз про то, чтобы не гадать, а знать.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ