CLI-приложения на Go
Посмотрите на инструменты, которыми вы пользуетесь каждый день: docker, kubectl, terraform, helm, gh, hugo, k9s, lazygit, golangci-lint. Все написаны на Go. Это не совпадение и не мода — в обзоре консольные инструменты названы одной из двух главных ниш языка, но весь курс мы строили сетевой сервис. Пора закрыть этот пробел: CLI — отдельное ремесло со своими правилами, и большинство из них не про код, а про контракт с пользователем и его терминалом.
Почему Go выиграл эту нишу
- Один файл. Результат сборки — статический бинарник без рантайма и зависимостей. Пользователь скачивает и запускает. Сравните с «поставьте Python 3.11, создайте venv, установите пакеты» или «установите JRE».
- Кросс-компиляция одной командой. Одна CI-джоба собирает под Linux, macOS и Windows, под amd64 и arm64 — из главы про деплой.
- Мгновенный старт. Единицы миллисекунд против сотен у JVM и десятков у Python с импортами. Для инструмента, который вызывают в цикле из скрипта, это решающе.
- Конкурентность даром. Скачать сто файлов, опросить пятьдесят хостов, обработать дерево каталогов параллельно — это
errgroupиз главы про конкурентность, а не борьба с потоками. - Богатая стандартная библиотека — HTTP, JSON, архивы, шаблоны уже есть, зависимостей почти не нужно.
Где Go не лучший выбор: разовый скрипт на двадцать строк (быстрее написать на shell или Python) и задачи, где половина работы — вызовы системных утилит через пайпы. Компиляция ради cat | grep не окупается.
Контракт CLI: то, что отличает инструмент от скрипта
Программа в терминале — участник экосистемы Unix, а не изолированное приложение. Соблюдение контракта делает её частью пайплайнов; нарушение превращает в источник раздражения.
Потоки: stdout — данные, stderr — всё остальное
Правило одно и оно железное: в stdout идёт только результат работы, в stderr — логи, ошибки, прогресс, предупреждения.
# Если контракт соблюдён, это работает:
mytool list --json | jq '.[].name'
mytool export > data.csv 2> export.log
# Если прогресс-бар пишется в stdout — вы сломали и jq, и файл
Проверять просто: любой вывод, который пользователь захочет передать дальше по пайпу, — stdout. Всё, что он захочет увидеть на экране, не испортив пайп, — stderr. Структурное логирование через slog из главы про архитектуру в CLI настраивают на os.Stderr.
Коды выхода
const (
exitOK = 0 // всё получилось
exitError = 1 // ошибка выполнения
exitUsage = 2 // неверные аргументы (соглашение Unix)
exitNotFound = 4 // свои осмысленные коды — если пользователю нужно их различать
)
Ноль — успех, любое ненулевое — неудача. Это то, на что смотрят &&, ||, set -e в скриптах и любой CI. Программа, которая печатает «Error:» и выходит с нулём, ломает автоматизацию всех своих пользователей.
Уважение к терминалу и его отсутствию
Инструмент должен вести себя по-разному, когда с ним говорит человек и когда — скрипт:
import "golang.org/x/term"
isTTY := term.IsTerminal(int(os.Stdout.Fd()))
// Цвет, прогресс-бары, спиннеры — только если на том конце терминал.
// И уважайте переменную NO_COLOR: это межъязыковое соглашение (no-color.org).
useColor := isTTY && os.Getenv("NO_COLOR") == ""
// Интерактивные вопросы — только при TTY. Иначе — ошибка с подсказкой про флаг.
if needsConfirmation && !isTTY {
return fmt.Errorf("требуется подтверждение: добавьте --yes для неинтерактивного режима")
}
И зеркально: если stdin не терминал, разумно прочитать данные оттуда — так работают jq, grep и все привычные утилиты.
Ещё несколько пунктов контракта
--helpсодержательный: что делает, как вызывать, примеры. Примеры важнее описания флагов.--versionс версией, коммитом и датой — вшивается через-ldflags -Xиз главы про деплой.--jsonили--output=jsonдля машиночитаемого вывода. Человекочитаемый формат — не контракт, JSON — контракт.- Флаги вместо интерактива в любом сценарии автоматизации: у всего, что спрашивается, должен быть флаг.
- Секреты не передаются аргументами. Аргументы видны всем в
ps auxи попадают в историю shell. Только переменная окружения, файл или stdin (подробнее — в главе про управление секретами).
Канонический свод этих правил — Command Line Interface Guidelines. Прочитайте целиком, это час времени и заметный скачок качества ваших инструментов.
Структура кода: тонкий main и тестируемый run
Прямые обращения к os.Args, os.Stdout и os.Exit по всему коду делают программу непроверяемой. Идиома, популяризированная Мэтом Райером, — вынести всё в функцию с явными зависимостями:
func main() {
ctx := context.Background()
if err := run(ctx, os.Args[1:], os.Stdin, os.Stdout, os.Stderr); err != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", filepath.Base(os.Args[0]), err)
os.Exit(1)
}
}
// run — вся программа. Никаких глобальных os.*: всё приходит параметрами,
// поэтому в тесте можно подсунуть буферы вместо потоков.
func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
fs := flag.NewFlagSet("mytool", flag.ContinueOnError)
fs.SetOutput(stderr)
var (
verbose = fs.Bool("v", false, "подробный вывод")
out = fs.String("output", "text", "формат вывода: text|json")
)
if err := fs.Parse(args); err != nil {
return err
}
// ... работа
return nil
}
Важная тонкость: os.Exit не выполняет отложенные вызовы. Все defer f.Close() и defer cancel() молча пропускаются. Поэтому выход из программы делается ровно в одном месте — в main, после того как run вернула ошибку и все defer уже отработали.
выход с кодом 2"] PARSE --> CONF["Слияние конфигурации:
флаги > env > файл > умолчания"] CONF --> SIG["signal.NotifyContext:
ctx отменяется по SIGINT и SIGTERM"] SIG --> TTY{"stdout — терминал?"} TTY -->|да| RICH["Цвет, прогресс, интерактив"] TTY -->|нет| PLAIN["Простой вывод, без ANSI-кодов"] RICH --> WORK["Полезная работа"] PLAIN --> WORK WORK -->|"успех"| OK["Результат в stdout
выход с кодом 0"] WORK -->|"ошибка"| ERR["Сообщение в stderr
выход с кодом 1"] WORK -->|"отмена по Ctrl+C"| CANCEL["Свернуть работу, убрать временные файлы
выход с кодом 130"]
flag: когда стандартной библиотеки достаточно
fs := flag.NewFlagSet("serve", flag.ExitOnError)
addr := fs.String("addr", ":8080", "адрес прослушивания")
workers := fs.Int("workers", runtime.NumCPU(), "число обработчиков")
_ = fs.Parse(os.Args[2:])
Пакет flag умеет меньше, чем принято думать, но покрывает многое:
- Свои типы флагов — через интерфейс
flag.Valueс методамиString()иSet(string) error. Так делают повторяемые флаги (--header a --header b) и enum-значения с валидацией. - Подкоманды — через отдельные
FlagSetна каждую и ручную диспетчеризацию поos.Args[1]. flag.ContinueOnErrorвместоExitOnError— чтобы обработать ошибку самому, а не выйти из процесса из глубины библиотеки.
Чего в flag нет: длинных флагов в стиле GNU (--flag=value работает, а -abc как объединение коротких — нет), автогенерации автодополнения, вложенных команд «из коробки». Для внутренней утилиты с двумя-тремя командами это не проблема, и лишняя зависимость не нужна.
cobra и альтернативы
Когда команд десятки, а нужны автодополнение, справка и вложенность, берут фреймворк. Стандарт индустрии — cobra: на ней написаны kubectl, hugo, gh, docker (частично).
var rootCmd = &cobra.Command{
Use: "mytool",
Short: "Инструмент для работы с заказами",
// Пример важнее описания: его читают первым
Example: " mytool orders list --status=paid --json",
}
var listCmd = &cobra.Command{
Use: "list",
Short: "Показать заказы",
// RunE, а не Run: ошибку возвращаем, а не печатаем и не выходим внутри команды
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context() // контекст пробрасывается из main
return listOrders(ctx, cmd.OutOrStdout(), status, asJSON)
},
}
func init() {
listCmd.Flags().StringVar(&status, "status", "", "фильтр по статусу")
listCmd.Flags().BoolVar(&asJSON, "json", false, "машиночитаемый вывод")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "подробный вывод")
rootCmd.AddCommand(listCmd)
}
Что даёт cobra помимо дерева команд: генерацию автодополнения для bash/zsh/fish/PowerShell одной командой, man-страницы, «вы имели в виду…» при опечатке в имени команды, единообразную справку. Связка с viper добавляет слияние источников конфигурации.
Ориентиры по выбору:
flag— утилита для команды, один-два флага, важен нулевой вес зависимостей.peterbourgon/ff— тот жеflag, но со слиянием env и конфиг-файла. Лучший компромисс, когда cobra избыточна.alecthomas/kong— команды и флаги описываются тегами структур (прямое применение того, что мы разбирали в главе про метапрограммирование). Очень компактно.urfave/cli— проще cobra, декларативнее.cobra+viper— публичный инструмент с десятками команд, где нужны автодополнение и man-страницы. Цена — заметный граф зависимостей, что не бесплатно с точки зрения цепочки поставок из главы про SDLC.
Конфигурация: приоритет источников
Пользователь ожидает предсказуемого порядка, и он одинаков во всех приличных инструментах:
флаг командной строки → переменная окружения → файл конфигурации → значение по умолчанию.
Ближайший к моменту вызова источник побеждает. Реализуется это либо viper, либо ff, либо десятью строками руками:
// cmp.Or из Go 1.22 — первое непустое значение
addr := cmp.Or(*flagAddr, os.Getenv("MYTOOL_ADDR"), cfgFile.Addr, ":8080")
Где хранить файл конфигурации: не в $HOME/.mytool россыпью, а по правилам платформы — os.UserConfigDir() даёт ~/.config на Linux, ~/Library/Application Support на macOS и %AppData% на Windows. Кеш — в os.UserCacheDir(). Это мелочь, по которой сразу видно качество инструмента.
Отмена по Ctrl+C
Пользователь нажимает Ctrl+C и ожидает, что программа остановится аккуратно: не оставит недописанный файл, снимет блокировку, удалит временный каталог. А если он нажал дважды — что она умрёт немедленно.
func main() {
// Первый SIGINT/SIGTERM отменяет ctx
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// Второй сигнал — выход без разговоров
go func() {
<-ctx.Done()
stop() // вернуть сигналам поведение по умолчанию
<-time.After(5 * time.Second)
fmt.Fprintln(os.Stderr, "принудительное завершение")
os.Exit(130) // 128 + номер SIGINT
}()
if err := run(ctx, os.Args[1:], os.Stdin, os.Stdout, os.Stderr); err != nil {
if errors.Is(err, context.Canceled) {
os.Exit(130) // отмена пользователем — не «ошибка»
}
fmt.Fprintf(os.Stderr, "ошибка: %v\n", err)
os.Exit(1)
}
}
сигнал обрабатывается по умолчанию, процесс умирает сразу
Отдельно про SIGPIPE. Когда вы пишете в пайп, а получатель закрылся (mytool list | head -5), ядро шлёт SIGPIPE. Рантайм Go обрабатывает это так: если сигнал пришёл на дескрипторы 1 или 2 — программа завершается, как принято в Unix; для остальных дескрипторов сигнал игнорируется, а Write возвращает EPIPE. Практический вывод: не считайте ошибку записи в stdout поводом для громкой диагностики — скорее всего, пользователь просто оборвал вывод через head.
Вывод, ошибки и TUI
Сообщение об ошибке — это интерфейс. Плохо: error: invalid input. Хорошо: что произошло, где и что делать дальше.
return fmt.Errorf("файл конфигурации %s: строка %d: неизвестный ключ %q\n"+
"Допустимые ключи: addr, timeout, log_level", path, line, key)
Ещё несколько практических правил:
- Уровни подробности.
-vдля деталей,-qдля тишины (только ошибки),--debugдля диагностики самой программы. По умолчанию — минимум шума: успешная операция может вообще ничего не печатать. - Прогресс только на TTY. В логах CI прогресс-бар превращается в километры мусора.
- Цвет — украшение, а не носитель информации. Всё, что выражено цветом, должно быть понятно и без него: пользователи с дальтонизмом, логи CI, перенаправление в файл.
- Таблицы и стили —
fatih/color,olekukonko/tablewriter,charmbracelet/lipgloss.
Отдельный жанр — TUI, полноэкранные интерактивные приложения: k9s, lazygit, gh dash. Стандарт де-факто — bubbletea с архитектурой в духе Elm: модель, сообщения, Update, View. Трезвая оценка: TUI оправдан, когда пользователь исследует данные (навигация по подам, история коммитов). Если задача — «сделать одну вещь и выйти», обычная CLI лучше: она скриптуется, а TUI — нет.
Тестирование CLI
Функция run с явными потоками тестируется как обычный код, без запуска процессов:
func TestRun_ListJSON(t *testing.T) {
var stdout, stderr bytes.Buffer
err := run(context.Background(),
[]string{"orders", "list", "--json"},
strings.NewReader(""), &stdout, &stderr)
require.NoError(t, err)
assert.Empty(t, stderr.String(), "диагностика не должна попадать в stderr при успехе")
var got []Order
require.NoError(t, json.Unmarshal(stdout.Bytes(), &got))
assert.Len(t, got, 3)
}
Три уровня, дополняющие друг друга:
- Юнит-тесты
runс буферами вместо потоков — быстро, покрывает разбор флагов и форматирование. - Golden-файлы для человекочитаемого вывода (приём из главы про тестирование): эталон в
testdata/, флаг-updateдля перегенерации. Идеально ловит случайные изменения формата. - Скриптовые тесты через
rogpeppe/go-internal/testscript— тот же механизм, которым тестируется сама командаgo. Сценарий пишется на мини-языке: выполнить команду, проверить код выхода, сравнить stdout, посмотреть файлы.
# testdata/script/list.txtar
exec mytool orders list --json
stdout '"status": "paid"'
! stderr .
Сборка и доставка пользователю
Сборка — из главы про деплой, но для CLI важна матрица платформ:
CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.version=$(git describe --tags) -X main.commit=$(git rev-parse --short HEAD)" \
-o dist/mytool ./cmd/mytool
CGO_ENABLED=0 здесь обязателен: он даёт статический бинарник, который запустится на любом дистрибутиве независимо от версии libc — и включает бесплатную кросс-компиляцию, о цене отказа от которой мы говорили в главе про cgo.
Дальше — каналы доставки, и их стоит закрывать по убыванию охвата:
- GoReleaser (упоминался в главе про SDLC) собирает бинарники под все платформы, считает контрольные суммы, подписывает, публикует релиз, обновляет формулы Homebrew и манифесты Scoop, собирает
.deb/.rpmи Docker-образы — всё из одного.goreleaser.yaml. go install github.com/acme/mytool@latest— бесплатный канал для аудитории разработчиков. Требует установленного Go, поэтому не заменяет бинарники.- Пакетные менеджеры — Homebrew, Scoop, apt/yum-репозитории, AUR. Про форматы пакетов и их устройство — глава про упаковку.
- Контрольные суммы и подписи обязательны: пользователь скачивает исполняемый файл из интернета.
cosign, SBOM — это то, что GoReleaser умеет включить в релиз.
И про самообновление: вежливый инструмент сообщает о новой версии (не чаще раза в сутки, с возможностью выключить), но не обновляет себя молча. Тихая автозамена бинарника ломает воспроизводимость сборок у всех, кто вызывает вас из CI. Стратегии версионирования и обновления разбирает глава про обновления.
Чек-лист качественной CLI
- stdout — только данные; логи, прогресс и ошибки — в stderr.
- Коды выхода: 0 — успех, 2 — неверные аргументы, 130 — отмена пользователем.
-
--helpс примерами,--versionс версией и коммитом. -
--jsonдля машиночитаемого вывода. - Цвет и интерактив только при TTY,
NO_COLORуважается. - Порядок конфигурации: флаг → env → файл → умолчание; пути через
os.UserConfigDir. - Секреты не передаются аргументами командной строки.
- Ctrl+C сворачивает работу аккуратно; повторный — немедленно.
-
run(ctx, args, stdin, stdout, stderr) errorвместо глобальныхos.*;os.Exitтолько вmain. - Тесты: юниты на
run, golden-файлы на вывод,testscriptна сценарии. -
CGO_ENABLED=0, кросс-компиляция под все целевые платформы. - Релиз через GoReleaser: суммы, подписи, пакеты, changelog.
Мини-итог
- Go выиграл нишу CLI благодаря статическому бинарнику, кросс-компиляции и мгновенному старту.
- Главный навык здесь не синтаксис, а контракт с терминалом: потоки, коды выхода, поведение вне TTY.
flagпокрывает больше, чем принято думать; cobra нужна ради дерева команд, автодополнения и справки.- Вынесите всё в
runс явными потоками — и получите тестируемость почти даром. - Отмена по сигналу — обязательная часть UX, а не опция.
- Доставка бинарника пользователю — отдельная инженерная задача: суммы, подписи, пакетные менеджеры.
Источники
- Command Line Interface Guidelines — лучший современный свод правил проектирования CLI.
- cobra, viper, ff, kong — документация инструментов.
- Charm —
bubbletea,lipgloss,bubblesдля TUI. - GoReleaser — сборка и публикация релизов.
- testscript — сценарные тесты, которыми тестируется сам Go.
- Исходники
gh,hugoиkubectl— живые образцы структуры больших CLI на Go.
Что дальше
На этом курс по Go закрыт полностью: от философии языка и синтаксиса — через конкурентность, тестирование, архитектуру и деплой — до рантайма, метапрограммирования и консольных инструментов. Дальше стоит углубляться не в язык, а в области вокруг него:
- Упаковка и дистрибуция ПО — форматы пакетов, репозитории, каналы доставки вашего бинарника.
- Рабочий процесс оптимизации — как системно искать узкие места, а не гадать по интуиции.
- RPC и gRPC — протокол, на котором говорит большинство Go-сервисов между собой.
- Безопасность цепочки поставок — то, без чего публикация инструментов сегодня безответственна.
А самый полезный следующий шаг — прежний: взять настоящую задачу и довести её до конца. Напишите инструмент, которым будете пользоваться сами каждый день, соберите его через GoReleaser и отдайте коллегам. Всё из этого курса встанет на места ровно там.