Il parsing grezzo di os.Args funziona per un singolo comando con un argomento posizionale. Nel momento in cui aggiungi un secondo sottocomando o un flag --dry-run, stai reinventando la ruota. Cobra gestisce il parsing degli argomenti, la validazione dei flag, la generazione dei testi di help e la shell completion out of the box.
Costruiremo un tool fileorg: un organizzatore che ordina i file per estensione e fornisce statistiche. L’esempio e semplice di proposito – i pattern si applicano a qualsiasi CLI.
Struttura del Progetto#
Il pattern con la directory cmd/ mantiene main.go minimo e rende ogni sottocomando testabile indipendentemente.
fileorg/
cmd/
root.go # root command, persistent flags
organize.go # sottocomando organize
stats.go # sottocomando stats
internal/
fs/
scanner.go # logica di scansione file
main.gopackage main
import "github.com/example/fileorg/cmd"
func main() {
cmd.Execute()
}main.go e esattamente tre righe. Tutta la logica vive in cmd/.
Root Command#
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
"go.uber.org/zap"
)
var (
verbose bool
logger *zap.SugaredLogger
)
var rootCmd = &cobra.Command{
Use: "fileorg",
Short: "Organizza e analizza i file per estensione",
Long: `fileorg organizza i file in sottodirectory per estensione
e fornisce statistiche sulla distribuzione dei file.`,
Version: "1.0.0",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Setup comune che viene eseguito prima di ogni sottocomando
cfg := zap.NewProductionConfig()
if verbose {
cfg.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
}
log, err := cfg.Build()
if err != nil {
return fmt.Errorf("initializing logger: %w", err)
}
logger = log.Sugar()
return nil
},
}
func Execute() {
cobra.CheckErr(rootCmd.Execute())
}
func init() {
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "abilita logging di debug")
}PersistentPreRunE viene eseguito prima di ogni sottocomando. Usalo per l’inizializzazione comune: logger, file di configurazione, connessioni al database. Se restituisce un errore, Cobra lo stampa ed esce con codice non-zero automaticamente.
Il Sottocomando organize#
package cmd
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"github.com/spf13/cobra"
)
var (
source string
dryRun bool
)
var organizeCmd = &cobra.Command{
Use: "organize",
Short: "Sposta i file in sottodirectory per estensione",
Example: ` fileorg organize --source /tmp/downloads
fileorg organize --source /tmp/downloads --dry-run`,
RunE: func(cmd *cobra.Command, args []string) error {
return runOrganize(source, dryRun)
},
}
func init() {
organizeCmd.Flags().StringVar(&source, "source", "", "directory da organizzare")
organizeCmd.Flags().BoolVar(&dryRun, "dry-run", false, "mostra le azioni senza eseguirle")
// i flag obbligatori falliscono subito con un messaggio chiaro
cobra.CheckErr(organizeCmd.MarkFlagRequired("source"))
rootCmd.AddCommand(organizeCmd)
}
func runOrganize(dir string, dryRun bool) error {
entries, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("reading directory %s: %w", dir, err)
}
moved := 0
for _, entry := range entries {
if entry.IsDir() {
continue
}
ext := filepath.Ext(entry.Name())
if ext == "" {
ext = "no-extension"
} else {
ext = ext[1:] // rimuove il punto iniziale
}
destDir := filepath.Join(dir, ext)
src := filepath.Join(dir, entry.Name())
dst := filepath.Join(destDir, entry.Name())
if dryRun {
fmt.Printf("would move: %s -> %s\n", src, dst)
continue
}
if err := os.MkdirAll(destDir, fs.ModePerm); err != nil {
return fmt.Errorf("creating directory %s: %w", destDir, err)
}
if err := os.Rename(src, dst); err != nil {
logger.Warnw("failed to move file", "src", src, "dst", dst, "error", err)
continue
}
moved++
}
if !dryRun {
fmt.Printf("spostati %d file\n", moved)
}
return nil
}Usa RunE invece di Run per tutti i sottocomandi. RunE ti permette di restituire errori in modo naturale; Cobra gestisce la stampa e il codice di uscita. Run ti obbliga a chiamare os.Exit manualmente, impedendo l’esecuzione dei defer.
Il Sottocomando stats#
package cmd
import (
"fmt"
"os"
"path/filepath"
"sort"
"text/tabwriter"
"github.com/spf13/cobra"
)
var statsCmd = &cobra.Command{
Use: "stats",
Short: "Mostra la distribuzione delle estensioni in una directory",
Args: cobra.ExactArgs(1), // validazione argomenti posizionali
RunE: func(cmd *cobra.Command, args []string) error {
return runStats(args[0])
},
}
func init() {
rootCmd.AddCommand(statsCmd)
}
func runStats(dir string) error {
counts := make(map[string]int)
total := 0
entries, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("reading directory: %w", err)
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
ext := filepath.Ext(entry.Name())
if ext == "" {
ext = "(nessuna)"
}
counts[ext]++
total++
}
type row struct {
ext string
count int
}
rows := make([]row, 0, len(counts))
for ext, n := range counts {
rows = append(rows, row{ext, n})
}
sort.Slice(rows, func(i, j int) bool {
return rows[i].count > rows[j].count
})
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
fmt.Fprintln(w, "ESTENSIONE\tCONTEGGIO\tPERCENTUALE")
fmt.Fprintln(w, "----------\t---------\t-----------")
for _, r := range rows {
pct := float64(r.count) / float64(total) * 100
fmt.Fprintf(w, "%s\t%d\t%.1f%%\n", r.ext, r.count, pct)
}
w.Flush()
fmt.Printf("\ntotale: %d file\n", total)
return nil
}Persistent Flag vs Local Flag#
I persistent flag (aggiunti tramite PersistentFlags()) vengono ereditati da tutti i sottocomandi. I local flag (aggiunti tramite Flags()) si applicano solo al comando su cui sono definiti.
// Disponibile per tutti i sottocomandi
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "...")
// Solo per organize
organizeCmd.Flags().BoolVar(&dryRun, "dry-run", false, "...")Usa MarkFlagRequired per imporre i flag obbligatori a livello Cobra invece che nella logica applicativa:
cobra.CheckErr(organizeCmd.MarkFlagRequired("source"))Quando --source viene omesso, Cobra stampa required flag(s) "source" not set ed esce con codice 1 prima che RunE venga mai chiamato.
Shell Completion#
Cobra genera script di completion per bash, zsh, fish e PowerShell automaticamente.
var completionCmd = &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Genera lo script di shell completion",
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
Args: cobra.ExactValidArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
switch args[0] {
case "bash":
return rootCmd.GenBashCompletion(os.Stdout)
case "zsh":
return rootCmd.GenZshCompletion(os.Stdout)
case "fish":
return rootCmd.GenFishCompletion(os.Stdout, true)
case "powershell":
return rootCmd.GenPowerShellCompletionWithDesc(os.Stdout)
}
return nil
},
}
func init() {
rootCmd.AddCommand(completionCmd)
}Installazione per zsh:
fileorg completion zsh > "${fpath[1]}/_fileorg"Testare i Comandi CLI#
La chiave per CLI testabili e scrivere l’output su un io.Writer configurabile invece di usare sempre os.Stdout. Nei test imposta l’output del comando su un bytes.Buffer.
package cmd
import (
"bytes"
"os"
"path/filepath"
"testing"
)
func TestStatsCommand(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{"a.go", "b.go", "c.py", "d.txt"} {
f, _ := os.Create(filepath.Join(dir, name))
f.Close()
}
buf := new(bytes.Buffer)
statsCmd.SetOut(buf)
statsCmd.SetErr(buf)
statsCmd.SetArgs([]string{dir})
err := statsCmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := buf.String()
if !bytes.Contains([]byte(out), []byte(".go")) {
t.Errorf("expected .go in output, got:\n%s", out)
}
}Build e Distribuzione#
# Binario singolo per la piattaforma corrente
go build -ldflags="-s -w" -o fileorg ./...
# Cross-compile per Linux amd64
GOOS=linux GOARCH=amd64 go build -o fileorg-linux-amd64 ./...Per release multi-piattaforma automatizzate, GoReleaser gestisce cross-compilation, checksum e creazione release GitHub da un singolo .goreleaser.yaml:
builds:
- env: [CGO_ENABLED=0]
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
archives:
- format: tar.gz
format_overrides:
- goos: windows
format: zip
checksum:
name_template: "checksums.txt"Errori comuni e come evitarli
Stato globale nei comandi
Dichiarare le variabili dei flag come variabili a livello di package funziona per tool piccoli ma causa contaminazione tra test quando piu test girano nello stesso processo. Preferisci passare i flag come argomenti di funzione o racchiudere i comandi in una struct con un costruttore NewRootCmd() *cobra.Command.
Non usare PersistentPreRunE
Ripetere l’inizializzazione del logger o il caricamento della configurazione nel RunE di ogni sottocomando e fragile. Metti il setup condiviso in PersistentPreRunE sul root command. Se un sottocomando sovrascrive PreRunE, deve chiamare esplicitamente parent.PersistentPreRunE, altrimenti il setup del parent viene saltato.
Ignorare i codici di uscita
cmd.Execute() restituisce un errore. Se lo chiami senza cobra.CheckErr o la tua gestione del codice di uscita, il binario esce con 0 anche quando un sottocomando fallisce. I chiamanti negli script di shell trattano silenziosamente i fallimenti come successi.
Nessuna shell completion Gli utenti che installano il tuo tool tramite Homebrew o un package manager si aspettano che il tab completion funzioni. Ci vogliono circa 20 righe per aggiungerlo (mostrato sopra). Senza di esso il tuo tool sembra grezzo rispetto a kubectl o git.
Usare ioutil.ReadDir (deprecato)
ioutil.ReadDir e stato deprecato in Go 1.16. Usa invece os.ReadDir – restituisce []os.DirEntry che e piu efficiente perche evita una chiamata Stat per ogni file.
Se vuoi approfondire questi argomenti, offro sessioni di coaching 1:1 per ingegneri che lavorano su integrazione AI, architettura cloud e platform engineering. Prenota una sessione (50 EUR / 60 min) o scrivimi a manuel.fedele+website@gmail.com.