Содержание урока · 15 разделов
- Паттерн binding за пять шагов
- Config-структура: флаги в структуру, одно поле — один флаг
- Почему биндинг — в init(), а не в RunE
- Дефолты и план приоритетов: флаг, конфиг, дефолт
- Слои настроек: пока два из четырёх
- Валидация настроек до старта
- Подкоманды со своими конфигами
- Признаки, что пора на паттерн binding
- Биндинг прямо в доменную структуру
- Тесты: конфиг без os.Args
- Config и Viper: стыковка двух миров
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Посмотрите на команду serve в любом реальном CLI: порт, адрес, таймауты, режим отладки, путь к хранилищу, лимиты. К пятому флагу Run превращается в функцию, которая держит в голове пять глобальных переменных, объявленных в другом файле, а к восьмому вы ловите себя на том, что боитесь переименовать поле. Сигнатура RunE фиксирована — cmd и args, — так что внутрь не протолкнёшь ни структуру, ни конструктор. Решение старое, как сам Go: собрать настройки в один тип, а флаги биндить прямо в его поля. Тогда у команды появляется собственный мини-конфиг: конструктор с дефолтами, метод биндинга, валидация — и RunE читает одно значение вместо пятнадцати переменных. Этот паттерн называют binding, и в этой статье разберём его от структуры до ошибок.
Паттерн binding за пять шагов#
- Завести структуру с полем под каждую настройку команды
- Написать конструктор defaultServeConfig с рабочими дефолтами
- Создать пакетную переменную cfg = defaultServeConfig()
- В init() биндить поля Var-методами, дефолт брать из структуры
- В RunE вызвать cfg.Validate() и работать только с cfg
Config-структура: флаги в структуру, одно поле — один флаг#
Основа паттерна — обычная структура с именованными полями. Никаких map[string]any: я с картами настроек наигрался в старых проектах и больше не борюсь с искушением — опечатка в ключе карты живёт до рантайма, опечатка в поле структуры ловится компилятором на месте. Каждому флагу соответствует своё поле с честным типом: Port int, Host string, Watch bool. Рядом живёт конструктор defaultServeConfig(), который возвращает структуру с дефолтами — это единственное место, где значения по умолчанию задаются в коде. Третьий элемент — метод bindFlags(cmd), который навешивает Var-методы на поля структуры. Связка получается замкнутая: поле — строка биндинга — флаг в справке. Компилятор следит за типами, справка печатается сама, а тест может собрать конфиг руками, без os.Args.
serveConfig: структура, конструктор, биндинг
GOpackage cmd
import (
"fmt"
"github.com/spf13/cobra"
)
// serveConfig — все настройки команды serve в одном типе.
type serveConfig struct {
Port int
Host string
Watch bool
Timeout int // секунды
}
// defaultServeConfig — единственное место с дефолтами.
func defaultServeConfig() serveConfig {
return serveConfig{Port: 8080, Host: "127.0.0.1", Watch: false, Timeout: 30}
}
// cfg — единственный экземпляр, который читает RunE.
var cfg = defaultServeConfig()
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Запустить сервер предпросмотра",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Printf("%s:%d (watch=%t)\n", cfg.Host, cfg.Port, cfg.Watch)
return nil
},
}
func init() {
// Биндинг полей структуры в флаги. Дефолт в Var-методе
// берём из самой структуры — значение задаётся один раз.
serveCmd.Flags().IntVarP(&cfg.Port, "port", "p", cfg.Port, "порт HTTP-сервера")
serveCmd.Flags().StringVar(&cfg.Host, "host", cfg.Host, "адрес привязки")
serveCmd.Flags().BoolVarP(&cfg.Watch, "watch", "w", cfg.Watch, "следить за файлами")
serveCmd.Flags().IntVar(&cfg.Timeout, "timeout", cfg.Timeout, "таймаут, сек")
rootCmd.AddCommand(serveCmd)
}Структура создаётся один раз при инициализации пакета; Var-методы биндят адреса её полей в флаги. Ключевой трюк — дефолт в Var-методе берётся из самой структуры: значение по умолчанию задаётся единожды в defaultServeConfig, и расхождения между структурой и справкой не бывает. RunE читает только cfg — глобальные флаговые переменные исчезли.
Почему биндинг — в init(), а не в RunE#
Самый частый вопрос по паттерну: почему бы не собрать структуру прямо внутри RunE? Потому что к моменту вызова RunE разбор строки запуска уже закончен. Cobra парсит флаги в Execute — до Run, — и каждый флаг, который она встретила, должен быть зарегистрирован к этому моменту. Флаг, объявленный внутри RunE, объявлен слишком поздно: на нём разбор падает с unknown flag ещё до запуска команды. Поэтому биндинг живёт в init() файла команды — именно так раскладывает код генератор cobra-cli, — либо в конструкторе команды, который вызывается до AddCommand. Проверить это легко только локально: соберите утилиту и попробуйте оба варианта, песочница сайта строку запуска не исполняет. Ручной вызов cmd.ParseFlags внутри RunE тоже возможен, но нужен редко — это костыль для динамически объявляемых флагов, и в обычном CLI ему делать нечего.
Дефолты и план приоритетов: флаг, конфиг, дефолт#
Пока у нас два источника настроек, и иерархия простая: дефолт из конструктора перекрывается флагом, если пользователь его передал. pflag делает это бесплатно — пользовательское значение пишется в то же поле, куда смотрит Var-метод. Но CLI зреют, и между дефолтом и флагом встаёт конфиг-файл: пользователь один раз прописал порт в notes.yaml и не хочет набирать --port 8080 каждый запуск. План приоритетов, к которому мы придём к десятому уроку: дефолт — самый слабый слой, над ним конфиг-файл, над ним переменные окружения, и сильнее всех флаг, набранный в терминале. Чтобы флаг честно побеждал конфиг, связку берут на себя Viper и BindPFlag — о них пойдут седьмой и десятый уроки. Пока запомните принцип: чем ближе источник к пользователю, тем он сильнее, и структура с дефолтами — фундамент, на котором слои раскладываются без переписывания RunE.
Если конструктор не написан, serveConfig{} даст порту 0, а строкам пустоту — и Var-методы объявят дефолтами именно эти нули: справка будет врать, а валидация выловит не сразу. Конструктор с дефолтами — не церемония, а единственное место, где утилита договаривается с пользователем о поведении без настроек. Пустой структурный литерал в биндинге — запах.
Слои настроек: пока два из четырёх#
| Слой | Кто задаёт | Сила | Когда появится в курсе |
|---|---|---|---|
| Дефолт | defaultServeConfig() | слабее всех | этот урок |
| Конфиг-файл | YAML или TOML на диске | выше дефолта, ниже флага | уроки 08 и 10 |
| Переменные окружения | shell или docker-compose | выше конфига | урок 09 |
| Флаг | пользователь в терминале | сильнее всех | этот урок |
Validate: валидация связности настроек
GO// Validate проверяет связность настроек до запуска сервера.
func (c serveConfig) Validate() error {
if c.Port <= 0 || c.Port > 65535 {
return fmt.Errorf("порт %d вне диапазона 1-65535", c.Port)
}
if c.Timeout < 1 {
return fmt.Errorf("таймаут %d сек слишком мал", c.Timeout)
}
if c.Watch && c.Host == "0.0.0.0" {
return fmt.Errorf("watch на 0.0.0.0 опасен: вас увидит вся сеть")
}
return nil
}
// В RunE проверка занимает одну строку:
// if err := cfg.Validate(); err != nil { return err }Validate ловит то, чего не умеет MarkFlagRequired: не наличие значения, а его связность. Порт в диапазоне, таймаут осмысленный, опасные комбинации выключены. RunE возвращает error — Cobra напечатает сообщение и уйдёт с кодом 1. Метод вешается на значение, а не на указатель: конфиг в валидации не меняется.
Валидация настроек до старта#
Валидация — недооценённая часть паттерна. MarkFlagRequired отвечает только на вопрос «флаг передан вообще», а большинство ошибок настройки — про значения: порт занят или вне диапазона, таймаут в наносекундах из-за опечатки в единицах, watch на адресе 0.0.0.0, который выставляет сервер наружу. Собирать эти проверки в RunE — значит утопить бизнес-логику под парсингом; с Config-структурой им естественно живётся в методе Validate, который вызывается первой строкой RunE. Дополнительный бонус: валидатор можно гонять в тестах на готовых структурах — без Execute, без os.Args, без сборки бинарника. Отсюда и выбор RunE вместо Run: команда, которая может отклонить настройку, обязана уметь возвращать ошибку, а Run ошибки не пробрасывает — об этом отдельный разговор в уроке про коды выхода.
Классическая ошибка паттерна: поле в структуре есть, а Var-метода для него нет. Компилятор молчит — поле просто всегда дефолтное, и баг живёт неделями, пока кто-то не наберёт несуществующий флаг. Я держу bindFlags рядом с определением структуры и читаю их сверху вниз: поле — строка биндинга. Дифф из ревью, где новая строка в структуре осталась без пары в биндинге, — стоп-комментарий.
Подкоманды со своими конфигами#
С ростом утилиты возникает соблазн сделать один гигантский Config на всё приложение — не поддавайтесь. У каждой подкоманды своя структура: serveConfig для serve, listConfig для list, и пересечение между ними — только персистентные флаги корня вроде --verbose, у которых своя маленькая структура или одиночные переменные. Общий конфиг оправдан в одном случае: несколько подкоманд одного семейства шарят один и тот же набор настроек — тогда общий тип в отдельном файле пакета cmd уместен. Связку между конфигом подкоманды и корневым состоянием делают параметры конструктора: подкоманда получает нужный кусок при создании, а не лезет в чужие пакеты за глобальной переменной. Такая изоляция бесплатна, пока дерево маленькое, и спасает, когда оно вырастает: правка настроек serve перестаёт задевать list чисто механически — файлы разные.
Признаки, что пора на паттерн binding#
- У команды больше пяти флагов, и Var-методы перестали помещаться на экран
- Два файла объявляют и читают одни и те же глобальные переменные
- Тест Run требует ручной сборки os.Args
- В справке появились настройки, которых в коде уже нет
- Новая подкоманда копирует биндинг старой почти целиком
Биндинг прямо в доменную структуру#
Когда доменный тип совпадает с набором настроек, промежуточный Config-тип не нужен: биндите флаги прямо в его поля. У репортера заметок есть тип Report с полями Limit, Author, Since — флаг --limit биндится в report.Limit, и слой передачи значений исчезает как класс. Нюанс один: доменный тип обязан жить в том же пакете, что и команда, либо в пакете, который команда может импортировать; циклические импорты в Go запрещены, и это само решает, чья ответственность где. Паттерн ломается, когда у команды настроек больше, чем полей у доменного типа: --verbose не про отчёт, а про вывод. В таких случаях комбинируйте: часть флагов в доменную структуру, часть — в маленький cmdConfig рядом. Я так делаю всегда: перебор абстракций ради одного булева флага дороже, чем два биндинга.
Тесты: конфиг без os.Args#
Скрытая выгода паттерна проявляется в тестах. До биндинга протестировать Run означало собрать os.Args руками и вызвать Execute — хрупко и многословно. С Config-структурой тест собирает структуру напрямую: cfg := serveConfig{Port: 8080, Watch: true}, прогоняет Validate и вызывает функцию serve с конфигом. Команда — тонкая оболочка над логикой, логика тестируется без CLI. Если вы уже проходили тестирование в продвинутом курсе, сравните оба подхода на своём проекте: разница в количестве кода на один тест — примерно в три раза. Мы к тестам Cobra-команд вернёмся ближе к финалу курса, когда дерево утилиты соберётся целиком; пока запомните приём — команда читает структуру, структура собирается где угодно.
Проверка: место биндинга
Где биндить флаги в поля Config-структуры, чтобы пользовательские значения дошли до RunE?
Проверка: пропущенный биндинг
В serveConfig добавили поле Debug bool, но в bindFlags строку для него забыли. Что произойдёт?
Что напечатает notes serve -p 9000
Прочитайте код и выберите, что он выведет:
package cmd
import "fmt"
type serveConfig struct {
Port int
Host string
}
func defaultServeConfig() serveConfig {
return serveConfig{Port: 8080, Host: "127.0.0.1"}
}
var cfg = defaultServeConfig()
var serveCmd = &cobra.Command{
Use: "serve",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Printf("%s:%d\n", cfg.Host, cfg.Port)
return nil
},
}
func init() {
serveCmd.Flags().IntVarP(&cfg.Port, "port", "p", cfg.Port, "порт")
serveCmd.Flags().StringVar(&cfg.Host, "host", cfg.Host, "адрес")
rootCmd.AddCommand(serveCmd)
}Восстановите паттерн binding
Впишите имя поля, метод биндинга с шорткатом и переменную для ошибки.
Соберите структуру конфига из шести строк
Расставьте строки: тип, два поля, закрытие типа, инициализация с дефолтом и однострочный init с биндингом.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: паттерн binding
Config и Viper: стыковка двух миров#
Со следующего урока в проекте появится Viper, и вопрос «а куда теперь девается cfg?» закономерен. Ответ: структура остаётся, а наполняется иначе. Пока источника два — дефолт и флаг, — биндинг из этого урока работает как есть. Когда добавятся файл и окружение, порядок наполнения станет таким: defaultServeConfig задаёт базу, Viper читает конфиг и окружение, и в конце срабатывает BindPFlag или ручной перенос флагов — самый сильный слой. В десятой функции Unmarshal соберёт реестр Viper в структуру одним вызовом, и тогда bindFlags либо перепишется на BindPFlag, либо останется командам без конфига. Важно, что интерфейс RunE не меняется ни на каком шаге: команда по-прежнему читает структуру. Именно поэтому паттерн binding стоит ставить сразу, а не «когда понадобится конфиг»: миграция слоёв не трогает ни одну команду.
Что дальше#
Структура с дефолтами и флагами закрывает два слоя из плана приоритетов; третий — конфиг-файл — потребует библиотеки, и следующий урок, знакомство с Viper, покажет, что она решает и когда её брать не нужно. Полная связка флагов с Viper и расстановка слоёв — в уроке про приоритеты конфигурации, а пример того же паттерна в другом фреймворке — в уроке про zap.Config, где YAML-конфиг собирается в структуру логгера. Обычное напоминание: паттерн проверяется только в терминале — соберите serve, прогоните его с -p 9000 и без флагов, сравните вывод; песочница сайта Cobra не исполняет.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Паттерн binding: настройки команды — один тип Config, поля биндятся в флаги Var-методами.
- Дефолты задаются один раз в конструкторе defaultServeConfig и переиспользуются в Var-методах.
- Биндинг живёт в init() или конструкторе команды: внутри RunE разбор уже закончен.
- RunE читает только cfg — глобальные флаговые переменные исчезают.
- Validate() ловит связность значений, которую не умеет MarkFlagRequired.
- Поле без строки биндинга — тихая ошибка: компилятор молчит, поле всегда дефолтное.
- План приоритетов: дефолт < конфиг-файл < env < флаг; полную связку даст Viper.
Домашнее задание
Перепишите serve из урока на паттерн binding: структура с полями Port, Host, Timeout, конструктор с дефолтами и Validate, которая отбрасывает порт вне 1-65535. Проверьте: notes serve без флагов печатает дефолты, -p 9000 перезаписывает порт, а некорректный таймаут возвращается ошибкой с кодом выхода 1.
Личные заметкиПусто
Вопросы и ответы по уроку
Как биндить флаги в структуру в Cobra?
Завести тип с полями под настройки и в init() команды вызвать Var-методы на адреса полей: cmd.Flags().IntVarP(&cfg.Port, "port", "p", cfg.Port, "порт"). В RunE читать структуру — pflag уже записал туда пользовательские значения или дефолты.
Можно ли биндить флаги внутри RunE?
Нет: разбор строки запуска происходит в Execute до вызова RunE, и незарегистрированный флаг роняет разбор с unknown flag. Биндинг выполняют в init() файла команды или в конструкторе, вызываемом до AddCommand.
Чем RunE лучше Run для команд с конфигом?
RunE возвращает error: валидатор конфига может отклонить запуск сообщением и кодом выхода 1. Run ошибок не пробрасывает, поэтому отказ по настройке пришлось бы обрабатывать через fmt.Println и os.Exit вручную.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
- Знакомство с Viper: конфигурация без боли
- Конфиг-файлы: YAML, TOML и JSON
- Переменные окружения: AutomaticEnv
- Приоритеты конфигурации: флаг, env, файл
- Флаги: локальные и персистентные
- Аргументы командной строки и валидация Args
- Команды и подкоманды: дерево AddCommand
- Установка Cobra и первый CLI-проект
- Все курсы по 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