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) | Який обробник буде викликаний |
|---|---|---|
|
|
|
|
|
|
|
|
|
Так, це виглядає майже надто просто. Але саме такі місця в коді й мають бути нудними.
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 — це діалог із користувачем, просто дуже лаконічний.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ