Go CLI-приложения на Go: flag и cobra, контракт терминала, конфигурация и дистрибуция
0%

CLI-приложения на Go: flag и cobra, контракт терминала, конфигурация и дистрибуция

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, а не изолированное приложение. Соблюдение контракта делает её частью пайплайнов; нарушение превращает в источник раздражения.

Контракт консольной программы: потоки stdin, stdout, stderr и коды выхода

Потоки: 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 уже отработали.

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)
}

Три уровня, дополняющие друг друга:

  1. Юнит-тесты run с буферами вместо потоков — быстро, покрывает разбор флагов и форматирование.
  2. Golden-файлы для человекочитаемого вывода (приём из главы про тестирование): эталон в testdata/, флаг -update для перегенерации. Идеально ловит случайные изменения формата.
  3. Скриптовые тесты через 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 — документация инструментов.
  • Charmbubbletea, lipgloss, bubbles для TUI.
  • GoReleaser — сборка и публикация релизов.
  • testscript — сценарные тесты, которыми тестируется сам Go.
  • Исходники gh, hugo и kubectl — живые образцы структуры больших CLI на Go.

Что дальше

На этом курс по Go закрыт полностью: от философии языка и синтаксиса — через конкурентность, тестирование, архитектуру и деплой — до рантайма, метапрограммирования и консольных инструментов. Дальше стоит углубляться не в язык, а в области вокруг него:

А самый полезный следующий шаг — прежний: взять настоящую задачу и довести её до конца. Напишите инструмент, которым будете пользоваться сами каждый день, соберите его через GoReleaser и отдайте коллегам. Всё из этого курса встанет на места ровно там.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов