JavaRush /Курсы /Go SELF /Обработка ошибок в CLI: детали в ошибке и логах

Обработка ошибок в CLI: детали в ошибке и логах

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

1. Почему «простыня» в CLI — плохой UX

Когда мы пишем консольную утилиту, кажется, что можно просто сделать fmt.Fprintln(os.Stderr, err) и не мучиться. Но у CLI есть две особенности: во‑первых, его часто читают люди (глаза устают), во‑вторых, его часто читают скрипты (они не любят непредсказуемость). Поэтому «простыня» из внутренностей почти всегда делает хуже.

Представьте разницу между двумя сообщениями.

Вариант A (плохо):
error: open /Users/you/.config/tasker/tasks.json: permission denied

Вариант B (лучше):
error: cannot read tasks storage

Почему вариант B лучше? Потому что он отвечает на вопрос пользователя: «что сделать?». Например: проверить права, путь, запустить с нужным доступом. А вот абсолютный путь, внутреннее имя файла, тип ошибки ОС — это уже детали для диагностики.

В Go стандартные обёртки ошибок часто содержат внутренние детали (пути, операции, errno), и сами авторы Go отдельно подчёркивают: не всё из низкоуровневых ошибок стоит «обещать» наружу как часть стабильного поведения API. В CLI эта мысль ещё важнее: текст ошибки — это ваш пользовательский интерфейс.

Нам нужно научиться разделять:

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

2. Две аудитории ошибки

Полезно мысленно считать, что у каждой ошибки есть две аудитории. Первая аудитория — пользователь, который хочет понять, что исправить. Вторая аудитория — разработчик (часто это вы же через два дня), который хочет понять, где сломалось, с какими данными, и какая первопричина. Эти аудитории редко хотят одно и то же.

Если вы печатаете пользователю всё, что знаете, получается «простыня». Если вы печатаете только «что-то пошло не так», вы сами потом не сможете разобраться. Компромисс выглядит так: наружу выдаём короткое сообщение, а детали держим в error-цепочке и раскрываем только на границе приложения (в логах, в debug-режиме, в тестах).

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

Сделаем маленький тип ошибки, который хранит оба слоя: «что показать пользователю» и «что считать причиной».

package main

type UserError struct {
	msg string
	err error
}

func (e *UserError) Error() string { return e.msg }
func (e *UserError) Unwrap() error { return e.err }

Здесь важная хитрость: Error() возвращает только короткое сообщение. А Unwrap() позволяет «достать» внутреннюю причину, если вам нужно её анализировать или печатать для диагностики.

3. Печатаем ошибку один раз

Если ошибка печатается в каждом слое (в команде, в диспетчере, в main), то пользователь получает три одинаковых сообщения и начинает подозревать, что приложение немного нервничает. Нужна дисциплина: внизу возвращаем error, наверху печатаем.

С архитектурной точки зрения это выглядит так: все функции типа runAdd/runList/runDone возвращают ошибку, диспетчер возвращает ошибку, main решает три вещи: что напечатать, какой exit code вернуть, и где поставить os.Exit.

Скелет получается спокойный:

package main

import (
	"fmt"
	"os"
)

func main() {
	err := run(os.Args[1:])
	printUserError(err)
	os.Exit(exitCode(err))
}

func printUserError(err error) {
	if err == nil {
		return
	}
	fmt.Fprintln(os.Stderr, "error:", err)
}

Обратите внимание: здесь printUserError печатает err как строку. Но если мы договоримся, что наши «красивые» ошибки — это *UserError, то строкой будет именно короткое msg, а не вся техническая история.

И ещё напоминание: os.Exit завершает процесс сразу, поэтому defer после него не выполнится. Из‑за этого os.Exit держим только на самом верхнем уровне, иначе вы однажды «потеряете» закрытие файла или Flush().

4. Exit codes: usage vs fail

Раньше мы договорились про exit codes: 0 /1 /2 . Теперь нужно сделать так, чтобы это работало не «по наитию», а стабильно. То есть чтобы мы могли программно отличить «пользователь неправильно запустил команду» от «команда запустилась правильно, но что-то пошло не так при выполнении».

Самый простой способ — маркерная (sentinel) ошибка ErrUsage и проверка через errors.Is. Смысл: мы не анализируем текст ошибки (это почти всегда ломается), а спрашиваем: «в цепочке причин есть ErrUsage или нет?».

package main

import (
	"errors"
)

var ErrUsage = errors.New("usage")

const (
	exitOK    = 0
	exitFail  = 1
	exitUsage = 2
)

func exitCode(err error) int {
	if err == nil {
		return exitOK
	}
	if errors.Is(err, ErrUsage) {
		return exitUsage
	}
	return exitFail
}

Теперь осталось научиться создавать ошибки так, чтобы ErrUsage действительно попадала в цепочку причин, но при этом пользователь не видел «внутреннюю кашу». Для этого заведём пару маленьких конструкторов.

package main

import "errors"

func usageError(msg string, cause error) error {
	return &UserError{
		msg: msg,
		err: errors.Join(ErrUsage, cause),
	}
}

func execError(msg string, cause error) error {
	return &UserError{
		msg: msg,
		err: cause,
	}
}

Здесь используется errors.Join: он позволяет прикрепить к ошибке сразу две причины (маркер ErrUsage и конкретную причину, например ошибку парсинга). Это удобно, потому что errors.Is(err, ErrUsage) будет работать, и при этом причина не теряется.

Пользователь же увидит только msg, потому что Error() у UserError возвращает короткий текст.

5. Короткие сообщения, но полезные

Короткое сообщение — это не значит «бессмысленное». Хорошее короткое сообщение обычно отвечает на один из вопросов: что именно ожидалось, что именно получили, и что пользователь может сделать дальше. И оно почти всегда помещается в одну строку.

Например, для команды done, которая ожидает числовой id, сообщение ID must be integer намного полезнее, чем strconv.Atoi: parsing "abc": invalid syntax. Второе сообщение технически точнее, но оно написано для разработчика, а не для пользователя.

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

package main

import (
	"flag"
	"fmt"
	"strconv"
)

func runDone(argv []string) error {
	fs := flag.NewFlagSet("done", flag.ContinueOnError)
	if err := fs.Parse(argv); err != nil {
		return usageError("invalid flags", err)
	}
	if fs.NArg() != 1 {
		return usageError("usage: tasker done ID", nil)
	}

	idStr := fs.Arg(0)
	id, err := strconv.Atoi(idStr)
	if err != nil {
		return usageError("ID must be integer", fmt.Errorf("parse ID %q: %w", idStr, err))
	}

	_ = id
	return nil
}

Обратите внимание: «детали» у нас в fmt.Errorf("parse ID %q: %w", ...), а наружу уходит ID must be integer. Пользователь быстро понимает, что исправить. А если вы будете разбираться, что именно пришло, у вас уже сохранён idStr в причине.

В коротких сообщениях для CLI полезно быть чуть менее «литературным» и чуть более «инструктивным». Консоль — это не роман, тут нормально говорить прямо.

6. Пограничный случай: «задача не найдена»

Есть один интересный пограничный случай, который почти всегда вызывает споры. Допустим, пользователь сделал tasker done 999, а задачи с таким ID нет. Это ошибка использования или ошибка выполнения?

Если пользователь написал tasker done abc, это точно usage: тип аргумента неправильный. А вот «задачи нет» — это уже не синтаксис запуска, а состояние данных. Команда запущена корректно, флаги и аргументы валидны, но выполнить действие нельзя. Поэтому логично считать это ошибкой выполнения и возвращать exit code 1 .

Чтобы это ощущалось последовательно, полезно держать в голове такую таблицу:

Ситуация Что говорим пользователю Какой exit code
Команды нет / флаги не распарсились / аргументов не то количество usage: ... или короткое missing command 2
Тип аргумента неверный (ID не число) ID must be integer 2
Данных нет (задача не найдена) task not found 1
Внутренний сбой (например, чтение хранилища, неожиданный nil, I/O) cannot load tasks 1

Это не единственно возможный выбор, но он очень практичный: 2 означает «перезапусти правильно», 1 означает «перезапуск не обязательно поможет, проблема в выполнении».

Добавим в наш учебный «tasker» минимальную ошибку «не найдено» с причиной.

package main

import "errors"

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

func markDone(id int) error {
	// допустим, внутри мы ищем задачу по id
	found := false
	if !found {
		return execError("task not found", ErrNotFound)
	}
	return nil
}

Пользователь увидит «task not found». А вы сможете в коде (или в диагностике) проверить errors.Is(err, ErrNotFound) и, например, отличить «не найдено» от «внутреннего сбоя».

7. Диагностика без «простыней»

Достаём цепочку причин без логгера

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

Сделаем простой диагностический принтер, который печатает цепочку причин. Его можно временно включать при отладке или держать выключенным в релизе.

package main

import (
	"errors"
	"fmt"
	"os"
)

func printDebugChain(err error) {
	for i := 0; err != nil; i++ {
		fmt.Fprintf(os.Stderr, "debug cause #%d: %v\n", i, err)
		err = errors.Unwrap(err)
	}
}

Если у вас ошибка — *UserError, то errors.Unwrap пойдёт внутрь по Unwrap() и достанет техническую причину. Если причина дальше обёрнута через %w, цепочка продолжится.

В main это можно использовать аккуратно: сначала печатаем короткое сообщение пользователю, а потом, например, временно вызываем debug‑печать.

package main

func main() {
	err := run(os.Args[1:])
	printUserError(err)

	if err != nil {
		// Временно для разработки. В релизе обычно выключают.
		printDebugChain(err)
	}

	os.Exit(exitCode(err))
}

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

Мини‑пример: flag.FlagSet без двойной печати

flag.FlagSet по умолчанию любит печатать свои сообщения сам, и если не контролировать вывод, у вас получится «двойная печать»: сначала flag что-то написал, потом вы напечатали error: .... Мы раньше выбрали flag.ContinueOnError, чтобы парсер возвращал ошибку, а не завершал процесс, но вывод всё равно лучше направлять туда, куда вы хотите.

Идея такая: FlagSet пишет диагностический текст в stderr, а мы превращаем это в короткое сообщение.

package main

import (
	"flag"
	"os"
)

func runAdd(argv []string) error {
	fs := flag.NewFlagSet("add", flag.ContinueOnError)
	fs.SetOutput(os.Stderr)

	title := fs.String("title", "", "task title")
	if err := fs.Parse(argv); err != nil {
		return usageError("invalid flags", err)
	}
	if *title == "" {
		return usageError("missing -title", nil)
	}
	return nil
}

Что важно: даже если err содержит довольно подробный текст от flag, мы его не показываем пользователю напрямую как «основное» сообщение. Основное сообщение — наше, короткое. А err остаётся внутри как причина.

8. Типичные ошибки

Ошибка №1: печатать ошибку в каждом слое («на всякий случай»).
Обычно это начинается невинно: «я тут поймал ошибку, распечатаю её, чтобы увидеть». Потом другой слой тоже печатает, потом третий — и в итоге пользователь видит два-три одинаковых error: подряд. Лечится дисциплиной: снизу только return err, печать — один раз на границе приложения.

Ошибка №2: определять usage-ошибку по тексту ошибки.
Очень хочется написать что-то вроде strings.Contains(err.Error(), "usage"), потому что «быстро». Но это ломается при малейшем рефакторинге текста, при локализации, и вообще от ветра в соседнем офисе. Гораздо надёжнее — маркерная ошибка ErrUsage и проверка errors.Is.

Ошибка №3: показывать пользователю технические детали вместо сообщения.
В Go fmt.Errorf("...: %w", err) делает строку ошибки «...: <текст причины>». Если вы печатаете эту ошибку пользователю как есть, вы снова получаете «простыню» из внутренних деталей. Решение — разделять msg (наружу) и cause (внутрь), например через UserError.

Ошибка №4: вызывать os.Exit внутри обработчика команды.
Это кажется удобным: «ой, ошибка — выйду сразу». Но так вы отрезаете себе возможность централизованной печати, классификации ошибок и, главное, ломаете defer (он не выполнится). Поэтому обработчик команды возвращает error, диспетчер возвращает error, и только main вызывает os.Exit.

Ошибка №5: делать сообщение коротким настолько, что оно перестаёт быть полезным.
Сообщение error happened действительно короткое, но оно бесполезное. Хороший короткий текст всё равно должен направлять: missing command, usage: tasker done ID, ID must be integer, task not found. Коротко — не значит пусто; коротко — значит без лишних внутренностей.

1
Задача
Go SELF, 49 уровень, 4 лекция
Недоступна
Карта exit кодов
Карта exit кодов
1
Задача
Go SELF, 49 уровень, 4 лекция
Недоступна
Одна строка ошибки
Одна строка ошибки
1
Задача
Go SELF, 49 уровень, 4 лекция
Недоступна
Ошибка для юзера
Ошибка для юзера
1
Задача
Go SELF, 49 уровень, 4 лекция
Недоступна
Каталог с debug
Каталог с debug
1
Опрос
Подкоманды через flag.FlagSet, 49 уровень, 4 лекция
Недоступен
Подкоманды через flag.FlagSet
Подкоманды через flag.FlagSet
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ