Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 21 из 25 ~18 мин +50 XP

Структура большого CLI-проекта

Структура cli проекта go перестаёт быть теорией в момент, когда cmd/root.go переваливает за восемьсот строк, а RunE команды add читает файлы, печатает таблицу и пишет хранилище одним телом. Разберём раскладку, по которой живут реальные CLI на Cobra: по файлу на команду, домен в internal и приёмник зависимостей вместо глобальных переменных.

Содержание урока · 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?

Раскладка репозитория 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 и Storeinternal/tasksдомен без терминальных зависимостей, тестируется без cobra
чтение YAML и envinternal/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 по шагам

Впишите конструктор хранилища, фабрику дерева и приём ошибки.

store := tasks.New(path) app := NewApp(store, cfg) root := new(app) := root.Execute()

Соберите цепочку запуска main

Расставьте: конфиг, хранилище, приёмник, дерево команд, запуск и выход.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: раскладка CLI-проекта

Термин

cmd/

нажмите, чтобы перевернуть
Определение

Пакет-витрина CLI: по файлу на команду, в каждом фабрика newListCmd() *cobra.Command. root.go только собирает дерево AddCommand и держит персистентные флаги.

1 / 6

Что дальше#

Каркас разложен, дальше — доводка до 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.

Личные заметкиПусто
Хранятся только в вашем браузере (Cobra · Структура большого CLI-проекта)

Вопросы и ответы по уроку

Как разложить большой 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 — иначе приоритеты источников размазываются по репозиторию и перестают тестироваться.

Термины урока

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

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Bubble Tea25 уроков~10 чпрактика25 уроков уже доступны

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