Cobra es un motor de comandos que implementa una estructura de árbol para aplicaciones de línea de comandos, donde cada nodo es un objeto cobra.Command. En lugar de lidar con un parsing rudimentario de os.Args, Cobra permite definir una jerarquía de subcomandos (como git remote add), donde los comandos padres pueden inyectar comportamiento y flags a sus hijos. Esto se logra mediante la composición de nodos y la propagación de propiedades. Debes usar este enfoque cuando tu herramienta necesite múltiples acciones distintas o una lógica de flags compleja que dependa del contexto del comando ejecutado. Si intentas construir una CLI robusta con flag.Parse() estándar o gestionando todo en un solo main, terminarás con un código monolítico, imposible de testear y con una lógica de ayuda (--help) inconsistente.
Un error crítico que cometen quienes vienen de scripts simples es abusar de las funciones init() para configurar los comandos y usar variables globales para los flags. Esto hace que las pruebas unitarias sean un infierno, ya que el estado de los flags persiste entre tests y los comandos están fuertemente acoplados al estado global del paquete. La forma profesional de hacerlo es mediante la inyección de dependencias, definiendo los comandos como métodos o funciones que reciben un contenedor de dependencias (un struct con la configuración y servicios) y utilizando RunE para propagar errores de forma idiomática en lugar de forzar salidas abruptas con os.Exit dentro de la lógica de negocio.
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
// App actúa como nuestro contenedor de dependencias.
// Evitamos variables globales para que cada test pueda instanciar un App limpio.
type App struct {
ConfigPath string
DBConn string
Verbose bool
}
func main() {
// La inicialización de la aplicación ocurre aquí, en el entrypoint.
app := &App{}
rootCmd := NewRootCmd(app)
// Usamos RunE en el comando raíz para capturar errores de ejecución
// y gestionar el código de salida de forma centralizada.
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error crítico: %v\n", err)
os.Exit(1)
}
}
// NewRootCmd es el punto de entrada para construir el árbol de comandos.
func NewRootCmd(app *App) *cobra.Command {
rootCmd := &cobra.Command{
Use: "dataproc",
Short: "Gestor de pipelines de datos",
Long: `Herramienta profesional para procesar datos y gestionar migraciones de infraestructura.`,
// RunE permite retornar un error, algo vital para la observabilidad.
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Printf("dataproc iniciado (Config: %s, Verbose: %v)\n", app.ConfigPath, app.Verbose)
return nil
},
}
// PersistentFlags() se heredan a todos los subcomandos (serve, migrate).
rootCmd.PersistentFlags().StringVar(&app.ConfigPath, "config", "config.yaml", "Ruta al archivo de configuración")
rootCmd.PersistentFlags().BoolVarP(&app.Verbose, "verbose", "v", false, "Habilitar modo debug")
// Enlazo los subcomandos al árbol raíz.
rootCmd.AddCommand(newServeCmd(app))
rootCmd.AddCommand(newMigrateCmd(app))
return rootCmd
}
func newServeCmd(app *App) *cobra.Command {
var port int
cmd := &cobra.Command{
Use: "serve",
Short: "Levanta el servidor de la API",
RunE: func(cmd *cobra.Command, args []string) error {
// El servidor accede a flags locales y a las persistentes del padre.
fmt.Printf("Servidor en puerto %d (Config: %s, Verbose: %v)\n", port, app.ConfigPath, app.Verbose)
return nil
},
}
// Flags() define flags que SOLO existen para este subcomando.
cmd.Flags().IntVar(&port, "port", 8080, "Puerto del servidor HTTP")
return cmd
}
func newMigrateCmd(app *App) *cobra.Command {
var dryRun bool
cmd := &cobra.Command{
Use: "migrate",
Short: "Ejecuta migraciones de base de datos",
// PreRunE se usa para validaciones antes de que se ejecute RunE.
PreRunE: func(cmd *cobra.Command, args []string) error {
if app.Verbose && dryRun {
fmt.Println("[DEBUG] Validando conexión antes del dry-run...")
}
if app.ConfigPath == "" {
return fmt.Errorf("la ruta de configuración es obligatoria para migraciones")
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Printf("Ejecutando migración (DryRun: %v)\n", dryRun)
return nil
},
}
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Simula la operación sin persistir cambios")
return cmd
}
Desglose de la arquitectura
Fíjate en cómo el comando raíz rootCmd no conoce los detalles de serve o migrate, solo sabe que son sus hijos. El uso de app *App en las funciones constructoras (NewRootCmd, newServeCmd, etc.) es la implementación del patrón de inyección de dependencias. Esto permite que newServeCmd reciba el estado necesario (como app.ConfigPath) sin depender de una variable global que podría estar siendo modificada por otro test en paralelo.
En newServeCmd, hemos usado cmd.Flags().IntVar(...). Esto define un flag local: si intentas ejecutar dataproc migrate --port 9000, el parser de Cobra lanzará un error porque migrate no conoce ese flag. Por el contrario, rootCmd.PersistentFlags() asegura que --config esté disponible en cualquier nivel del árbol, desde el comando raíz hasta el más profundo subcomando.
Respecto al ciclo de vida, el uso de PreRunE en migrateCmd es fundamental para la validación de reglas de negocio antes de la ejecución principal. Si PreRunE devuelve un error, la ejecución se detiene y el error se propaga hacia el main.go mediante RunE, evitando que la lógica de RunE se ejecute con un estado inválido.
El error frecuente
Un error común es usar el método Run en lugar de RunE cuando el comando realiza operaciones que pueden fallar (como llamadas a red o lectura de archivos).
// MAL: El error se "traga" o requiere un os.Exit manual, rompiendo la testabilidad.
Run: func(cmd *cobra.Command, args []string) {
err := realizarAlgoQueFalla()
if err != nil {
fmt.Println(err)
os.Exit(1) // Esto mata el proceso y es imposible de capturar en un test unitario.
}
}
// BIEN: El error se retorna y el control vuelve al llamador.
RunE: func(cmd *cobra.Command, args []string) error {
return realizarAlgoQueFalla() // El error viaja hasta el main.
}
Al usar RunE, permites que tu lógica de comandos sea puramente funcional y que el control de salida del programa resida exclusivamente en el main, que es el único lugar donde debe decidir cómo cerrar el proceso.
N° 205