1. Зачем нужны подкоманды
Когда программа делает больше одного действия, у новичка есть естественное желание: «давайте добавим флаг -mode=... и будем переключаться». Это работает ровно до того момента, когда режимов становится 5, флагов 12, а комбинаций — бесконечность (и вы внезапно пишете собственный мини‑язык командной строки, сами того не планируя).
Подкоманды решают эту боль человеческим способом: первая «позиция» после имени программы — это имя режима работы. То есть команда — это не флаг, а первый аргумент. Получается понятный контракт запуска:
app <cmd> [flags] [args]
Где <cmd> — это подкоманда (например, add, list, done), а дальше уже идут флаги и позиционные аргументы конкретно этой подкоманды.
Для нашего учебного приложения будем представлять, что мы пишем маленький менеджер задач todo. (Супергеройская часть тут в том, что он пока ничего не хранит на диске и не синхронизируется с облаком. Да, я тоже расстроен. Но зато вы не утонете в деталях.)
2. Роль диспетчера подкоманд
Когда вы делаете подкоманды, появляется важная архитектурная роль: диспетчер. Он похож на охранника на входе в клуб (не самый умный, но очень полезный): проверяет, что вы вообще пришли (команда указана), что вы не пытаетесь зайти «по левому имени» (команда существует), и потом пропускает вас внутрь — в обработчик конкретной команды.
Важно, что диспетчер не должен делать «всю работу программы». Его задача — маршрутизация: «кому передать управление».
Полезно держать в голове простую схему:
flowchart TD
A[main: читаем os.Args] --> B{Есть команда?}
B -- нет --> U[Ошибка использования: нет команды]
B -- да --> C{Команда известна?}
C -- нет --> X[Ошибка использования: неизвестная команда]
C -- да --> D["run<Cmd>(argv команды)"]
D --> E[Результат или ошибка]
Если диспетчер начнёт разбирать флаги всех команд, валидировать все аргументы всех режимов и печатать всё подряд, он быстро превратится в «бога-функцию», которой боятся даже собственные тесты.
3. Откуда берётся команда: os.Args
Перед тем как писать диспетчер, нужно чётко понять: os.Args включает имя программы.
Например, пользователь запускает:
todo add -title "Купить молоко"
- os.Args[0] — это "todo" (или путь к бинарнику)
- os.Args[1] — это "add" (наша команда)
- дальше уже идут аргументы команды
Мини‑пример, который печатает команду:
package main
import (
"fmt"
"os"
)
func main() {
args := os.Args[1:]
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "missing command") // missing command
return
}
fmt.Fprintln(os.Stdout, "cmd:", args[0]) // cmd: add
}
Здесь есть маленькая, но критичная деталь: сначала проверяем len(args), и только потом трогаем args[0]. Иначе вы познакомитесь с паникой (а это, конечно, интересный опыт, но мы всё-таки делаем CLI).
4. Минимальный диспетчер на switch
Самый простой диспетчер — это switch по строке. Это нормально, читаемо и отлично подходит, пока команд мало.
Мы договоримся о простом контракте обработчика команды: обработчик получает argv этой команды (то есть всё после имени команды) и возвращает error, если что-то пошло не так.
package main
import "fmt"
func dispatch(args []string) error {
if len(args) == 0 {
return fmt.Errorf("missing command")
}
cmd := args[0]
argv := args[1:]
switch cmd {
case "add":
return runAdd(argv)
case "list":
return runList(argv)
default:
return fmt.Errorf("unknown command: %s", cmd)
}
}
func runAdd(argv []string) error { _ = argv; return nil }
func runList(argv []string) error { _ = argv; return nil }
Обратите внимание: диспетчер не читает os.Args сам. Он получает args параметром. Это маленькое решение внезапно делает код намного приятнее: функцию dispatch можно тестировать и вызывать с любыми аргументами, не подменяя окружение процесса.
5. Таблица маршрутов вместо большого switch
Пока команд две — switch идеален. Но если команд станет 10–15, switch разрастается, а вы начинаете пролистывать код как ленту новостей: «где там мой done…».
Тут помогает таблица маршрутов: map[string]handler, где ключ — имя команды, а значение — функция-обработчик.
Сначала объявим тип обработчика:
package main
type handler func([]string) error
Теперь сам диспетчер:
package main
import "fmt"
func dispatchByTable(args []string, routes map[string]handler) error {
if len(args) == 0 {
return fmt.Errorf("missing command")
}
cmd := args[0]
h, ok := routes[cmd]
if !ok {
return fmt.Errorf("unknown command: %s", cmd)
}
return h(args[1:])
}
Обратите внимание, что routes мы тоже передаём параметром. Это опять же помогает тестируемости и делает зависимости явными.
В main это будет выглядеть так (пока без деталей вывода и кодов завершения процесса — их мы будем унифицировать в следующих шагах):
package main
import (
"fmt"
"os"
)
func main() {
routes := map[string]handler{
"add": runAdd,
"list": runList,
}
if err := dispatchByTable(os.Args[1:], routes); err != nil {
fmt.Fprintln(os.Stderr, "error:", err) // error: missing command
}
}
Мини-таблица маршрутов
| Ввод пользователя | Команда (cmd) | Какой обработчик вызовется |
|---|---|---|
|
|
|
|
|
|
|
|
|
Да, это выглядит почти слишком просто. Но как раз такие места в коде и должны быть скучными.
6. Граница ответственности: флаги парсит команда
Сейчас может появиться соблазн: «а давайте в диспетчере сразу сделаем flag.Parse() и потом передадим дальше». И это тот самый момент, когда хочется вежливо, но настойчиво сказать: не надо.
Почему?
Потому что у каждой команды свои флаги. У add может быть -title, у list может быть -done, у done может быть -id. Если вы начнёте парсить всё глобально, флаги будут конфликтовать, справка начнёт путаться, а пользователю придётся угадывать, в каком порядке писать аргументы, чтобы парсер не обиделся.
Правильная мысль такая: диспетчер выбирает команду, а команда сама разбирает свои флаги и аргументы.
В этой лекции мы только фиксируем границу. Технические детали «как именно команда разбирает свои флаги через отдельный парсер» мы уже начали разбирать в соседней теме дня, а дальше будем доводить до устойчивого каркаса.
7. UX: usage и понятные ошибки
Давайте аккуратно добавим две вещи, которые делают CLI дружелюбнее, но не уходят в будущие темы слишком далеко:
- сообщение «нет команды» должно подсказать формат запуска;
- сообщение «неизвестная команда» тоже должно подсказать, что можно сделать.
Сделаем маленькую функцию usage (пока просто печать):
package main
import (
"fmt"
"io"
)
func printUsage(w io.Writer) {
fmt.Fprintln(w, "Usage: todo <cmd> [args]")
fmt.Fprintln(w, "Commands: add, list")
}
И применим её при ошибках диспетчера:
package main
import (
"fmt"
"os"
)
func main() {
routes := map[string]handler{
"add": runAdd,
"list": runList,
}
args := os.Args[1:]
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "error: missing command") // error: missing command
printUsage(os.Stderr)
return
}
if err := dispatchByTable(args, routes); err != nil {
fmt.Fprintln(os.Stderr, "error:", err) // error: unknown command: foo
printUsage(os.Stderr)
}
}
Это ещё не «идеальная» схема, потому что печать ошибок и выбор «что именно печатать» мы позже приведём к единому контракту. Но как учебный промежуточный шаг — отлично: диспетчерские ошибки становятся исправимыми («ага, надо указать команду»), а не загадочными.
Как выглядит обработчик команды на этом этапе
Пока мы не углубляемся в парсинг FlagSet в этой лекции, полезно всё равно увидеть «форму» обработчика: он получает argv, проверяет, чего там пришло, и делает действие.
Например, «заглушка» команды list:
package main
import (
"fmt"
"os"
)
func runList(argv []string) error {
_ = argv // позже разберём аргументы команды
fmt.Fprintln(os.Stdout, "no tasks yet") // no tasks yet
return nil
}
И «заглушка» команды add:
package main
import "fmt"
func runAdd(argv []string) error {
if len(argv) == 0 {
return fmt.Errorf("add: missing title")
}
// пока просто демонстрация, без хранения
return nil
}
Сейчас это выглядит как минимум странно («а где задачи?»), но это нормально: цель лекции — именно диспетчер и маршрутизация. Хранилище, модели данных и богатый вывод — отдельные большие темы.
Почему лучше передавать args []string параметром
На практике одна из самых полезных дисциплин в CLI-коде — избегать «магии глобального состояния». Когда команда сама читает os.Args, вы получаете три проблемы одновременно.
Первая проблема — тесты. Чтобы проверить runAdd, вам придётся подменять os.Args (а это уже пахнет трюками и неожиданными побочными эффектами). Когда runAdd(argv) получает слайс строк параметром, тест становится «обычным»: вызвали функцию, проверили ошибку.
Вторая проблема — переиспользование. Вы можете захотеть вызвать обработчик команды из другого места (например, сделать alias‑команду или сценарий help, который дёргает Usage другой команды). Если обработчик приклеен к os.Args, переиспользование превращается в неудобство.
Третья проблема — ясность. Когда функция зависит от argv параметром, это видно в сигнатуре. Когда функция тянет os.Args «из воздуха» — читателю кода нужно помнить, что она так делает. А память у человека не бесконечная (в отличие от списка «запущенных вкладок браузера», который почему-то бесконечный).
8. Типичные ошибки
Ошибка №1: обращаться к args[0], не проверив len(args).
Это классическая паника «index out of range». В CLI она особенно обидна, потому что пользователь ничего «не сломал» — он просто забыл команду. Правильное поведение — аккуратная ошибка и подсказка по использованию.
Ошибка №2: пытаться парсить флаги всех команд в диспетчере.
Сначала кажется, что так «меньше кода», но очень быстро флаги начнут конфликтовать, а команда todo list -title ... внезапно станет валидной, хотя смысла не имеет. Диспетчер выбирает команду, а флаги разбирает команда.
Ошибка №3: читать os.Args внутри обработчиков команд.
Так вы привязываете команды к глобальному состоянию процесса. Код становится сложнее тестировать и труднее переиспользовать. Гораздо спокойнее передавать argv параметром и считать обработчик чистой функцией «аргументы → действие/ошибка».
Ошибка №4: превращать диспетчер в «комбайн» (маршрутизация + бизнес-логика).
Диспетчер должен быть скучным. Если он вдруг начал «добавлять задачу сам», «считать статистику», «форматировать вывод» — значит, вы смешали уровни ответственности. В итоге любое изменение команды заставит переписывать диспетчер, и наоборот.
Ошибка №5: делать ошибки диспетчера непонятными для пользователя.
Сообщение «unknown command» — уже неплохо, но намного лучше, когда рядом есть подсказка Usage: todo <cmd> ... и список доступных команд. CLI — это диалог с пользователем, просто очень лаконичный.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ