Содержание урока · 11 разделов
Утилита tasks выросла незаметно: cmd/root.go перевалил за восемьсот строк, RunE команды add читает файл, парсит аргументы, печатает таблицу и пишет хранилище телом на девяносто строк, а viper дергается из шести разных файлов. Каждая правка списка задач трогает три пакета, а тесты из урока про тестирование команд отказываются идти параллельно — все делят одни глобальные переменные. Это не проблема дисциплины, это проблема раскладки: у кода нет мест, где он обязан жить. Структура cli проекта go — это несколько твёрдых правил: одна команда — один файл в cmd, домен в internal, зависимости текут вниз через приёмник, и ни одной глобальной команды. Разложим tasks так, как это делают kubectl и hugo, и понесём получившийся каркас в итоговый проект курса.
Организация кода Cobra: одна команда — один файл#
Пакет cmd — витрина утилиты: здесь живут фабрики команд и больше ничего. Конвенция, которую генератор показал ещё на этапе установки, продолжается на любое число подкоманд: cmd/list.go, cmd/add.go, cmd/done.go — по файлу на команду, и в каждом одна фабрика newListCmd() *cobra.Command. root.go остаётся диспетчером: собирает дерево через AddCommand, объявляет персистентные флаги вроде --config и не знает деталей ни одной команды. При открытии чужого репозитория это экономит минуты: нужен list — открываете cmd/list.go, и весь его код перед глазами, от флагов до RunE. Генератор cobra-cli add умеет создавать такие заготовки сам, но после урока про фабрики вы напишете их быстрее руками — сразу с нужным приёмником зависимостей, а не с пакетными переменными, которые генератор подсовывает по умолчанию.
cmd/list.go: фабрика команды
GO// cmd/list.go, пакет cmd
func newListCmd(app *App) *cobra.Command {
var limit int
cmd := &cobra.Command{
Use: "list",
Short: "Показать задачи",
RunE: func(cmd *cobra.Command, args []string) error {
items, err := app.store.All()
if err != nil {
return fmt.Errorf("хранилище: %w", err)
}
if limit > 0 && limit < len(items) {
items = items[:limit]
}
return printList(cmd.OutOrStdout(), items)
},
}
cmd.Flags().IntVarP(&limit, "limit", "n", 10, "сколько задач показать")
return cmd
}RunE — только склейка: достали задачи, отрезали лимит, отдали выводчику. Ни чтения файлов, ни правил домена здесь нет — это забота internal/tasks. Флаг объявлен на экземпляре команды, а не пакетной переменной: значения pflag персистентны, и общий var limit делил бы состояние между тестами. Ошибка хранилища оборачивается с %w — main отличит её по сентинелу и выберет код выхода.
Второй слой — internal: каталог, который компилятор прячет от соседних модулей. Импортировать github.com/you/tasks/internal/tasks может только код вашего же модуля — пользователи библиотеки до него не достанутся, а значит, вы вправе менять домен, не спрашивая никого. Направление импортов одно: cmd импортирует internal, internal не знает о cmd. Внутри internal/tasks лежит модель Task, хранилище Store и правила — например, что задача с пустым текстом не сохраняется даже при явной просьбе. Терминальные заботы — цвета, tabwriter, шаблоны справки — остаются в cmd: печать на экран не обязанность домена. Эта граница проверяется компилятором только при живой сборке модуля — песочница сайта многопакетные проекты не исполняет, здесь нужен локальный Go.
internal/tasks: домен без терминала
GO// internal/tasks/store.go, пакет tasks
type Task struct {
ID int
Text string
Done bool
Created time.Time
}
type Store struct {
path string
}
func NewStore(path string) *Store { return &Store{path: path} }
func (s *Store) All() ([]Task, error) {
raw, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) {
return nil, nil // первый запуск: файла нет — это не ошибка
}
if err != nil {
return nil, err
}
var tasks []Task
if err := json.Unmarshal(raw, &tasks); err != nil {
return nil, fmt.Errorf("хранилище %s: %w", s.path, err)
}
return tasks, nil
}Домен не знает про флаги и терминал: Task, Store и правила — чистая логика, которую можно гонять без cobra. ErrNotExist превращается в пустой список: первый запуск — легальный сценарий, а не сбой, тот же вывод, что в уроке про конфиг-файлы. Ошибка парсинга обёрнута с %w и путём файла — пользователь увидит, какой именно JSON битый.
Приёмник зависимостей: структура App#
RunE фиксирована сигнатурой — внутрь не протолкнёшь ни хранилище, ни конфиг, и долгое время команды тянули данные из глобальных переменных. Решение — приёмник зависимостей: структура App, в полях которой живёт всё, что нужно командам, и конструктор NewApp, который её собирает. Команды получают App параметром фабрики — newListCmd(app App) — и в RunE работают только с app.store и app.cfg. Никакого фреймворка DI тут нет и не нужно: Go-код передаёт зависимости руками, от main вниз. Выигрыш виден в тестах: newRootCmd(app) с временным каталогом вместо реального хранилища — и тест гоняет команду по-настоящему, не задевая ваши данные. Подмена одной строки в тесте — вся цена связности проекта.
cmd/app.go: связка App и дерева команд
GO// cmd/app.go, пакет cmd
type App struct {
store *tasks.Store
cfg *config.Config
}
func NewApp(store *tasks.Store, cfg *config.Config) *App {
return &App{store: store, cfg: cfg}
}
func newRootCmd(app *App) *cobra.Command {
root := &cobra.Command{Use: "tasks", SilenceUsage: true}
root.AddCommand(newListCmd(app), newAddCmd(app), newDoneCmd(app))
return root
}App — обычная структура, конструктор — обычная функция: DI здесь руками, без фреймворков и рефлексии. newRootCmd строит свежее дерево на каждый вызов: main зовёт один раз, каждый тест — заново, и фабрика из урока про тестирование собирается поверх того же кода. Добавить команду — значит дописать одну строку AddCommand и создать один файл в cmd.
Где жить Viper-инициализации#
Всё, что Viper делает с источниками — чтение YAML, AutomaticEnv, BindPFlag, Unmarshal — обязано случиться ровно один раз, при старте, в одном месте. Я держу эту работу в отдельном внутреннем пакете internal/config: функция Load() читает файл, накладывает переменные окружения и возвращает готовую структуру Config, которую съедает NewApp. Анти-паттерн — команды, дёргающие viper.GetString в каждом RunE: приоритеты из урока про приоритеты конфигурации собираются в куче разных мест, а тест уже не может подменить источник. После Unmarshal конфиг иммутабелен: команды читают поля структуры, а не глобальный синглтон. Живому перезагрузу конфига посвящён отдельный урок — там мы отпустим это ограничение и посмотрим, чего оно потребует взамен.
Проверка: место для загрузки конфига
Куда положить код, который читает YAML-конфиг и собирает структуру Config?
Генератор старых версий рисовал соблазнительную схему: var rootCmd = &cobra.Command{...} на уровне пакета и init(), который навешивает подкоманды. Проект собирается, работает — и становится минным полем: порядок init между файлами неочевиден, две Execute в тестах делят персистентные флаги, SilenceUsage правится в двух местах, а фабрика из урока про тестирование непостроима в принципе. Я видел репозиторий, где добавление одной команды требовало правки четырёх файлов с init. Лечение одно: никаких пакетных rootCmd — только newRootCmd(app), возвращающая свежее дерево; main вызывает её для продакшена, тесты — для себя.
Раскладка репозитория tasks целиком#
- main.go — точка входа на двадцать строк: конфиг, App, Execute, код выхода
- cmd/ — по файлу на команду плюс app.go с приёмником зависимостей
- internal/tasks — домен: модель Task, хранилище Store, правила статусов
- internal/config — единственное место, где живёт Viper и собирается Config
- cmd/testdata — эталоны вывода для golden-тестов
- docs/ — сгенерированные man и markdown
- .goreleaser.yaml — релизная раскладка, достроится в уроке про сборку
Что где живёт и почему#
| Артефакт | Пакет | Почему именно здесь |
|---|---|---|
| фабрика newListCmd и флаги | cmd | интерфейс пользователя: справка, парсинг, вывод |
| модель Task и Store | internal/tasks | домен без терминальных зависимостей, тестируется без cobra |
| чтение YAML и env | internal/config | один владелец источников конфигурации |
| os.Exit и коды выхода | main.go | процесс умирает в одном месте, RunE возвращает ошибки |
| шаблоны справки | cmd | функции --help меняются вместе с командами |
main.go: тонкая точка входа#
main в правильно разложенном проекте — на двадцать строк: собрать конфиг, собрать App, запустить Execute, перевести ошибку в код выхода. Логика в main — запах: если там появился if по типам ошибок длиннее трёх строк, место этой ветке в cmd или internal. Рядом с командами живут и тестовые конвенции: файлы cmd/*_test.go компилируются только в тестовом бинарнике и не попадают в релиз, а эталоны вывода лежат в cmd/testdata — go test найдёт их по относительному пути пакета. Каталог docs для man-страниц, README и .goreleaser.yaml достроятся в следующих уроках — раскладка нарочно оставляет под них место, чтобы позже не переезжать с места на место и не переписывать пути импортов.
Циклы импортов и граница слоёв#
Цикл импортов — самая частая авария при раскладке: пакет A импортирует B, B импортирует A, и сборка падает со словами import cycle not allowed. В CLI-проекте он обычно появляется, когда internal/tasks зачем-то понадобилась справка или цвета из cmd — сигнал, что уровень перепутан и домен полез в интерфейс. Лечится переносом общего кода вниз: константы статусов, сентинел-ошибки вроде ErrNotFound живут в internal, а cmd их только читает. Помощники вывода — наоборот, вверх, в cmd: домен не должен знать про ANSI-коды и tabwriter. Быстрая проверка архитектуры: поиск импортов по пакету internal не должен находить путей на cmd. За этим следит компилятор, но дольше всего границу держат руки и ревью — глаз цепляется на цикл быстрее, чем скрипт на запрет.
Что выдаст сборка
Прочитайте код и выберите, что он выведет:
// internal/tasks/report.go
package tasks
import "github.com/you/tasks/cmd"
// при этом cmd импортирует пакет tasksПроверка: зачем фабрика дерева
Зачем newRootCmd(app) строить новое дерево на каждый вызов, вместо пакетной rootCmd?
Соберите main по шагам
Впишите конструктор хранилища, фабрику дерева и приём ошибки.
Соберите цепочку запуска main
Расставьте: конфиг, хранилище, приёмник, дерево команд, запуск и выход.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: раскладка CLI-проекта
Что дальше#
Каркас разложен, дальше — доводка до production: в уроке про сигналы и контексты утилита научится умирать красиво по Ctrl+C, а урок про сборку и версии оденет её в ldflags и goreleaser. Всё это сложится в итоговый проект — менеджер задач с хранилищем, цветами и тестами. Обзор курса со списком тем — на странице Cobra. Если переносите существующую утилиту — переезжайте по одной команде за раз, компилятор укажет на каждое нарушение границы. И стандартная оговорка: раскладку из нескольких пакетов проверяет только живая сборка — соберите tasks командой go build ./... локально и убедитесь, что internal не тянет cmd.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- cmd/ — витрина: по файлу на команду, фабрика newListCmd() на каждую; root.go только собирает дерево.
- internal/ скрыт от соседних модулей: домен, хранилище и правила живут там и не знают о cmd.
- Приёмник зависимостей — struct App{store, cfg}: зависимости передаются руками от main вниз.
- Viper-инициализация — одно место при старте (internal/config.Load()); команды читают поля структуры, а не viper.Get.
- Глобальный rootCmd из main — анти-паттерн: ломает тесты, init-порядок и фабрику newRootCmd.
- Граница слоёв: cmd импортирует internal и никогда наоборот; общий код — вниз, цвета и справка — вверх.
- Раскладку проверяет только живая сборка go build ./... — песочница многопакетные проекты не исполняет.
Домашнее задание
Разложите свою утилиту по схеме урока: выделите internal с моделью и хранилищем, соберите команды в cmd по файлу на команду, заведите App и newRootCmd(app). Соберите go build ./..., убедитесь, что internal не импортирует cmd, и переведите один тест с урока про тестирование на подмену хранилища через App.
Личные заметкиПусто
Вопросы и ответы по уроку
Как разложить большой CLI-проект на Go?
Пакет cmd — по файлу на команду с фабрикой, домен — в internal (модель, хранилище, правила), загрузка конфига — в internal/config, main.go — тонкая точка входа. Зависимости передаются приёмником: структура App собирается конструктором NewApp и уходит в фабрики команд. Направление импортов одно: cmd импортирует internal.
Почему глобальный rootCmd в пакетной переменной — плохо?
Значения флагов pflag переживают Execute, поэтому два запуска — реальный и тестовый — делят состояние; init-порядок файлов навешивает подкоманды неявно; фабрика newRootCmd(app) из урока про тестирование непостроима. Фабрика, возвращающая свежее дерево на каждый вызов, снимает все три проблемы сразу.
Где инициализировать Viper в проекте Cobra?
В одном месте при старте — обычно internal/config.Load(): SetConfigFile, ReadInConfig, AutomaticEnv, Unmarshal в структуру. Команды работают с полями конфига из App и не вызывают viper.Get в RunE — иначе приоритеты источников размазываются по репозиторию и перестают тестироваться.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
- Сигналы и контексты: корректное завершение
- Сборка и версии: ldflags и goreleaser
- Viper: слежение за конфигом и секреты
- Итоговый проект: CLI-менеджер задач
- Тестирование команд CLI
- Генерация документации: man и markdown
- Кастомные подсказки: ValidArgsFunction
- Автодополнение: bash, zsh, fish, powershell
- Все курсы по 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