open atlas
↑ К треку
Go с нуля до senior GO · 13 · 01

CLI на Go: stdout — данные, stderr — диагностика, код выхода — API

Unix-контракт Go CLI: stdout — данные, stderr — диагностика, коды выхода — машинный API (0 успех, 1 ошибка, 2 usage). Определяй TTY для выбора таблиц или --json, читай stdin как фильтр, убирай temp-файлы по ctrl-C через signal.NotifyContext, отдавай один статический бинарник.

GO Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Деплой сломался в два часа ночи, посреди инцидента — деплои ломаются именно тогда. Пайплайн состоял из одной строки: release-tool manifest --json | jq -r '.images[]', и она безотказно работала год. На той неделе кто-то улучшил инструмент — добавил дружелюбный fmt.Println("resolving 14 services..."), чтобы люди не смотрели на молчащий курсор. Дружелюбно для людей, фатально для jq: строка прогресса попала на stdout, перед JSON, и jq умер с parse error, что под set -euo pipefail убило деплой-скрипт, что разбудило дежурного, который и так уже будил кого-то ещё. Фикс уместился в одиннадцать символов — fmt.Fprintln(os.Stderr, ...), — но урок оказался крупнее: у CLI-инструмента есть поверхность API, и это не его флаги. Это то, какие байты идут в какой поток и с каким числом процесс умирает. Сломай любое из двух — и сломаешь каждый скрипт, cron и пайплайн, который тебе доверял, молча и в самый неподходящий час.

Три канала, три смысла

Через несколько минут ты будешь знать, какая функция Go в какой поток пишет байты — и почему ошибка здесь роняет пайплайны в два часа ночи.

Каждый Unix-процесс рождается с тремя файловыми дескрипторами, и контракт над ними старше Go: stdout (fd 1) — продукт — байты, которые потребляет следующая программа; stderr (fd 2) — комментарий — прогресс, предупреждения, ошибки, всё, что адресовано человеку; код выхода — вердикт — единственное, на чём шелл может ветвиться. Пайп соединяет со следующим процессом только stdout; stderr идёт мимо пайпа и всё равно достигает терминала. В этом весь механизм истории из Hook: всё, что ты печатаешь в stdout, становится частью твоего формата данных — хотел ты этого или нет.

Стандартная библиотека Go здесь играет за тебя, если знать где: пакет log по умолчанию пишет в stderrlog.Printf безопасен для пайплайнов из коробки. fmt.Println пишет в stdout — он для результатов, а не для повествования:

json.NewEncoder(os.Stdout).Encode(report)            // данные: это съест следующий процесс
fmt.Fprintf(os.Stderr, "resolved %d services\n", n)  // диагностика: это читает оператор
log.Printf("retrying registry fetch")                // тоже stderr — log безопасен по умолчанию

Коды выхода — вторая половина API. Рабочая конвенция: 0 — успех, 1 — общая ошибка, 2 — ошибка использования; сам flag выходит с кодом 2 при неудачном парсинге, так что последний пункт твой инструмент уже соблюдает. Таблица BSD sysexits.h (64–78: EX_USAGE, EX_NOINPUT, EX_TEMPFAIL…) заслуживает честной оговорки: это конвенция из почтового софта восьмидесятых, а не стандарт, который ОС навязывает, — сегодня эти значения почти никто не проверяет. Важно другое: ты выбираешь маленькую таблицу, документируешь её в --help и никогда не меняешь между делом — скрипты ветвятся на этих числах. Держись диапазона 0–255 (шелл видит только младшие 8 бит) и не трогай значения от 126: шеллы используют 126 как «не исполняемо», 127 как «не найдено», а смерть от сигнала сообщают как 128 плюс номер сигнала (убийство по ctrl-C выглядит как 130).

Викторина

Инструмент печатает прогресс через fmt.Println, а JSON-отчёт пишет через json.NewEncoder(os.Stdout). Деплой-скрипт пайпит его в jq. Что увидит jq?

Машинный режим и человеческий режим

Наследуя незнакомый инструмент, спроси себя: ведёт ли он себя одинаково в пайпе и в демо? У серьёзного инструмента две аудитории и один бинарник, поэтому ему нужен явный машинный формат — --json — и способ заметить, какая аудитория перед ним. Механизм — проверка TTY на дескрипторе вывода: term.IsTerminal(int(os.Stdout.Fd())) (из golang.org/x/term). Интерактивный терминал: таблицы, цвет, прогресс-спиннеры. Пайп или редирект: убрать всё это.

isTTY := term.IsTerminal(int(os.Stdout.Fd()))
useColor := isTTY && os.Getenv("NO_COLOR") == ""   // уважаем конвенцию NO_COLOR

Трейдофф, который стоит сформулировать точно: автопереключение декора (цвет, спиннеры, трюки с курсором) по TTY безопасно всегда — сырые ANSI-последовательности вроде \x1b[32m в логе CI или в выводе grep — чистый шум. Автопереключение формата — нет: инструмент, молча выдающий JSON в пайпе и таблицу в терминале, в скрипте поведёт себя не так, как в демо перед этим скриптом, и этот сюрприз стоит сессии дебага. Надёжный контракт: декор следует за TTY, формат — за явным флагом.

flag против cobra, честно

Stdlib-пакет flag полностью закрывает инструмент с одним глаголом: типизированные флаги, генерация -h, выход с кодом 2 на плохом вводе, ноль зависимостей. Его края: длинные флаги только с одним дефисом (-verbose, не --verbose — хотя два дефиса парсер принимает), нет сабкоманд, нет shell-комплишенов. Сабкоманды можно собрать руками — flag.NewFlagSet на глагол плюс switch по os.Args[1]: нормально для двух-трёх глаголов, мучительно для двенадцати.

Вот настоящая граница для cobra или urfave/cli: не эргономика, а площадь поверхности. Git-образный инструмент — вложенные сабкоманды, генерируемые комплишены для трёх шеллов, man-страницы — ровно то, что cobra автоматизирует, и заплатить её деревом зависимостей (cobra тянет pflag и компанию — реальная цена, которую теперь вечно аудитить, в бинарнике, который существует отчасти затем, чтобы избегать разрастания зависимостей) — честная сделка. Инструмент с одним глаголом и шестью флагами, импортирующий cobra, купил фреймворк ради вызова функции. Начинай с flag; мигрируй, когда switch по сабкомандам начнёт болеть.

stdin — это вход: контракт фильтра

Инструменты, которым ты уже доверяешь — grep, sort, jq, — соблюдают cat-pipe-контракт: файловые аргументы, если они есть, иначе stdin. Одна эта конвенция делает их композируемыми, и стоит она пять строк:

var in io.Reader = os.Stdin
if flag.NArg() > 0 {
	f, err := os.Open(flag.Arg(0))
	if err != nil { fmt.Fprintln(os.Stderr, err); os.Exit(1) }
	defer f.Close()
	in = f
}

Одно честное число про сканер, за которым ты потянешься дальше: bufio.Scanner по умолчанию ограничивает токен 64 КиБ и за пределом возвращает ErrTooLong — реальные строки логов бывают длиннее; вызови sc.Buffer с большим лимитом или возьми bufio.Reader. А когда настройки приходят сразу из трёх мест, лестница приоритетов из урока про конфигурацию действует без изменений: флаги бьют переменные окружения, окружение бьёт файл — побеждает явное и ближайшее к вызову.

Почему это работает

Почему прогресс — в stderr, даже когда на экран явно смотрит человек? Потому что в момент печати ты не можешь знать, кто потребляет stdout: один и тот же вызов должен работать интерактивно сегодня и внутри пайпа завтра, а байтовый поток не умеет отзывать строку обратно. stderr — канал, который по определению «всегда человек»: он переживает пайп, переживает редирект потока данных, и каждый Unix-инструмент с семидесятых обращается с ним именно так. Печатать повествование в stdout — значит занимать в долг у контракта, по которому расплатишься во время аварии.

Ctrl-C обязан убирать за собой

CLI, который пишет temp-файлы, держит lock или наполовину закончил выгрузку, должен пользователю чистый выход по прерыванию. Современная проводка — signal.NotifyContext плюс цепочка defer, и одно острое ребро: os.Exit не выполняет отложенные вызовы. Обработчик сигнала, зовущий os.Exit напрямую, пропустит каждый старательно написанный defer os.RemoveAll.

func main() { os.Exit(run()) }   // ровно один os.Exit, на самом верху

func run() int {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	tmp, err := os.MkdirTemp("", "release-*")
	if err != nil { fmt.Fprintln(os.Stderr, err); return 1 }
	defer os.RemoveAll(tmp)      // сработает и на обычном return, и на выходе по сигналу

	if err := build(ctx, tmp); err != nil {
		if ctx.Err() != nil { return 130 }   // прервано: 128 + SIGINT(2), по конвенции
		fmt.Fprintln(os.Stderr, err)
		return 1
	}
	return 0
}

Механизм: первый ctrl-C отменяет ctx; рабочая функция замечает ctx.Err() и возвращается; стек раскручивается через каждый defer; run отдаёт код единственному os.Exit наверху. Очистка — обычный поток управления: ни реестра очистки, ни лапши из обработчиков.

Викторина

CLI создаёт temp-каталог, делает defer os.RemoveAll, а горутина с сигналами зовёт os.Exit(1) по SIGINT. Пользователи жалуются: после ctrl-C копятся temp-каталоги. Почему?

Доставка

Дистрибуция — место, где Go CLI выигрывает вчистую: CGO_ENABLED=0 go build даёт один статический бинарник на платформу, кросс-компилируемый одним лишь GOOS=linux GOARCH=arm64. Ожидай 5–15 МБ для типичного инструмента; -ldflags="-s -w" срезает таблицы символов и возвращает примерно четверть объёма. Два канала доставки различаются аудиторией: go install example.com/tool@v1.4.2 собирает из исходников на машине пользователя — идеально для Go-разработчиков, бесполезно для всех без тулчейна и непиннуемо в CI глубже тега. Релизные архивы (паттерн goreleaser: tar-архивы по платформам плюс чексуммы на странице релизов) обслуживают остальных и дают воспроизводимые, подписываемые артефакты. Зрелые инструменты делают и то и другое.

Вспомните перед уходом
  1. 01
    Сформулируй трёхканальный Unix-контракт и что каждая половина печатающей stdlib Go делает с ним по умолчанию.
  2. 02
    Разбери прерывание с очисткой в Go CLI: проводку, раскрутку и ловушку os.Exit.
Итог

Командный инструмент — это процесс с API из трёх каналов, и Go даёт точный контроль над каждым. stdout существует только для данных — JSON, строки таблиц, байты для следующей стадии пайпа, — а stderr несёт каждый байт для человека: прогресс, предупреждения, ошибки; пакет log в Go попадает туда по умолчанию, а fmt.Println — нет, и путаница между ними — это то, как дружелюбное сообщение о прогрессе убивает продовый деплой через parse error в jq. Код выхода — третий канал: 0 успех, 1 ошибка, 2 usage — документируй свою таблицу и относись к sysexits.h как к исторической конвенции, держась подальше от зарезервированных шеллом значений выше 125. Определяй TTY на stdout, чтобы переключать декор — цвет, спиннеры, уважение к NO_COLOR, — но никогда не переключай формат молча: формат принадлежит явному флагу —json. Читай stdin, когда файловых аргументов нет, — и твой инструмент за пять строк кода входит в семью grep-sort-jq; помни про дефолтный лимит Scanner в 64 КиБ. Прерывания идут через signal.NotifyContext в обычный поток управления: контекст отменяется, функции возвращаются, defer-ы чистят temp-файлы, а единственный os.Exit наверху main сообщает 130 — потому что os.Exit в любом другом месте пропустит каждый написанный тобой defer. Выбирай flag, пока сабкоманды и комплишены не начнут по-настоящему болеть, — тогда осознанно плати цену зависимости cobra. Поставляй один статический бинарник; предлагай go install для Go-разработчиков и архивы с чексуммами для всех остальных. Теперь, когда встретишь таинственно сломанный пайплайн после чьего-то «улучшения» инструмента, ты будешь знать, где искать: какой поток получил лишний байт, которому там не место.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.