O poder do CLI com Golang e Cobra CLI

Wiliam V. Joaquim - Nov 15 '23 - - Dev Community

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"))
    }
  }
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Recebemos:

  -date
    Exibir a data atual
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

Project structure

Agora vamos baixar o pacote Cobra com o comando:

  go get -u github.com/spf13/cobra@latest
Enter fullscreen mode Exit fullscreen mode

Nosso boilerplate vai ter uma estrutura bem simples, a ideia é criar uma estrutura muito utilizada pela comunidade em Go, veja como vai ficar:

Project structure

  • 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()
  }
Enter fullscreen mode Exit fullscreen mode

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 o projectName e o projectPath 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 nosso cmd ao rootCommand criado no inicio do nosso main.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 .
Enter fullscreen mode Exit fullscreen mode

Vai criar na raiz um arquivo chamado cli, vamos rodar o binário da nossa CLI:

  ./cli --help
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Vamos rodar agora passando nossas flags:

  ./cli create -n my-project -p ~/documents
Enter fullscreen mode Exit fullscreen mode

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
  }
Enter fullscreen mode Exit fullscreen mode

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)
  }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
  }
Enter fullscreen mode Exit fullscreen mode

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 ser go 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 retornar go: 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)
  }
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Acima criamos os arquivos main.go e routes.go.

  • mainPath: determinamos o caminho, usando o mainPath 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 o routesPath 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 o defer 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
  }
Enter fullscreen mode Exit fullscreen mode

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 em mainPath.
  • defer mainFile.Close(): Fechamos o arquivo por último com defer.
  • 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_APPENDisso 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
  }
Enter fullscreen mode Exit fullscreen mode

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)
  }
Enter fullscreen mode Exit fullscreen mode

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
  }
Enter fullscreen mode Exit fullscreen mode

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 .
Enter fullscreen mode Exit fullscreen mode

Executando a CLI:

  ./cli create -n my-project -p ~/documents
Enter fullscreen mode Exit fullscreen mode

Vamos ter o retorno:

  Creating project...
  go: creating new go.mod: module my-project
Enter fullscreen mode Exit fullscreen mode

Acessando nosso projeto e abrindo no Visual Studio Code com:

  cd /documents/my-project && code .
Enter fullscreen mode Exit fullscreen mode

Teremos noss boilerplate criado:

Final Project

Se rodarmos o projeto criado via CLI, podemos ver que tudo funciona.

  go run cmd/main.go

  output:
    Hello World!
Enter fullscreen mode Exit fullscreen mode

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

. . . . . . . . . . . . . . . . . . . . . .