O poder do CLI com Golang e Cobra CLI
Wiliam V. Joaquim
Posted on November 15, 2023
Hoje vamos ver todo o poder quem uma CLI (Command line interface) pode trazer para o desenvolvimento, uma CLI pode nos ajudar a executar tarefas de forma mais eficaz e leve através de comandos via terminal, sem precisar de uma interface. Como por exemplo o git e o Docker , praticamente usamos a CLI deles o tempo inteiro, quando executamos um git commit -m "commit message"
ou docker ps -a
estamos utilizando uma CLI. Vou deixar um artigo que detalha melhor o que é uma CLI.
Nesse post vamos criar um boilerplate para projetos em GO, onde com apenas 1 comando via CLI, vai ser criado toda a estrutura do projeto.
GO e CLI
Bom, o Go é extremamente poderoso para contrução de CLI, é umas das linguagens mais utilizadas para isso, não é atoa que é a amplamente utilizada entre os DevOps, justamente por ser tão poderosa e simples.
Só para dar um exemplo do poder do Go para construções de CLI, você já deve ter utilizado ou pelo menos ouviu falar do Docker, Kubernetes, Prometheus, Terraform,mas o que todos eles tem em comum? todos eles tem grande parte da sua usabilidade via CLI e são desenvolvidos em Go 🐿.
Iniciando uma CLI com GO
O Go tem um pacote para lidar com CLI de forma nativa. Mas vamos abordar de forma rápida, o intuito do post é utilizar o pacote Cobra CLI, que vai facilitar a construção da nossa CLI.
Vamos utilizar o pacote flag
package main
import (
"flag"
"fmt"
"time"
)
func main() {
dateFlag := flag.Bool("date", false, "Exibir a data atual")
flag.Parse()
if *dateFlag {
currentTime := time.Now()
fmt.Println("Data atual:", currentTime.Format("2006-01-02 15:04:05"))
}
}
Nesse exemplo acima, criamos uma flag date
, ao passar essa flag é retornado a data atual, algo bem simples, rodando o projeto com go run main.go --date
, vamos ter o valor Data atual: 2023-11-15 12:26:14
.
dateFlag := flag.Bool("date", false, "Exibir a data atual")
No código acima, criamos uma flag, o primeiro argumento date
é o nome de flag, o false como valor padrão significa que, se você rodar o programa sem especificar explicitamente a flag --date, o valor associado a dateFlag
será false
. Isso permite que o programa tenha um comportamento padrão específico caso essa flag não seja fornecida quando o programa é executado, já o terceiro argumento Exibir a data atual
é o detalhe do que essa flag faz.
Se rodarmos:
go run main.go -h
Recebemos:
-date
Exibir a data atual
Podemos usar a flag com --date
ou -date
, o Go já faz a verificação automática.
Podemos fazer todo o nosso boilerplate com essa abordage, porém vamos facilitar um pouco e usar o pacote Cobra CLI.
Cobra CLI
Esse pacote é muito utilizado para contruções de CLI poderosas, é utilizado por exemplo para o Kubernetes CLI e GitHub CLI, além de oferecer alguns recursos bacanas como preenchimento automático do shell, reconhecimento automático de sinalizadores (as tags), podendo utilizar -h
ou -help
por exemplo, entre outras facilidades.
Criando o projeto
Nosso projeto vai ser bem simples, vamos ter apenas o main.go
e o go.mod
e consequentemente nosso go.sum
, vamos iniciar o projeto com o comando:
go mod init github.com/wiliamvj/boilerplate-cli-go
Você pode utilizar o nome que desejar, por convenção geralmente criamos o nome do projeto sendo o link do nosso repositório.
ficando assim:
Agora vamos baixar o pacote Cobra com o comando:
go get -u github.com/spf13/cobra@latest
Nosso boilerplate vai ter uma estrutura bem simples, a ideia é criar uma estrutura muito utilizada pela comunidade em Go, veja como vai ficar:
-
cmd: Aqui é onde vamos deixar o
main.go
que inicia nosso app. -
internal: Nessa pasta onde deve ficar todo o código da nossa aplicação.
- handler: Aqui vai ficar os arquivos responsáveis por receber nossas solicitações http, você pode conhecer também como controllers.
- routes: Aqui vamos organizar nossas rotas.
Não é a estrutura completa, estamos apenas criando o básico para o nosso exemplo.
Todo o nosso código vai se concentrar em nosso main.go
.
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
func main() {
var rootCommand = &cobra.Command{}
var projectName, projectPath string
var cmd = &cobra.Command{
Use: "create",
Short: "Create boilerplate for a new project",
Run: func(cmd *cobra.Command, args []string) {
// validations
if projectName == "" {
fmt.Println("You must supply a project name.")
return
}
if projectPath == "" {
fmt.Println("You must supply a project path.")
return
}
fmt.Println("Creating project...")
},
}
cmd.Flags().StringVarP(&projectName, "name", "n", "", "Name of the project")
cmd.Flags().StringVarP(&projectPath, "path", "p", "", "Path where the project will be created")
rootCommand.AddCommand(cmd)
rootCommand.Execute()
}
O código acima é apenas para iniciar o nosso CLI, vamos ter apenas duas váriaveis:
-
projectName
: será o nome do nosso projeto que vamos capturar no input da nossa CLI. -
projectPath
: será o caminho onde o boilerplate será criado, vamos capturar no input do CLI. -
&cobra.Command{}
: inicia o pacote Cobra. -
Run:
Recebe uma funcão anônima, é nessa função que capturamos o input do usuário digitado no CLI e validamos, nossa validação é simples, apenas verificamos se oprojectName
e oprojectPath
não são nulos. -
cmd.Flags()
: Aqui criamos as flags com sinalizadores, dessa forma pode ser usado-name
ou-n
, ambos serão aceitos, também colocamos a descrição do que esse sinalizado faz. -
rootCommand.AddCommand(cmd)
: Adicionamos nossocmd
aorootCommand
criado no inicio do nossomain.go
. -
rootCommand.Execute()
: Por fim, executamos nossa CLI.
Isso é tudo que precisamos para deixar nossa CLI funcionando, claro que sem a lógica do nosso boilerplate, mas com isso já conseguimos utilizar via terminal. Vamos testar!
Podemos fazer um build do projeto o usar sem o build
Com build:
go build -o cli .
Vai criar na raiz um arquivo chamado cli
, vamos rodar o binário da nossa CLI:
./cli --help
Vamos ter uma saida igual a essa:
Usage:
[command]
Available Commands:
completion Generate the autocompletion script for the specified shell
create Create boilerplate for a new project
help Help about any command
Flags:
-h, --help help for this command
Use " [command] --help" for more information about a command.
Veja que já temos as dicas de como utilizar o comando que criamos create Create boilerplate for a new project
, se rodarmos:
./cli create --help
Teremos:
Create boilerplate for a new project
Usage:
create [flags]
Flags:
-h, --help help for create
-n, --name string Name of the project
-p, --path string Path where the project will be created
Vamos rodar agora passando nossas flags:
./cli create -n my-project -p ~/documents
Vamos ter nossa mensagem Creating project...
, indicando que funcionou, mas nada ainda acontece, pois não implementamos a lógica.
Podemos ainda criar subcomandos, novas flags, novas validações, mas por enquanto vamos deixar assim, se quiser você pode criar mais opções, veja a documentação do pacote Cobra.
Criando o boilerplate
Com a nossa CLI pronta, vamos agora a lógica do boilerplate, que é bem simples, teremos que criar as pastas, depois precisamos criar os arquivos e por fim abrir os arquivos e inserir o código, para isso vamos utilizar bastante o pacote os do Go, que permite acessar recursos do sistema operacional.
Vamos primeiro pegar o diretório principal e validar se já existe uma pasta com o nome que vai se usado para criar o nosso projeto:
globalPath := filepath.Join(projectPath, projectName)
if _, err := os.Stat(globalPath); err == nil {
fmt.Println("Project directory already exists.")
return
}
Se passarmos o projectName
como test e o projectPath
como /documents
, isso valida se não existe nenhuma outra pasta em documents chamado test, se existir returnamos e devolvemos uma mensagem de erro.
Você pode modificar e caso exista uma pasta com mesmo nome, alterar o nome do projectName
ou deletar a pasta que já existe, mas por hora vamos apenas retornar erro.
if err := os.Mkdir(globalPath, os.ModePerm); err != nil {
log.Fatal(err)
}
Nessa parte, vamos criar o diretório no caminho que foi informado usando nossa flag -p
, se usarmos:
./cli create -n my-project -p ~/documents
Iniciando o Go
Vai ser criado uma pasta chamada my-project no diretório documents.
startGo := exec.Command("go", "mod", "init", projectName)
startGo.Dir = globalPath
startGo.Stdout = os.Stdout
startGo.Stderr = os.Stderr
err := startGo.Run()
if err != nil {
log.Fatal(err)
}
No código acima executamos o comando para iniciar o projeto em Go, vai ser criado no diretório raiz que escolhemos, no nosso exemplo vai rodar dentro de documents/my-project, isso vai criar o arquivo go.mod
e vai definir o nome do módulo como my-projects.
-
exec.Command
: Cria o comando que vamos rodar no terminal, no caso vai sergo mod init my-project
. -
startGo.Dir
: Determinar onde vai rodar esse comando, no exemplo vai rodar em documents/my-project. -
startGo.Stdout
: Vai colocar no terminal o retorno do comando, vai retornargo: creating new go.mod: module my-project
. -
startGo.Stderr
: Redireciona a saida de um possivel erro para onde o programa está sendo executado. -
startGo.Run()
: Por fim, executamos tudo.
Criando as pastas
Vamos criar nossas pastas, são elas cmd, internal, handler e routes.
cmdPath := filepath.Join(globalPath, "cmd")
if err := os.Mkdir(cmdPath, os.ModePerm); err != nil {
log.Fatal(err)
}
internalPath := filepath.Join(globalPath, "internal")
if err := os.Mkdir(internalPath, os.ModePerm); err != nil {
log.Fatal(err)
}
handlerPath := filepath.Join(internalPath, "handler")
if err := os.Mkdir(handlerPath, os.ModePerm); err != nil {
log.Fatal(err)
}
routesPath := filepath.Join(handlerPath, "routes")
if err := os.Mkdir(routesPath, os.ModePerm); err != nil {
log.Fatal(err)
}
Esse código acima cria na sequência as pastas necessárias, usando os.Mkdir
, (veja nas docs), para as pastas handler e routes, precisamos acessar a pasta internal, pois serão criadas dentro da internal, para isso pegamos usando o Join
mesclamos o caminho, ficando:
-
handlerPath
: documents/my-project/internal -
routesPath
: documents/my-project/internal/handler
Criando os arquivos
Com as pastas criadas, vamos criar os aquivos, para exemplo vamos criar o main.go
é claro e o routes.go
, dentro da pasta routes.
mainPath := filepath.Join(cmdPath, "main.go")
mainFile, err := os.Create(mainPath)
if err != nil {
log.Fatal(err)
}
defer mainFile.Close()
routesFilePath := filepath.Join(routesPath, "routes.go")
routesFile, err := os.Create(routesFilePath)
if err != nil {
log.Fatal(err)
}
defer routesFile.Close()
Acima criamos os arquivos main.go
e routes.go
.
-
mainPath
: determinamos o caminho, usando omainPath
usado para criar a pasta cmd. -
os.Create(mainPath)
: Criamos o arquivo, no diretório especificado. (documents/my-project/cmd) -
routesFilePath
: determinamos o caminho, usando oroutesPath
usado para criar a pasta routes. -
os.Create(routesFilePath)
: Criamos o arquivo, no diretório especificado. (documents/my-project/internal/handler/routes) -
defer routesFile.Close()
: Fechamos o arquivo,defer
, usando essa palavra reservada do GO, garantimos que a última coisa a acontecer é fechar o arquivo. Veja mais sobre odefer
aqui.
Escrevendo nos arquivos
Com as pastas e arquivos criados, agora vamos escrever nos arquivos main.go
e routes.go
, vamos fazer algo simples, apenas para exemplo, para organizar melhor, vamos separar em funções que escrevem em cada arquivo.
func WriteMainFile(mainPath string) error {
packageContent := []byte(`package main
import "fmt"
func main() {
fmt.Println("Hello World!")
}
`)
mainFile, err := os.OpenFile(mainPath, os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
return err
}
defer mainFile.Close()
_, err = mainFile.Write(packageContent)
if err != nil {
return err
}
return nil
}
Na função acima, recemos por parâmetro o mainPath
, que é o caminho do arquivo, vamos adiciona um código simples, que apenas fazer o log de um Hello World.
-
packageContent
: Criamos o código que vai ser escrito no arquivo. -
os.OpenFile
: Abrimos o arquivo especificado emmainPath
. -
defer mainFile.Close()
: Fechamos o arquivo por último comdefer
. -
mainFile.Write
: Por fim, escrevemos no arquivo, e tratamos o erro se houver.
O_WRONLY
e O_APPEND
, são constantes usadas para definir modo de abertura de um arquivo, O_WRONLY
indica que o arquivo será aberto apenas para escrita, O_APPEND
isso faz com o conteúdo adicionado serão acrescentados no fim do arquivo, sem sobrescrever o conteúdo existente.
func WriteRoutesFile(routesFilePath string) error {
packageContent := []byte(`package routes
// Seu código aqui
`)
routesFile, err := os.OpenFile(routesFilePath, os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
return err
}
defer routesFile.Close()
_, err = routesFile.Write(packageContent)
if err != nil {
return err
}
return nil
}
Fazemos o mesmo para o arquivo routes.go
.
Agora basta chamar as novas funções na função main
, ficando assim:
mainPath := filepath.Join(cmdPath, "main.go")
mainFile, err := os.Create(mainPath)
if err != nil {
log.Fatal(err)
}
defer mainFile.Close()
if err := WriteMainFile(mainPath); err != nil {
log.Fatal(err)
}
routesFilePath := filepath.Join(routesPath, "routes.go")
routesFile, err := os.Create(routesFilePath)
if err != nil {
log.Fatal(err)
}
defer routesFile.Close()
if err := WriteRoutesFile(routesFilePath); err != nil {
log.Fatal(err)
}
Código final
package main
import (
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"github.com/spf13/cobra"
)
func main() {
var rootCommand = &cobra.Command{}
var projectName, projectPath string
var cmd = &cobra.Command{
Use: "create",
Short: "Create boilerplate for a new project",
Run: func(cmd *cobra.Command, args []string) {
if projectName == "" {
fmt.Println("You must supply a project name.")
return
}
if projectPath == "" {
fmt.Println("You must supply a project path.")
return
}
fmt.Println("Creating project...")
globalPath := filepath.Join(projectPath, projectName)
if _, err := os.Stat(globalPath); err == nil {
fmt.Println("Project directory already exists.")
return
}
if err := os.Mkdir(globalPath, os.ModePerm); err != nil {
log.Fatal(err)
}
startGo := exec.Command("go", "mod", "init", projectName)
startGo.Dir = globalPath
startGo.Stdout = os.Stdout
startGo.Stderr = os.Stderr
err := startGo.Run()
if err != nil {
log.Fatal(err)
}
cmdPath := filepath.Join(globalPath, "cmd")
if err := os.Mkdir(cmdPath, os.ModePerm); err != nil {
log.Fatal(err)
}
internalPath := filepath.Join(globalPath, "internal")
if err := os.Mkdir(internalPath, os.ModePerm); err != nil {
log.Fatal(err)
}
handlerPath := filepath.Join(internalPath, "handler")
if err := os.Mkdir(handlerPath, os.ModePerm); err != nil {
log.Fatal(err)
}
routesPath := filepath.Join(handlerPath, "routes")
fmt.Println(routesPath)
if err := os.Mkdir(routesPath, os.ModePerm); err != nil {
log.Fatal(err)
}
mainPath := filepath.Join(cmdPath, "main.go")
mainFile, err := os.Create(mainPath)
if err != nil {
log.Fatal(err)
}
defer mainFile.Close()
if err := WriteMainFile(mainPath); err != nil {
log.Fatal(err)
}
routesFilePath := filepath.Join(routesPath, "routes.go")
routesFile, err := os.Create(routesFilePath)
if err != nil {
log.Fatal(err)
}
defer routesFile.Close()
if err := WriteRoutesFile(routesFilePath); err != nil {
log.Fatal(err)
}
},
}
cmd.Flags().StringVarP(&projectName, "name", "n", "", "Name of the project")
cmd.Flags().StringVarP(&projectPath, "path", "p", "", "Path where the project will be created")
rootCommand.AddCommand(cmd)
rootCommand.Execute()
}
func WriteMainFile(mainPath string) error {
packageContent := []byte(`package main
import "fmt"
func main() {
fmt.Println("Hello World!")
}
`)
mainFile, err := os.OpenFile(mainPath, os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
return err
}
defer mainFile.Close()
_, err = mainFile.Write(packageContent)
if err != nil {
return err
}
return nil
}
func WriteRoutesFile(routesFilePath string) error {
packageContent := []byte(`package routes
// Seu código aqui
`)
routesFile, err := os.OpenFile(routesFilePath, os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
return err
}
defer routesFile.Close()
_, err = routesFile.Write(packageContent)
if err != nil {
return err
}
return nil
}
Testando a CLI
Bom, com tudo pronto, vamos testar! Para isso vamos compilar nosso código com o bom e velho go build
.
go build -o cli .
Executando a CLI:
./cli create -n my-project -p ~/documents
Vamos ter o retorno:
Creating project...
go: creating new go.mod: module my-project
Acessando nosso projeto e abrindo no Visual Studio Code com:
cd /documents/my-project && code .
Teremos noss boilerplate criado:
Se rodarmos o projeto criado via CLI, podemos ver que tudo funciona.
go run cmd/main.go
output:
Hello World!
Com isso finalizamos a criação de nossa CLI que cria um boilerplate.
Considerações finais
Vimos o poder que uma CLI pode nos proporcionar, sem contar a rapidez da sua execução. Usando o pacote Cobra CLI temos ainda mais facilidade, a criação de um boilerplate é apenas um exemplo, podemos automatizar muitas tarefas.
O nosso boilerplate poderia ser ainda mais automatizado, conseguimos por exemplo instalar um pacote como o Go Chi, criando endpoints padrões, tudo isso usando a CLI, você pode até mesmo criar seu próprio framework, já pensou que com apenas 1 comando seu projeto inicial já vem todo configurado?
Com o conhecimento em na criação de CLI, você tem um grande poder em suas mãos!
Link do repositório
repositório do projeto
link do projeto no meu blog
Posted on November 15, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.