Crear una CLI en Go per automatitzar tasques locals
Tutorial per crear una eina de terminal en Go: llegir fitxers, validar inputs, generar sortida i compilar a un binari distribuïble.

Solia escriure scripts de Bash per a tot. Processar un CSV, reanomenar fitxers, validar configuracions, generar informes. Funcionava, fins que deixava de funcionar. Un espai mal escapar, un sed que es comporta diferent a macOS i Linux, una variable sense cometes que peta amb noms de fitxer estranys. Depurar un script de Bash de 200 línies és una experiència que no li desitjo a ningú.
Un dia vaig reescriure un d’aquells scripts en Go. Vaig trigar una mica més a escriure’l, però el resultat va ser un binari que vaig compilar una vegada, vaig copiar a tres màquines diferents i va funcionar a totes sense instal·lar res. Sense Python, sense Node, sense dependències de sistema, sense “works on my machine”. Només un executable.
Des de llavors, cada vegada que un script de Bash supera les 50 línies, el reescric en Go. I en aquest article vaig a mostrar com construir una CLI real pas a pas: una eina que llegeix fitxers CSV, valida dades, genera un informe i es compila a un binari que pots distribuir a qualsevol màquina.
Per què Go per a eines de terminal
Hi ha desenes de llenguatges per escriure CLIs. Python té argparse i click. Rust té clap. Node té commander. Però Go té tres avantatges que el fan especialment bo per a eines de terminal:
Binari estàtic sense dependències. Quan compiles un programa en Go, obtens un únic fitxer executable. No necessites un runtime, no necessites un intèrpret, no necessites que la màquina destinació tingui Go instal·lat. Copies el binari i funciona. Per a eines internes que distribueixes a un equip, això elimina tota la fricció d’instal·lació.
Compilació creuada trivial. Des del teu Mac pots generar binaris per a Linux i Windows amb dues variables d’entorn. Sense Docker, sense màquines virtuals, sense CI especial. Si el teu equip utilitza una barreja de sistemes operatius, això és transformador.
Arrencada instantània. Els programes Go arrenquen en mil·lisegons. No hi ha JVM que escalfar, no hi ha intèrpret que inicialitzar. Per a una eina de terminal que executes cent vegades al dia, la diferència es nota. Compara-ho amb una CLI en Python que importa pandas i tarda dos segons a arrencar per processar un fitxer de deu línies.
A més, la llibreria estàndard de Go inclou tot el necessari per a una CLI: parseo d’arguments, lectura de fitxers, manipulació de text, codificació JSON/CSV, i gestió robusta d’errors. Per a la majoria d’eines no necessites ni una sola dependència externa.
Si estàs explorant Go i busques idees pràctiques, a projectes per aprendre Go incloc les CLIs com un dels millors punts d’entrada al llenguatge.
Preparar el projecte
Comencem creant un mòdul Go. Si no tens Go instal·lat, a com començar amb Go cobreixo la instal·lació i configuració de l’entorn.
mkdir csv-reporter && cd csv-reporter
go mod init github.com/el-teu-usuari/csv-reporterCreem el fitxer main.go amb l’estructura mínima:
package main
import \"fmt\"
func main() {
fmt.Println(\"csv-reporter v0.1.0\")
}Verifiquem que compila i executa:
go run main.goAixò hauria d’imprimir csv-reporter v0.1.0. Si funciona, tens un projecte Go funcional. La comanda go run compila i executa en un sol pas, ideal per al desenvolupament. Si vols aprofundir en què fa cada subcomanda de Go, a la comanda go cobreixo run, build, test, fmt i la resta.
Parsear arguments: flag vs os.Args
Go ofereix dues formes bàsiques de llegir arguments del terminal: accedir directament a os.Args o usar el paquet flag de la llibreria estàndard.
os.Args: accés directe
os.Args és un slice de strings amb tots els arguments que rep el programa. El primer element és sempre el nom de l’executable:
package main
import (
\"fmt\"
\"os\"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, \"ús: csv-reporter <fitxer.csv>\")
os.Exit(1)
}
fmt.Println(\"Processant:\", os.Args[1])
}Això funciona per a CLIs trivials amb un o dos arguments posicionals. Però tan aviat com necessites flags opcionals (--output, --format, --verbose), parsear os.Args a mà es converteix en un infern d’ifs i switches.
flag: el paquet estàndard
El paquet flag resol aquest problema. Defineix flags amb tipus, valor per defecte i descripció, i s’encarrega del parseo:
package main
import (
\"flag\"
\"fmt\"
\"os\"
)
func main() {
input := flag.String(\"input\", \"\", \"fitxer CSV d'entrada (obligatori)\")
output := flag.String(\"output\", \"report.txt\", \"fitxer de sortida\")
verbose := flag.Bool(\"verbose\", false, \"mostrar informació de depuració\")
flag.Parse()
if *input == \"\" {
fmt.Fprintln(os.Stderr, \"error: el flag --input és obligatori\")
flag.Usage()
os.Exit(1)
}
if *verbose {
fmt.Printf(\"Entrada: %s\n\", *input)
fmt.Printf(\"Sortida: %s\n\", *output)
}
fmt.Printf(\"Processant %s -> %s\n\", *input, *output)
}go run main.go --input dades.csv --output informe.txt --verboseUn detall important: flag genera automàticament un missatge d’ajuda amb -h o --help. No necessites escriure’l tu. Cada flag apareix amb el seu nom, tipus, valor per defecte i descripció.
$ go run main.go --help
Usage of csv-reporter:
-input string
fitxer CSV d'entrada (obligatori)
-output string
fitxer de sortida (default \"report.txt\")
-verbose
mostrar informació de depuracióEls flags retornen punters (*string, *bool), per això els uses amb *input, *output, etc. Si prefereixes evitar punters, pots usar les variants StringVar, BoolVar, etc., que escriuen directament en una variable:
var input string
flag.StringVar(&input, \"input\", \"\", \"fitxer CSV d'entrada\")Per a la majoria de CLIs, flag és suficient. És simple, està a la llibreria estàndard i no afegeix dependències. Només quan necessites subcomandes estil git commit o docker build val la pena portar alguna cosa externa.
Construir una eina real: processador de CSV
Anem a construir alguna cosa útil. La nostra CLI llegirà un fitxer CSV amb dades de vendes, validarà les files, calcularà estadístiques i generarà un informe. El CSV d’entrada té aquest format:
data,producte,quantitat,preu
2026-01-15,Widget A,10,29.99
2026-01-15,Widget B,5,49.99
2026-01-16,Widget A,8,29.99
2026-01-16,Widget C,3,99.99Estructura del projecte
Abans de posar-ho tot a main.go, anem a separar responsabilitats. No necessites una arquitectura complexa per a una CLI, però sí que el codi estigui organitzat de forma llegible:
csv-reporter/
├── main.go # Punt d'entrada, parseo de flags
├── reader.go # Lectura i parseo del CSV
├── validator.go # Validació de files
├── reporter.go # Generació de l'informe
└── go.modA estructura de projecte Go cobreixo patrons més elaborats, però per a una CLI aquesta estructura plana funciona bé.
Definir els tipus
Comencem definint les estructures de dades a reader.go:
package main
import (
\"encoding/csv\"
\"fmt\"
\"io\"
\"os\"
\"strconv\"
\"strings\"
\"time\"
)
type Sale struct {
Date time.Time
Product string
Quantity int
Price float64
Line int // Número de línia per a missatges d'error
}
func ReadCSV(path string) ([]Sale, []string, error) {
file, err := os.Open(path)
if err != nil {
return nil, nil, fmt.Errorf(\"obrint fitxer: %w\", err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
// Llegir i descartar la capçalera
header, err := reader.Read()
if err != nil {
return nil, nil, fmt.Errorf(\"llegint capçalera CSV: %w\", err)
}
if len(header) < 4 {
return nil, nil, fmt.Errorf(\"capçalera invàlida: s'esperen 4 columnes, n'hi ha %d\", len(header))
}
var sales []Sale
var warnings []string
lineNum := 1 // La capçalera és la línia 1
for {
lineNum++
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
warnings = append(warnings, fmt.Sprintf(\"línia %d: error de format CSV: %v\", lineNum, err))
continue
}
sale, err := parseRecord(record, lineNum)
if err != nil {
warnings = append(warnings, fmt.Sprintf(\"línia %d: %v\", lineNum, err))
continue
}
sales = append(sales, sale)
}
return sales, warnings, nil
}
func parseRecord(record []string, line int) (Sale, error) {
if len(record) < 4 {
return Sale{}, fmt.Errorf(\"s'esperen 4 camps, n'hi ha %d\", len(record))
}
date, err := time.Parse(\"2006-01-02\", strings.TrimSpace(record[0]))
if err != nil {
return Sale{}, fmt.Errorf(\"data invàlida %q: %w\", record[0], err)
}
product := strings.TrimSpace(record[1])
if product == \"\" {
return Sale{}, fmt.Errorf(\"el producte no pot estar buit\")
}
quantity, err := strconv.Atoi(strings.TrimSpace(record[2]))
if err != nil {
return Sale{}, fmt.Errorf(\"quantitat invàlida %q: %w\", record[2], err)
}
if quantity <= 0 {
return Sale{}, fmt.Errorf(\"la quantitat ha de ser positiva: %d\", quantity)
}
price, err := strconv.ParseFloat(strings.TrimSpace(record[3]), 64)
if err != nil {
return Sale{}, fmt.Errorf(\"preu invàlid %q: %w\", record[3], err)
}
if price < 0 {
return Sale{}, fmt.Errorf(\"el preu no pot ser negatiu: %.2f\", price)
}
return Sale{
Date: date,
Product: product,
Quantity: quantity,
Price: price,
Line: line,
}, nil
}Hi ha diverses decisions importants aquí. Primera: la funció ReadCSV retorna tant les dades vàlides com una llista de warnings. No falla en trobar la primera fila dolenta. En una eina de terminal, és molt més útil processar tot el que es pot i reportar els problemes al final. L’usuari no vol executar l’eina deu vegades per descobrir deu errors un per un.
Segona: cada error inclou el número de línia. Quan un CSV té 10.000 files i tres estan malament, saber que el problema és a la línia 4.287 t’estalvia mitja hora de cerca.
Tercera: usem fmt.Errorf amb %w per embolcallar errors. Si vols repassar per què això importa, a errors en Go ho cobreixo en detall.
Validació de dades
A validator.go afegim regles de negoci que van més enllà del format:
package main
import (
\"fmt\"
\"time\"
)
type ValidationResult struct {
Valid []Sale
Invalid []string
}
func Validate(sales []Sale) ValidationResult {
var result ValidationResult
now := time.Now()
for _, s := range sales {
if s.Date.After(now) {
result.Invalid = append(result.Invalid,
fmt.Sprintf(\"línia %d: data futura %s\", s.Line, s.Date.Format(\"2006-01-02\")))
continue
}
if s.Price > 10000 {
result.Invalid = append(result.Invalid,
fmt.Sprintf(\"línia %d: preu sospitosament alt %.2f per a %s\",
s.Line, s.Price, s.Product))
continue
}
if s.Quantity > 1000 {
result.Invalid = append(result.Invalid,
fmt.Sprintf(\"línia %d: quantitat sospitosament alta %d per a %s\",
s.Line, s.Quantity, s.Product))
continue
}
result.Valid = append(result.Valid, s)
}
return result
}La separació entre parseo i validació no és casual. El lector s’encarrega que les dades tinguin el format correcte. El validador s’encarrega que les dades tinguin sentit. Són dues coses diferents i canviaran per motius diferents. Si demà necessites acceptar un format de data diferent, toques el lector. Si necessites un llindar de preu diferent, toques el validador.
Generar l’informe
A reporter.go calculem estadístiques i generem la sortida:
package main
import (
\"fmt\"
\"io\"
\"sort\"
\"strings\"
\"text/tabwriter\"
)
type ProductStats struct {
Product string
TotalQuantity int
TotalRevenue float64
AvgPrice float64
NumSales int
}
func GenerateReport(w io.Writer, sales []Sale) {
if len(sales) == 0 {
fmt.Fprintln(w, \"No hi ha dades vàlides per generar l'informe.\")
return
}
// Agrupar per producte
statsMap := make(map[string]*ProductStats)
var totalRevenue float64
var totalQuantity int
for _, s := range sales {
stats, ok := statsMap[s.Product]
if !ok {
stats = &ProductStats{Product: s.Product}
statsMap[s.Product] = stats
}
revenue := float64(s.Quantity) * s.Price
stats.TotalQuantity += s.Quantity
stats.TotalRevenue += revenue
stats.NumSales++
totalRevenue += revenue
totalQuantity += s.Quantity
}
// Calcular preu mitjà i ordenar
var statsList []ProductStats
for _, stats := range statsMap {
stats.AvgPrice = stats.TotalRevenue / float64(stats.TotalQuantity)
statsList = append(statsList, *stats)
}
sort.Slice(statsList, func(i, j int) bool {
return statsList[i].TotalRevenue > statsList[j].TotalRevenue
})
// Escriure informe
fmt.Fprintln(w, strings.Repeat(\"=\", 60))
fmt.Fprintln(w, \"INFORME DE VENDES\")
fmt.Fprintln(w, strings.Repeat(\"=\", 60))
fmt.Fprintf(w, \"Registres processats: %d\n\", len(sales))
fmt.Fprintf(w, \"Productes únics: %d\n\", len(statsList))
fmt.Fprintf(w, \"Quantitat total: %d\n\", totalQuantity)
fmt.Fprintf(w, \"Facturació total: %.2f EUR\n\n\", totalRevenue)
// Taula per producte
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, \"PRODUCTE\tVENDES\tQUANTITAT\tPREU MITJÀ\tFACTURACIÓ\")
fmt.Fprintln(tw, \"--------\t------\t---------\t----------\t----------\")
for _, s := range statsList {
fmt.Fprintf(tw, \"%s\t%d\t%d\t%.2f EUR\t%.2f EUR\n\",
s.Product, s.NumSales, s.TotalQuantity, s.AvgPrice, s.TotalRevenue)
}
tw.Flush()
}Fixa’t que GenerateReport rep un io.Writer, no un nom de fitxer. Aquesta és una decisió de disseny fonamental en Go: escriure contra interfícies, no contra implementacions concretes. Si li passes os.Stdout, imprimeix al terminal. Si li passes un fitxer, escriu al fitxer. Si li passes un bytes.Buffer, el pots testejar sense tocar el sistema de fitxers. És la mateixa funció per als tres casos.
tabwriter és un paquet de la llibreria estàndard que alinea columnes automàticament usant tabuladors. Produeix sortida neta al terminal sense necessitat de llibreries externes.
Connectar-ho tot a main.go
Ara unim els mòduls a main.go:
package main
import (
\"flag\"
\"fmt\"
\"os\"
)
const version = \"0.1.0\"
func main() {
input := flag.String(\"input\", \"\", \"fitxer CSV d'entrada (obligatori)\")
output := flag.String(\"output\", \"\", \"fitxer de sortida (per defecte: stdout)\")
showVersion := flag.Bool(\"version\", false, \"mostrar versió\")
verbose := flag.Bool(\"verbose\", false, \"mostrar informació de depuració\")
flag.Parse()
if *showVersion {
fmt.Printf(\"csv-reporter v%s\n\", version)
os.Exit(0)
}
if *input == \"\" {
fmt.Fprintln(os.Stderr, \"error: el flag --input és obligatori\")
fmt.Fprintln(os.Stderr, \"\")
fmt.Fprintln(os.Stderr, \"Ús: csv-reporter --input <fitxer.csv> [--output <fitxer.txt>]\")
os.Exit(1)
}
// Llegir CSV
sales, warnings, err := ReadCSV(*input)
if err != nil {
fmt.Fprintf(os.Stderr, \"error: %v\n\", err)
os.Exit(1)
}
if *verbose {
fmt.Fprintf(os.Stderr, \"Files llegides: %d\n\", len(sales))
fmt.Fprintf(os.Stderr, \"Warnings: %d\n\", len(warnings))
}
// Mostrar warnings
for _, w := range warnings {
fmt.Fprintf(os.Stderr, \"warning: %s\n\", w)
}
// Validar
result := Validate(sales)
if *verbose {
fmt.Fprintf(os.Stderr, \"Files vàlides: %d\n\", len(result.Valid))
fmt.Fprintf(os.Stderr, \"Files invàlides: %d\n\", len(result.Invalid))
}
for _, inv := range result.Invalid {
fmt.Fprintf(os.Stderr, \"validació: %s\n\", inv)
}
// Generar informe
var writer *os.File
if *output != \"\" {
writer, err = os.Create(*output)
if err != nil {
fmt.Fprintf(os.Stderr, \"error creant fitxer de sortida: %v\n\", err)
os.Exit(1)
}
defer writer.Close()
} else {
writer = os.Stdout
}
GenerateReport(writer, result.Valid)
if *output != \"\" {
fmt.Fprintf(os.Stderr, \"Informe generat a %s\n\", *output)
}
}Observa que tots els missatges d’estat (warnings, errors, informació de depuració) van a os.Stderr, i només l’informe va a os.Stdout o al fitxer de sortida. Aquesta és una convenció important en eines de terminal: stdout és per a dades, stderr és per a missatges. Si algú fa pipe de la teva eina (csv-reporter --input dades.csv | less), els warnings no contaminen la sortida.
Pots provar-ho tot amb:
go run . --input dades.csv --verbose
go run . --input dades.csv --output informe.txtAfegir subcomandes amb Cobra
El paquet flag és suficient per a CLIs simples amb una sola funció. Però si la teva eina creix i necessita subcomandes (pensa git commit, docker build, kubectl get), necessites alguna cosa més estructurada.
Cobra és la llibreria de facto per a CLIs en Go. La usen Kubernetes, Hugo, GitHub CLI i desenes de projectes rellevants.
go get github.com/spf13/cobra@latestLa idea central de Cobra és simple: cada subcomanda és un cobra.Command amb el seu propi conjunt de flags i la seva funció Run:
package main
import (
\"fmt\"
\"os\"
\"github.com/spf13/cobra\"
)
var rootCmd = &cobra.Command{
Use: \"csv-reporter\",
Short: \"Eina per generar informes a partir de fitxers CSV\",
Version: \"0.1.0\",
}
var reportCmd = &cobra.Command{
Use: \"report\",
Short: \"Generar un informe de vendes\",
RunE: func(cmd *cobra.Command, args []string) error {
input, _ := cmd.Flags().GetString(\"input\")
output, _ := cmd.Flags().GetString(\"output\")
sales, warnings, err := ReadCSV(input)
if err != nil {
return err
}
for _, w := range warnings {
fmt.Fprintf(os.Stderr, \"warning: %s\n\", w)
}
result := Validate(sales)
for _, inv := range result.Invalid {
fmt.Fprintf(os.Stderr, \"validació: %s\n\", inv)
}
writer := os.Stdout
if output != \"\" {
f, err := os.Create(output)
if err != nil {
return fmt.Errorf(\"creant fitxer de sortida: %w\", err)
}
defer f.Close()
writer = f
}
GenerateReport(writer, result.Valid)
return nil
},
}
var validateCmd = &cobra.Command{
Use: \"validate\",
Short: \"Validar un fitxer CSV sense generar informe\",
RunE: func(cmd *cobra.Command, args []string) error {
input, _ := cmd.Flags().GetString(\"input\")
sales, warnings, err := ReadCSV(input)
if err != nil {
return err
}
for _, w := range warnings {
fmt.Fprintf(os.Stderr, \"warning: %s\n\", w)
}
result := Validate(sales)
if len(result.Invalid) == 0 && len(warnings) == 0 {
fmt.Println(\"Totes les files són vàlides.\")
} else {
for _, inv := range result.Invalid {
fmt.Println(inv)
}
}
fmt.Printf(\"\nResum: %d vàlides, %d invàlides, %d warnings\n\",
len(result.Valid), len(result.Invalid), len(warnings))
return nil
},
}
func init() {
reportCmd.Flags().StringP(\"input\", \"i\", \"\", \"fitxer CSV d'entrada\")
reportCmd.Flags().StringP(\"output\", \"o\", \"\", \"fitxer de sortida\")
reportCmd.MarkFlagRequired(\"input\")
validateCmd.Flags().StringP(\"input\", \"i\", \"\", \"fitxer CSV d'entrada\")
validateCmd.MarkFlagRequired(\"input\")
rootCmd.AddCommand(reportCmd)
rootCmd.AddCommand(validateCmd)
}
func main() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}csv-reporter report --input dades.csv --output informe.txt
csv-reporter validate --input dades.csv
csv-reporter --helpCobra genera ajuda automàticament, suporta autocompletat per a Bash/Zsh/Fish, i gestiona els codis de sortida. El preu és una dependència externa. Per a una eina interna d’equip, val la pena. Per a un script personal que només uses tu, queda’t amb flag.
No m’estendré més amb Cobra aquí. L’important és que sàpigues que existeix i quan té sentit usar-lo. Per a la versió amb flag que hem construït a les seccions anteriors, no el necessites.
Llegir des de stdin i des de fitxers
Una CLI ben dissenyada ha de poder llegir tant d’un fitxer com de stdin. Això permet usar pipes:
cat dades.csv | csv-reporter --input -
curl -s https://exemple.com/dades.csv | csv-reporter --input -La convenció universal és usar - com a nom de fitxer per indicar stdin. Implementar-ho és trivial:
func OpenInput(path string) (io.ReadCloser, error) {
if path == \"-\" {
return os.Stdin, nil
}
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf(\"obrint %s: %w\", path, err)
}
return file, nil
}Retornem io.ReadCloser perquè tant os.File com os.Stdin implementen aquesta interfície. El codi que crida OpenInput no necessita saber si està llegint d’un fitxer o de l’entrada estàndard.
Per llegir línia a línia des de stdin (útil per a eines que processen streams):
func ProcessStdin() error {
scanner := bufio.NewScanner(os.Stdin)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
// Processar cada línia
fmt.Printf(\"[%d] %s\n\", lineNum, line)
}
if err := scanner.Err(); err != nil {
return fmt.Errorf(\"llegint stdin: %w\", err)
}
return nil
}Un detall: bufio.Scanner té un límit per defecte de 64KB per línia. Si processes fitxers amb línies molt llargues (JSON en una sola línia, per exemple), necessites augmentar-lo:
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024) // 1MB per líniaSortida amb color i indicadors de progrés
Una CLI no necessita ser bonica, però una mica de color ajuda a distingir errors d’informació normal. La forma més senzilla és usar codis ANSI directament:
const (
colorReset = \"\033[0m\"
colorRed = \"\033[31m\"
colorGreen = \"\033[32m\"
colorYellow = \"\033[33m\"
colorCyan = \"\033[36m\"
)
func printError(msg string) {
fmt.Fprintf(os.Stderr, \"%serror:%s %s\n\", colorRed, colorReset, msg)
}
func printWarning(msg string) {
fmt.Fprintf(os.Stderr, \"%swarning:%s %s\n\", colorYellow, colorReset, msg)
}
func printSuccess(msg string) {
fmt.Fprintf(os.Stderr, \"%s%s%s\n\", colorGreen, msg, colorReset)
}
func printInfo(msg string) {
fmt.Fprintf(os.Stderr, \"%s%s%s\n\", colorCyan, msg, colorReset)
}Això funciona a la majoria de terminals modernes (macOS Terminal, iTerm2, terminals de Linux, Windows Terminal). Però hi ha un matís important: si la sortida de la teva eina es redirigeix a un fitxer o a un pipe, els codis ANSI contaminen la sortida. La solució és detectar si stderr és un terminal:
import \"golang.org/x/term\"
func useColor() bool {
return term.IsTerminal(int(os.Stderr.Fd()))
}Per a indicadors de progrés quan processes fitxers grans, un spinner simple n’hi ha prou:
func showProgress(current, total int) {
percent := float64(current) / float64(total) * 100
fmt.Fprintf(os.Stderr, \"\rprocessant: %d/%d (%.0f%%)\", current, total, percent)
if current == total {
fmt.Fprintln(os.Stderr)
}
}El \r (retorn de carro) mou el cursor al principi de la línia sense avançar a la següent, cosa que sobreescriu el text anterior. Simple i efectiu.
Gestió d’errors en CLIs: codis de sortida i missatges clars
La gestió d’errors en una CLI té requisits diferents als d’un servidor web o una llibreria. En una CLI, l’usuari és un humà assegut davant d’un terminal. Els missatges d’error han de ser comprensibles, i els codis de sortida han de ser correctes perquè funcionin en scripts.
Codis de sortida
La convenció estàndard és:
- 0: execució correcta
- 1: error genèric
- 2: ús incorrecte (arguments invàlids)
Algunes eines defineixen codis addicionals (per exemple, grep usa 1 per a “no trobat” i 2 per a errors). Per a la majoria de CLIs, 0 i 1 són suficients, més 2 per a errors d’arguments.
const (
exitOK = 0
exitError = 1
exitBadUsage = 2
)
func main() {
if err := run(); err != nil {
var usageErr *UsageError
if errors.As(err, &usageErr) {
fmt.Fprintf(os.Stderr, \"error: %v\n\", err)
flag.Usage()
os.Exit(exitBadUsage)
}
fmt.Fprintf(os.Stderr, \"error: %v\n\", err)
os.Exit(exitError)
}
}
type UsageError struct {
Msg string
}
func (e *UsageError) Error() string {
return e.Msg
}Un patró important: delegar tota la lògica a una funció run() que retorna error, i deixar que main() només s’encarregui d’imprimir l’error i cridar os.Exit. Això té dos avantatges. Primer, pots testejar run() sense que el test cridi os.Exit (que mataria el procés del test). Segon, defer no s’executa després de os.Exit, així que com menys os.Exit tinguis repartits pel codi, menys risc de recursos sense tancar.
Missatges d’error llegibles
Un error com open config.yaml: no such file or directory és tècnicament correcte però no ajuda l’usuari. Millor:
fmt.Fprintf(os.Stderr, \"error: no es pot obrir %q: existeix el fitxer?\n\", path)Algunes regles per a missatges d’error en CLIs:
- Comença amb minúscula (convenció de Go i de moltes eines Unix)
- No acabis amb punt
- Inclou la dada concreta que ha fallat (nom del fitxer, número de línia, valor invàlid)
- Si és un error de l’usuari, suggereix la solució
// Malament
fmt.Fprintln(os.Stderr, \"Error occurred\")
// Bé
fmt.Fprintf(os.Stderr, \"error: el fitxer %q no existeix\n\", path)
// Millor
fmt.Fprintf(os.Stderr, \"error: el fitxer %q no existeix; comprova la ruta o usa --input per especificar-ne un altre\n\", path)Compilació creuada: generar binaris per a Linux, macOS i Windows
Una de les raons per les quals Go és ideal per a CLIs és que pots compilar per a qualsevol plataforma des de qualsevol màquina. Només necessites dues variables d’entorn: GOOS i GOARCH.
# Linux AMD64 (servidors, contenidors Docker, WSL)
GOOS=linux GOARCH=amd64 go build -o csv-reporter-linux-amd64 .
# macOS Apple Silicon
GOOS=darwin GOARCH=arm64 go build -o csv-reporter-darwin-arm64 .
# macOS Intel
GOOS=darwin GOARCH=amd64 go build -o csv-reporter-darwin-amd64 .
# Windows
GOOS=windows GOARCH=amd64 go build -o csv-reporter-windows-amd64.exe .Si vols saber quines variables d’entorn afecten la compilació, a variables d’entorn en Go cobreixo GOOS, GOARCH i la resta.
Per automatitzar la compilació de totes les plataformes, un script simple:
#!/bin/bash
APP_NAME=\"csv-reporter\"
VERSION=\"0.1.0\"
OUTPUT_DIR=\"dist\"
mkdir -p \"$OUTPUT_DIR\"
platforms=(
\"linux/amd64\"
\"linux/arm64\"
\"darwin/amd64\"
\"darwin/arm64\"
\"windows/amd64\"
)
for platform in \"${platforms[@]}\"; do
GOOS=\"${platform%/*}\"
GOARCH=\"${platform#*/}\"
output=\"${OUTPUT_DIR}/${APP_NAME}-${VERSION}-${GOOS}-${GOARCH}\"
if [ \"$GOOS\" = \"windows\" ]; then
output=\"${output}.exe\"
fi
echo \"Compilant $GOOS/$GOARCH...\"
GOOS=$GOOS GOARCH=$GOARCH go build -o \"$output\" .
done
echo \"Binaris generats a $OUTPUT_DIR/\"
ls -lh \"$OUTPUT_DIR/\"Incrustar informació de versió al binari
És una bona pràctica que el binari sàpiga la seva pròpia versió. Go permet injectar valors en temps de compilació amb -ldflags:
// A main.go
var (
version = \"dev\"
commit = \"none\"
date = \"unknown\"
)go build -ldflags \"-X main.version=0.1.0 -X main.commit=$(git rev-parse --short HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)\" -o csv-reporter .Ara csv-reporter --version mostra la versió exacta, el commit i la data de compilació. Quan algú reporta un bug, saps exactament quina versió del codi està executant.
Distribució amb GoReleaser
Si la teva eina és alguna cosa més que un script personal i necessites distribuir-la (a companys d’equip, a un repositori públic, com a part d’un pipeline), GoReleaser automatitza tot el procés de compilació, empaquetament i publicació.
GoReleaser llegeix un fitxer .goreleaser.yaml a l’arrel del teu projecte i genera binaris per a múltiples plataformes, crea arxius .tar.gz i .zip, genera checksums i publica a GitHub Releases.
# .goreleaser.yaml
version: 2
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ldflags:
- -s -w
- -X main.version={{.Version}}
- -X main.commit={{.Commit}}
- -X main.date={{.Date}}
archives:
- format: tar.gz
name_template: \"{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}\"
format_overrides:
- goos: windows
format: zip
checksum:
name_template: \"checksums.txt\"
changelog:
sort: asc
filters:
exclude:
- \"^docs:\"
- \"^test:\"Per generar un release local (sense publicar a GitHub):
goreleaser release --snapshot --cleanPer publicar a GitHub Releases quan fas un tag:
git tag v0.1.0
git push origin v0.1.0
goreleaser releaseAixò genera els binaris, els puja com a assets del release, i crea un changelog automàtic basat en els commits entre el tag anterior i l’actual. Per a eines d’equip, això converteix la distribució de “envia un Slack amb el binari adjunt” a “descarrega’l de la pàgina de releases”.
Els flags -s -w a ldflags eliminen la taula de símbols i la informació de debug del binari. El resultat és un executable més petit (típicament un 30% menys) sense afectar el funcionament. Per a eines de terminal que distribueixes, val la pena.
Codi complet i resum
Aquí tens la versió completa del main.go amb flag (sense Cobra), incorporant tot el cobert a l’article:
package main
import (
\"errors\"
\"flag\"
\"fmt\"
\"os\"
)
var (
version = \"dev\"
commit = \"none\"
date = \"unknown\"
)
const (
exitOK = 0
exitError = 1
exitBadUsage = 2
)
type UsageError struct {
Msg string
}
func (e *UsageError) Error() string {
return e.Msg
}
func run() error {
input := flag.String(\"input\", \"\", \"fitxer CSV d'entrada (obligatori)\")
output := flag.String(\"output\", \"\", \"fitxer de sortida (per defecte: stdout)\")
showVersion := flag.Bool(\"version\", false, \"mostrar versió i sortir\")
verbose := flag.Bool(\"verbose\", false, \"mostrar informació de depuració\")
flag.Parse()
if *showVersion {
fmt.Printf(\"csv-reporter %s (commit: %s, data: %s)\n\", version, commit, date)
return nil
}
if *input == \"\" {
return &UsageError{Msg: \"el flag --input és obligatori\"}
}
// Llegir CSV
reader, err := OpenInput(*input)
if err != nil {
return err
}
defer reader.Close()
sales, warnings, err := ReadCSVFromReader(reader)
if err != nil {
return fmt.Errorf(\"llegint CSV: %w\", err)
}
if *verbose {
fmt.Fprintf(os.Stderr, \"files llegides: %d, warnings: %d\n\", len(sales), len(warnings))
}
for _, w := range warnings {
printWarning(w)
}
// Validar
result := Validate(sales)
if *verbose {
fmt.Fprintf(os.Stderr, \"vàlides: %d, invàlides: %d\n\",
len(result.Valid), len(result.Invalid))
}
for _, inv := range result.Invalid {
printWarning(inv)
}
// Generar informe
var writer *os.File
if *output != \"\" {
writer, err = os.Create(*output)
if err != nil {
return fmt.Errorf(\"creant fitxer de sortida: %w\", err)
}
defer writer.Close()
} else {
writer = os.Stdout
}
GenerateReport(writer, result.Valid)
if *output != \"\" {
printSuccess(fmt.Sprintf(\"informe generat a %s\", *output))
}
return nil
}
func main() {
if err := run(); err != nil {
var usageErr *UsageError
if errors.As(err, &usageErr) {
printError(err.Error())
fmt.Fprintln(os.Stderr)
flag.Usage()
os.Exit(exitBadUsage)
}
printError(err.Error())
os.Exit(exitError)
}
}Què hem cobert
- Parseo d’arguments amb
flag(i una introducció a Cobra per a subcomandes). - Lectura de CSV amb la llibreria estàndard, amb gestió d’errors fila per fila.
- Validació separada del parseo, amb regles de negoci explícites.
- Generació d’informes amb
tabwriter, escrivint contraio.Writerper a flexibilitat. - Lectura des de stdin i fitxers amb la convenció
-. - Sortida amb color usant codis ANSI i detecció de terminal.
- Codis de sortida correctes per a integració amb scripts.
- Compilació creuada per a Linux, macOS i Windows.
- Distribució amb GoReleaser.
Propers passos
El que falta en aquesta CLI és un bon conjunt de tests. Go té un framework de testing integrat que no necessita llibreries externes. Pots testejar cada component (lector, validador, generador d’informes) de forma aïllada gràcies a que usem interfícies (io.Writer, io.ReadCloser) en lloc de fitxers concrets.
Go brilla per a eines de terminal per la mateixa raó que brilla per a serveis backend: et dona un binari simple que funciona, sense sorpreses, sense dependències ocultes, sense “instal·la això primer”. Per a un desenvolupador que necessita automatitzar tasques i distribuir eines a un equip, això val més que qualsevol framework.


