Projeto: ferramenta CLI em Go
LidoNesta aula, você aprenderá a criar uma ferramenta de linha de comando (CLI) em Go, abordando desde o parsing de argumentos até a leitura de arquivos, saída formatada e testes. O projeto prático consolidará os conceitos de forma aplicada, preparando você para desenvolver utilitários reais.
Bem-vindo à aula 87! Nesta aula, vamos consolidar os conhecimentos adquiridos ao longo do curso, construindo uma ferramenta de linha de comando (CLI) completa em Go. Uma CLI é um programa que roda no terminal, recebendo argumentos e produzindo saída textual. Vamos explorar os pilares de qualquer CLI: o parsing de argumentos, a leitura de arquivos, a saída formatada e, claro, a escrita de testes para garantir a qualidade do código.
Nosso projeto será um utilitário de análise de texto: ele receberá um arquivo de texto e opções como -palavras, -linhas ou -caracteres, e exibirá estatísticas sobre o conteúdo. Esse exemplo prático toca vários pontos importantes da linguagem e da biblioteca padrão, tornando o aprendizado concreto.
Parsing de argumentos
Em Go, a forma mais comum de processar argumentos de linha de comando é usando o pacote flag, que faz parte da biblioteca padrão. Ele permite definir flags (opções) e argumentos posicionais. Vamos começar com um exemplo simples: um programa que aceita uma flag -nome e um argumento posicional (um arquivo).
package main
import (
"flag"
"fmt"
"os"
)
func main() {
// Define uma flag chamada "nome" com valor padrão "mundo" e descrição
nome := flag.String("nome", "mundo", "nome para saudação")
// Faz o parsing dos argumentos
flag.Parse()
// Argumentos posicionais restantes (após as flags)
args := flag.Args()
if len(args) == 0 {
fmt.Println("Uso: meuapp [flags] arquivo")
os.Exit(1)
}
arquivo := args[0]
fmt.Printf("Olá, %s! Arquivo: %s\n", *nome, arquivo)
}
No exemplo, criamos uma flag do tipo string com flag.String, que retorna um ponteiro para o valor. Após flag.Parse(), as flags são processadas e os argumentos posicionais ficam disponíveis em flag.Args(). Note que precisamos desreferenciar o ponteiro para obter o valor da flag.
Para o nosso projeto, vamos definir flags como -palavras, -linhas e -caracteres, que serão booleanas (presença indica que queremos aquela estatística). Além disso, podemos ter uma flag -o para especificar um arquivo de saída, caso o usuário queira salvar o resultado em vez de imprimir na tela.
package main
import (
"flag"
"fmt"
"os"
)
func main() {
// Flags booleanas: se presentes, ativam a contagem
contarPalavras := flag.Bool("palavras", false, "contar palavras")
contarLinhas := flag.Bool("linhas", false, "contar linhas")
contarCaracteres := flag.Bool("caracteres", false, "contar caracteres")
// Flag para arquivo de saída opcional
saidaArquivo := flag.String("o", "", "arquivo de saída (opcional)")
flag.Parse()
// Verifica se pelo menos uma flag foi fornecida
if !*contarPalavras && !*contarLinhas && !*contarCaracteres {
fmt.Fprintln(os.Stderr, "Erro: especifique pelo menos uma estatística (-palavras, -linhas, -caracteres)")
flag.Usage()
os.Exit(1)
}
// Obtém o arquivo de entrada
args := flag.Args()
if len(args) != 1 {
fmt.Fprintln(os.Stderr, "Uso: analisador [flags] arquivo")
os.Exit(1)
}
arquivoEntrada := args[0]
// ... resto da implementação
}
Uma boa prática é usar flag.Usage para exibir mensagens de ajuda personalizadas. Podemos substituir a função padrão por uma que explique melhor o uso do programa.
Leitura de arquivos
Para ler o conteúdo de um arquivo, temos várias opções em Go. A mais simples é usar os.ReadFile, que lê o arquivo inteiro para a memória. Isso é adequado para arquivos de tamanho moderado. Para arquivos grandes, seria melhor usar bufio.Scanner linha por linha, economizando memória.
No nosso caso, como precisamos contar palavras, linhas e caracteres, podemos ler o arquivo inteiro ou processá-lo linha por linha. Vamos optar por ler o conteúdo completo para simplificar a contagem de caracteres e palavras.
conteudo, err := os.ReadFile(arquivoEntrada)
if err != nil {
fmt.Fprintf(os.Stderr, "Erro ao ler arquivo: %v\n", err)
os.Exit(1)
}
texto := string(conteudo)
Uma alternativa mais robusta é usar os.Open e depois bufio.NewReader ou bufio.Scanner. Isso permite ler arquivos linha a linha e é útil quando queremos processar grandes volumes sem carregar tudo na memória. No exemplo a seguir, mostramos como ler um arquivo linha por linha, o que pode ser útil se quisermos contar linhas separadamente.
arquivo, err := os.Open(arquivoEntrada)
if err != nil {
fmt.Fprintf(os.Stderr, "Erro ao abrir arquivo: %v\n", err)
os.Exit(1)
}
defer arquivo.Close()
scanner := bufio.NewScanner(arquivo)
linhas := 0
for scanner.Scan() {
linhas++
// Processar cada linha, se necessário
}
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "Erro ao ler arquivo: %v\n", err)
}
No nosso projeto, vamos usar os.ReadFile para simplificar, mas vamos também demonstrar o uso do scanner para contar linhas. Na verdade, para contar linhas, podemos simplesmente usar strings.Count(texto, "\n") ou dividir o texto em linhas com strings.Split, mas cuidado com a última linha sem quebra. Uma função robusta de contagem de linhas pode ser:
func contarLinhas(texto string) int {
if texto == "" {
return 0
}
return strings.Count(texto, "\n") + 1 // +1 para a última linha sem newline
}
Essa abordagem é eficiente e simples.
Saída
A saída de uma CLI pode ser direcionada para a tela (stdout) ou para um arquivo. Em Go, usamos fmt.Println, fmt.Printf e fmt.Fprintf para escrever em diferentes destinos. Vamos criar uma função que formata os resultados de forma clara e amigável.
Para o nosso analisador, queremos exibir as estatísticas solicitadas. Podemos imprimir cada uma em uma linha, precedida por um rótulo. Se o usuário passou a flag -o, escrevemos em um arquivo; caso contrário, usamos stdout.
func gerarResultado(texto string, contarPalavras, contarLinhas, contarCaracteres bool) string {
var resultado strings.Builder
if contarPalavras {
palavras := contarPalavrasTexto(texto)
fmt.Fprintf(&resultado, "Palavras: %d\n", palavras)
}
if contarLinhas {
linhas := contarLinhas(texto)
fmt.Fprintf(&resultado, "Linhas: %d\n", linhas)
}
if contarCaracteres {
caracteres := len([]rune(texto)) // considera caracteres unicode
fmt.Fprintf(&resultado, "Caracteres: %d\n", caracteres)
}
return resultado.String()
}
Para contar palavras, podemos usar strings.Fields, que divide a string por espaços em branco e retorna uma fatia de palavras. O número de palavras é o comprimento dessa fatia.
func contarPalavrasTexto(texto string) int {
palavras := strings.Fields(texto)
return len(palavras)
}
No main, depois de gerar o resultado, decidimos o destino:
resultado := gerarResultado(texto, *contarPalavras, *contarLinhas, *contarCaracteres)
if *saidaArquivo != "" {
err := os.WriteFile(*saidaArquivo, []byte(resultado), 0644)
if err != nil {
fmt.Fprintf(os.Stderr, "Erro ao escrever arquivo de saída: %v\n", err)
os.Exit(1)
}
} else {
fmt.Print(resultado)
}
É importante usar len([]rune(texto)) em vez de len(texto) para contar caracteres corretamente quando há caracteres multibyte (como acentos ou emojis), pois len retorna o número de bytes.
Testes
Testar é fundamental para garantir que nossa CLI funcione corretamente. Em Go, escrevemos testes em arquivos com sufixo _test.go e usamos o pacote testing. Vamos criar testes para as funções auxiliares, como contarLinhas, contarPalavrasTexto e gerarResultado.
Primeiro, vamos separar as funções lógicas em um arquivo, por exemplo analisador.go, para podermos testá-las isoladamente. O main ficará em outro arquivo, como main.go.
Exemplo de teste para contarPalavrasTexto:
package main
import "testing"
func TestContarPalavrasTexto(t *testing.T) {
testes := []struct {
nome string
texto string
esperado int
}{
{"texto vazio", "", 0},
{"uma palavra", "olá", 1},
{"frase simples", "Olá, mundo!", 2},
{"texto com múltiplos espaços", " Go é incrível ", 3},
}
for _, tt := range testes {
t.Run(tt.nome, func(t *testing.T) {
resultado := contarPalavrasTexto(tt.texto)
if resultado != tt.esperado {
t.Errorf("contarPalavrasTexto(%q) = %d; esperado %d", tt.texto, resultado, tt.esperado)
}
})
}
}
Da mesma forma, testamos contarLinhas com casos que incluam quebras de linha no final ou não:
func TestContarLinhas(t *testing.T) {
testes := []struct {
nome string
texto string
esperado int
}{
{"texto vazio", "", 0},
{"uma linha sem newline", "linha única", 1},
{"duas linhas com newline final", "linha1\nlinha2\n", 2},
{"duas linhas sem newline final", "linha1\nlinha2", 2},
}
for _, tt := range testes {
t.Run(tt.nome, func(t *testing.T) {
resultado := contarLinhas(tt.texto)
if resultado != tt.esperado {
t.Errorf("contarLinhas(%q) = %d; esperado %d", tt.texto, resultado, tt.esperado)
}
})
}
}
Além dos testes unitários, podemos testar a integração do programa usando os/exec para executar o binário e verificar a saída. Isso é um pouco mais avançado, mas vamos incluir um teste simples para o fluxo completo:
func TestMainFluxo(t *testing.T) {
// Cria um arquivo temporário com conteúdo conhecido
dir := t.TempDir()
arquivo := filepath.Join(dir, "teste.txt")
conteudo := "Olá, mundo!\nEsta é uma linha.\n"
err := os.WriteFile(arquivo, []byte(conteudo), 0644)
if err != nil {
t.Fatal(err)
}
// Executa o programa com as flags -palavras e -linhas
cmd := exec.Command("go", "run", ".", "-palavras", "-linhas", arquivo)
saida, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("erro ao executar: %v", err)
}
// Verifica se a saída contém as contagens esperadas
esperadoPalavras := "Palavras: 6" // palavras: Olá, mundo! Esta é uma linha.
esperadoLinhas := "Linhas: 2"
if !strings.Contains(string(saida), esperadoPalavras) {
t.Errorf("saída não contém %q; saída: %s", esperadoPalavras, saida)
}
if !strings.Contains(string(saida), esperadoLinhas) {
t.Errorf("saída não contém %q; saída: %s", esperadoLinhas, saida)
}
}
Esse teste garante que o programa compila e funciona corretamente para um caso simples. Lembre-se de que, para executar testes que usam go run, o diretório atual deve ser o diretório do pacote.
Boas práticas e observações finais
Ao desenvolver CLIs em Go, algumas boas práticas incluem: usar o pacote flag para consistência, fornecer mensagens de erro claras no stderr, retornar códigos de saída apropriados (0 para sucesso, 1 para erro), e separar a lógica de negócios das funções de I/O para facilitar testes. Além disso, considere usar context para cancelamento e timeouts em operações longas, e explore bibliotecas como cobra para CLIs mais complexas, mas lembre-se de que a biblioteca padrão é suficiente para muitos casos.
Outra dica: ao contar caracteres, use utf8.RuneCountInString para obter o número de runas (caracteres visíveis), que é mais eficiente do que converter para []rune.
Por fim, documente seu código com comentários e forneça uma boa mensagem de ajuda com flag.Usage. Testes bem escritos são um investimento que economiza tempo no futuro.
Referências
- Documentação oficial do pacote flag
- Documentação oficial do pacote os
- Documentação oficial do pacote bufio
- Documentação oficial do pacote strings
- Documentação oficial do pacote testing
- Effective Go
- Usando módulos Go
Exercícios
- Exercício 1: Crie uma função chamada
contarCaracteresque recebe uma string e retorna o número de caracteres (runas). Teste com strings contendo acentos e emojis. - Exercício 2: Adicione uma flag
-mao programa que, quando presente, imprime o resultado em formato JSON. Dica: useencoding/json. - Exercício 3: Modifique o programa para aceitar múltiplos arquivos de entrada e exibir estatísticas separadas para cada um, além de um total combinado.
- Exercício 4: Escreva um teste para a função
contarLinhasque verifique o caso de texto vazio, uma linha sem newline, e duas linhas com e sem newline final. - Exercício 5: Implemente uma flag
-v(verbose) que imprime informações adicionais, como o nome do arquivo, tamanho em bytes e tempo de processamento.
func contarCaracteres(texto string) int {
return utf8.RuneCountInString(texto)
}
// Teste: contarCaracteres("Olá, mundo!") deve retornar 12 (incluindo espaços e pontuação)
// Teste com emoji: contarCaracteres("😀") retorna 1.
// No main, defina a flag:
jsonSaida := flag.Bool("json", false, "saída em JSON")
// Depois de gerar as estatísticas, monte um map ou struct e use json.MarshalIndent.
// Exemplo:
tipoResultado := struct {
Palavras int `json:"palavras,omitempty"`
Linhas int `json:"linhas,omitempty"`
Caracteres int `json:"caracteres,omitempty"`
}{}
if *contarPalavras { tipoResultado.Palavras = contarPalavrasTexto(texto) }
if *contarLinhas { tipoResultado.Linhas = contarLinhas(texto) }
if *contarCaracteres { tipoResultado.Caracteres = utf8.RuneCountInString(texto) }
if *jsonSaida {
saida, _ := json.MarshalIndent(tipoResultado, "", " ")
fmt.Println(string(saida))
} else {
// saída normal
}
// Em vez de exigir um único arquivo, itere sobre flag.Args().
// Para cada arquivo, leia e calcule as estatísticas, imprimindo o nome do arquivo.
// Acumule totais para uma seção final "Total".
// Exemplo de estrutura:
for _, arquivo := range flag.Args() {
conteudo, err := os.ReadFile(arquivo)
if err != nil { /* tratar */ }
texto := string(conteudo)
fmt.Printf("== %s ==\n", arquivo)
if *contarPalavras { fmt.Printf("Palavras: %d\n", contarPalavrasTexto(texto)) }
// ...
// Acumule totais
}
// Imprima totais se houver mais de um arquivo.
func TestContarLinhas(t *testing.T) {
testes := []struct {
nome string
texto string
esperado int
}{
{"vazio", "", 0},
{"uma linha sem newline", "abc", 1},
{"duas com newline final", "a\nb\n", 2},
{"duas sem newline final", "a\nb", 2},
}
for _, tt := range testes {
t.Run(tt.nome, func(t *testing.T) {
got := contarLinhas(tt.texto)
if got != tt.esperado {
t.Errorf("contarLinhas(%q) = %d, esperado %d", tt.texto, got, tt.esperado)
}
})
}
}
// Defina: verbose := flag.Bool("v", false, "modo verboso")
// No main, meça o tempo com time.Now() antes e depois do processamento.
// Se *verbose, imprima detalhes:
if *verbose {
info, _ := os.Stat(arquivoEntrada)
fmt.Printf("Arquivo: %s (%d bytes)\n", arquivoEntrada, info.Size())
fmt.Printf("Processado em %v\n", time.Since(inicio))
}