JavaRush /Курси /Go SELF /Дизайн CLI: команди, прапорці, позиційні аргументи

Дизайн CLI: команди, прапорці, позиційні аргументи

Go SELF
Рівень 48 , Лекція 2
Відкрита

1. Команди й аргументи

Коли ви вперше пишете консольну програму, зазвичай хочеться якнайшвидше змусити її працювати: прочитати аргументи, щось зробити, вивести результат. Але CLI — це маленький публічний API. Навіть якщо, крім вас, ним користується лише колега, він швидко втомиться від програми, де параметри то позиційні, то прапорці, то в іншому порядку, то «сьогодні чомусь інакше». Добрий дизайн CLI робить команду передбачуваною, а отже — зменшує кількість помилок, запитань і зайвої магії.

Уявімо, що ми робимо невеликий навчальний CLI‑проєкт todo — менеджер завдань. Сьогодні наше завдання — зрозуміти, як акуратно розкласти вхідні параметри по поличках.

Команда як «дієслово»

Якщо дивитися на CLI як на фразу, то часто виходить цілком людська конструкція: «зроби дію над обʼєктом». Дія — це команда (command), а обʼєкт і деталі — це або позиційні аргументи, або прапорці.

Найпоширеніший і найзрозуміліший стиль має такий вигляд:

todo <command> [options] [args]

Де:

  • <command> — «дієслово» (add, list, done, delete).
  • [options] — прапорці, які налаштовують команду (-priority, -limit, -format).
  • [args] — позиційні аргументи, без яких команда не має сенсу (наприклад, текст завдання).

Важливо: команда майже завжди має бути першим позиційним аргументом після глобальних прапорців, якщо вони є. Чому? Бо так користувачеві простіше «прочитати» команду очима: спочатку дія, потім деталі.

Невелика таблиця: як зазвичай називають команди

Завдання Хороша назва команди Чому
Додати завдання
add
коротко, як дієслово
Показати список
list
очікувано за аналогією з іншими CLI
Позначити як виконане
done
коротко і зрозуміло
Видалити delete або rm залежить від аудиторії (новачкам зрозуміліше delete)

Уникайте «внутрішніх» назв на кшталт doTaskMutation. Так, це жарт, але такі назви справді трапляються — і створюють проблеми.

Позиційні аргументи

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

Хороше правило для початківців і не лише: позиційні аргументи — це обовʼязкове ядро команди, те, без чого команда не має сенсу.

Наприклад:

  • 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, у doneID. І тут важливо не вгадувати, а суворо перевіряти контракт.

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 має бути захищений перевірками на вході: чи вистачає аргументів, чи правильний формат, чи правильний діапазон.

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