Привет, Хабр. Меня зовут Иван Добряев, я разработчик программного обеспечения в Центре технологий VK. Сегодня хочу поделиться опытом по одной достаточно прикладной, но весьма увлекательной теме — разработке командной строки (CLI) на Go.
Платформа для инференса ML-моделей (inference platform) у нас молодая, ей всего лишь полгода, и мы активно расширяем команду. Так что, если вы хотите писать сервисы на Go с нуля, то приходите к нам, у нас найдутся задачи на любой вкус.
Напомню, что 24 апреля пройдёт VK Go Meetup 2025, в рамках которого расскажем про практический опыт и нетривиальные задачи, которые мы решаем. А кроме этого, обсудим большой технологический проект по переводу ВКонтакте на сервисную архитектуру и построению единой платформы разработки. Регистрация бесплатна и доступна по ссылке.
Так выглядит обычное взаимодействие с нашим API:

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

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

Пакет 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.