O poder do CLI com Golang e Cobra CLI
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.
- handler: Aqui vai ficar os arquivos responsáveis por receber nossas solicitações http, você pode conhecer também como controllers.
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/internalroutesPath
: documents/my-project/internal/handler
Criando os arquivos
Com as pastas criadas, vamos criar os arquivos, 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, recebemos 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