Перейти до змісту

CLI без фреймворків: subcommand routing

CLI без фреймворків: subcommand routing

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn


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