Разработка CLI с помощью пакета Cobra: как не наступить на змею при написании. cli.. cli. Go.. cli. Go. inference platform.. cli. Go. inference platform. ml.. cli. Go. inference platform. ml. инференс.

Меня зовут Иван Добряев, я разработчик программного обеспечения в Центре технологий VK. Сегодня хочу поделиться опытом по одной достаточно прикладной, но весьма увлекательной теме — разработке командной строки (CLI) на Go.

Платформа для инференса ML-моделей (inference platform) у нас молодая, ей всего лишь полгода, и мы активно расширяем команду. Так что, если вы хотите писать сервисы на Go с нуля, то приходите к нам, у нас найдутся задачи на любой вкус.

Напомню, что 24 апреля пройдёт VK Go Meetup 2025, в рамках которого расскажем про практический опыт и нетривиальные задачи, которые мы решаем. А кроме этого, обсудим большой технологический проект по переводу ВКонтакте на сервисную архитектуру и построению единой платформы разработки. Регистрация бесплатна и доступна по ссылке.

Так выглядит обычное взаимодействие с нашим API:

Разработка CLI с помощью пакета Cobra: как не наступить на змею при написании - 1

Пользователь взаимодействует с системой через удобный веб-интерфейс. Однако если доступ осуществляется с виртуальных машин или сторонних сервисов, используется интерфейс командной строки (CLI).

Когда я впервые задумался над созданием собственного CLI на Go, у меня практически отсутствовал такой опыт — ранее решал аналогичную задачу только с Python. Даже подходящего пакета под рукой не оказалось. Проведя экспресс-анализ популярных решений в репозиториях и специализированных ресурсах, я свел найденные варианты в следующую сравнительную таблицу:

Разработка CLI с помощью пакета Cobra: как не наступить на змею при написании - 2

Большинство пакетов не подходили,по причине того, что есть необходимость использовать вложенные команды (их в нашей CLI уже штук 30). Но такая оценка очень поверхностна, нужен был более вдумчивый анализ для выбора подходящего пакета.

Разработка CLI с помощью пакета Cobra: как не наступить на змею при написании - 3

Пакет Kingpin очень похож на питоновский argparse, то есть используется скорее как входная точка для запуска бинарника. А у нас всё-таки обращение к API, поэтому выбор пал на Сobra. 

Проблемы и примеры

А теперь поделюсь проблемами, которые мне встретились во время поиска примеров использования пакета Cobra.

Парсинг параметров

Посмотрим простенький код:

var cmd = &cobra.Command{
    Use:   "cmd",
    Short: "Short description",
    RunE: func(cmd *cobra.Command, args []string) error {
        . . .
    },
}

func init() {
    flags := addCmd.Flags()
    flags.StringP("src", "s", "", "some descr")
}

В функции init() объявляется флаг и выполняется команда Cobra, а многоточие — это какая-то обработка. Проблема заключается в строке flags.StringP("src", "s", "", "some descr"). Представьте, что подкоманд будет много, штук 20, и другой разработчик решит назвать флаг не src, а полностью (source). Такого допускать не стоит, у нас должно быть единообразие, поэтому давайте вынесем названия флагов и их описания в константы. 

const (
    srcFlag      = "source"
    srcShortFlag = "s"
    srcDesc      = "source of smth"
)

var cmd = &cobra.Command{
    Use:   "cmd",
    Short: "Short description",
    RunE: func(cmd *cobra.Command, args []string) error {
        . . .
    },
}

func init() {
    flags := addCmd.Flags()
    flags.StringP(srcFlag, srcShortFlag, srcDesc)
}

Передача аргументов в конструкторе

Эта проблема чуть сложнее. Вот фрагмент ещё одного известного кода, в который я специально внёс некоторые изменения, чтобы показать наглядный пример:

var cmd = &cobra.Command{
    Use:   "cmd",
    Short: "Short description",
    RunE: func(cmd *cobra.Command, args []string) error {
        if addOpts.Src == "" {
            return fmt.Errorf("some error")
        }
        return processCmd()
    },
}

func init() {
    flags := addCmd.Flags()
    flags.StringVarP(&addOpts.Src, "src", "", "some descr")
}

func processCmd() error {
    srcid, srcalias, err := parseSrcDst(addOpts.Src)
    . . .
    return nil
}

В 14 строке мы парсим параметры и сразу пишем в глобальную переменную. В 8 строке вызываем функцию processCmd с пустыми аргументами, и в самой функции опять вытаскиваем эту глобальную переменную. Так делать точно не стоит, потому что это сильно усложняет читабельность кода, потом будет очень тяжело редактировать.

Давайте переделаем:

var cmd = &cobra.Command{
    Use:   "cmd",
    Short: "Short description",
    RunE: func(cmd *cobra.Command, args []string) error {
        src, err := cmd.Flags().GetString(srcFlag)
        if err != nil {
            return err
        }
        return processCmd(src)
    },
}

func init() {
    flags := addCmd.Flags()
    flags.StringVarP(srcFlag, srcShortFlag, srcDesc)
    flags.MarkFlagRequired(srcFlag)
}

func processCmd(src string) error {
    srcid, srcalias, err := parseSrcDst(src)
    . . .
    return nil
}

Теперь мы отдельно парсим флаг. Отдельно говорим, что он должен быть обязательным. В 5 строке мы его получаем, а в 9 — передаём. И если посмотрим на функцию processCmd, то сразу становится понятно, что она принимает. 

Структура пакетов

На мой взгляд, это самый сложный тип возможных проблем. Вот пример из нашего репозитория:

├── .gitignore
├── README.md
└── file.go

Там около 600 строк в одном файле. Его уже 7 лет никто не обновлял. 

Следующий пример чуть лучше:

├── add.go 
├── add_only_for_check.go 
├── bbenv.go 
├── helpers.go 
├── helpers_test.go 
├── root.go 
├── setport.go 
└── version.go

Тут уже есть какое-то разбиение по командам, но всё-равно сходу непонятно, что происходит. 

А вот репозиторий KubeFlow Arena из GitHub:

project/
├── cron/
│   ├── list.go
│   └── ...
├── data/
│   ├── list.go
│   └── ...
├── evaluate/
│   ├── list.go
│   └── ...
├── model/
│   ├── list.go
│   └── ...
 ...
├── completion.go
├── root.go
├── version.go
└── whoami.go

Есть команды, у них внутри — подкоманды, то есть появляется структура. И если захочется добавить, например, метод delete, то сразу понятно, куда его надо положить. 

И самый лучший пример — наша инференс-платформа (приходится хвалить себя самому):

├── inference-client
│   ├── utils
│   │   ├── utils.go
│   │   └── print.go
│   ├── pkg
│   │   ├── validation
│   │   │   ├── get.go
│   │   │   └── create.go
│   │   ├─…
│   ├── cmd
│   │   ├── validation
│   │   │   ├── validation.go
│   │   │   ├── get.go
│   │   │   └── create.go
│   │   ├── …
│   │   ├── root.go
│   ├── main.go

У нас есть отдельный пакет cmd, в котором лежит всё, что касается CLI, и отдельный пакет package с логикой обращения к API, преобразования и так далее. 

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

Шаблон

На основе этой структуры я написал шаблон для Go. Он даже будет запускаться. 

├── client
│   ├── utils
│   │   ├── utils.go
│   ├── pkg
│   │   ├── task
│   │   │   ├── get.go
│   ├── cmd
│   │   ├── task
│   │   │   ├── get.go
│   │   │   ├── task.go
│   │   ├── root.go
│   ├── main.go

В cmd и utils находятся разные функции. Давайте пройдём по каждому файлу и разберёмся, что там лежит. 

Начнём с main. Всё предельно просто:

package main

import "longabonga.com/cobra/template/client/cmd"

func main() {
    cmd.Execute()
}

Root чуть поинтереснее. Тут в функции subcommand добавляются все наши команды. 

var rootCmd = &cobra.Command{
    Use:   "template-cli",
    Short: "A template CLI",
    Long:  `A template CLI for demo`,
}

func addSubcommandPalletes() {
    rootCmd.AddCommand(task.TaskCmd)
}

func Execute() {
    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(-1)
    }
}

func init() {
    addSubcommandPalletes()
}

Так выглядит cmd — task — task.go. Здесь лежит просто описание команды. Мы таким образом всегда сможем найти, что она делает и какие у неё константы. Это позволяет избежать проблемы с флагами, о которой я говорил выше. 

const (
    taskIDFlag      = "id"
    taskIDShortFlag = "i"
    taskIDDesc      = "task id"
)

var TaskCmd = &cobra.Command{
    Use:   "task",
    Short: "command to manage tasks",
    Long: `command fot managing tasks. 
    It allows users to create, task`,
}

Вот команда cmd — task — get.go. Сразу видим флаги и функции. В 10 строке есть вызов функции из пакета package, где лежит вся логика. Код очень аккуратный. 

var getCmd = &cobra.Command{
    Use:   "get",
    Short: "get a new task",
    Long:  `get a new task`,
    RunE: func(cmd *cobra.Command, args []string) error {
        id, err := utils.GetStringFlag(cmd, taskIDFlag)
        if err != nil {
            return err
        }
        return task_utils.GetByID(id)
    },
}

func init() {
    utils.SetRequiredStringFlag(
        getCmd,
        taskIDFlag,
        taskIDShortFlag,
        taskIDDesc,
    )

    TaskCmd.AddCommand(getCmd)
}

А вот pkg — task — get.go. Тут хранится вся логика, которую вы можете написать. 

func GetByID(id string) error {
    // implementation of get task
    return nil
}

И в завершение utils.go — всякие простые команды для более приятного парсинга аргументов. 

func SetRequiredStringFlag(cmd *cobra.Command, name, shorthand, description string) {
    cmd.Flags().StringP(name, shorthand, "", description)
    cmd.MarkFlagRequired(name)
}

func GetStringFlag(cmd *cobra.Command, name string) (string, error) {
    value, err := cmd.Flags().GetString(name)
    if err != nil {
        return "", err
    }
    if value == "" {
        return "", fmt.Errorf("%s flag is required", name)
    }
    return value, nil
}

Теперь мы знаем, как выбрать пакет, который нам нужен. Знаем, каких практик стоит избегать. И у нас есть очень удачный шаблон. Пользуйтесь на здоровье. И до встречи на VK GO Meetup 2025.

Автор: LongaBonga

Источник

Рейтинг@Mail.ru
Rambler's Top100