Содержание урока · 10 разделов
Пайплайн зелёный, а бэкапа нет — классический сюжет с CLI-утилитой, которая упала, но вернула процессу ноль. Ноль для CI означает «всё хорошо»: едут следующие шаги, ошибка всплывает через неделю, когда восстановление из несуществующего бэкапа срочно нужно продакшену. В Cobra ответственность за код выхода лежит на двух концах. Первый — RunE возвращает error вместо того, чтобы вызывать os.Exit прямо из команды. Второй — main обрабатывает то, что вернул Execute, и превращает ошибку в ненулевой код. Пропустите второй конец — и любая ошибка превратится в вежливый ноль, потому что Execute ничего не завершает сама. Разберём всю линию целиком: от ошибки в RunE через печать в stderr до кода выхода, который читает шелл, и по пути уберём из вывода шум в виде usage-страницы, которая по умолчанию вываливается после каждой ошибки.
Cobra обработка ошибок: RunE вместо os.Exit#
Контракт простой: RunE возвращает error, Cobra печатает его и usage-страницу, Execute отдаёт ошибку вызывающему. Звать os.Exit внутри RunE не нужно и вредно: os.Exit завершает процесс немедленно, минуя defer, а значит, открытые в команде файлы не закроются и PostRun-хуки из предыдущего урока не выполнятся. Пока ошибка возвращается по цепочке, у программы остаётся шанс прибраться. Стандартный хвост main выглядит так: if err := rootCmd.Execute(); err != nil { os.Exit(1) } — сообщение об ошибке уже напечатано Cobra, main лишь выставляет код. Забытый хвост — самый дешёвый баг года: Execute вернул ошибку, main дошёл до конца нормально, процесс закрылся с нулём, и мониторинг остался в неведении. Проверьте свой main прямо сейчас, это одна строка, — и переходите к тонкостям печати.
main, который не прячет падение
GOfunc main() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}Execute печатает Error: с текстом ошибки в stderr и возвращает ошибку; main превращает её в код выхода 1. Этого достаточно для скриптов и CI: ненулевой код — сигнал «упало». Более тонкие коды и собственная печать добавляются поверх этой схемы, а не вместо неё.
Проверка: код выхода у забывчивого main
main выглядит так: rootCmd.Execute() без обработки результата. RunE вернул ошибку. Какой код выхода у процесса?
SilenceUsage и SilenceErrors: тише — лучше#
По умолчанию ошибка в RunE тянет за собой usage-страницу: пользователь опечатался во флаге и получил два экрана списка команд, среди которых ещё нужно найти саму ошибку. Для человека это шум, для скрипта — мусор в stderr. rootCmd.SilenceUsage = true убирает печать usage при ошибках; справка по --help продолжает работать, потому что SilenceUsage глушит только реакцию на ошибку, а не сам механизм usage. SilenceErrors идёт дальше: Cobra не печатает и само сообщение Error:, ошибка возвращается нетронутой, и печать берёт на себя main — один формат, одно место. Флаги можно ставить и на конкретную команду, а проверка в ExecuteC смотрит оба уровня: достаточно SilenceUsage или SilenceErrors на команде или на корне, чтобы соответствующая печать заглохла. Рабочий дефолт для утилит с командами — SilenceUsage на корне: пользователи знают, что флаги перечислены в --help, и не нуждаются в их повторе после каждой опечатки.
SilenceUsage на корне
GOvar rootCmd = &cobra.Command{
Use: "notes",
SilenceUsage: true,
}
func main() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1) // Error: уже напечатан Cobra в stderr
}
}SilenceUsage глушит печать usage при ошибке: пользователь видит одну строку Error: с текстом вместо страницы команд. Само сообщение Cobra печатает сама, поэтому main только выставляет код выхода. Хотите свой формат ошибки — добавьте SilenceErrors и печатайте в main сами, но выберите одно место печати, а не оба.
Что напечатает Execute
Прочитайте код и выберите, что он выведет:
var rootCmd = &cobra.Command{
Use: "notes",
SilenceUsage: true,
SilenceErrors: true,
}
// RunE возвращает errors.New("boom")
_ = rootCmd.Execute()Коды выхода: язык для скриптов и CI#
Код выхода — единственный канал, через который утилита говорит с обёртками: шелл читает $?, CI сравнивает его с нулём, оркестратор решает, перезапускать ли контейнер. База простая: ноль — успех, всё остальное — ошибка. Но у разветвлённых утилит полезен словарь кодов: 1 — общий провал, 2 — ошибка использования, как поступает ряд системных утилит, 3 — частичный успех вроде «синхронизировано 9 из 12». Словарь фиксируется в документации рядом с описанием команд, а не в голове автора, — иначе скриптописатели начнут угадывать. Реализация аккуратно укладывается в тип: RunE возвращает свой тип ошибки с полем Code, main разбирает его через errors.As и зовёт os.Exit с нужным числом. Дополнение к словарю: код 128+N шелл показывает, когда процесс убит сигналом N — знакомый всем 130 после Ctrl+C это 128+2. Возвращайте такой код и сами, когда прерывание пришло извне, а cleanup успел завершиться: скрипты отличат отмену от обычного провала. И помните свойство os.Exit из начала урока: он завершает процесс немедленно и defer не выполняет, поэтому весь словарь живёт в main, а не в командах.
Тип ошибки с кодом выхода
GOtype ExitError struct {
Code int
Err error
}
func (e *ExitError) Error() string { return e.Err.Error() }
func (e *ExitError) Unwrap() error { return e.Err }
func main() {
err := rootCmd.Execute()
if err == nil {
return
}
var ee *ExitError
if errors.As(err, &ee) {
fmt.Fprintln(os.Stderr, err)
os.Exit(ee.Code)
}
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}ExitError несёт код поверх обычной ошибки и разворачивается через Unwrap, поэтому errors.Is и errors.As продолжают видеть исходную причину. RunE возвращает &ExitError{Code: 3, Err: ...} там, где сценарий «частичный успех», а main один раз разбирает тип и выставляет код. Печать в обеих ветках идёт в os.Stderr: stdout остаётся чистым для перенаправлений — правило разделения потоков работает и здесь. Словарь кодов живёт комментарием рядом с этим типом.
Проверка: os.Exit и defer
RunE открыл файл и зарегистрировал закрытие через defer f.Close(). Внутри RunE вызван os.Exit(3). Что произойдёт?
Оборачивание ошибок: %w и контекст#
Ошибка «permission denied» без контекста — детектив для пользователя: что именно не открылось и в какой команде? fmt.Errorf("read %s: %w", path, err) добавляет путь и причину в одну строку, а глагол %w сохраняет исходную ошибку внутри: errors.Is(err, os.ErrNotExist) и errors.As продолжают работать по всей обёрнутой цепочке. В CLI это превращается в конкретику двух уровней: RunE добавляет операцию — что делали, main добавляет команду — где делали. Разверните привычку из языка Go: каждый слой добавляет контекст, ничего не теряя, и ваши сообщения станут самодиагностируемыми — по одной строке из лога видно, какая команда, на каком файле и по какой причине упала. Полная механика %w, errors.Is и errors.As разобрана отдельным уроком в продвинутом курсе, здесь мы используем только её следствия.
Обёртка и разбор в main
GOfunc runAdd(cmd *cobra.Command, args []string) error {
data, err := os.ReadFile(storagePath)
if err != nil {
return fmt.Errorf("read storage: %w", err)
}
// ... работа с заметками
return nil
}
func main() {
if err := rootCmd.Execute(); err != nil {
if errors.Is(err, os.ErrNotExist) {
fmt.Fprintln(os.Stderr, "хранилище не найдено, выполните notes init")
os.Exit(2)
}
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}RunE оборачивает ошибку чтения через %w, сохраняя os.ErrNotExist внутри цепочки. main разворачивает её через errors.Is и переводит в подсказку с отдельным кодом 2: скрипт по коду отличит «нет хранилища» от общего провала и запустит init. Это словарь кодов в действии — без единого os.Exit внутри команды.
Включили SilenceErrors на корне, а в main забыли печать — пользователь видит только код выхода, без единого слова о причине. Обратная комбинация хуже: SilenceErrors не поставили, а в main напечатали err повторно — каждая ошибка выходит дважды: сначала Error: от Cobra, потом ваша копия. Выберите одну из двух схем и не смешивайте: либо молчаливая Cobra с печатью в main, либо болтливая Cobra с bare os.Exit(1). И в обеих схемах пишите в stderr через fmt.Fprintln(os.Stderr, err) — оставляя stdout чистым для результата, который скрипты перенаправляют в файлы.
stderr против stdout: кто что читает#
Разделение потоков — половина культуры вывода CLI. В stdout пишется результат: список заметок, идентификатор созданной записи, путь к файлу — то, ради чего команду зовут и что скрипты ловят через перенаправление. В stderr — всё остальное: ошибки, предупреждения, прогресс, диагностику. Форматирование здесь то же, что и у fmt.Println, только поток другой: fmt.Fprintln(os.Stderr, ...) и fmt.Fprintf(cmd.ErrOrStderr(), ...). Cobra по умолчанию вежлива: и Error:, и usage при ошибке уходят в stderr, если вывод не переопределён через SetOut — проверьте это, прежде чем парсить вывод своих команд в шелле. Нарушение разделения стоит дорого: notes list 2>/dev/null | grep id перестанет находить идентификаторы, как только одна строка диагностики уедет в stdout, а отладка таких утечек — то ещё удовольствие, потому что отдельно взятый запуск в терминале выглядит правильно.
Куда что печатаем#
| Поток | Что печатаем | Кто читает |
|---|---|---|
| stdout | результат: список заметок, id созданной записи | пользователь и скрипты с перенаправлением |
| stderr | ошибки, предупреждения, прогресс | человек перед экраном и логи CI |
| код выхода | итог: 0 — успех, ненулевой — провал | шелл, CI, оркестратор |
Соберите тихий main
Впишите поле тишины usage, поток для ошибок и вызов завершения.
Соберите обработку ошибки из шести строк
Расставьте: тишина usage, выполнение, проверка ошибки, печать, код выхода, закрытие if.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: ошибки и коды выхода
Что дальше#
Схема ошибки — RunE, SilenceUsage, код выхода — теперь финальная, осталось научиться её проверять. В уроке про тестирование команд Execute вызывается из теста, вывод ловится буфером вместо терминала, и ошибки сравниваются без os.Exit — ровно поэтому os.Exit и держат в main, подальше от RunE. Как всё это собирается в цельную утилиту, покажет итоговый проект: команды add/list/done с хранилищем, кодами выхода и тестами. Механика оборачивания разобрана отдельно в уроке про ошибки и %w продвинутого курса, обзор остальных тем — на странице Cobra. Как всегда с CLI: код выхода видит только живой процесс — соберите утилиту локально, уроните её специально и проверьте значение $? в шелле.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- RunE возвращает error, main превращает его в код выхода: забытый os.Exit(1) после Execute — нулевой код у упавшей утилиты.
- SilenceUsage убирает usage-дамп при ошибке, SilenceErrors — печать Error:; печатайте ошибку в одном месте.
- Код выхода — канал для скриптов и CI: ноль — успех, словарь кодов — для различимых провалов.
- os.Exit завершает процесс без defer: только в main, никогда в RunE.
- Оборачивайте ошибки через fmt.Errorf с %w: контекст растёт, errors.Is и errors.As продолжают работать.
- Результат — в stdout, ошибки — в stderr через fmt.Fprintln(os.Stderr, ...).
- Две схемы печати: молчаливая Cobra плюс main или болтливая Cobra плюс bare os.Exit(1) — не смешивайте.
Домашнее задание
Проверьте main своей утилиты: обрабатывает ли она ошибку из Execute. Затем включите SilenceUsage на корне, добавьте тип ExitError с полем Code и верните код 3 из команды sync, когда часть заметок не синхронизировалась. Уроните команду специально: неверный флаг, отсутствие хранилища, частичный успех — и выпишите полученные коды выхода через $? в терминале. Отдельно проверьте прерывание: запустите долгую команду и прибейте её Ctrl+C — echo $? покажет 130, то есть 128+2, даже без единой строки вашего кода об ошибках.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему Cobra печатает usage при каждой ошибке?
Это поведение по умолчанию: ошибка тянет за собой usage-страницу. Задайте SilenceUsage: true на корневой команде — останется одна строка Error:. Флаг на конкретной команде гасит usage только для неё, на корне — для всего дерева.
Как вернуть свой код выхода из команды Cobra?
Не вызывайте os.Exit в RunE. Верните обычную ошибку или тип-обёртку с полем Code, а в main после Execute разберите её через errors.As и вызовите os.Exit с нужным числом. Дефолт для любой ошибки — os.Exit(1).
Куда Cobra печатает ошибку — в stdout или stderr?
В stderr: и сообщение Error:, и usage при ошибке идут в поток ошибок, если вывод не переопределён SetOut. Ваш вывод через fmt.Println уходит в stdout, поэтому ошибки не попадут в перенаправленный результат; для своих сообщений используйте fmt.Fprintln(os.Stderr, ...).
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
- Интерактивный ввод: вопросы и подтверждения
- Красивый вывод: цвета и таблицы в терминале
- Автодополнение: bash, zsh, fish, powershell
- Кастомные подсказки: ValidArgsFunction
- Хуки PreRun и PostRun: цепочки выполнения
- Справка: кастомизация help и usage
- Приоритеты конфигурации: флаг, env, файл
- Переменные окружения: AutomaticEnv
- Все курсы по Go
Следующая ступень учебного пути
Bubble Tea: TUI-приложения в терминале
Интерактивный курс Bubble Tea — TUI-фреймворка на Go: Elm-архитектура, bubbles, Lip Gloss, мышь и клавиатура. 25 уроков с примерами и итоговым приложением.
- Понимать Elm-архитектуру: Model, Update, View, Init
- Обрабатывать клавиатуру и мышь в терминале
- Писать асинхронные операции через tea.Cmd, Batch и Sequence
- Использовать bubbles: list, textinput, textarea, table, viewport, progress, spinner