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, "бракує команди") // бракує команди
		return
	}
	fmt.Fprintln(os.Stdout, "команда:", args[0]) // команда: 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("бракує команди")
	}

	cmd := args[0]
	argv := args[1:]

	switch cmd {
	case "add":
		return runAdd(argv)
	case "list":
		return runList(argv)
	default:
		return fmt.Errorf("невідома команда: %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("бракує команди")
	}

	cmd := args[0]
	h, ok := routes[cmd]
	if !ok {
		return fmt.Errorf("невідома команда: %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, "помилка:", err) // помилка: бракує команди
	}
}

Міні-таблиця маршрутів

Ввід користувача Команда (cmd) Який обробник буде викликаний
todo add ...
add
runAdd(argv)
todo list ...
list
runList(argv)
todo foo ...
foo
помилка «невідома команда»

Так, це виглядає майже надто просто. Але саме такі місця в коді й мають бути нудними.

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, "Використання: todo <cmd> [args]")
	fmt.Fprintln(w, "Команди: 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, "помилка: бракує команди") // помилка: бракує команди
		printUsage(os.Stderr)
		return
	}

	if err := dispatchByTable(args, routes); err != nil {
		fmt.Fprintln(os.Stderr, "помилка:", err) // помилка: невідома команда: foo
		printUsage(os.Stderr)
	}
}

Це ще не «ідеальна» схема, бо друк помилок і вибір того, що саме показувати, ми пізніше зведемо до єдиного контракту. Але як навчальний проміжний крок це чудово: диспетчерські помилки стають виправними («Ага, треба вказати команду»), а не загадковими.

Як виглядає обробник команди на цьому етапі

Поки ми не заглиблюємося в парсинг FlagSet у цій лекції, корисно все одно побачити «форму» обробника: він отримує argv, перевіряє, що там прийшло, і виконує дію.

Наприклад, «заглушка» команди list:

package main

import (
	"fmt"
	"os"
)

func runList(argv []string) error {
	_ = argv // пізніше розберемо аргументи команди
	fmt.Fprintln(os.Stdout, "поки що немає задач") // поки що немає задач
	return nil
}

І «заглушка» команди add:

package main

import "fmt"

func runAdd(argv []string) error {
	if len(argv) == 0 {
		return fmt.Errorf("add: бракує назви")
	}
	// поки просто демонстрація, без зберігання
	return nil
}

Зараз це виглядає щонайменше дивно («а де задачі?»), але це нормально: мета лекції — саме диспетчер і маршрутизація. Сховище, моделі даних і багатий вивід — окремі великі теми.

Чому краще передавати args []string параметром

На практиці одна з найкорисніших дисциплін у CLI-коді — уникати магії глобального стану. Коли команда сама читає os.Args, ви отримуєте три проблеми одночасно.

Перша проблема — тести. Щоб перевірити runAdd, вам доведеться підміняти os.Args (а це вже пахне трюками й неочікуваними побічними ефектами). Коли runAdd(argv) отримує слайс рядків параметром, тест стає звичайним: викликали функцію, перевірили помилку.

Друга проблема — повторне використання. Ви можете захотіти викликати обробник команди з іншого місця (наприклад, зробити команду-аліас або сценарій 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: робити помилки диспетчера незрозумілими для користувача.
Повідомлення «невідома команда» — уже непогано, але набагато краще, коли поруч є підказка Usage: todo <cmd> ... і список доступних команд. CLI — це діалог із користувачем, просто дуже лаконічний.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ