1. Команди й аргументи
Коли ви вперше пишете консольну програму, зазвичай хочеться якнайшвидше змусити її працювати: прочитати аргументи, щось зробити, вивести результат. Але CLI — це маленький публічний API. Навіть якщо, крім вас, ним користується лише колега, він швидко втомиться від програми, де параметри то позиційні, то прапорці, то в іншому порядку, то «сьогодні чомусь інакше». Добрий дизайн CLI робить команду передбачуваною, а отже — зменшує кількість помилок, запитань і зайвої магії.
Уявімо, що ми робимо невеликий навчальний CLI‑проєкт todo — менеджер завдань. Сьогодні наше завдання — зрозуміти, як акуратно розкласти вхідні параметри по поличках.
Команда як «дієслово»
Якщо дивитися на CLI як на фразу, то часто виходить цілком людська конструкція: «зроби дію над обʼєктом». Дія — це команда (command), а обʼєкт і деталі — це або позиційні аргументи, або прапорці.
Найпоширеніший і найзрозуміліший стиль має такий вигляд:
todo <command> [options] [args]
Де:
- <command> — «дієслово» (add, list, done, delete).
- [options] — прапорці, які налаштовують команду (-priority, -limit, -format).
- [args] — позиційні аргументи, без яких команда не має сенсу (наприклад, текст завдання).
Важливо: команда майже завжди має бути першим позиційним аргументом після глобальних прапорців, якщо вони є. Чому? Бо так користувачеві простіше «прочитати» команду очима: спочатку дія, потім деталі.
Невелика таблиця: як зазвичай називають команди
| Завдання | Хороша назва команди | Чому |
|---|---|---|
| Додати завдання | |
коротко, як дієслово |
| Показати список | |
очікувано за аналогією з іншими CLI |
| Позначити як виконане | |
коротко і зрозуміло |
| Видалити | delete або rm | залежить від аудиторії (новачкам зрозуміліше delete) |
Уникайте «внутрішніх» назв на кшталт doTaskMutation. Так, це жарт, але такі назви справді трапляються — і створюють проблеми.
Позиційні аргументи
Позиційні аргументи працюють дуже просто: вони йдуть «як є», без назви, а сенс визначається порядком. Це зручно, поки у вас 1–2 аргументи. Але щойно їх стає більше, користувач перетворюється на сапера: помилився порядком — і програма робить не те.
Хороше правило для початківців і не лише: позиційні аргументи — це обовʼязкове ядро команди, те, без чого команда не має сенсу.
Наприклад:
- todo add "Buy milk" — без тексту завдання додавати нічого.
- todo done 3 — без ідентифікатора незрозуміло, що позначати.
А от «формат виводу», «ліміт» і «фільтр» краще робити прапорцями, бо вони опційні й не залежать від порядку.
Приклад: беремо команду та її аргументи з flag.Args()
Почнімо з міні‑скелета: після flag.Parse() усе, що залишилося, лежить у flag.Args().
package main
import (
"flag"
"fmt"
)
func main() {
flag.Parse()
args := flag.Args()
fmt.Printf("args=%#v\n", args) // args=["add" "Buy milk"]
}
Тут важливо звикнути до думки: позиційні аргументи — це «залишок» після розбору прапорців.
Приклад: «команда — перший аргумент»
package main
import (
"flag"
"fmt"
)
func main() {
flag.Parse()
if flag.NArg() == 0 {
fmt.Println("немає команди") // немає команди
return
}
cmd := flag.Arg(0)
fmt.Println("команда:", cmd) // команда: add
}
flag.NArg() — це зручна перевірка, щоб не читати flag.Arg(0) для порожнього списку.
Про лапки й пробіли
Коли аргумент містить пробіли, користувачеві потрібно взяти його в лапки. І це не примха Go — так працює командний рядок:
todo add Buy milk
Найімовірніше, це перетвориться на два аргументи: "Buy" і "milk". Тому коректніше:
todo add "Buy milk"
Ваша програма має бути спроєктована так, щоб такі речі були інтуїтивними: якщо текст завдання передається одним аргументом, користувач має розуміти, що потрібні лапки.
2. Прапорці як опції
Прапорці — це іменовані параметри. Вони зручні тим, що не залежать від порядку й можуть мати значення за замовчуванням. Якщо позиційні аргументи — це «хто?» або «що?», то прапорці — це «який?», «скільки?» і «у якому режимі?».
Типові приклади для todo:
- -priority 3 — пріоритет завдання.
- -limit 10 — скільки записів показати.
- -all — показати все (булевий прапорець).
Чому прапорці майже завжди кращі для опцій
Бо їх легко зчитати з першого погляду:
todo list -limit 10
одразу зрозуміло, що означає 10. А от так:
todo list 10
вже змушує тримати в голові контракт: «а 10 — це limit чи, наприклад, пріоритет фільтра?».
Міні‑приклад: прапорець зі значенням за замовчуванням
package main
import (
"flag"
"fmt"
)
func main() {
limit := flag.Int("limit", 10, "максимум елементів")
flag.Parse()
fmt.Println("ліміт:", *limit) // ліміт: 10
}
Те, що flag.Int повертає *int, — це нормально. Пакет flag записує значення через цей вказівник.
Обов’язковий прапорець
Пакет flag не знає, що саме ви вважаєте обовʼязковим. Отже, перевіряємо це вручну.
package main
import (
"flag"
"fmt"
)
func main() {
title := flag.String("title", "", "назва завдання (обовʼязково)")
flag.Parse()
if *title == "" {
fmt.Println("немає -title") // немає -title
return
}
}
Це не недопрацювання Go, а свідома філософія: flag займається синтаксисом, а сенс і правила — ваша відповідальність.
3. Команди й прапорці: домовляємося про формат
Тепер переходимо до найтоншого місця: ви хочете команди (add, list…), ви хочете прапорці (-priority…), і ви хочете, щоб користувач не страждав.
З погляду UX хочеться писати так:
todo add -priority 3 "Buy milk"
Але стандартний пакет flag за замовчуванням не любить, коли прапорці йдуть після першого позиційного аргументу: він зазвичай припиняє розбір, щойно натрапляє на звичайний аргумент. Тому на базовому рівні без додаткових інструментів найпростіший контракт виглядає так:
todo [global flags] <command> [command args]
Тобто прапорці йдуть до команди:
todo -priority 3 add "Buy milk"
Такий варіант трохи менш звичний. Зате він чесно й просто реалізується одним flag.Parse().
Щоб це не здавалося дивним, зазвичай роблять так: залишають глобальні прапорці, які підходять для всіх команд, а специфічні прапорці команди додають уже в більш просунутому дизайні. Сьогодні ми фіксуємо саме базову, але робочу модель.
Схема розбору аргументів
flowchart TD
A["os.Args (сирі аргументи)"] --> B["flag.Parse() (розбираємо прапорці)"]
B --> C["flag.Args() (решта аргументів)"]
C --> D["cmd = Args[0]"]
C --> E["cmdArgs = Args[1:]"]
D --> F["switch cmd { ... }"]
Ця схема зручна тим, що її легко тримати в голові під час налагодження: якщо команду «не знаходять», то проблема майже завжди в тому, що аргументи пішли не туди або прапорці написані після команди.
4. Контракт todo: прапорці та позиційні аргументи
Зараз ми зробимо найважливішу вправу: зафіксуємо домовленість.
Уявімо, що нам потрібні 3 команди:
- add — додати завдання
- list — показати завдання
- done — позначити завдання виконаним
І ще кілька прапорців:
- -priority (для add)
- -limit (для list)
Але, як ми вже домовилися, у межах одного flag.Parse() простіше зробити прапорці глобальними. Тоді контракт може виглядати так:
todo -priority <n> add <TITLE>
todo -limit <n> list
todo done <ID>
У done немає прапорця — і це нормально: не потрібно силоміць робити все прапорцями.
Таблиця: приклад рішень
| Параметр | Робити прапорцем? | Робити позиційним? | Чому |
|---|---|---|---|
| command (add/list/done) | ні | так | це ключовий перемикач поведінки |
| TITLE | ні | так | це головний зміст add |
| ID | ні | так | це головний зміст done |
| priority | так | ні | опція, має значення за замовчуванням і не повинна залежати від порядку |
| limit | так | ні | опція виводу, має значення за замовчуванням |
Це не «єдино правильний» варіант, але це хороший стартовий дизайн, який легко пояснити й легко підтримувати.
5. Диспетчер команд через switch
Зараз зберемо невеликий, але зв’язний шматок коду. Він не буде зберігати завдання у файлі (це окрема велика тема), але вже поводитиметься як справжній CLI за структурою параметрів.
Крок 1: беремо команду й аргументи
package main
import (
"flag"
"fmt"
)
func main() {
flag.Parse()
if flag.NArg() == 0 {
fmt.Println("немає команди") // немає команди
return
}
cmd := flag.Arg(0)
cmdArgs := flag.Args()[1:]
fmt.Println("cmd:", cmd) // cmd: add
fmt.Println("cmdArgs:", cmdArgs) // cmdArgs: ["Buy milk"]
}
Так, тут є дублювання (Arg(0) і Args()[1:]), але зараз нам важливіша наочність.
Крок 2: розгалужуємося за командами
package main
import (
"flag"
"fmt"
)
func main() {
flag.Parse()
if flag.NArg() == 0 {
fmt.Println("немає команди") // немає команди
return
}
switch flag.Arg(0) {
case "add":
fmt.Println("додаємо...") // додаємо...
case "list":
fmt.Println("показуємо список...") // показуємо список...
case "done":
fmt.Println("позначаємо як виконане...") // позначаємо як виконане...
default:
fmt.Println("невідома команда:", flag.Arg(0)) // невідома команда: x
}
}
switch тут — саме те, що потрібно: команди — це фіксований набір рядків.
6. Валідація та складання команди
Валідація позиційних аргументів
Тепер додамо змістовну перевірку: у add має бути TITLE, у done — ID. І тут важливо не вгадувати, а суворо перевіряти контракт.
add: потрібен принаймні один аргумент.
package main
import (
"flag"
"fmt"
)
func main() {
flag.Parse()
if flag.NArg() < 2 {
fmt.Println("Використання: todo add <TITLE>") // Використання: todo add <TITLE>
return
}
cmd := flag.Arg(0)
if cmd != "add" {
fmt.Println("тут підтримується лише add") // тут підтримується лише add
return
}
title := flag.Arg(1)
fmt.Println("назва:", title) // назва: Buy milk
}
Так, це приклад лише add, але він показує принцип: перед читанням Arg(1) ми перевіряємо, що аргументів достатньо.
Валідація прапорців і робота з помилками
Коли ви проєктуєте CLI, помилки введення — це норма, а не виняток. Тому вже зараз корисно ставитися до них як до значень: повертати error, перевіряти err != nil і не намагатися латати все друком у випадкових місцях. Такий стиль — один із базових у Go: помилку порівнюють із nil, а за потреби їй додають контекст через fmt.Errorf.
Нижче — простий приклад: перевіряємо діапазон -priority.
package main
import (
"flag"
"fmt"
)
func main() {
priority := flag.Int("priority", 1, "пріоритет 1..5")
flag.Parse()
if *priority < 1 || *priority > 5 {
fmt.Println("пріоритет має бути в межах 1..5") // пріоритет має бути в межах 1..5
return
}
fmt.Println("пріоритет:", *priority) // пріоритет: 1
}
Ми ще не обговорювали, куди саме друкувати помилки (це окрема розмова), але сам факт валідації має стати звичкою: flag дає тип (int), а сенс (1..5) задаєте ви.
Складання: «прапорці + команда + аргументи»
Зараз зробимо цілісний приклад, який уже виглядає як справжня команда.
Функціональність буде проста:
- todo -priority 3 add "Buy milk" → друкує, що додали завдання.
- todo -limit 5 list → друкує, що показуємо список.
- todo done 2 → друкує, що позначили завдання.
Один файл, один flag.Parse(), зрозумілий контракт.
package main
import (
"flag"
"fmt"
"strconv"
)
func main() {
priority := flag.Int("priority", 1, "пріоритет для add (1..5)")
limit := flag.Int("limit", 10, "ліміт для list")
flag.Parse()
if flag.NArg() == 0 {
fmt.Println("немає команди") // немає команди
return
}
cmd := flag.Arg(0)
switch cmd {
case "add":
if flag.NArg() < 2 {
fmt.Println("Використання: todo -priority N add <TITLE>") // Використання: todo -priority N add <TITLE>
return
}
if *priority < 1 || *priority > 5 {
fmt.Println("пріоритет має бути в межах 1..5") // пріоритет має бути в межах 1..5
return
}
title := flag.Arg(1)
fmt.Println("додано:", title, "пріоритет=", *priority) // додано: Buy milk пріоритет= 3
case "list":
if *limit <= 0 {
fmt.Println("ліміт має бути додатним") // ліміт має бути додатним
return
}
fmt.Println("показуємо список, ліміт:", *limit) // показуємо список, ліміт: 10
case "done":
if flag.NArg() != 2 {
fmt.Println("Використання: todo done <ID>") // Використання: todo done <ID>
return
}
id, err := strconv.Atoi(flag.Arg(1))
if err != nil {
fmt.Println("ID має бути цілим числом") // ID має бути цілим числом
return
}
fmt.Println("позначено як виконане, ID:", id) // позначено як виконане, ID: 2
default:
fmt.Println("невідома команда:", cmd) // невідома команда: xxx
}
}
Цей приклад важливий не тим, що він «багатий на можливості», а тим, що в ньому видно головне: команда обирає гілку поведінки, позиційні аргументи дають обов’язкові дані, а прапорці налаштовують режим.
Нюанс --
Буває незручна ситуація: позиційний аргумент починається з - і схожий на прапорець. Наприклад, ви хочете додати завдання з текстом -buy milk (дивно, але припустімо). Тоді командний рядок може вирішити, що -buy — це прапорець.
Для таких випадків існує термінатор --: він каже парсеру прапорців «усе, далі — лише позиційні аргументи».
На практиці це виглядає так:
todo add -- "-buy milk"
З погляду дизайну CLI це не обов’язкова можливість, але корисно пам’ятати, що такий рятівний інструмент існує.
7. Типові помилки в дизайні CLI
Помилка №1: робити занадто багато позиційних аргументів «бо так коротше».
Спочатку здається, що це зручно: зробити todo add TITLE PRIORITY TAG. Але за тиждень уже ніхто не пам’ятає порядок — і починається плутанина: пріоритет і тег міняються місцями, а програма мовчки приймає введення. У таких місцях прапорці виграють, бо саме ім’я параметра вбудоване в команду.
Помилка №2: змішувати обов’язкові дані й опції в одну купу.
Якщо ви робите todo list all, а завтра додаєте todo list 10, то стає незрозуміло: all — це фільтр чи ліміт? Такі речі швидко перетворюються на мову, яку ви самі випадково й вигадали. Краще тримати правило: обов’язкове — позиційно, опційне — прапорцем, і не змінювати сенс аргументу залежно від значення.
Помилка №3: вважати, що flag перевірить «сенс» за вас.
flag.Int("priority", 1, "...") гарантує лише те, що при введенні -priority abc ви не отримаєте int, але діапазон 1..5 він не знає і знати не зобов’язаний. Якщо забути смислову валідацію, ви отримаєте «валідний» priority=-1000, і баг виглядатиме як «чому застосунок дивно працює».
Помилка №4: приймати «порожній рядок» як нормальне введення там, де він беззмістовний.
Для add порожній TITLE — це майже завжди помилка. Те саме для обов’язкових прапорців: -title "" технічно рядок, але логічно — порожнеча. Якщо не перевіряти це явно, ви отримаєте завдання без назви й відчуття, що програма сама собі шкодить.
Помилка №5: не перевіряти кількість аргументів перед flag.Arg(i).
Це класика: у гілці done читають flag.Arg(1), але забувають перевірити flag.NArg(). У найкращому разі буде неправильна логіка, у найгіршому — паніка. Контракт CLI має бути захищений перевірками на вході: чи вистачає аргументів, чи правильний формат, чи правильний діапазон.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ