Технологии

Лень – двигатель прогресса, на примере создания облачной CLI

Привет! Я Эмиль Ибрагимов — старший Go-разработчик команды Development Platform в MWS Cloud Platform. В этой статье расскажу о том, как мы создали облачную CLI на Go и что из этого вышло. Спойлер: получилось реализовать утилиту и поддерживать её силами небольшой команды внутри Development Platform. Что такое облачная CLI Облачная CLI — консольная утилита для взаимодействия с облаком. С её помощью можно, например, просматривать бакеты в S3, создавать и просматривать виртуальные машины и базы данных, управлять сетевыми дисками и многое другое. Примеры работы с облачными CLI популярных провайдеров: aws s3 ls --summarize gcloud compute instances create instance-1 --zone=zone-1 az mysql flexible-server create --name=mysql-db-1 Мы в MWS Cloud Platform тоже захотели облачную CLI и задались вопросом — как написать её на Go? Почему именно Go? В нашем облаке самые распространённые языки программирования — это Kotlin, Go и C++, поэтому мы выбирали из этих трёх вариантов. Go оказался фаворитом, потому что сейчас множество консольных утилит пишется именно на этом языке. Как написать свою CLI на Go Выбор фреймворка В команде мы единогласно определились, что точно не будем писать CLI с нуля, ведь это сложно, долго и дорого: придётся реализовывать работу с деревом команд, флагами, делать гибкий help, самостоятельно поддерживать автодополнение и так далее. К счастью, для Go существует несколько проверенных решений для нашей задачи, вот самые популярные из них: spf13/cobra (41к звёзд) urfave/cli (23к звёзд) alexthomas/kong (3к звёзд) Cobra — де-факто стандарт индустрии, кроме того, у нашей команды наибольший опыт именно с этим фреймворком, поэтому мы выбрали его. Перед тем как воспользоваться этой библиотекой, мы написали у себя небольшую обёртку, которая упростила работу с деревом команд и флагами. А ещё немного расширили количество доступных типов для флагов, например, нам понадобился DateTime. Выбор подхода для написания Вооружившись фреймворком Cobra, мы в качестве примера написали реализацию одной облачной команды, а именно, просмотр списка виртуальных машин в облаке. После сборки утилиты команда выглядела так: mws compute virtual-machine list --project=project А так выглядел код, реализующий эту команду: func Compute() cmdtool.Command { return cmdtool.Command{ Action: cli.HelpAction(), Use: "compute", Short: "Сервис Compute", SubCommands: []cmdtool.Command{ computeDisk(), computeImage(), computeVirtualMachine(), }, } } func computeVirtualMachine() cmdtool.Command { return cmdtool.Command{ Action: cli.HelpAction(), Use: "virtual-machine", Short: "Виртуальные машины", SubCommands: []cmdtool.Command{ computeVirtualMachineCreate(), computeVirtualMachineDelete(), computeVirtualMachineGet(), computeVirtualMachineList(), computeVirtualMachineUpdate(), }, } } func computeVirtualMachineList() cmdtool.Command { req := client.ListVirtualMachinesRequest{} return cmdtool.Command{ Action: cmdtool.Action{ BindFlags: func(set cmdtool.FlagSet) { set.StringVar(&req.Project, "project", "", "Проект.") }, Run: func(ctx context.Context, args []string) error { sdk, err := cli.LoadSDK(ctx) if err != nil { return fmt.Errorf("load sdk: %w", err) } client, err := computesdk.NewVirtualMachine(ctx, sdk) if err != nil { return fmt.Errorf("init client: %w", err) } resp, err := client.ListVirtualMachines(ctx, req) if err != nil { return fmt.Errorf("request failed: %w", err) } return cli.Println(ctx, resp) }, }, Use: "list", Short: "Получить список виртуальных машин", } } Жизненный цикл команды CLI состоит из функциональных полей структуры Action — BindFlags и Run. BindFlags отвечает за привязку флагов к сущностям. Run содержит логику команды и вызывается после BindFlags. Получилось три несложные функции, каждая из которых отвечает за соответствующий узел поддерева команд. Однако в облаке множество сервисов и ещё больше различных компонент, для каждого из которых нужно будет писать похожий код. Мы задумались о том, чтобы генерировать похожий код, поскольку ручное написание несёт за собой риски: Много однообразного кода. Каждая команда — это узел в каком-то поддереве, каждый лист поддерева содержит похожий код с инициализацией SDK и совершением запроса в облако. Больше возможностей ошибиться. Тут всё просто: множество людей работает над продуктом, соответственно, человеческий фактор может влиять на количество багов в результате. Долгая доставка новой функциональности. Сперва продуктовая команда должна реализовать и протестировать новые возможности, только потом она должна выделить человеко-часы на их поддержку в CLI. В это время пользователи не могут работать с этой функциональностью. Сложнее поддерживать. Ответственность за рукописное решение распределяется на множество людей, в то время как сгенерированный код может поддерживаться одной небольшой командой. Отсутствие единого стиля кода. Сколько людей, столько и мнений о том, как должен выглядеть красивый код. Это разнообразие делает код облачного CLI разнородным. Но не всё так плохо, ведь у ручного написания есть и плюс: больше возможностей для кастомизации. Иногда такая возможность очень полезна для улучшения пользовательского опыта, а в случае с генерацией кода необходимость кастомизации зачастую означает добавление каких-то костылей в генератор. Опыт конкурентов Популярные облачные провайдеры по-разному реализовали облачные CLI: AWS — python, вручную DigitalOcean — go, вручную Azure — python, вручную, постепенно мигрирует на сгенерированную Alibaba Cloud — go, сгенерирована на основе метаданных Linode (Akamai Cloud) — python, сгенерирована на основе OpenAPI Прослеживается интересная тенденция — более старые CLI написаны вручную, а более новые всё чаще используют кодогенерацию. Примечательно, что опытный облачный провайдер Azure первоначально полностью реализовал CLI вручную, но сейчас постепенно уходит в сторону генерации. Мы тщательно взвесили все за и против и решили, что будем генерировать нашу CLI, осталось продумать план реализации. Облачный API Откатимся немного назад, чтобы поближе изучить, с чем придётся работать в CLI. Облачный API представлен в виде набора OpenAPI-спецификаций и обладает набором характеристик: Декларативность. За редким исключением, операции отвечают на вопрос «что делать?», а не «как делать?», не содержат никаких глаголов в пути. Например, создание диска — POST /compute/v1/projects/{project}/disks/{diskID}. Исключение — операции, которые сложно выразить декларативно, например, включение/выключение виртуальной машины. Такие операции отличаются добавлением суффикса :{action} в конец пути, например, POST /compute/v1/projects/{project}/virtualMachines/{virtualMachine}:start. Строгость. Весь API строго следует регламентам, описанным в документе — API Design. Документ устанавливает правила именования ресурсов, работы с дефолтами, версионирования API, структуры запросов и многое другое. В документе описаны расширения стандарта OpenAPI, которые нужны нам для более тонкой настройки API. Единообразие. Хотя этот пункт вытекает из пункта про строгость, хочется заострить на нём отдельное внимание. Подавляющее большинство ресурсов облака представлено в виде набора из четырёх полей: kind. Обозначает вид объекта и его версию. Пример: iam/v1/user; metadata. Набор стандартных атрибутов, релевантных для большинства ресурсов; spec. Декларация требуемого состояния ресурса; status. Отображение реального состояния ресурса. Кроме того, все операции работают с одним и тем же заранее заданным набором ошибок, по необходимости расширяя их дополнительными атрибутами. Наконец, для каждого ресурса есть три HTTP-метода для работы с ним: GET для получения информации о конкретном ресурсе или списке всех доступных ресурсов, POST для создания или изменения, DELETE для удаления. PUT в нашем облачном API запрещён. Эти характеристики API сильно упрощают генерацию любого кода на основе OpenAPI-спецификаций. Поговаривают даже, что API Design создавался в том числе с расчётом на активную кодогенерацию. В итоге так и произошло, у нашей команды уже есть опыт написания генератора. Например, по OpenAPI-спецификациям сгенерирован go SDK для работы с облаком. Это тоже повлияло на выбор подхода к написанию CLI, но даже без сгенерированного SDK генерация CLI более чем реальна, просто вместо красивых методов SDK будут вызываться HTTP-запросы. Генерация CLI по OpenAPI спецификации Выбор подхода к генерации Прежде чем приступить к генерации, нужно выбрать подход. Мы выбирали из трёх вариантов: Шаблонизация. Например, через go templates. AST. Например, через библиотеку jennifer. Буфер. Обычный код на Go построчно пишет сгенерированный код в некоторый буфер, после чего результат записывается в файл. Можно сказать, что это генерация кода напрямую, без промежуточных представлений. В итоге выбрали третий вариант, поскольку посчитали его наиболее простым и надёжным. К счастью, у нас в команде есть человек, который собаку съел на кодогенерации, он и рассказал нам подробно про подходы к генерации. Вскоре это превратилось в доклад на конференции HighLoad, в котором было и про подходы, и про нюансы, и про многое другое о генерации кода. Генерация дерева команд Наконец, пришло время сгенерировать что-нибудь для будущей облачной CLI. Мы решили начать с дерева команд. На картинке видно, в чём логика устройства команд облачной CLI. Осталось решить, как научить генератор составлять дерево таких команд на основе набора OpenAPI-спецификаций. Учет расположения папок и operationId Первое, что пришло в голову, — использовать структуру папок и файлов со спецификациями в комбинации с operationId. OperationId — стандартное поле OpenAPI, опциональная уникальная строка, позволяющая идентифицировать операцию. В нашем API у каждой операции выставлено это поле. Но здесь мы сталкиваемся с проблемой: разбиение по папкам не регламентировано в API Design. Это означает, что команды могут называть папки как им вздумается, делать любые уровни вложенности. Для генератора это может создать дополнительную помеху: зачастую часть пути нужно пропускать, часть видоизменять. На картинке выше довольно удачный пример иерархии: маленький уровень вложенности, понятные названия сервисов. Для таких папок дерево команд получится примерно следующим: mws iam access-service mws iam auth-client-service mws iam auth-service Для такой структуры в генераторе не понадобится никаких костылей и заморочек. Но бывают и менее удачные примеры. Для такого примера дерево команд получится примерно следующим: mws compute compute-cpl mws compute az-compute-cpl Получилось дублирование compute, кроме того, неочевидно, что вообще такое compute-cpl или az-compute-cpl. Следовательно, в генераторе может понадобиться какая-то специальная конфигурация, позволяющая пропускать подобные случаи, а это не очень удобно. Похожая проблема ждёт нас с operationId — он тоже не регламентирован, из-за чего возникают ситуации с сильно отличающимися значениями даже в рамках одного вида операции. Например, для операции upsert встречаются upsertVirtualMachine и updateBillingAccount. А иногда проблема немного в другом: глагол может стоять в начале или в конце id: imageList, listVirtualMachines. Отсутствие единообразия приводит к необходимости введения в генератор дополнительных эвристик, а этого по возможности нужно избегать. Таким образом, данный вариант не подходит для нашего случая. Но стоит отметить, что если бы регламент включал в себя правила расположения папок и формат operationId, то данный способ был бы достаточно жизнеспособным. Разбор пути запроса Путь — гораздо более регламентированная часть спецификации, чем расположение папок и формат operationId. В частности, в большинстве случаев путь выгляди�� следующим образом: //v//{value}//{value} Здесь key и value — название параметра пути и параметр соответственно, названия параметров фиксированные и напрямую относятся к доменной области ресурса, с которым происходит взаимодействие в операции. Последний параметр всегда — название ресурса, а в случае CLI — компонент. Учитывая всё вышеописанное, становится очевидным, откуда в целевом дереве команд появятся сервис и компонент — это первая и последняя часть пути запроса. Для полноты картины не хватает только операции. Операцию можно получить на основе HTTP-метода и последней части пути запроса. Так, HTTP-метод DELETE однозначно определяет операцию delete, а POST — операцию upsert. PUT, как мы помним, запрещён, остаётся решить вопрос с GET. Для GET потребуется воспользоваться простым правилом — если в конце пути есть параметр, значит, операция работает с конкретным ресурсом и это get. В противном случае это list. Таким образом, у нас есть способ определить все необходимые части команды, например: GET /compute/v1/projects/{project}/virtualMachines/{virtualMachine} mws compute virtual-machine get GET /compute/v1/projects/{project}/virtualMachines mws compute virtual-machine list POST /compute/v1/projects/{project}/virtualMachines/{virtualMachine} mws compute virtual-machine upsert DELETE /compute/v1/projects/{project}/virtualMachines/{virtualMachine} mws compute virtual-machine delete Итоговый алгоритм превращения пути запроса в команду: Взять первую и последнюю часть пути — это сервис и компонент Определить операцию по HTTP-методу и концовке пути Превратить множественное число в единственное. Действительно, все команды CLI работают с сущностями в единственном числе, в то время как в пути запроса чаще всего встречается именно множественное число. Для перевода в единственное число мы воспользовались библиотекой go-pluralize. Реализация алгоритма заняла немного времени, и на выходе получилось готовое дерево команд, очень похожее на рукописный код, который приведён в начале статьи. Большая часть кода содержит промежуточные узлы дерева, которые просто вызывают help, поскольку на этом этапе команда введена не полностью, и описывают дочерние узлы. func Compute[T cli.ProfileConfig](deps cli.Deps[T]) cmdtool.Command { return cmdtool.Command{ Action: cli.HelpAction(), Use: "compute", Short: "Сервисы MWS Compute", SubCommands: []cmdtool.Command{ computeDisk(deps), computeImage(deps), computeSnapshot(deps), computeVirtualMachine(deps), }, } } func computeVirtualMachine[T cli.ProfileConfig](deps cli.Deps[T]) cmdtool.Command { return cmdtool.Command{ Action: cli.HelpAction(), Use: "virtual-machine", Short: "Virtual-machine service", SubCommands: []cmdtool.Command{ computeVirtualMachineCreate(deps), computeVirtualMachineDelete(deps), computeVirtualMachineGet(deps), computeVirtualMachineList(deps), computeVirtualMachineUpdate(deps), }, } } Самое интересное происходит в листьях — там совершается инициализация SDK, запрос в облако и вывод ответа на экран. func computeVirtualMachineList[T cli.ProfileConfig](deps cli.Deps[T]) cmdtool.Command { req := computecplclient.ListVirtualMachinesRequest{} return cmdtool.Command{ Action: cmdtool.Action{ BindFlags: func(set cmdtool.FlagSet) { // ... }, Run: func(ctx context.Context, args []string) error { sdk, err := cli.LoadSDK(ctx, deps) // ... client, err := computecplsdk.NewVirtualMachine(ctx, sdk) // ... resp, err := client.ListVirtualMachines(ctx, req) // ... return cli.Println(ctx, deps, resp) }, }, Use: "list", Short: "List of virtual machines", } } Однако на данном этапе сгенерированный код не способен полноценно работать с облаком, поскольку не хватает механизма передачи параметров запроса. Об этом пойдёт речь в следующем разделе статьи. Генерация флагов При помощи флагов можно задавать параметры запроса. Например: GET /compute/v1/projects/{project}/virtualMachines?pageSize={pageSize} mws compute virtual-machine list --project=my-project --page-size=5 Как видно из примера, флаги могут управлять query- и path-параметрами запроса. Для того чтобы собрать весь набор флагов, нужно: Пройтись по спецификации, выбрать query- и path-параметры. Все параметры привести к kebab-case, по аналогии с деревом команд. Обозначить все path-параметры и обязательные query-параметры обязательными флагами. Это означает, что в сгенерированном коде будет проверка наличия этих флагов. Теперь в сгенерированном коде заполнена функция BindFlags, а также дополнена функция Run. func computeDiskCreate[T cli.ProfileConfig](deps cli.Deps[T]) cmdtool.Command { req := client.UpsertDiskRequest{} body := "" return cmdtool.Command{ Action: cmdtool.Action{ BindFlags: func(set cmdtool.FlagSet) { set.StringVar(&req.Project, "project", "", "Project") set.StringVar(&req.Name, "name", "", "Disk name") }, Run: func(ctx context.Context, args []string) error { if req.Project == "" { return errors.New("flag --project not set") } if req.Name == "" { return errors.New("flag --name not set") } // … }, }, Use: "create", Short: "Upsert disk", } } В случае операций get, list и delete, в которых, как правило, отсутствует тело запроса, для взаимодействия с облаком в CLI достаточно иметь флаги на основе query- и path-параметров. Но для операций create и update, где почти всегда требуется передать тело запроса, было бы удобно иметь набор флагов, соответствующих его полям. Так, команда для создания криптографического ключа mws kms crypto-key create test-key --body ' spec: defaultAlgorithm: AES_256_GCM rotationPolicy: enabled: true rotationIntervalDays: 1 usagePolicy: enabled: true ' может быть записана более лаконично при помощи флагов: mws kms crypto-key create test-key --default-algorithm AES_256_GCM --rotation-interval-days 1 --enabled --rotation-enabled Если требуется, например, поменять значение rotationIntervalDays, то со вспомогательными флагами команда mws kms crypto-key update test-key --body ' spec: rotationPolicy: rotationIntervalDays: 2 ' упрощается до mws kms crypto-key update test-key --rotation-interval-days 2 Для реализации этого механизма нужно рекурсивно обойти спецификацию, бережно пропустив read-only поля, после чего сгенерировать для каждого полученного поля примерно следующую вспомогательную функцию: func kmsCryptoKeyCreateEnabledFlag(req *keymanagementserviceclient.UpsertCryptoKeyRequest) cli.LazyValue { return cli.LazyCustomValue("bool", func(s string) (*bool, error) { out, err := conv.StringToBool(s) if err != nil { return nil, err } return &out, nil }, func(out *bool) error { if req.Body.Spec.UsagePolicy == nil { req.Body.Spec.UsagePolicy = new(keymanagementservicemodel.CryptoKeySpecUsagePolicy) } req.Body.Spec.UsagePolicy.Enabled = out return nil }, ) } Данная функция содержит вызов библиотечной функции, определяющей флаги произвольного типа, с отложенным применением. Сигнатура функции LazyCustomValue: func LazyCustomValue[T any](typ string, parse parseFunc[T], apply applyFunc[T]) LazyValue Здесь parse отвечает за разбор строкового флага, а apply выставляет поле тела в значение, полученное из parse. Внутри apply также происходит инициализация вложенных структур, на случай если те являются указателями, чтобы избежать nil pointer dereference. Две функции нужны для того, чтобы реализовать отложенное применение флага: теоретически пользователь может передать и тело запроса, и набор флагов, соответствующих этому телу. Мы решили, что в этом случае приоритет должен быть у флагов, для этого и понадобилось отложенное применение: сперва происходит разбор тела запроса, а затем поверх него применяются флаги. Совместно с функцией BindFlags это выглядит следующим образом: func kmsCryptoKeyCreate[T cli.ProfileConfig](deps cli.Deps[T]) cmdtool.Command { req := keymanagementserviceclient.UpsertCryptoKeyRequest{} flags := cli.LazyValueRegistry{} body := "" return cmdtool.Command{ Action: cmdtool.Action{ BindFlags: func(set cmdtool.FlagSet) { // Флаг, отвечающий за тело запроса set.StringVar(&body, "body", "", "Тело запроса. Может быть передано в виде текста или пути к файлу (@path/to/file) в формате JSON или YAML") // Флаги из заголовков, а также query и path параметров cli.StringPtrVar(set, &req.IdempotencyKey, "idempotency-key", "", "Ключ идемпотентности") cli.BoolPtrVar(set, &req.ValidateOnly, "validate-only", false, "Dry run, позволяет выполнить все проверки для выполнения операции но не выполнять саму операцию") set.StringVar(&req.Project, "project", "", "Имя проекта. По умолчанию равно параметру профиля \"project\"") // Флаги тела запроса set.Var(flags.Add(kmsCryptoKeyCreateDefaultAlgorithmFlag(&req, deps)), "default-algorithm", "Криптографический алгоритм по-умолчанию, используемый для выпуска новых версий ключа") set.Var(flags.Add(kmsCryptoKeyCreateEnabledFlag(&req)), "enabled", "Флаг, указывающий, разрешены ли криптографические операции с этим ключом") set.Var(flags.Add(kmsCryptoKeyCreateRotationEnabledFlag(&req)), "rotation-enabled", "Флаг, указывающий, включена ли автоматическая ротация для ключа") set.Var(flags.Add(kmsCryptoKeyCreateRotationIntervalDaysFlag(&req)), "rotation-interval-days", "Интервал в днях, через который должна выполняться ротация ключа") set.Var(flags.Add(kmsCryptoKeyCreatePrimaryKeyVersionRefFlag(&req, deps)), "primary-key-version-ref", "Идентификатор основной версии криптографического ключа") }, Run: func(ctx context.Context, args []string) error { // Валидация флагов, аргументов, инициализация SDK ... // Разбор тела запроса в объект req.Body err = cli.Parse(deps.FS, deps.Input.Get(), body, &req.Body) if err != nil { return fmt.Errorf("parse body: %w", err) } // Отложенное применение флагов тела запроса err = flags.Apply() if err != nil { return fmt.Errorf("apply body flags: %w", err) } // Запрос в бэкенд, вывод ответа ... }, }, ... } } Поля тела запроса могут быть не только скалярных типов, но и объектами, массивами и отображениями. Эти случаи несколько сложнее: для объектов мы решили сгенерировать вызов unmarshal, для массивов поддержали многократную передачу флагов, а отображения решили пока что проигнорировать, поскольку в нашем API таких случаев крайне мало. Пример для тестового внутреннего API, для которого сгенерировался флаг, отвечающий за массив объектов: func devpDuckCreateCarsFlag[T cli.ProfileConfig](req *spaceducktestobjectclient.UpsertDuckRequest, deps cli.Deps[T]) cli.LazyValue { return cli.SliceLazyCustomValue("objectSlice", func(s string) (spaceducktestobjectmodel.DuckSpecAssetsCarsRequest, error) { var out spaceducktestobjectmodel.DuckSpecAssetsCarsRequest if err := cli.Parse(deps.FS, deps.Input.Get(), s, &out); err != nil { return cli.EmptyValue[spaceducktestobjectmodel.DuckSpecAssetsCarsRequest](), err } return out, nil }, func(i int, out spaceducktestobjectmodel.DuckSpecAssetsCarsRequest) error { if req.Body.Spec.Assets == nil { req.Body.Spec.Assets = new(spaceducktestobjectmodel.DuckSpecAssetsRequest) } if i > len(req.Body.Spec.Assets.Cars)-1 { req.Body.Spec.Assets.Cars = cli.AppendEmptyElement(req.Body.Spec.Assets.Cars) } req.Body.Spec.Assets.Cars[i] = out return nil }, ) } При генерации флагов из тела запроса есть проблема: названия полей могут совпадать у совершенно разных полей. Например, у сущности virtualMachine может быть поле name, при этом поле name может быть и у дочерней для virtualMachine сущности disk. Для таких случаев мы ввели эвристики и поддержали ручное регулирование названий флагов через расширение OpenAPI-спецификации. В итоге если совместить логику генерации дерева команд и флагов, получится рабочий прототип CLI. Осталось добавить полезной функциональности. Генерация полезных фич Позиционный аргумент для операций create, get, update и delete Первая крупица дополнительной функциональности, которую можно сгенерировать, — позиционный аргумент для некоторых видов операций. Так, большинство операций имеет обязательный параметр пути запроса, отвечающий за id ресурса, например: GET /compute/v1/projects/{project}/virtualMachines/{virtualMachine} POST /vpc/v1/projects/{project}/networks/{network}/subnets/{subnet} DELETE /certmanager/v1/projects/{project}/certificates/{name} В процессе генерации каждый из этих параметров превращается в обязательный флаг, но у всех этих флагов разные имена, например, --subnet, --name, это не очень единообразно. Можно либо переименовать все такие флаги в --id, либо сделать вместо флага полноценный аргумент. Мы решили пойти по второму пути, таким образом, для примеров выше мы получили команды: mws compute virtual-machine get test-vm mws vpc subnet create test-subnet --network test-network mws vpc subnet update test-subnet --network test-network mws certmanager certificate delete test-certificate Для того чтобы реализовать эту функциональность, нужно сгенерировать в теле функции Run дополнительную проверку на наличие аргумента, а затем выставить значение этого аргумента в соответствующее поле запроса: func computeDiskUpdate[T cli.ProfileConfig](deps cli.Deps[T]) cmdtool.Command { req := client.UpdateDiskRequest{} return cmdtool.Command{ Action: cmdtool.Action{ BindFlags: func(set cmdtool.FlagSet) { // ... }, Run: func(ctx context.Context, args []string) error { if len(args) != 1 { return cli.ArgumentCountError{Accepts: 1, Received: len(args)} } req.DiskID = args[0] // ... }, }, Use: "update ", Short: "Update disk", } } Табличный вывод для операции list В нашем облачном API почти все list-операции поддерживают пагинацию, а ресурсы содержат множество полей. Возможность постранично просматривать набор самых важных полей ресурса в виде таблицы сильно упрощает жизнь пользователям CLI, поэтому мы решили поддержать эту механику. Пример табличного вывода: mws compute image list +----------+-----------------+------------+---------------+---------+----------+----------------------+ | NAME | DISPLAY NAME | STATE | SIZE | FAMILY | ACTIVITY | CREATED | +----------+-----------------+------------+---------------+---------+----------+----------------------+ | i-surfux | i-surfux | OK | 32212254720 B | test | ACTUAL | 2025-05-01T18:00:18Z | +----------+-----------------+------------+---------------+---------+----------+----------------------+ | i-yaqmer | i-yaqmer | OK | 32212254720 B | wqe1231 | ACTUAL | 2025-05-01T18:02:07Z | +----------+-----------------+------------+---------------+---------+----------+----------------------+ Для того чтобы можно было просматривать список экземпляров ресурса в виде таблицы, этот ресурс должен удовлетворять двум условиям: В запросе операции list для этого ресурса присутствует параметр pageToken, а в ответе — поля items и nextPageToken. Другими словами, для ресурса должна быть поддержана пагинация. Ресурс имеет поля metadata и status, либо в его спецификации присутствует разметка табличного вывода. Два вышеописанных поля нужны для того, чтобы выбрать колонки по умолчанию, а разметка позволяет переопределить набор этих колонок либо задать их для нестандартных ресурсов. Разметка представлена в виде нашего кастомного расширения x-cli, которое мы учитываем при генерации, а в этом расширении представлен список колонок с различными свойствами. Например, название и ширина колонки. Для в��ех ресурсов, которые удовлетворяют этим условиям, генерируются два дополнительных метода: GetColumns и GetRow. Первый метод задаёт заголовок таблицы, а второй позволяет превратить объект в запись таблицы. Для наглядности в комментарии к методу GetColumns генерируется пример таблицы для данной сущности. // GetColumns returns list of columns for CLI table view. Here's an example of a rendered table with these columns: // // +--------------------------------+------------------------------------------+------------+----------------------+-----------------+ // | ID | DESCRIPTION | STATUS | UPDATE TIME | VM TYPE | // +--------------------------------+------------------------------------------+------------+----------------------+-----------------+ // | xxx/yyy/zzz/www | Lorem ipsum dolor sit amet, consectetur | OK | 2025-04-02T00:00:00Z | xxx/yyy/zzz/www | // | | adipiscing elit. Integer id velit id nis | | | | // | | l semper ullamcorper... | | | | // +--------------------------------+------------------------------------------+------------+----------------------+-----------------+ func (VirtualMachine) GetColumns() []table.Column { return append(table.DefaultMetadataStatusColumns(), []table.Column{ {Name: "VM type", MaxWidth: 20}, }...) } func (m VirtualMachine) GetRow() []any { return table.Row( m.Metadata.GetId(), ptr.Value(m.Metadata.GetDescription()), m.Status.GetReady().State, ptr.Value(m.Metadata.GetUpdateTime()), m.Spec.GetVmType(), ) } Данные методы используются в библиотечном коде CLI, написанном вручную, об этом речь пойдёт чуть позже. В сгенерированном коде для list-операций, поддерживающих табличный вывод, генерируется дополнительная проверка с вызовом библиотечных функций: func computeVirtualMachineList[T cli.ProfileConfig](deps cli.Deps[T]) cmdtool.Command { req := computecplclient.ListVirtualMachinesRequest{} return cmdtool.Command{ Action: cmdtool.Action{ BindFlags: func(set cmdtool.FlagSet) { // ... }, Run: func(ctx context.Context, args []string) error { // ... if cli.OutputFormat(deps.Output.Get()) == cli.OutputFormatText { return cli.PrintPager(ctx, deps, page.NewPager(req, client.ListVirtualMachines)) } // ... }, }, Use: "list", Short: "List of virtual machines", } } Для запросов генерируется метод WithPageToken, чтобы можно было выделить интерфейс для работы с такими запросами. Об этом также пойдёт речь в следующем разделе. func (m ListVirtualMachinesRequest) WithPageToken(token *string) ListVirtualMachinesRequest { m.PageToken = token return m } Что написать руками? К сожалению, как бы нам ни хотелось, полностью сгенерировать CLI не получится, часть функциональности требуется написать вручную. В данном разделе будут перечислены основные компоненты, которые составляют библиотечную часть CLI. Точка входа в CLI Точка входа в CLI представляет собой функцию, принимающую на вход корневую команду. Корневая команда, в свою очередь, содержит в себе основные настройки CLI и набор поддеревьев команд. func main() { cobra.Main(cli.NewRootCommand( name, shortDesc, longDesc, append( gencli.All[*Config](), cli.Init(NewDefaultConfig), cli.Profile(NewDefaultConfig), cli.Version[*Config](rootcmd.Version), cli.Update[*Config], ), cli.DefaultDeps[*Config]().WithDefaultCLIName(name), debug, )) } Такое свойство корневой команды позволяет собирать кастомные CLI на основе общего ядра, например, можно создать утилиту для дежурного, в которую выборочно попадет сгенерированное поддерево команд одного из сервисов и набор полезных команд для дежурного. func main() { cobra.Main(cli.NewRootCommand( name, shortDesc, longDesc, []cli.Command[*Config]{ gencli.Compute[*Config], // only Compute subtree dutycli.Duty[*Config], // new custom commands cli.Profile(NewDefaultConfig), }, cli.DefaultDeps[*Config](), debug, )) } Управление профилями Профиль — набор настроек CLI, собранный в виде конфигурационного YAML-файла, в него входят такие параметры, как зона доступности по умолчанию, выбранный проект и способ аутентификации. Профили можно использовать для работы с разными окружениями или проектами, использовать разные способы аутентификации. zone: ru-central1-a project: test-project service_account: name: my-test authorized_key: id: test-cli base_endpoint: http://api.mwsapis.ru log_level: info trace_enabled: true Для профилей в CLI есть отдельное поддерево команд, полностью написанное вручную, в нём присутствуют команды по созданию, просмотру, изменению и удалению профилей. Ввод и вывод CLI поддерживает форматы JSON и YAML для входных данных и вывода, а также особый формат Text для вывода. Формат ввода и вывода задаётся с помощью двух глобальных флагов. В случае JSON и YAML всё довольно просто, а вот Text имеет свои особенности. Реальный вывод при выборе формата Text зависит от ситуации: для ошибок будет выведен их текст, для ответа API будет выведен текст в формате YAML, а для list-операций, поддерживающих табличный вывод, выведется таблица. На табличном выводе остановимся подробнее и разберёмся, как он реализован в библиотечном коде. Для начала общая часть всех табличных сущностей выделяется в интерфейс View. type View interface { GetColumns() []Column GetRow() []any } Далее, с помощью интерфейса и функции описывается обход произвольного ресурса с пагинацией. type Pager[T any] interface { Next(context.Context) ([]T, error) } func Iterate[T any](ctx context.Context, pager Pager[T]) iter.Seq2[[]T, error] { return func(yield func([]T, error) bool) { for { items, err := pager.Next(ctx) if errors.Is(err, ErrNoItems) || !yield(items, err) { return } } } } Наконец, осталось реализовать постраничный обход для случая облачного API. Для этого нужен интерфейс Request, который описывает запросы, в которых можно отправлять параметр pageToken. А также интерфейс Response, который описывает ответы list-операций, содержащие поля items и nextPageToken. Далее логика следующая: на вход подаётся функция, описывающая запрос в API, после чего совершается первый запрос с пустым pageToken. Если в ответе пришёл пустой nextPageToken, это означает конец списка, в противном случае nextPageToken сохраняется и используется в качестве pageToken для следующего запроса. Независимо от значения nextPageToken, поле items из ответа возвращается наружу. type Request[T any] interface { WithPageToken(*string) T } type Response[Token ~string, T any] interface { GetNextPageToken() *Token GetItems() []T } type Do[Token ~string, T any, Req Request[Req], Resp Response[Token, T]] func(context.Context, Req) (Resp, error) func NewPager[Token ~string, T any, Req Request[Req], Resp Response[Token, T]]( req Req, do Do[Token, T, Req, Resp], ) *Pager[Token, T, Req, Resp] { return &Pager[Token, T, Req, Resp]{ req: req, do: do, } } type Pager[Token ~string, T any, Req Request[Req], Resp Response[Token, T]] struct { req Req do Do[Token, T, Req, Resp] stop bool } func (p *Pager[Token, T, Req, Resp]) Next(ctx context.Context) (data []T, err error) { if p.stop { return nil, page.ErrNoItems } resp, err := p.do(ctx, p.req) if err != nil { return nil, err } var token *string if nextToken := resp.GetNextPageToken(); nextToken == nil || *nextToken == "" { p.stop = true } else { token = ptr.Get(string(*nextToken)) } p.req = p.req.WithPageToken(token) return resp.GetItems(), nil } func (p *Pager[Token, T, Req, Resp]) HasNext() bool { return !p.stop } Метод HasNext нужен для того, чтобы остановить постраничный обход ровно на последней странице, а не после конца списка, это полезно именно для CLI, поэтому в реализации вывода таблицы на экран используется другой интерфейс: func paginate[V devptable.View](ctx context.Context, in InputReader, pager page.PagerWithHasNext[V]) error { out := cmdtool.Output(ctx) t := devptable.DefaultWriter() i := 0 for items, err := range page.Iterate(ctx, pager) { if err != nil { return err } err = printPageWithWriter(t, out, i, items) switch { case errors.Is(err, errNoTable): return defaultPrint(out, items) case err != nil: return err } if !pager.HasNext() { return nil } interrupt, err := waitPageControls(in) if err != nil { return err } if interrupt { break } i++ } return nil } Функция waitPageControls ждёт пользовательского ввода, поскольку таблица не простая, а золотая интерактивная. На гифке ниже представлен постепенный вывод таблицы по две записи на страницу. Обсервабилити Логирование, генерация trace и span ID, режим отладки — весь этот набор полезных функциональностей для расследования проблем с CLI тоже полностью написан руками в виде подключаемых плагинов. Здесь больше нечего добавить, поэтому вот пример: 2025-05-26T13:38:19+03:00 INFO cli starting {"version": "", "os": "darwin", "arch": "arm64"} 2025-05-26T13:38:19+03:00 INFO cli selected profile {"profile": "prod"} 2025-05-26T13:38:19+03:00 INFO sdk.http.client request completed {"http_req_host": "api.mwsapis.ru", "http_req_method": "GET", "http_req_url": "http://api.mwsapis.ru/endpoint", "trace_id": "d3ec15b057a3ff8c367cd093b94dc748", "span_id": "74a6ed7a0cca057a", "http_req_duration": "126.935041ms", "http_resp_code": 200, "http_resp_proto": "HTTP/1.1", "http_resp_status": "200 OK", "http_resp_size": 594} Обновление Раз в сутки наша CLI настойчиво предлагает обновиться, если найдена более свежая её версия. Если вызвать команду mws update, то произойдёт обновление на последнюю версию утилиты. Этот компонент написан вручную и активно переиспользуется во внутренних утилитах нашей команды. Автодополнение и help На момент написания данной статьи у нашей CLI большей части стандартное автодополнение, предоставляемое фреймворком Cobra. При этом активно используются кастомные функции для автодополнения, например, CLI подсказывает название профиля на основе информации о существующих профилях. Что касается help, то на момент выхода статьи почти никаких правок стандартный help из фреймворка не претерпел. Однако в планах написание своих help и usage, для более удобного использования CLI. Итоги Мы довольны тем, что получилось, потому что: Сгенерирована большая часть CLI. В частности, 12 тысяч строк кода, включая 6 тысяч строк кода в тестах, содержится в библиотечной части, 3 тысячи строк ушло на генератор, а сгенерировано более 70 тысяч строк кода. Ядро CLI можно переиспользовать, можно собирать свои CLI. Нам понравился процесс. Генерация — это весело. Всем спасибо за внимание, увидимся в следующих статьях!

Фильтры и сортировка