JavaRush /Курсы /Go SELF /Паттерн диспетчера: app <cmd> [flags]

Паттерн диспетчера: app <cmd> [flags]

Go SELF
49 уровень , 1 лекция
Открыта

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) Какой обработчик вызовется
todo add ...
add
runAdd(argv)
todo list ...
list
runList(argv)
todo foo ...
foo
ошибка “unknown command”

Да, это выглядит почти слишком просто. Но как раз такие места в коде и должны быть скучными.

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 — это диалог с пользователем, просто очень лаконичный.

1
Задача
Go SELF, 49 уровень, 1 лекция
Недоступна
Подсмотр команды
Подсмотр команды
1
Задача
Go SELF, 49 уровень, 1 лекция
Недоступна
Роутер на switch
Роутер на switch
1
Задача
Go SELF, 49 уровень, 1 лекция
Недоступна
Таблица маршрутов
Таблица маршрутов
1
Задача
Go SELF, 49 уровень, 1 лекция
Недоступна
Команды с флагами
Команды с флагами
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ