CLI без фреймворків: subcommand routing¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
Sheep і sheepctl побудовані без cobra, urfave/cli або інших фреймворків. Тільки os.Args і flag зі стандартної бібліотеки. Два CLI, ~1170 рядків на двох, 30+ команд. Ось як це організовано.
Sheep CLI - маршрутизація¶
func main() {
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
// Команди, що не потребують Manager
switch os.Args[1] {
case "init":
handleInit()
return
case "version":
fmt.Println("sheep v0.1.0")
return
case "help", "--help", "-h":
printUsage()
return
}
// Ініціалізуємо Manager для решти команд
dataDir := os.Getenv("SHEEP_DATA_DIR")
mgr := container.NewManager(dataDir)
if err := mgr.Init(); err != nil {
fatal("init: %v", err)
}
switch os.Args[1] {
case "run": cmdRun(mgr)
case "create": cmdCreate(mgr)
case "start": cmdStart(mgr)
case "stop": cmdStop(mgr)
case "rm": cmdRemove(mgr)
case "ps": cmdPs(mgr)
case "inspect": cmdInspect(mgr)
case "images": cmdImages(mgr)
case "pull": cmdPull(mgr)
case "push": cmdPush(mgr)
case "tag": cmdTag(mgr)
case "import": cmdImport(mgr)
case "bootstrap": cmdBootstrap(mgr)
case "rmi": cmdRemoveImage(mgr)
case "logs": cmdLogs(mgr)
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
printUsage()
os.Exit(1)
}
}
Два switch'і: перший для команд без Manager (init, version, help), другий для решти. init обробляється окремо, бо це re-exec від дочірнього процесу - Manager в namespace'і не потрібний.
Парсинг прапорців для run¶
parseRunFlags обробляє os.Args[2:] вручну:
func parseRunFlags(args []string) container.RunOpts {
opts := container.RunOpts{}
for i := 0; i < len(args); i++ {
switch args[i] {
case "--name":
i++
if i < len(args) { opts.Name = args[i] }
case "-d", "--detach":
opts.Detach = true
case "-m", "--memory":
i++
if i < len(args) {
opts.Config.Memory = parseMemory(args[i])
}
case "--cpu-shares":
i++
if i < len(args) {
v, _ := strconv.ParseInt(args[i], 10, 64)
opts.Config.CPUShares = v
}
case "--cpu-quota":
i++
if i < len(args) {
v, _ := strconv.ParseInt(args[i], 10, 64)
opts.Config.CPUQuota = v
}
case "--pids-limit":
i++
if i < len(args) {
v, _ := strconv.ParseInt(args[i], 10, 64)
opts.Config.PidsLimit = v
}
case "--hostname", "-h":
i++
if i < len(args) {
opts.Config.Hostname = args[i]
}
case "-e", "--env":
i++
if i < len(args) {
opts.Config.Env = append(opts.Config.Env, args[i])
}
case "-v", "--volume":
i++
if i < len(args) {
parts := strings.SplitN(args[i], ":", 3)
if len(parts) >= 2 {
m := container.Mount{
Source: parts[0],
Target: parts[1],
}
if len(parts) == 3 && parts[2] == "ro" {
m.ReadOnly = true
}
opts.Mounts = append(opts.Mounts, m)
}
}
case "-w", "--workdir":
i++
if i < len(args) {
opts.Config.WorkDir = args[i]
}
default:
// Перший non-flag = image, решта = command
if opts.Image == "" {
opts.Image = args[i]
} else {
opts.Command = append(opts.Command, args[i])
}
}
}
if opts.Image == "" { fatal("image is required") }
if len(opts.Command) == 0 {
opts.Command = []string{"/bin/sh"}
}
return opts
}
Патерн: прапорці з -- мають значення (наступний аргумент), булеві (-d) не мають. Все, що без -- - позиційні аргументи: перший = image, решта = command.
Це працює як Docker:
sheep run --name web -m 256m -d -e PORT=8080 \
-v /data:/app/data:ro nginx nginx -g "daemon off;"
Sheepctl CLI - flag package¶
Sheepctl (kubectl-аналог) використовує flag для глобальних прапорців:
// В sheepctl:
var (
apiAddr = flag.String("api", "localhost:9876", "API server")
namespace = flag.String("n", "default", "Namespace")
)
func main() {
flag.Parse()
args := flag.Args()
if len(args) == 0 { printUsage(); os.Exit(1) }
switch args[0] {
case "apply": cmdApply(args[1:])
case "get": cmdGet(args[1:])
case "delete": cmdDelete(args[1:])
case "describe": cmdDescribe(args[1:])
case "scale": cmdScale(args[1:])
case "logs": cmdLogs(args[1:])
case "nodes": cmdNodes()
case "events": cmdEvents()
case "info": cmdInfo()
}
}
flag.Parse() обробляє -api і -n, flag.Args() повертає решту. Це поєднання flag package для глобальних прапорців і ручного switch для підкоманд.
Форматований вивід¶
Sheep має свій пакет cli для таблиць:
// internal/cli/table.go
type Table struct {
headers []string
rows [][]string
widths []int
}
func NewTable(headers ...string) *Table {
t := &Table{headers: headers}
t.widths = make([]int, len(headers))
for i, h := range headers {
t.widths[i] = len(h)
}
return t
}
func (t *Table) AddRow(cols ...string) {
t.rows = append(t.rows, cols)
for i, c := range cols {
if i < len(t.widths) && len(c) > t.widths[i] {
t.widths[i] = len(c)
}
}
}
Використання в sheep ps:
func cmdPs(mgr *container.Manager) {
all := false
for _, arg := range os.Args[2:] {
if arg == "-a" || arg == "--all" { all = true }
}
containers := mgr.List(all)
tbl := cli.NewTable("CONTAINER ID", "IMAGE",
"COMMAND", "CREATED", "STATUS", "NAME")
for _, c := range containers {
tbl.AddRow(
container.ShortID(c.ID),
c.Image,
cli.Truncate(strings.Join(c.Command, " "), 30),
timeAgo(c.CreatedAt),
formatStatus(c),
c.Name,
)
}
tbl.Render(os.Stdout)
}
Вивід:
CONTAINER ID IMAGE COMMAND CREATED STATUS NAME
a1b2c3d4e5f6 nginx nginx -g 5m ago Up 5m ago web
f6e5d4c3b2a1 minimal /bin/sh 1h ago Exited (0) 30m test
Ручний парсинг vs cobra¶
| os.Args + flag | cobra | |
|---|---|---|
| Залежності | 0 | ~3 MB binary size |
| Автодоповнення | Ні | Bash/Zsh/Fish |
| Вкладені команди | Ручне | Вбудоване |
| Help generation | Ручне | Автоматичне |
| Тестування | Прямий виклик функції | Через cobra.Command |
| Рядків коду | ~100 для routing | ~50 для setup |
Для Sheep з 15 командами і Sheepctl з 9 - ручний підхід простіший і зрозуміліший. Для CLI з 100+ командами і вкладеними групами (як kubectl get, kubectl create, kubectl config) - cobra виправданий.
Чого не вистачає¶
Ручний парсинг не валідує невідомі прапорці. sheep run --typo value nginx тихо проігнорує --typo і value стане image name. Cobra виведе помилку "unknown flag: --typo". Для production CLI це важливо.
Ще дві пастки нашого підходу:
- Прапорці, які приймають значення (
--name,-m), мовчки ламаються, якщо значення немає в кінці рядка:sheep run nginx --nameпросто проігнорує--name, боi++виходить за межі слайсу. Cobra тут сказав би "flag needs an argument". - Змішаний стиль: sheep парсить руками, sheepctl використовує
flag. Через це у sheep прапорці пишуться--api, а в sheepctl --api(один дефіс, бо так працює пакетflag). Користувач мусить пам'ятати різницю між двома бінарниками.
💡 Цікаві факти¶
- Пакет
flagнавмисно не підтримує GNU-стиль--flagз подвійним дефісом як окрему семантику: для нього-nі--n- те саме. Саме тому в Go-тулзах ти часто бачиш одинарний дефіс навіть для довгих імен (go test -count,-run). - Docker CLI колись теж був "без фреймворку", але потім переїхав на cobra - той самий cobra, що й kubectl, Hugo та GitHub CLI. Spf13/cobra де-факто став стандартом CLI в Go-екосистемі.
os.Args[0]- це не команда, а шлях до самого бінарника. Деякі тулзи (як BusyBox чи нашsheep init) дивляться наArgs[0], щоб поводитись по-різному залежно від того, під яким ім'ям їх викликали.- kubectl має понад 60 команд і десятки тисяч рядків саме на парсинг та валідацію прапорців. Те, що в Sheep вміщається в ~100 рядків switch, у "дорослому" CLI - окрема велика підсистема.
Що я зрозумів, поки розбирався з темою¶
Я звик тягнути cobra рефлекторно в кожен новий тул. А тут виявилось, що для 15 команд весь "фреймворк" - це два switch'і й ручний цикл по аргументах, який читається зверху вниз без жодної магії. Найбільше здивувало, скільки коштує зручність: cobra додає мегабайти до бінарника і шар абстракції, у який доводиться залазити при дебагу. Для навчального проєкту прозорість виявилась ціннішою за автодоповнення. Але як тільки команд стало б 30+ з вкладеними групами - я б здався і повернувся до cobra без вагань.
Що можна покращити¶
- Додати валідацію невідомих прапорців: будь-який аргумент, що починається з
-і не матчиться в switch, має падати з помилкою, а не ставати image name. - Згенерувати
--helpдля кожної підкоманди з єдиної структури-опису прапорців, замість того щоб писати usage руками і ризикувати, що він розійдеться з реальним парсером. - Звести sheep і sheepctl до одного стилю парсингу, щоб користувач не плутав
--apiі-api. - Додати shell-автодоповнення (хоча б через статичний bash-completion файл) - це найбільше, чого бракує користувачу порівняно з cobra.
Спробуй сам¶
./sheep help
./sheep run --help # покаже всі прапорці
./sheep ps -a
./sheep images
./sheepctl info
./sheepctl get pods -n default
Далі - JSON serialization: один формат для файлів, бази, API і OCI manifests.
Ресурси¶
- flag package: вбудований парсер прапорців у Go
- spf13/cobra: повноцінний CLI-фреймворк, для порівняння
Вихідний код циклу: github.com/igorgorovoy/sheep-shepherd-meadow
Попередня: Goroutines для control loops | Наступна: JSON Serialization
