Представьте: вы смотрите на экран, в руке у вас кофе, и вы думаете, использовать ли ещё один фреймворк CLI или создать свой собственный. Налейте себе ещё чашку, потому что сегодня мы погрузимся в кроличью нору создания расширяемого фреймворка CLI на Go, за что ваше будущее «я» скажет вам спасибо (и, возможно, даже даст вам пять через экран).

Создание CLI-приложений на Go похоже на сборку мебели IKEA — кажется простым, пока вы не поймёте, что вам нужен несуществующий фреймворк. Конечно, есть отличные варианты, такие как Cobra, но иногда вам нужно что-то, что соответствует вашим точным требованиям, как хорошо сшитый костюм. Давайте засучим рукава и создадим что-то великолепное.

Зачем изобретать колесо (и делать его лучше)

Прежде чем мы углубимся в код, давайте ответим на вопрос, который всех волнует. Зачем создавать свой собственный фреймворк CLI, если существуют такие инструменты, как Cobra? Ответ кроется в красоте настройки и обучения. Иногда существующие фреймворки подобны чужой обуви — они работают, но не совсем идеально подходят.

Создание собственного фреймворка даёт вам:

  • Полный контроль над архитектурой и поведением;
  • Лёгкие решения, адаптированные к вашим конкретным потребностям;
  • Глубокое понимание того, как работают фреймворки CLI под капотом;
  • Гибкость для реализации уникальных функций, которые стандартные фреймворки не предлагают.

Архитектура: основа совершенства

Давайте начнём с архитектурного обзора. Наш фреймворк будет следовать модульному дизайну, где команды — это автономные блоки, которые можно легко подключить к основному приложению.

graph TD A[CLI приложение] --> B[Реестр команд] B --> C[Корневая команда] B --> D[Подкоманда 1] B --> E[Подкоманда 2] B --> F[Подкоманда N] C --> G[Парсер флагов] D --> H[Парсер флагов] E --> I[Парсер флагов] F --> J[Парсер флагов] G --> K[Обработчик выполнения] H --> L[Обработчик выполнения] I --> M[Обработчик выполнения] J --> N[Обработчик выполнения]

Шаг 1: Создание базовой структуры команд

Давайте начнём с основы — нашей структуры Command. Это будет ДНК нашего фреймворка.

// framework/command.go
package framework
import (
    "flag"
    "fmt"
    "os"
    "strings"
)
// Command представляет команду CLI с её флагами и логикой выполнения
type Command struct {
    Name        string
    Usage       string
    Description string
    flags       *flag.FlagSet
    subCommands map[string]*Command
    parent      *Command
    Execute     func(cmd *Command, args []string) error
}
// NewCommand создаёт новый экземпляр команды
func NewCommand(name, usage, description string) *Command {
    return &Command{
        Name:        name,
        Usage:       usage,
        Description: description,
        flags:       flag.NewFlagSet(name, flag.ContinueOnError),
        subCommands: make(map[string]*Command),
    }
}
// Init инициализирует команду с предоставленными аргументами
func (c *Command) Init(args []string) error {
    return c.flags.Parse(args)
}
// Called возвращает true, если команда была обработана
func (c *Command) Called() bool {
    return c.flags.Parsed()
}
// Run выполняет команду
func (c *Command) Run(args []string) error {
    if c.Execute != nil {
        return c.Execute(c, args)
    }
    return fmt.Errorf("нет обработчика выполнения для команды: %s", c.Name)
}

Эта базовая структура даёт нам всё необходимое, чтобы начать строить нашу CLI-империю. Обратите внимание, как мы храним подкоманды в карте — это делает поиск быстрым и сохраняет наш код чистым.

Шаг 2: Добавление суперспособностей управления флагами

Флаги — это изюминка CLI-приложений. Давайте добавим некоторые надёжные возможности управления флагами:

// Типы флагов для обеспечения типобезопасности
type FlagType int
const (
    StringFlag FlagType = iota
    IntFlag
    BoolFlag
    Float64Flag
)
// FlagDefinition определяет флаг с его свойствами
type FlagDefinition struct {
    Name         string
    Type         FlagType
    Usage        string
    DefaultValue interface{}
    Required     bool
}
// AddFlag добавляет определение флага к команде
func (c *Command) AddFlag(def FlagDefinition) {
    switch def.Type {
    case StringFlag:
        defaultVal := ""
        if def.DefaultValue != nil {
            defaultVal = def.DefaultValue.(string)
        }
        c.flags.String(def.Name, defaultVal, def.Usage)
    case IntFlag:
        defaultVal := 0
        if def.DefaultValue != nil {
            defaultVal = def.DefaultValue.(int)
        }
        c.flags.Int(def.Name, defaultVal, def.Usage)
    case BoolFlag:
        defaultVal := false
        if def.DefaultValue != nil {
            defaultVal = def.DefaultValue.(bool)
        }
        c.flags.Bool(def.Name, defaultVal, def.Usage)
    case Float64Flag:
        defaultVal := 0.0
        if def.DefaultValue != nil {
            defaultVal = def.DefaultValue.(float64)
        }
        c.flags.Float64(def.Name, defaultVal, def.Usage)
    }
}
// GetStringFlag получает значение строкового флага
func (c *Command) GetStringFlag(name string) string {
    if flag := c.flags.Lookup(name); flag != nil {
        return flag.Value.String()
    }
    return ""
}
// GetIntFlag получает значение целочисленного флага
func (c *Command) GetIntFlag(name string) int {
    if flag := c.flags.Lookup(name); flag != nil {
        if val, ok := flag.Value.(*flag.Value); ok {
            // Это упрощённая версия — в продакшене вы захотите надлежащее утверждение типа
            return 0 // placeholder
        }
    }
    return 0
}
// GetBoolFlag получает значение логического флага
func (c *Command) GetBoolFlag(name string) bool {
    if flag := c.flags.Lookup(name); flag != nil {
        return flag.Value.String() == "true"
    }
    return false
}

Шаг 3: Регистр команд — телефонная книга вашего CLI

Теперь давайте создадим систему реестра, которая управляет всеми нашими командами, как хорошо организованная телефонная книга:

// framework/registry.go
package framework
import (
    "fmt"
    "sort"
    "strings"
)
// Registry управляет всеми зарегистрированными командами
type Registry struct {
    commands map[string]*Command
    rootCmd  *Command
}
// NewRegistry создаёт новый реестр команд
func NewRegistry() *Registry {
    return &Registry{
        commands: make(map[string]*Command),
    }
}
// SetRootCommand устанавливает корневую команду для CLI
func (r *Registry) SetRootCommand(cmd *Command) {
    r.rootCmd = cmd
    r.commands[""] = cmd // Пустой ключ строки для корня
}
// RegisterCommand регистрирует новую команду
func (r *Registry) RegisterCommand(cmd *Command) error {
    if cmd.Name == "" {
        return fmt.Errorf("имя команды не может быть пустым")
    }
    if _, exists := r.commands[cmd.Name]; exists {
        return fmt.Errorf("команда '%s' уже зарегистрирована", cmd.Name)
    }
    r.commands[cmd.Name] = cmd
    return nil
}
// GetCommand извлекает команду по имени
func (r *Registry) GetCommand(name string) (*Command, bool) {
    cmd, exists := r.commands[name]
    return cmd, exists
}
// ListCommands возвращает все зарегистрированные имена команд
func (r *Registry) ListCommands() []string {
    var names []string
    for name := range r.commands {
        if name != "" { // Пропускаем корневую команду
            names = append(names, name)
        }
    }
    sort.Strings(names)
    return names
}
// Execute выполняет соответствующую команду на основе аргументов
func (r *Registry) Execute(args []string) error {
    if len(args) == 0 {
        if r.rootCmd != nil {
            return r.rootCmd.Run([]string{})
        }
        return r.showHelp()
    }
    cmdName := args