Представьте: вы смотрите на экран, в руке у вас кофе, и вы думаете, использовать ли ещё один фреймворк CLI или создать свой собственный. Налейте себе ещё чашку, потому что сегодня мы погрузимся в кроличью нору создания расширяемого фреймворка CLI на Go, за что ваше будущее «я» скажет вам спасибо (и, возможно, даже даст вам пять через экран).
Создание CLI-приложений на Go похоже на сборку мебели IKEA — кажется простым, пока вы не поймёте, что вам нужен несуществующий фреймворк. Конечно, есть отличные варианты, такие как Cobra, но иногда вам нужно что-то, что соответствует вашим точным требованиям, как хорошо сшитый костюм. Давайте засучим рукава и создадим что-то великолепное.
Зачем изобретать колесо (и делать его лучше)
Прежде чем мы углубимся в код, давайте ответим на вопрос, который всех волнует. Зачем создавать свой собственный фреймворк CLI, если существуют такие инструменты, как Cobra? Ответ кроется в красоте настройки и обучения. Иногда существующие фреймворки подобны чужой обуви — они работают, но не совсем идеально подходят.
Создание собственного фреймворка даёт вам:
- Полный контроль над архитектурой и поведением;
- Лёгкие решения, адаптированные к вашим конкретным потребностям;
- Глубокое понимание того, как работают фреймворки CLI под капотом;
- Гибкость для реализации уникальных функций, которые стандартные фреймворки не предлагают.
Архитектура: основа совершенства
Давайте начнём с архитектурного обзора. Наш фреймворк будет следовать модульному дизайну, где команды — это автономные блоки, которые можно легко подключить к основному приложению.
Шаг 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