BiblioTech tiene arquitectura, módulos, patrones y cuarenta y una pruebas. Y sigue sin que nadie pueda usarlo.
Esta lección construye la primera interfaz real del proyecto: el módulo bibliotech-consola. Y conviene desactivar de entrada un prejuicio muy extendido: la consola no es «la interfaz de segunda, mientras llega la web». En una empresa como Nexus Software, la CLI es la interfaz que más se usa sin que nadie la vea, porque es la única que se puede meter en un cron, encadenar con otras herramientas, ejecutar por SSH en un servidor sin entorno gráfico y llamar desde un script de despliegue.
En el módulo 2 hiciste un menú interactivo con Scanner y un switch. Aquello era un ejercicio de flujo de control. Lo que vamos a construir aquí es otra cosa: una herramienta con subcomandos, opciones tipadas y validadas, ayuda generada automáticamente, autocompletado en el terminal, códigos de salida con significado, salida en varios formatos, tuberías, colores que se desactivan solos cuando no procede, barras de progreso, cancelación limpia con Ctrl+C y un jar ejecutable con su script de arranque.
La diferencia entre las dos cosas se resume en una frase: el menú del módulo 2 lo usa una persona; esta CLI la usa una persona y también un script. Y diseñar para las dos a la vez es lo que la hace profesional.
Al terminar sabrás cuándo una CLI es la interfaz correcta, dominarás Picocli integrado con Spring Boot, diseñarás una jerarquía de subcomandos coherente, entenderás por qué la separación entre salida estándar y salida de error importa de verdad, usarás códigos de salida que los scripts puedan interpretar, formatearás la salida para humanos y para máquinas, gestionarás operaciones largas con progreso y cancelación, empaquetarás la aplicación y —algo que casi nadie hace— probarás una aplicación de consola.
Contenido
- Cuándo una CLI es la interfaz correcta
- Qué hace buena a una CLI
- Argumentos de línea de comandos a mano, y sus límites
- Picocli: el modelo de anotaciones
- Opciones, parámetros y tipos
- Validación y conversión de tipos
- Subcomandos y jerarquía
- Ayuda automática y autocompletado
- Integración con Spring Boot
- Diseño de la CLI de BiblioTech
- Modo de un solo comando frente a modo interactivo
- Entrada y salida estándar: componer con tuberías
- Códigos de salida
- Formatear la salida: tabla, CSV y JSON
- Verbosidad:
--silenciosoy--verboso - Operaciones largas: progreso y señales de vida
- Cancelación limpia con
Ctrl+C - Colores ANSI y cuándo desactivarlos
- Errores orientados al usuario
- Empaquetado y distribución
- Probar una aplicación de consola
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- Cuándo una CLI es la interfaz correcta
| Situación | ¿CLI? | Por qué |
|---|---|---|
| Tarea programada nocturna (avisos de vencimiento) | Sí | cron no sabe pulsar botones |
| Importación masiva del catálogo | Sí | Se ejecuta por SSH, sin entorno gráfico, y se puede redirigir la salida a un fichero |
| Herramienta interna para el equipo técnico | Sí | Rápida de escribir, rápida de usar, componible |
| Paso de un pipeline de despliegue | Sí | Códigos de salida que el pipeline interpreta |
| Diagnóstico en producción a las 3 de la mañana | Sí | Es lo único que hay en un servidor |
| Consulta del catálogo por cualquier empleado | No | Necesitan buscar visualmente; web |
| Alta de un material con quince campos | No | Formulario con validación en vivo |
| Panel de indicadores | No | Gráficos |
La regla útil: una CLI gana cuando la tarea es repetible, automatizable o la ejecuta alguien técnico. Pierde cuando la tarea es exploratoria o la ejecuta alguien que no vive en un terminal.
Y ambas conviven perfectamente. BiblioTech tendrá CLI (esta lección) y API REST (12-04), compartiendo exactamente los mismos casos de uso del módulo bibliotech-aplicacion. Esa es la recompensa de la arquitectura de 12-01: dos adaptadores de entrada, un solo núcleo.
- Qué hace buena a una CLI
Cuatro propiedades, y ninguna es opcional:
Predecible. Las mismas convenciones que el resto de herramientas del sistema: -v y --verboso, --help, --version, verbo antes que sustantivo o al revés pero siempre igual. Una CLI que inventa su propia sintaxis obliga a leer la documentación cada vez.
Componible. Lee de la entrada estándar, escribe en la salida estándar, y los diagnósticos van a la salida de error. Eso permite:
bibliotech catalogo listar --formato=csv | grep "Java" | wc -l
cat isbns.txt | bibliotech catalogo importar --desde-stdin
bibliotech informe multas --formato=json | jq '.[] | select(.importe > 10)'Con buenos mensajes de error. Un error debe decir qué pasó, por qué y qué hacer. Compara:
con:
Error: no se encontró ningún material con ISBN 978-0000000009.
Causa: el ISBN no existe en el catálogo.
Sugerencia: comprueba el ISBN con 'bibliotech catalogo buscar --titulo="..."'
o impórtalo con 'bibliotech catalogo importar'.Honesta con el estado. Si va a tardar, lo dice. Si va a modificar datos, lo advierte. Si tiene un modo de simulación (--simular), lo ofrece para las operaciones destructivas.
- Argumentos de línea de comandos a mano, y sus límites
Java te entrega los argumentos en String[] args. Parsearlos a mano parece trivial:
public static void main(String[] args) {
String formato = "tabla";
boolean verboso = false;
String isbn = null;
for (int i = 0; i < args.length; i++) {
switch (args[i]) {
case "--formato" -> formato = args[++i];
case "-v", "--verboso" -> verboso = true;
case "--isbn" -> isbn = args[++i];
default -> {
System.err.println("Opción desconocida: " + args[i]);
System.exit(2);
}
}
}
// …
}Funciona para tres opciones. Ahora la lista de lo que no hace, y que un usuario de terminal espera que funcione:
| Falta | Ejemplo que falla |
|---|---|
Forma --opcion=valor |
--formato=json se lee como una opción desconocida |
| Opciones cortas agrupadas | -vq en lugar de -v -q |
-- para separar opciones de argumentos |
bibliotech buscar -- --titulo-raro |
| Conversión de tipos | Todo es String; convertir y validar a mano |
| Opciones obligatorias | Nada comprueba que --isbn esté presente |
| Grupos exclusivos | --formato=json --formato=csv no da error |
| Ayuda | Hay que escribirla y mantenerla sincronizada a mano |
| Índice fuera de rango | --formato al final: ArrayIndexOutOfBoundsException |
| Subcomandos | catalogo listar requiere otro nivel de parseo |
| Autocompletado | Imposible |
Ese último ++i sin comprobar el límite es un bug real esperando a que alguien escriba bibliotech listar --formato y pulse Enter.
Conclusión: parsear a mano está bien para un script de veinte líneas. Para una herramienta de verdad, se usa una librería. En Java hay tres candidatas serias:
| Librería | Ventajas | Inconvenientes |
|---|---|---|
| Picocli | Anotaciones, subcomandos, colores, autocompletado, sin dependencias, soporte GraalVM | — |
| Apache Commons CLI | Muy estable, veterana | API imperativa y verbosa; sin subcomandos nativos |
| JCommander | Sencilla, con anotaciones | Menos activa; menos funcionalidades |
Usaremos Picocli: es la opción estándar de facto en Java moderno, y es la que integra Spring Boot con un starter oficial.
- Picocli: el modelo de anotaciones
Dependencia en bibliotech-consola/pom.xml:
<dependencies>
<dependency>
<groupId>com.nexussoftware</groupId>
<artifactId>bibliotech-aplicacion</artifactId>
</dependency>
<dependency>
<groupId>com.nexussoftware</groupId>
<artifactId>bibliotech-infraestructura</artifactId>
</dependency>
<dependency>
<groupId>info.picocli</groupId>
<artifactId>picocli-spring-boot-starter</artifactId> <!-- trae picocli + integración -->
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId> <!-- sin 'web': esto no es un servidor -->
</dependency>
</dependencies>El «hola mundo» de Picocli, con todo lo esencial:
package com.nexussoftware.bibliotech.consola;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
import java.util.concurrent.Callable;
@Command(
name = "bibliotech", // cómo se invoca
mixinStandardHelpOptions = true, // añade --help y --version gratis
version = "BiblioTech CLI 1.0.0",
description = "Herramienta de gestión de la biblioteca técnica de Nexus Software.")
public class Ejemplo implements Callable<Integer> { // Callable<Integer>: el int es el código de salida
@Parameters(index = "0", description = "ISBN del material a consultar.")
private String isbn;
@Option(names = {"-f", "--formato"}, defaultValue = "tabla",
description = "Formato de salida: ${COMPLETION-CANDIDATES}. Por defecto: ${DEFAULT-VALUE}")
private Formato formato;
@Override
public Integer call() {
System.out.println("Consultando " + isbn + " en formato " + formato);
return 0; // 0 = éxito
}
public static void main(String[] args) {
int codigo = new CommandLine(new Ejemplo()).execute(args);
System.exit(codigo);
}
}Con esas veinte líneas ya tienes:
$ java -jar ejemplo.jar --help
Usage: bibliotech [-hV] [-f=<formato>] <isbn>
Herramienta de gestión de la biblioteca técnica de Nexus Software.
<isbn> ISBN del material a consultar.
-f, --formato=<formato>
Formato de salida: TABLA, CSV, JSON. Por defecto: TABLA
-h, --help Show this help message and exit.
-V, --version Print version information and exit.Los tres elementos del modelo:
| Anotación | Qué marca | Ejemplo en la línea de comandos |
|---|---|---|
@Command |
La clase (o método) que es un comando | bibliotech |
@Option |
Un argumento con nombre | --formato=json, -v |
@Parameters |
Un argumento posicional | el 978-0000000001 de bibliotech ficha 978-0000000001 |
- Opciones, parámetros y tipos
Picocli convierte a más de 40 tipos automáticamente, incluidos los de java.time (módulo 10):
@Command(name = "prestamo")
class OpcionesEjemplo {
// Booleana: su sola presencia la pone a true
@Option(names = {"-v", "--verboso"}, description = "Muestra detalle de la ejecución.")
boolean verboso;
// Obligatoria: si falta, Picocli produce un error claro
@Option(names = "--isbn", required = true, description = "ISBN del material.")
String isbn;
// Con valor por defecto
@Option(names = "--dias", defaultValue = "15", description = "Duración en días.")
int dias;
// Tipo de java.time: conversión automática desde ISO-8601
@Option(names = "--desde", description = "Fecha de inicio (yyyy-MM-dd).")
LocalDate desde;
// Enum: Picocli valida los valores permitidos y los lista en la ayuda
@Option(names = "--formato", defaultValue = "TABLA")
Formato formato;
// Repetible: --etiqueta java --etiqueta diseño
@Option(names = "--etiqueta", description = "Etiqueta (repetible).")
List<String> etiquetas = new ArrayList<>();
// Con aridad: exactamente dos valores
@Option(names = "--rango", arity = "2", paramLabel = "<desde> <hasta>")
int[] rango;
// Mapa: --propiedad clave=valor
@Option(names = "-D", description = "Propiedad adicional.")
Map<String, String> propiedades = new LinkedHashMap<>();
// Sensible: Picocli la pide por teclado sin mostrarla si no se pasa
@Option(names = "--clave", interactive = true, arity = "0..1")
char[] clave;
// Posicionales: todos los restantes
@Parameters(index = "0", description = "Fichero de entrada.")
Path fichero;
@Parameters(index = "1..*", description = "ISBNs adicionales.")
List<String> isbnsExtra;
}Sobre interactive = true y char[]: es la forma correcta de pedir una contraseña. char[] en lugar de String para poder sobrescribirla en memoria tras usarla; una String queda en el pool hasta que el recolector la retire, y puede aparecer en un volcado de memoria (se retoma en 12-07).
Grupos exclusivos, para opciones incompatibles entre sí:
static class OrigenDatos {
@Option(names = "--fichero", required = true) Path fichero;
@Option(names = "--url", required = true) URI url;
@Option(names = "--stdin", required = true) boolean stdin;
}
@ArgGroup(exclusive = true, multiplicity = "1") // exactamente uno de los tres
OrigenDatos origen;Si el usuario pasa dos, Picocli lo rechaza con un mensaje claro, sin que escribas ninguna comprobación.
- Validación y conversión de tipos
Picocli valida tipos; la validación de negocio la escribes tú, y hay un sitio correcto para ella: un conversor propio, para que el error aparezca en el parseo y no a mitad de la ejecución.
/** Convierte el texto de la línea de comandos en el objeto de valor del dominio. */
public class ConversorIsbn implements CommandLine.ITypeConverter<Isbn> {
@Override
public Isbn convert(String valor) {
try {
return Isbn.de(valor); // el objeto de valor ya valida (12-02)
} catch (IsbnInvalidoException e) {
// TypeConversionException produce un mensaje de uso, no una traza
throw new CommandLine.TypeConversionException(
"'" + valor + "' no es un ISBN-13 válido. Formato esperado: 978-XXXXXXXXXX");
}
}
}@Option(names = "--isbn", required = true, converter = ConversorIsbn.class)
private Isbn isbn; // ¡el campo es del tipo del dominio, no String!Resultado en el terminal:
$ bibliotech prestamo crear --isbn=1234 --empleado=1
Invalid value for option '--isbn': '1234' no es un ISBN-13 válido.
Formato esperado: 978-XXXXXXXXXX
Usage: bibliotech prestamo crear [-hV] --empleado=<id> --isbn=<isbn> [--dias=<n>]
…Para reglas que dependen de varias opciones, se usa la especificación de comando:
@Spec CommandLine.Model.CommandSpec spec;
private void validar() {
if (desde != null && hasta != null && desde.isAfter(hasta)) {
throw new CommandLine.ParameterException(spec.commandLine(),
"--desde (" + desde + ") no puede ser posterior a --hasta (" + hasta + ")");
}
}ParameterException es importante: hace que Picocli imprima el mensaje y el uso, y devuelva el código de salida de error de uso (2), que es lo que un script espera para «me has llamado mal».
- Subcomandos y jerarquía
Una CLI con más de cinco operaciones necesita subcomandos. El patrón que ha ganado en la industria es sustantivo + verbo (git remote add, docker container run, kubectl get pods), porque agrupa por área y escala mejor.
flowchart TD
R["bibliotech"]
C["catalogo"]
P["prestamo"]
A["avisos"]
I["informe"]
R --> C
R --> P
R --> A
R --> I
C --> C1["listar"]
C --> C2["buscar"]
C --> C3["importar"]
C --> C4["ficha"]
P --> P1["crear"]
P --> P2["devolver"]
P --> P3["renovar"]
P --> P4["listar"]
A --> A1["enviar"]
A --> A2["previsualizar"]
I --> I1["multas"]
I --> I2["uso"]
El comando raíz declara sus hijos:
@Command(
name = "bibliotech",
mixinStandardHelpOptions = true,
version = "BiblioTech CLI 1.0.0",
description = "Gestión de la biblioteca técnica de Nexus Software.",
subcommands = {
ComandoCatalogo.class,
ComandoPrestamo.class,
ComandoAvisos.class,
ComandoInforme.class,
CommandLine.HelpCommand.class // 'bibliotech help catalogo'
})
@Component
public class ComandoRaiz implements Callable<Integer> {
@Override
public Integer call() {
// Sin subcomando, mostrar la ayuda y devolver error de uso:
// así un script sabe que la invocación estaba incompleta.
CommandLine.usage(this, System.out);
return CodigoSalida.ERROR_USO;
}
}Y un comando intermedio, que solo agrupa:
@Command(name = "catalogo",
description = "Operaciones sobre el catálogo de materiales.",
subcommands = {ComandoCatalogoListar.class, ComandoCatalogoBuscar.class,
ComandoCatalogoImportar.class, ComandoCatalogoFicha.class})
@Component
public class ComandoCatalogo implements Callable<Integer> {
@Override public Integer call() {
CommandLine.usage(this, System.out);
return CodigoSalida.ERROR_USO;
}
}Opciones heredadas. Las globales (--formato, --verboso) se declaran una vez con scope = ScopeType.INHERIT:
@Command(name = "bibliotech", …)
public class ComandoRaiz {
@Option(names = {"-f", "--formato"}, scope = CommandLine.ScopeType.INHERIT,
defaultValue = "TABLA", description = "Formato de salida: ${COMPLETION-CANDIDATES}")
Formato formato;
@Option(names = {"-q", "--silencioso"}, scope = CommandLine.ScopeType.INHERIT,
description = "Solo errores.")
boolean silencioso;
@Option(names = {"-v", "--verboso"}, scope = CommandLine.ScopeType.INHERIT,
description = "Detalle de la ejecución.")
boolean verboso;
}Ahora bibliotech catalogo listar --formato=json funciona sin declarar --formato en cada subcomando.
- Ayuda automática y autocompletado
mixinStandardHelpOptions = true genera --help y --version. La calidad de esa ayuda depende de lo que escribas en description, y hay variables útiles:
| Variable | Se sustituye por |
|---|---|
${DEFAULT-VALUE} |
El valor por defecto de la opción |
${COMPLETION-CANDIDATES} |
Los valores válidos (útil con enum) |
${sys:usuario} |
Una propiedad del sistema |
Y se pueden añadir secciones completas, que es lo que separa una ayuda útil de una lista de opciones:
@Command(name = "importar",
description = "Importa materiales al catálogo desde un fichero o desde la entrada estándar.",
footerHeading = "%nEjemplos:%n",
footer = {
" bibliotech catalogo importar catalogo.csv",
" bibliotech catalogo importar --formato-entrada=json datos.json --simular",
" cat isbns.txt | bibliotech catalogo importar --desde-stdin --enriquecer",
"",
"Códigos de salida: 0 correcto, 1 error, 2 uso incorrecto, 4 importación parcial."
})
class ComandoCatalogoImportar implements Callable<Integer> { … }Autocompletado. Picocli genera un script de completado para bash y zsh:
# Generar el script (una vez, en la construcción)
java -cp bibliotech-consola.jar picocli.AutoComplete \
-n bibliotech com.nexussoftware.bibliotech.consola.ComandoRaiz
# Activarlo en la sesión
source bibliotech_completion
# Ahora funciona el tabulador
$ bibliotech cat<TAB> → bibliotech catalogo
$ bibliotech catalogo <TAB> → buscar ficha importar listar
$ bibliotech catalogo listar --formato=<TAB> → TABLA CSV JSONEste detalle es de los que más agradecen quienes usan la herramienta a diario, y cuesta una línea en el POM.
- Integración con Spring Boot
El problema a resolver: los comandos necesitan los servicios de aplicación (GestionarPrestamos, ConsultarCatalogo), que son beans de Spring. Pero Picocli instancia los comandos por reflexión. Sin integración, acabarías con new ComandoPrestamo(contexto.getBean(...)), que es Service Locator, un antipatrón (12-02).
picocli-spring-boot-starter lo resuelve con una fábrica que delega en el contexto:
package com.nexussoftware.bibliotech.consola;
@SpringBootApplication(scanBasePackages = "com.nexussoftware.bibliotech")
public class BiblioTechCli implements CommandLineRunner, ExitCodeGenerator {
private final ComandoRaiz comandoRaiz;
private final CommandLine.IFactory fabrica; // la aporta el starter de Picocli
private int codigoSalida;
public BiblioTechCli(ComandoRaiz comandoRaiz, CommandLine.IFactory fabrica) {
this.comandoRaiz = comandoRaiz;
this.fabrica = fabrica;
}
@Override
public void run(String... args) {
this.codigoSalida = new CommandLine(comandoRaiz, fabrica)
.setCaseInsensitiveEnumValuesAllowed(true) // --formato=json y JSON
.setExecutionExceptionHandler(new ManejadorErroresCli())
.execute(args);
}
@Override
public int getExitCode() { return codigoSalida; }
public static void main(String[] args) {
// SpringApplication.exit devuelve el código del ExitCodeGenerator,
// y cierra el contexto ordenadamente antes de salir.
System.exit(SpringApplication.exit(SpringApplication.run(BiblioTechCli.class, args)));
}
}Con esto, los comandos son beans normales y reciben sus dependencias por constructor:
@Command(name = "crear", description = "Crea un préstamo de un material a un empleado.")
@Component
public class ComandoPrestamoCrear implements Callable<Integer> {
private final GestionarPrestamos gestor; // ¡inyección por constructor, como siempre!
private final Salida salida;
public ComandoPrestamoCrear(GestionarPrestamos gestor, Salida salida) {
this.gestor = gestor;
this.salida = salida;
}
@Option(names = "--isbn", required = true, converter = ConversorIsbn.class)
private Isbn isbn;
@Option(names = "--empleado", required = true, paramLabel = "<id>")
private Long idEmpleado;
@Option(names = "--dias", description = "Duración. Por defecto, la del tipo de material.")
private Integer dias;
@Override
public Integer call() {
Prestamo prestamo = gestor.prestar(isbn, idEmpleado, dias);
salida.exito("Préstamo #%d creado. Vence el %s."
.formatted(prestamo.getId(), prestamo.getFechaVencimiento()));
return CodigoSalida.OK;
}
}Un detalle de configuración importante. La CLI no debe arrancar un servidor web ni imprimir el banner de Spring. En bibliotech-consola/src/main/resources/application.yml:
spring:
main:
web-application-type: none # sin Tomcat
banner-mode: off # sin banner ASCII contaminando la salida
output:
ansi:
enabled: detect # colores solo si el terminal los soporta
logging:
pattern:
console: "%d{HH:mm:ss} %-5level %msg%n"
level:
root: WARN # una CLI silenciosa por defecto
com.nexussoftware.bibliotech: INFOQue banner-mode esté a off no es cosmético: si la salida se va a encadenar con jq, el banner rompe el JSON.
- Diseño de la CLI de BiblioTech
La superficie completa de la herramienta:
| Comando | Descripción | Opciones principales |
|---|---|---|
catalogo listar |
Lista materiales | --tipo, --disponibles, --limite, --ordenar-por |
catalogo buscar |
Busca por criterios | --titulo, --autor, --desde, --hasta |
catalogo ficha |
Detalle de un material | <isbn> posicional, --con-historial |
catalogo importar |
Importa desde fichero o stdin | --desde-stdin, --formato-entrada, --simular, --enriquecer, --hilos |
prestamo crear |
Crea un préstamo | --isbn, --empleado, --dias |
prestamo devolver |
Registra una devolución | <idPrestamo>, --fecha |
prestamo renovar |
Renueva un préstamo | <idPrestamo>, --dias |
prestamo listar |
Lista préstamos | --empleado, --estado, --vencidos |
avisos enviar |
Envía avisos de vencimiento | --dias-antelacion, --simular |
informe multas |
Informe de multas | --desde, --hasta, --empleado |
informe uso |
Informe de uso mensual | --periodo |
Opciones globales, disponibles en todos:
| Opción | Efecto |
|---|---|
-f, --formato=<TABLA|CSV|JSON> |
Formato de salida |
-q, --silencioso |
Solo errores; sin cabeceras ni mensajes de progreso |
-v, --verboso |
Detalle de la ejecución (repetible: -vv para trazas) |
--sin-color |
Desactiva los colores ANSI |
-h, --help / -V, --version |
Ayuda y versión |
Un comando completo, con todo lo que hemos visto junto:
@Command(name = "listar",
description = "Lista los materiales del catálogo.",
footerHeading = "%nEjemplos:%n",
footer = {
" bibliotech catalogo listar --tipo=LIBRO --disponibles",
" bibliotech catalogo listar --formato=csv > catalogo.csv",
" bibliotech catalogo listar --formato=json | jq '.[].titulo'"
})
@Component
public class ComandoCatalogoListar implements Callable<Integer> {
private final ConsultarCatalogo catalogo;
private final Salida salida;
public ComandoCatalogoListar(ConsultarCatalogo catalogo, Salida salida) {
this.catalogo = catalogo;
this.salida = salida;
}
@Option(names = "--tipo", description = "Filtrar por tipo: ${COMPLETION-CANDIDATES}")
private TipoMaterial tipo;
@Option(names = "--disponibles", description = "Solo materiales con unidades libres.")
private boolean soloDisponibles;
@Option(names = "--limite", defaultValue = "50",
description = "Número máximo de resultados. Por defecto: ${DEFAULT-VALUE}")
private int limite;
@Option(names = "--ordenar-por", defaultValue = "TITULO",
description = "Criterio de orden: ${COMPLETION-CANDIDATES}")
private CriterioOrden orden;
@Override
public Integer call() {
var criterio = CriterioBusqueda.builder() // Builder de 12-02
.tipo(tipo)
.soloDisponibles(soloDisponibles)
.construir();
List<Material> resultados = catalogo.buscar(criterio, orden, limite);
if (resultados.isEmpty()) {
salida.aviso("No se encontró ningún material con esos criterios.");
return CodigoSalida.SIN_RESULTADOS; // 3: distinto de error
}
salida.escribirMateriales(resultados); // el formato lo decide Salida
salida.info("%d materiales.".formatted(resultados.size()));
return CodigoSalida.OK;
}
}Observa que el comando no imprime nada directamente. Delega en Salida, que es quien sabe de formatos, colores, verbosidad y a qué flujo va cada cosa. Es responsabilidad única aplicada a la consola.
- Modo de un solo comando frente a modo interactivo
| Aspecto | Un solo comando | Interactivo (REPL) |
|---|---|---|
| Invocación | bibliotech prestamo crear --isbn=… |
bibliotech shell, y luego órdenes |
| Automatizable | Sí | No |
| Componible con tuberías | Sí | No |
| Coste por operación | Arranque de la JVM cada vez (~1 s) | Solo el primero |
| Adecuado para | Scripts, cron, CI |
Exploración, muchas operaciones seguidas |
Pueden convivir, y la clave para que convivan bien es que el modo interactivo no duplique lógica: se limita a leer una línea, trocearla y pasársela al mismo CommandLine.
@Command(name = "shell", description = "Modo interactivo. Escribe 'salir' para terminar.")
@Component
public class ComandoShell implements Callable<Integer> {
private final ComandoRaiz raiz;
private final CommandLine.IFactory fabrica;
@Override
public Integer call() {
// Console es null si la entrada está redirigida: entonces el shell no tiene sentido
Console consola = System.console();
if (consola == null) {
System.err.println("El modo interactivo requiere un terminal.");
return CodigoSalida.ERROR_USO;
}
System.out.println("BiblioTech 1.0.0 — escribe 'ayuda' o 'salir'.");
CommandLine cl = new CommandLine(raiz, fabrica);
while (true) {
String linea = consola.readLine("bibliotech> ");
if (linea == null || linea.isBlank()) continue;
if (linea.equals("salir") || linea.equals("exit")) return CodigoSalida.OK;
if (linea.equals("ayuda")) { cl.usage(System.out); continue; }
// Reutiliza EXACTAMENTE el mismo parseo y los mismos comandos
cl.execute(trocear(linea));
}
}
/** Trocea respetando las comillas: buscar --titulo="Java Efectivo" */
private String[] trocear(String linea) {
List<String> partes = new ArrayList<>();
Matcher m = Pattern.compile("\"([^\"]*)\"|(\\S+)").matcher(linea);
while (m.find()) {
partes.add(m.group(1) != null ? m.group(1) : m.group(2));
}
return partes.toArray(String[]::new);
}
}Para un REPL serio (historial, edición de línea, autocompletado en vivo), la librería es JLine, que además Picocli integra oficialmente. Aquí basta con lo anterior.
- Entrada y salida estándar: componer con tuberías
Esto retoma 01-06 y 06-07, y es lo que convierte una CLI en una pieza del sistema en lugar de una isla.
Los tres flujos, y su regla de uso:
| Flujo | Java | Qué va aquí | Se redirige con |
|---|---|---|---|
| stdin (0) | System.in |
Datos de entrada | < o | |
| stdout (1) | System.out |
El resultado, y solo el resultado | > o | |
| stderr (2) | System.err |
Diagnósticos: avisos, progreso, errores | 2> |
La regla de oro: si algo no forma parte del resultado, no va a
System.out. Un mensaje de progreso, una cabecera decorativa o un «Procesando…» en la salida estándar rompen| jqy> fichero.csv.
Leer de la entrada estándar:
@Option(names = "--desde-stdin", description = "Lee los ISBN de la entrada estándar, uno por línea.")
private boolean desdeStdin;
@Parameters(index = "0", arity = "0..1", description = "Fichero de entrada.")
private Path fichero;
private List<String> leerEntrada() throws IOException {
if (desdeStdin) {
// Ojo con la codificación: en Java 18+ el defecto es UTF-8, pero ser explícito no sobra
try (BufferedReader lector = new BufferedReader(
new InputStreamReader(System.in, StandardCharsets.UTF_8))) {
return lector.lines()
.map(String::strip)
.filter(l -> !l.isEmpty() && !l.startsWith("#")) // ignorar comentarios
.toList();
}
}
if (fichero != null) {
return Files.readAllLines(fichero, StandardCharsets.UTF_8);
}
throw new CommandLine.ParameterException(spec.commandLine(),
"Indica un fichero de entrada o usa --desde-stdin.");
}Detectar si la salida es un terminal. Esto gobierna colores, progreso y cabeceras:
/** true si stdout va a un terminal; false si va a un fichero o a otro proceso. */
public static boolean esTerminal() {
return System.console() != null;
}Con eso, la CLI se adapta sola:
$ bibliotech catalogo listar # terminal: colores, cabeceras, totales
$ bibliotech catalogo listar > catalogo.txt # fichero: sin colores ni adornos
$ bibliotech catalogo listar | grep Java # tubería: idemY el resultado práctico de haber respetado la regla de oro:
# El resultado va al fichero; los avisos siguen viéndose en pantalla
bibliotech catalogo importar datos.csv > resultado.json 2> importacion.log
# Encadenar sin que nada se contamine
bibliotech informe multas --formato=json | jq '[.[] | select(.importe > 10)] | length'
# Usar la salida de un comando como entrada de otro
bibliotech prestamo listar --vencidos --formato=csv | cut -d';' -f2 | \
bibliotech avisos enviar --desde-stdin
- Códigos de salida
Todo proceso devuelve un entero al sistema operativo. Para una persona es invisible; para un script lo es todo:
Convenio de BiblioTech:
| Código | Constante | Significado | Reacción típica del script |
|---|---|---|---|
| 0 | OK |
Éxito | Continuar |
| 1 | ERROR |
Error general de ejecución | Abortar y avisar |
| 2 | ERROR_USO |
Argumentos inválidos | Corregir la invocación |
| 3 | SIN_RESULTADOS |
Se ejecutó bien, pero no hubo nada | Continuar, sin alarma |
| 4 | PARCIAL |
Terminó con errores parciales | Revisar el detalle |
| 5 | NO_ENCONTRADO |
El recurso solicitado no existe | Depende |
| 6 | CONFLICTO |
Regla de negocio violada | No reintentar |
| 7 | NO_DISPONIBLE |
Dependencia externa caída | Reintentar más tarde |
| 130 | — | Interrumpido con Ctrl+C |
Convenio POSIX: 128 + SIGINT(2) |
public final class CodigoSalida {
public static final int OK = 0;
public static final int ERROR = 1;
public static final int ERROR_USO = 2;
public static final int SIN_RESULTADOS = 3;
public static final int PARCIAL = 4;
public static final int NO_ENCONTRADO = 5;
public static final int CONFLICTO = 6;
public static final int NO_DISPONIBLE = 7;
public static final int INTERRUMPIDO = 130;
private CodigoSalida() { }
}La distinción entre 6 y 7 es la que más valor aporta en la práctica: un script de reintentos debe reintentar ante NO_DISPONIBLE (la API de metadatos está caída) y no ante CONFLICTO (el empleado ya tiene tres préstamos: reintentar mil veces no lo va a arreglar).
#!/usr/bin/env bash
# Reintentar solo cuando tiene sentido
for intento in 1 2 3; do
bibliotech catalogo importar --enriquecer datos.csv
codigo=$?
case $codigo in
0) echo "Importación correcta"; exit 0 ;;
7) echo "Servicio no disponible; reintento $intento"; sleep $((intento * 30)) ;;
*) echo "Error no recuperable (código $codigo)"; exit $codigo ;;
esac
done
exit 7
- Formatear la salida: tabla, CSV y JSON
La clase Salida centraliza todo lo relativo a presentación. Es una fachada (12-02) sobre el formato, el color y la verbosidad.
@Component
public class Salida {
private final ObjectMapper json; // Jackson, del módulo 11: bean único
private final PrintStream out;
private final PrintStream err;
private Formato formato = Formato.TABLA;
private Nivel nivel = Nivel.NORMAL;
private boolean color = true;
public Salida(ObjectMapper json) {
this.json = json;
// Codificación explícita: sin esto, los acentos se rompen al redirigir en Windows
this.out = new PrintStream(new FileOutputStream(FileDescriptor.out), true, UTF_8);
this.err = new PrintStream(new FileOutputStream(FileDescriptor.err), true, UTF_8);
}
public void escribirMateriales(List<Material> materiales) {
switch (formato) {
case TABLA -> tabla(materiales);
case CSV -> csv(materiales);
case JSON -> json(materiales.stream().map(MaterialDto::desde).toList());
}
}
// …
}Formato tabla, con columnas que se ajustan al contenido:
private void tabla(List<Material> materiales) {
// 1. Calcular el ancho de cada columna: el del contenido más largo, con un máximo
int anchoTitulo = Math.min(45, Math.max(6,
materiales.stream().mapToInt(m -> m.getTitulo().length()).max().orElse(6)));
String formatoFila = "%-17s %-" + anchoTitulo + "s %-8s %5s%n";
// 2. Cabecera solo si NO es una tubería y no estamos en modo silencioso
if (mostrarDecoracion()) {
out.printf(formatoFila, "ISBN", "TÍTULO", "TIPO", "LIBRES");
out.println("-".repeat(17 + anchoTitulo + 8 + 5 + 6));
}
// 3. Filas
for (Material m : materiales) {
out.printf(formatoFila,
m.getIsbn().valor(),
recortar(m.getTitulo(), anchoTitulo),
m.tipo(),
colorearDisponibilidad(m.unidadesDisponibles()));
}
}
/** Recorta con puntos suspensivos para no descuadrar la tabla. */
private String recortar(String texto, int max) {
return texto.length() <= max ? texto : texto.substring(0, max - 1) + "…";
}ISBN TÍTULO TIPO LIBRES ----------------------------------------------------------------------- 978-0000000001 Java Efectivo LIBRO 2 978-0000000002 Patrones de Diseño LIBRO 0 978-0000000003 Refactorización LIBRO 1
Formato CSV, con el escapado que 07-07 enseñó a no improvisar:
private void csv(List<Material> materiales) {
if (mostrarDecoracion()) out.println("isbn;titulo;tipo;disponibles");
for (Material m : materiales) {
out.printf("%s;%s;%s;%d%n",
m.getIsbn().valor(), escapar(m.getTitulo()), m.tipo(), m.unidadesDisponibles());
}
}
private String escapar(String valor) {
// Si contiene separador, comillas o saltos de línea, hay que entrecomillar y duplicar comillas
if (valor.contains(";") || valor.contains("\"") || valor.contains("\n")) {
return '"' + valor.replace("\"", "\"\"") + '"';
}
return valor;
}Formato JSON, con Jackson:
private void json(Object valor) {
try {
// Sin indentar si es una tubería (más compacto); indentado si lo lee una persona
ObjectWriter escritor = esTerminal()
? json.writerWithDefaultPrettyPrinter()
: json.writer();
out.println(escritor.writeValueAsString(valor));
} catch (JsonProcessingException e) {
throw new SalidaFallidaException("No se pudo serializar el resultado", e);
}
}Comparativa de uso:
| Formato | Para quién | Cuándo |
|---|---|---|
tabla |
Personas | Uso interactivo (por defecto) |
csv |
Hojas de cálculo, cut, awk |
Informes, importación en Excel |
json |
jq, otros programas |
Automatización, integración |
- Verbosidad:
--silencioso y --verboso
--silencioso y --verbosoTres niveles, y una regla clara sobre a qué flujo va cada uno:
| Nivel | Opción | Qué se imprime | Flujo |
|---|---|---|---|
| Silencioso | -q |
Solo el resultado y los errores | out / err |
| Normal | (ninguna) | Resultado, cabeceras, totales, avisos | out / err |
| Verboso | -v |
Además, pasos intermedios y tiempos | err |
| Traza | -vv |
Además, el log de la aplicación en DEBUG | err |
public enum Nivel { SILENCIOSO, NORMAL, VERBOSO, TRAZA }
@Component
public class Salida {
/** Resultado: SIEMPRE a stdout, incluso en modo silencioso. Es lo que pidió el usuario. */
public void resultado(String texto) { out.println(texto); }
/** Información contextual: a stderr, para no contaminar la tubería. */
public void info(String texto) {
if (nivel.ordinal() >= Nivel.NORMAL.ordinal()) err.println(texto);
}
/** Detalle de ejecución: solo con -v. */
public void detalle(String texto) {
if (nivel.ordinal() >= Nivel.VERBOSO.ordinal()) err.println(gris(" " + texto));
}
public void aviso(String texto) { err.println(amarillo("Aviso: ") + texto); }
public void error(String texto) { err.println(rojo("Error: ") + texto); }
public void exito(String texto) { if (nivel != Nivel.SILENCIOSO) err.println(verde("✓ ") + texto); }
}El nivel se aplica al arrancar, y -vv sube también el nivel de logging de la aplicación:
@Option(names = {"-v", "--verboso"}, scope = ScopeType.INHERIT)
void setVerboso(boolean[] veces) {
Nivel nivel = veces.length >= 2 ? Nivel.TRAZA : Nivel.VERBOSO;
salida.setNivel(nivel);
if (nivel == Nivel.TRAZA) {
// Subir el nivel de Logback en caliente (11-07)
((ch.qos.logback.classic.Logger) LoggerFactory.getLogger("com.nexussoftware.bibliotech"))
.setLevel(ch.qos.logback.classic.Level.DEBUG);
}
}
- Operaciones largas: progreso y señales de vida
La importación del catálogo con enriquecimiento consulta la API de metadatos para cada material. Con 5.000 materiales, eso son varios minutos. Sin señales de vida, el usuario asume que se ha colgado y pulsa Ctrl+C.
Aquí se retoma la importación concurrente del módulo 8: un ExecutorService con hilos virtuales (10-06), que para tareas dominadas por E/S es exactamente el caso de uso ideal.
@Command(name = "importar", description = "Importa materiales al catálogo.")
@Component
public class ComandoCatalogoImportar implements Callable<Integer> {
private final ImportadorCatalogo importador;
private final Salida salida;
@Parameters(index = "0", arity = "0..1") private Path fichero;
@Option(names = "--desde-stdin") private boolean desdeStdin;
@Option(names = "--enriquecer", description = "Completa los metadatos desde la API externa.")
private boolean enriquecer;
@Option(names = "--simular", description = "No escribe nada; muestra lo que haría.")
private boolean simular;
@Override
public Integer call() throws Exception {
List<RegistroImportacion> registros = leerEntrada();
salida.info("Importando %d registros%s…"
.formatted(registros.size(), simular ? " (SIMULACIÓN)" : ""));
var progreso = new BarraProgreso(registros.size(), salida);
var correctos = new AtomicInteger(); // módulo 8
var errores = Collections.synchronizedList(new ArrayList<ErrorImportacion>());
// Hilos virtuales: miles de tareas bloqueantes de E/S sin agotar el sistema
try (var ejecutor = Executors.newVirtualThreadPerTaskExecutor()) {
for (RegistroImportacion registro : registros) {
ejecutor.submit(() -> {
try {
if (!simular) importador.importar(registro, enriquecer);
correctos.incrementAndGet();
} catch (BiblioTechException e) {
errores.add(ErrorImportacion.de(registro, e));
} finally {
progreso.avanzar();
}
});
}
} // el cierre del try-with-resources espera a que TODAS terminen
progreso.terminar();
salida.escribirResultadoImportacion(correctos.get(), errores);
if (errores.isEmpty()) return CodigoSalida.OK;
if (correctos.get() == 0) return CodigoSalida.ERROR;
return CodigoSalida.PARCIAL; // el 4 de la tabla
}
}La barra de progreso, con las tres decisiones que la hacen correcta:
public class BarraProgreso {
private static final int ANCHO = 40;
private final int total;
private final Salida salida;
private final AtomicInteger actual = new AtomicInteger();
private final long inicio = System.nanoTime();
private final boolean activa;
private volatile long ultimoRepintado;
public BarraProgreso(int total, Salida salida) {
this.total = total;
this.salida = salida;
// DECISIÓN 1: solo si hay terminal y no estamos en silencioso.
// Una barra de progreso en un fichero de log es basura ilegible.
this.activa = salida.esTerminal() && salida.nivel() != Nivel.SILENCIOSO;
}
public void avanzar() {
int n = actual.incrementAndGet();
if (!activa) return;
// DECISIÓN 2: limitar el repintado. Repintar 5.000 veces por segundo
// consume más CPU que el propio trabajo.
long ahora = System.nanoTime();
if (n < total && ahora - ultimoRepintado < 100_000_000L) return; // 100 ms
ultimoRepintado = ahora;
pintar(n);
}
private void pintar(int n) {
int llenos = (int) ((double) n / total * ANCHO);
long segundos = (System.nanoTime() - inicio) / 1_000_000_000L;
long restantes = n > 0 ? segundos * (total - n) / n : 0;
// DECISIÓN 3: a stderr, no a stdout. El progreso NO es el resultado.
// \r vuelve al principio de la línea sin saltar: la barra se sobreescribe.
salida.err().printf("\r[%s%s] %d/%d (%d%%) ETA %ds ",
"=".repeat(llenos), " ".repeat(ANCHO - llenos),
n, total, n * 100 / total, restantes);
}
public void terminar() {
if (activa) salida.err().println(); // cerrar la línea de la barra
}
}Para operaciones sin total conocido, un girador (|, /, -, \) o un simple punto cada N elementos cumple la misma función: decir «sigo vivo».
- Cancelación limpia con
Ctrl+C
Ctrl+CCtrl+C envía SIGINT. Por defecto, la JVM termina de inmediato: transacciones a medias, ficheros a medio escribir, contexto de Spring sin cerrar.
La solución es un shutdown hook, que retoma lo visto en el módulo 7 sobre cierre ordenado de recursos:
@Component
public class GestorCancelacion {
private static final Logger log = LoggerFactory.getLogger(GestorCancelacion.class);
private final AtomicBoolean cancelado = new AtomicBoolean(false);
private final CountDownLatch trabajoTerminado = new CountDownLatch(1);
@PostConstruct
void registrar() {
Runtime.getRuntime().addShutdownHook(new Thread(this::alRecibirSenal, "cancelacion"));
}
private void alRecibirSenal() {
cancelado.set(true);
System.err.println("\nCancelando… (pulsa Ctrl+C de nuevo para forzar)");
try {
// Dar un margen para terminar ordenadamente, pero NO esperar indefinidamente:
// un hook que no acaba deja el proceso colgado.
if (!trabajoTerminado.await(10, TimeUnit.SECONDS)) {
System.err.println("El trabajo no terminó a tiempo; saliendo de todos modos.");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
/** Los bucles largos consultan esto en cada iteración. */
public boolean cancelado() { return cancelado.get(); }
public void trabajoCompletado() { trabajoTerminado.countDown(); }
}Uso en el comando:
@Override
public Integer call() {
try {
for (RegistroImportacion registro : registros) {
if (cancelacion.cancelado()) {
salida.aviso("Importación cancelada por el usuario. "
+ "Procesados %d de %d registros.".formatted(procesados, registros.size()));
return CodigoSalida.INTERRUMPIDO; // 130, convenio POSIX
}
importador.importar(registro, enriquecer);
procesados++;
}
return CodigoSalida.OK;
} finally {
cancelacion.trabajoCompletado(); // libera el hook
}
}Tres reglas del shutdown hook que conviene no olvidar:
- Debe ser rápido. El sistema puede matar el proceso si tarda demasiado (
SIGKILLno se puede interceptar). - No puede depender del contexto de Spring. Puede estar cerrándose ya.
- Debe ser idempotente. Un segundo
Ctrl+Cno debe romper nada.
- Colores ANSI y cuándo desactivarlos
Los colores se producen con secuencias de escape ANSI:
public final class Ansi {
public static final String RESET = "[0m";
public static final String ROJO = "[31m";
public static final String VERDE = "[32m";
public static final String AMARILLO= "[33m";
public static final String GRIS = "[90m";
public static final String NEGRITA = "[1m";
}El problema es que, si la salida no va a un terminal, esas secuencias se escriben literalmente:
$ bibliotech catalogo listar > salida.txt
$ cat salida.txt
^[[32m978-0000000001^[[0m Java Efectivo …Lógica de decisión, en orden de precedencia:
public class DetectorColor {
public static boolean debeUsarColor(boolean opcionSinColor) {
// 1. La opción explícita del usuario manda
if (opcionSinColor) return false;
// 2. NO_COLOR: convenio universal (no-color.org). Si existe, con cualquier valor, se respeta
if (System.getenv("NO_COLOR") != null) return false;
// 3. FORCE_COLOR: para forzar colores en un pipeline de CI que sí los renderiza
if (System.getenv("FORCE_COLOR") != null) return true;
// 4. Terminal "tonto" (algunos entornos de CI, Emacs shell)
String term = System.getenv("TERM");
if ("dumb".equals(term)) return false;
// 5. Sin terminal (tubería o redirección): sin color
return System.console() != null;
}
}| Condición | Color |
|---|---|
--sin-color |
No |
NO_COLOR definida |
No |
FORCE_COLOR definida |
Sí |
TERM=dumb |
No |
| Salida redirigida o en tubería | No |
| Terminal interactivo | Sí |
Y dos consejos de uso: el color nunca debe ser el único portador de información (por accesibilidad y por daltonismo: acompáñalo de un símbolo o una palabra), y menos es más — rojo para errores, amarillo para avisos, verde para éxito, gris para lo secundario. Nada más.
- Errores orientados al usuario
Una traza de pila en la consola de un usuario es una confesión de que no se ha pensado en él. La estructura de un buen error tiene cuatro partes:
Error: no se puede prestar "Java Efectivo" a Diego Alonso.
Causa: el empleado ya tiene 3 préstamos activos (máximo permitido: 3).
Sugerencia: consulta sus préstamos con
bibliotech prestamo listar --empleado=2
y devuelve alguno antes de crear uno nuevo.
Detalle técnico registrado con id: a7f3e91cEl manejador centralizado de Picocli, que traduce la jerarquía BiblioTechException del módulo 6:
public class ManejadorErroresCli implements CommandLine.IExecutionExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(ManejadorErroresCli.class);
@Override
public int handleExecutionException(Exception e, CommandLine cl, CommandLine.ParseResult parseo) {
String idIncidencia = UUID.randomUUID().toString().substring(0, 8);
// El detalle técnico COMPLETO va al log, no a la pantalla del usuario (06-07)
log.error("Error en la ejecución del comando [id={}]", idIncidencia, e);
PrintWriter err = cl.getErr();
return switch (e) {
case MaterialNoEncontradoException ex -> {
err.println(rojo("Error: ") + "no existe ningún material con ISBN " + ex.getIsbn() + ".");
err.println(" Sugerencia: búscalo con 'bibliotech catalogo buscar --titulo=\"...\"'");
yield CodigoSalida.NO_ENCONTRADO;
}
case LimiteDePrestamosExcedidoException ex -> {
err.println(rojo("Error: ") + "el empleado " + ex.getNombre()
+ " ya tiene " + ex.getMaximo() + " préstamos activos.");
err.println(" Sugerencia: 'bibliotech prestamo listar --empleado=" + ex.getId() + "'");
yield CodigoSalida.CONFLICTO;
}
case ServicioExternoNoDisponibleException ex -> {
err.println(rojo("Error: ") + "el servicio de metadatos no responde.");
err.println(" Sugerencia: reintenta más tarde, o usa --sin-enriquecer.");
yield CodigoSalida.NO_DISPONIBLE; // 7: el script SÍ debe reintentar
}
case BiblioTechException ex -> {
err.println(rojo("Error: ") + ex.getMessage());
yield CodigoSalida.ERROR;
}
default -> {
// Lo inesperado: mensaje genérico + identificador para correlacionar con el log
err.println(rojo("Error inesperado. ") + "Incidencia " + idIncidencia + ".");
err.println(" Ejecuta con -vv para ver el detalle, o envía ese identificador a soporte.");
yield CodigoSalida.ERROR;
}
};
}
}El switch con patrones sobre tipos es el pattern matching de Java 21 (10-06), y aquí luce especialmente: sustituye una escalera de instanceof.
El identificador de incidencia es el detalle que más agradece el soporte técnico: el usuario ve ocho caracteres, y con ellos se localiza la traza completa en el log (que ya está correlacionado con MDC desde 11-07).
- Empaquetado y distribución
Jar ejecutable con spring-boot-maven-plugin:
<build>
<finalName>bibliotech-cli</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>com.nexussoftware.bibliotech.consola.BiblioTechCli</mainClass>
<executable>true</executable> <!-- añade un script de arranque al propio jar -->
</configuration>
<executions>
<execution><goals><goal>repackage</goal></goals></execution>
</executions>
</plugin>
</plugins>
</build>./mvnw -pl bibliotech-consola clean package
java -jar bibliotech-consola/target/bibliotech-cli.jar catalogo listarScript de arranque, para que se invoque como bibliotech y no como java -jar …:
#!/usr/bin/env bash
# scripts/bibliotech — instálalo en /usr/local/bin/bibliotech
set -euo pipefail
BIBLIOTECH_HOME="${BIBLIOTECH_HOME:-/opt/bibliotech}"
JAVA_BIN="${JAVA_HOME:+$JAVA_HOME/bin/java}"
JAVA_BIN="${JAVA_BIN:-java}"
# Opciones de JVM pensadas para arranque rápido, no para un servidor de larga vida:
# -XX:TieredStopAtLevel=1 no compilar a fondo: el proceso dura segundos
# -XX:+UseSerialGC el GC más barato de inicializar
# -Xshare:auto usar el archivo de clases compartidas
exec "$JAVA_BIN" \
-XX:TieredStopAtLevel=1 \
-XX:+UseSerialGC \
-Xshare:auto \
-Dfile.encoding=UTF-8 \
${BIBLIOTECH_OPTS:-} \
-jar "$BIBLIOTECH_HOME/bibliotech-cli.jar" "$@" # "$@" preserva los argumentos con espaciosEse "$@" con comillas es importante: sin ellas, bibliotech catalogo buscar --titulo="Java Efectivo" se rompe en dos argumentos.
El problema del arranque. Una CLI con Spring Boot tarda entre 1 y 3 segundos en arrancar. Para un uso ocasional es tolerable; para un comando que se invoca mil veces en un bucle, no.
| Opción | Arranque | Coste |
|---|---|---|
| Jar normal | 1-3 s | Ninguno |
CDS (-XX:SharedArchiveFile) |
0,7-2 s | Un paso extra en la construcción |
AppCDS + -Xshare |
~0,6 s | Idem |
jpackage (instalador nativo con JRE incluido) |
Igual que el jar | No hace falta Java instalado |
| GraalVM Native Image | ~0,05 s | Construcción lenta; reflexión hay que declararla |
jpackage (incluido en el JDK desde Java 14) produce un .deb, .rpm, .msi o .dmg con la JRE dentro:
jpackage --type deb \
--name bibliotech \
--input bibliotech-consola/target \
--main-jar bibliotech-cli.jar \
--main-class org.springframework.boot.loader.launch.JarLauncher \
--app-version 1.0.0 \
--vendor "Nexus Software"GraalVM Native Image es la opción cuando el arranque instantáneo importa de verdad. Picocli tiene soporte de primera clase (genera los metadatos de reflexión automáticamente), y Spring Boot 3 también:
./mvnw -pl bibliotech-consola -Pnative native:compile
./bibliotech-consola/target/bibliotech catalogo listar # arranca en ~50 msEl precio: la construcción tarda varios minutos, y todo lo que use reflexión (Jackson, JPA) necesita metadatos declarados. Para una CLI pequeña compensa; para una aplicación grande hay que valorarlo.
- Probar una aplicación de consola
Es la parte que casi todos los proyectos se saltan, y no hay motivo: una CLI se prueba en tres niveles.
Nivel 1: la lógica del comando, sin Picocli. El comando es un bean normal; se prueba con Mockito (11-06):
@ExtendWith(MockitoExtension.class)
class ComandoPrestamoCrearTest {
@Mock GestionarPrestamos gestor;
@Mock Salida salida;
@InjectMocks ComandoPrestamoCrear comando;
@Test
void devuelveOkYAvisaCuandoElPrestamoSeCrea() {
var prestamo = unPrestamoDe("978-0000000001", "Marta Ruiz");
when(gestor.prestar(any(), eq(1L), isNull())).thenReturn(prestamo);
ReflectionTestUtils.setField(comando, "isbn", Isbn.de("978-0000000001"));
ReflectionTestUtils.setField(comando, "idEmpleado", 1L);
Integer codigo = comando.call();
assertThat(codigo).isEqualTo(CodigoSalida.OK);
verify(salida).exito(contains("Préstamo #" + prestamo.getId()));
}
}Nivel 2: el parseo de argumentos. Se comprueba que las opciones se convierten bien, sin ejecutar nada:
class ParseoArgumentosTest {
@Test
void parseaLasOpcionesDelComandoListar() {
var comando = new ComandoCatalogoListar(mock(ConsultarCatalogo.class), mock(Salida.class));
var cl = new CommandLine(comando);
cl.parseArgs("--tipo=LIBRO", "--disponibles", "--limite=10");
assertThat(ReflectionTestUtils.getField(comando, "tipo")).isEqualTo(TipoMaterial.LIBRO);
assertThat(ReflectionTestUtils.getField(comando, "soloDisponibles")).isEqualTo(true);
assertThat(ReflectionTestUtils.getField(comando, "limite")).isEqualTo(10);
}
@Test
void rechazaUnIsbnInvalidoConMensajeUtil() {
var cl = new CommandLine(new ComandoPrestamoCrear(mock(…), mock(…)));
assertThatThrownBy(() -> cl.parseArgs("--isbn=1234", "--empleado=1"))
.isInstanceOf(CommandLine.ParameterException.class)
.hasMessageContaining("no es un ISBN-13 válido");
}
@Test
void exigeLasOpcionesObligatorias() {
var cl = new CommandLine(new ComandoPrestamoCrear(mock(…), mock(…)));
assertThatThrownBy(() -> cl.parseArgs("--empleado=1"))
.isInstanceOf(CommandLine.MissingParameterException.class)
.hasMessageContaining("--isbn");
}
}Nivel 3: extremo a extremo, capturando la salida. Se ejecuta el comando completo y se comprueba lo que escribe y qué código devuelve:
@SpringBootTest
class BiblioTechCliIT {
@Autowired ComandoRaiz raiz;
@Autowired CommandLine.IFactory fabrica;
@Test
void listarCatalogoEnJsonProduceJsonValido() throws Exception {
var salidaCapturada = new StringWriter();
var errorCapturado = new StringWriter();
int codigo = new CommandLine(raiz, fabrica)
.setOut(new PrintWriter(salidaCapturada)) // Picocli permite redirigir
.setErr(new PrintWriter(errorCapturado))
.execute("catalogo", "listar", "--formato=json");
assertThat(codigo).isEqualTo(CodigoSalida.OK);
// El resultado debe ser JSON VÁLIDO: nada de banners ni mensajes contaminándolo
JsonNode arbol = new ObjectMapper().readTree(salidaCapturada.toString());
assertThat(arbol.isArray()).isTrue();
assertThat(arbol).hasSize(3);
assertThat(arbol.get(0).get("titulo").asText()).isEqualTo("Java Efectivo");
}
@Test
void devuelveCodigo5CuandoElMaterialNoExiste() {
int codigo = new CommandLine(raiz, fabrica)
.setExecutionExceptionHandler(new ManejadorErroresCli())
.execute("catalogo", "ficha", "978-9999999999");
assertThat(codigo).isEqualTo(CodigoSalida.NO_ENCONTRADO);
}
@Test
void devuelveCodigo2CuandoFaltaUnaOpcionObligatoria() {
int codigo = new CommandLine(raiz, fabrica).execute("prestamo", "crear", "--empleado=1");
assertThat(codigo).isEqualTo(2); // error de uso, el que Picocli usa por defecto
}
}La prueba de que el JSON es válido es especialmente valiosa: detecta al instante que alguien ha metido un System.out.println("Procesando…") donde no debía.
Errores Comunes y Consejos
1. Mezclar resultado y diagnósticos en System.out. Es el error más frecuente y el que más rompe la automatización. Un solo println de progreso invalida | jq. Regla: si no es el resultado, va a System.err.
2. Devolver siempre 0. Un script no puede distinguir el éxito del fracaso, y un fallo nocturno pasa inadvertido durante semanas. Devuelve códigos con significado.
3. Imprimir trazas de pila al usuario. El usuario no puede hacer nada con NullPointerException. La traza va al log; a la pantalla va un mensaje con causa, sugerencia e identificador de incidencia.
4. No detectar si hay terminal. Colores, barras de progreso y cabeceras decorativas deben desactivarse solos cuando la salida se redirige. Comprueba System.console() != null.
5. Ignorar NO_COLOR. Es un convenio establecido. Si no lo respetas, tu herramienta será la que estropee la salida en el terminal de alguien.
6. Barras de progreso que repintan sin control. Repintar 5.000 veces por segundo consume más CPU que el trabajo real y satura el terminal. Limita a un repintado cada 100 ms.
7. Olvidar la codificación. Sin -Dfile.encoding=UTF-8 o PrintStream explícito, «Refactorización» sale como «Refactorizaci?n» al redirigir en algunos sistemas.
8. Un --help que no ayuda. «Muestra materiales» no explica nada. Escribe descripciones útiles y añade ejemplos en el footer: es lo primero que se lee y lo último que se escribe.
9. Operaciones destructivas sin confirmación ni simulación. Todo comando que borra o modifica en masa debe tener --simular y, en modo interactivo, pedir confirmación. En modo no interactivo, --si para saltarla.
10. Duplicar lógica entre CLI y web. Si ComandoPrestamoCrear valida reglas de negocio que el controlador REST también valida, tarde o temprano divergen. Ambos deben limitarse a llamar al mismo caso de uso.
Consejo final: prueba tu CLI dentro de una tubería desde el primer día. bibliotech catalogo listar --formato=json | jq . detecta al instante casi todos los errores de esta lista.
Ejercicios
Ejercicio 1: comando prestamo devolver completo
Implementa bibliotech prestamo devolver con:
- Un parámetro posicional obligatorio: el identificador del préstamo.
--fechaopcional (por defecto, hoy), validando que no sea futura.--simular: calcula y muestra la multa sin registrar la devolución.- Salida en los tres formatos.
- Códigos: 0 correcto, 5 préstamo no encontrado, 6 ya devuelto, 2 fecha inválida.
- Un mensaje de éxito que indique la multa si la hay.
Ejercicio 2: entrada estándar y códigos de salida
Implementa bibliotech avisos enviar que:
- Por defecto, envía avisos de los préstamos que vencen en
--dias-antelaciondías (por defecto 3). - Con
--desde-stdin, lee identificadores de empleado de la entrada estándar (uno por línea) y avisa solo a esos. - Tiene
--simularpara mostrar a quién se avisaría sin enviar nada. - Devuelve 0 si se enviaron todos, 3 si no había nada que enviar, 4 si algunos fallaron y 7 si el servidor de correo no responde.
- Muestra progreso solo si hay terminal.
Escribe además el script de shell que lo usaría en un cron con reintentos.
Ejercicio 3: formateador de tabla reutilizable
Escribe una clase TablaConsola genérica que:
- Acepte columnas con nombre, una función extractora y una alineación.
- Calcule los anchos automáticamente, con un máximo por columna y recorte con «…».
- Soporte separador de cabecera y totales opcionales al pie.
- Se adapte al ancho del terminal si es posible.
- Sea usable así:
TablaConsola.de(materiales)
.columna("ISBN", m -> m.getIsbn().valor())
.columna("TÍTULO", Material::getTitulo, 45)
.columna("TIPO", m -> m.tipo().name())
.columnaNumerica("LIBRES", m -> m.unidadesDisponibles())
.conTotales()
.imprimir(salida);Soluciones
Solución 1
@Command(name = "devolver",
description = "Registra la devolución de un préstamo.",
footerHeading = "%nEjemplos:%n",
footer = {
" bibliotech prestamo devolver 42",
" bibliotech prestamo devolver 42 --fecha=2026-03-15",
" bibliotech prestamo devolver 42 --simular --formato=json"
})
@Component
public class ComandoPrestamoDevolver implements Callable<Integer> {
private final GestionarPrestamos gestor;
private final CalculadoraMultas calculadora;
private final Salida salida;
private final Clock reloj;
@Spec CommandLine.Model.CommandSpec spec;
public ComandoPrestamoDevolver(GestionarPrestamos gestor, CalculadoraMultas calculadora,
Salida salida, Clock reloj) {
this.gestor = gestor;
this.calculadora = calculadora;
this.salida = salida;
this.reloj = reloj;
}
@Parameters(index = "0", paramLabel = "<idPrestamo>",
description = "Identificador del préstamo a devolver.")
private Long idPrestamo;
@Option(names = "--fecha", description = "Fecha de devolución (yyyy-MM-dd). Por defecto, hoy.")
private LocalDate fecha;
@Option(names = "--simular",
description = "Calcula la multa sin registrar la devolución.")
private boolean simular;
@Override
public Integer call() {
LocalDate hoy = LocalDate.now(reloj);
LocalDate fechaEfectiva = (fecha != null) ? fecha : hoy;
// Validación cruzada: ParameterException produce mensaje + uso + código 2
if (fechaEfectiva.isAfter(hoy)) {
throw new CommandLine.ParameterException(spec.commandLine(),
"--fecha (%s) no puede ser posterior a hoy (%s)."
.formatted(fechaEfectiva, hoy));
}
Prestamo prestamo = gestor.buscar(idPrestamo)
.orElseThrow(() -> new PrestamoNoEncontradoException(idPrestamo));
if (prestamo.getFechaDevolucion().isPresent()) {
// La excepción la traduce ManejadorErroresCli a código 6 (CONFLICTO)
throw new PrestamoYaDevueltoException(idPrestamo,
prestamo.getFechaDevolucion().orElseThrow());
}
if (fechaEfectiva.isBefore(prestamo.getFechaPrestamo())) {
throw new CommandLine.ParameterException(spec.commandLine(),
"--fecha (%s) es anterior a la fecha del préstamo (%s)."
.formatted(fechaEfectiva, prestamo.getFechaPrestamo()));
}
if (simular) {
Dinero multa = calculadora.calcular(prestamo, fechaEfectiva);
salida.escribirDevolucion(new ResultadoDevolucion(
prestamo.getId(), prestamo.tituloDelMaterial(), prestamo.nombreDelEmpleado(),
fechaEfectiva, multa, true));
salida.aviso("SIMULACIÓN: no se ha registrado nada.");
return CodigoSalida.OK;
}
ResultadoDevolucion resultado = gestor.devolver(idPrestamo, fechaEfectiva);
salida.escribirDevolucion(resultado);
if (resultado.multa().esPositiva()) {
salida.exito("Devolución registrada. Multa: %s (%d días de retraso)."
.formatted(resultado.multa(), resultado.diasDeRetraso()));
} else {
salida.exito("Devolución registrada en plazo. Sin multa.");
}
return CodigoSalida.OK;
}
}El formateo, en Salida:
public void escribirDevolucion(ResultadoDevolucion r) {
switch (formato) {
case TABLA -> {
out.printf("%-14s %s%n", "Préstamo:", "#" + r.idPrestamo());
out.printf("%-14s %s%n", "Material:", r.tituloMaterial());
out.printf("%-14s %s%n", "Empleado:", r.nombreEmpleado());
out.printf("%-14s %s%n", "Devolución:", r.fecha());
out.printf("%-14s %s%n", "Multa:",
r.multa().esPositiva() ? rojo(r.multa().toString()) : verde("sin multa"));
}
case CSV -> {
if (mostrarDecoracion()) out.println("id;material;empleado;fecha;multa");
out.printf("%d;%s;%s;%s;%s%n", r.idPrestamo(), escapar(r.tituloMaterial()),
escapar(r.nombreEmpleado()), r.fecha(), r.multa().importe());
}
case JSON -> json(r);
}
}Comprobación de los códigos de salida:
$ bibliotech prestamo devolver 42; echo $? # 0
$ bibliotech prestamo devolver 9999; echo $? # 5 (no encontrado)
$ bibliotech prestamo devolver 42; echo $? # 6 (ya devuelto)
$ bibliotech prestamo devolver 42 --fecha=2099-01-01; echo $? # 2 (uso incorrecto)Solución 2
@Command(name = "enviar",
description = "Envía avisos de vencimiento próximo a los empleados afectados.",
footerHeading = "%nCódigos de salida:%n",
footer = {
" 0 todos los avisos se enviaron",
" 3 no había ningún aviso que enviar",
" 4 algunos avisos fallaron",
" 7 el servidor de correo no responde"
})
@Component
public class ComandoAvisosEnviar implements Callable<Integer> {
private final ServicioAvisos avisos;
private final RepositorioPrestamos prestamos;
private final Salida salida;
private final GestorCancelacion cancelacion;
private final Clock reloj;
@Option(names = "--dias-antelacion", defaultValue = "3",
description = "Avisar de los vencimientos en N días. Por defecto: ${DEFAULT-VALUE}")
private int diasAntelacion;
@Option(names = "--desde-stdin",
description = "Lee identificadores de empleado de la entrada estándar, uno por línea.")
private boolean desdeStdin;
@Option(names = "--simular", description = "Muestra a quién se avisaría, sin enviar nada.")
private boolean simular;
@Override
public Integer call() throws IOException {
List<Prestamo> objetivo = seleccionarPrestamos();
if (objetivo.isEmpty()) {
salida.info("No hay ningún préstamo que requiera aviso.");
return CodigoSalida.SIN_RESULTADOS; // 3: NO es un error
}
salida.info("Preparando %d avisos%s…".formatted(objetivo.size(), simular ? " (SIMULACIÓN)" : ""));
var progreso = new BarraProgreso(objetivo.size(), salida); // se autodesactiva sin terminal
int enviados = 0;
List<FalloEnvio> fallos = new ArrayList<>();
for (Prestamo p : objetivo) {
if (cancelacion.cancelado()) {
progreso.terminar();
salida.aviso("Cancelado. Enviados %d de %d.".formatted(enviados, objetivo.size()));
return CodigoSalida.INTERRUMPIDO;
}
try {
if (simular) {
// El resultado de la simulación SÍ es resultado: va a stdout
salida.resultado("%s <%s> — \"%s\" vence el %s"
.formatted(p.nombreDelEmpleado(), p.correoDelEmpleado(),
p.tituloDelMaterial(), p.getFechaVencimiento()));
} else {
avisos.enviar(Aviso.porVencimiento(p));
}
enviados++;
} catch (ServicioExternoNoDisponibleException e) {
// El servidor de correo caído afecta a TODOS: no tiene sentido seguir
progreso.terminar();
throw e; // el manejador lo traduce a 7
} catch (BiblioTechException e) {
fallos.add(new FalloEnvio(p.getId(), p.correoDelEmpleado(), e.getMessage()));
} finally {
progreso.avanzar();
}
}
progreso.terminar();
if (!fallos.isEmpty()) {
salida.aviso("%d avisos fallaron:".formatted(fallos.size()));
fallos.forEach(f -> salida.aviso(" préstamo #%d (%s): %s"
.formatted(f.idPrestamo(), f.destino(), f.motivo())));
salida.info("Enviados %d de %d.".formatted(enviados, objetivo.size()));
return CodigoSalida.PARCIAL; // 4
}
salida.exito("Enviados %d avisos.".formatted(enviados));
return CodigoSalida.OK;
}
private List<Prestamo> seleccionarPrestamos() throws IOException {
LocalDate limite = LocalDate.now(reloj).plusDays(diasAntelacion);
if (!desdeStdin) {
return prestamos.activosQueVencenAntesDe(limite);
}
List<Long> ids = leerIdsDeStdin();
if (ids.isEmpty()) {
salida.aviso("No se leyó ningún identificador de la entrada estándar.");
return List.of();
}
salida.detalle("Filtrando por %d empleados leídos de stdin".formatted(ids.size()));
return prestamos.activosQueVencenAntesDe(limite).stream()
.filter(p -> ids.contains(p.getIdEmpleado()))
.toList();
}
private List<Long> leerIdsDeStdin() throws IOException {
try (var lector = new BufferedReader(new InputStreamReader(System.in, UTF_8))) {
return lector.lines()
.map(String::strip)
.filter(l -> !l.isEmpty() && !l.startsWith("#"))
.map(l -> {
try {
return Long.parseLong(l);
} catch (NumberFormatException e) {
salida.aviso("Se ignora la línea no numérica: '%s'".formatted(l));
return null;
}
})
.filter(Objects::nonNull)
.distinct()
.toList();
}
}
}Uso y composición:
# Todos los vencimientos de los próximos 3 días
bibliotech avisos enviar
# Solo a los empleados del departamento de Arquitectura
bibliotech empleado listar --departamento=Arquitectura --formato=csv \
| cut -d';' -f1 | tail -n +2 \
| bibliotech avisos enviar --desde-stdin --dias-antelacion=7
# Ver a quién se avisaría, sin enviar nada
bibliotech avisos enviar --simular --formato=json | jq -r '.[].correo'Script para cron:
#!/usr/bin/env bash
# /opt/bibliotech/scripts/avisos-diarios.sh
# Ejecutar con: 0 8 * * * /opt/bibliotech/scripts/avisos-diarios.sh
set -uo pipefail # sin -e: queremos gestionar los códigos nosotros
LOG="/var/log/bibliotech/avisos-$(date +%F).log"
MAX_INTENTOS=3
for intento in $(seq 1 $MAX_INTENTOS); do
# El resultado al log; los diagnósticos también, pero separables por si acaso
bibliotech avisos enviar --dias-antelacion=3 --silencioso >>"$LOG" 2>&1
codigo=$?
case $codigo in
0) echo "$(date -Is) OK: avisos enviados" >>"$LOG"; exit 0 ;;
3) echo "$(date -Is) Nada que enviar hoy" >>"$LOG"; exit 0 ;; # NO es un fallo
4) echo "$(date -Is) AVISO: envío parcial; revisa el log" >>"$LOG"
mail -s "BiblioTech: avisos parciales" [email protected] <"$LOG"
exit 0 ;;
7) echo "$(date -Is) Correo no disponible; reintento $intento de $MAX_INTENTOS" >>"$LOG"
sleep $((intento * 120)) ;; # 2, 4, 6 minutos
*) echo "$(date -Is) ERROR irrecuperable (código $codigo)" >>"$LOG"
mail -s "BiblioTech: fallo en avisos" [email protected] <"$LOG"
exit "$codigo" ;;
esac
done
echo "$(date -Is) ERROR: agotados los reintentos" >>"$LOG"
mail -s "BiblioTech: correo caído tras 3 intentos" [email protected] <"$LOG"
exit 7Fíjate en la decisión de diseño que hace útil todo esto: el código 3 no dispara alarma (que un martes no haya vencimientos es normal) y el 7 sí dispara reintento. Con un único código de error genérico, este script no se podría escribir.
Solución 3
/**
* Formateador de tablas para consola, con anchos automáticos.
* Uso:
* TablaConsola.de(materiales)
* .columna("ISBN", m -> m.getIsbn().valor())
* .columna("TÍTULO", Material::getTitulo, 45)
* .columnaNumerica("LIBRES", Material::unidadesDisponibles)
* .conTotales()
* .imprimir(salida);
*/
public class TablaConsola<T> {
private static final int ANCHO_TERMINAL_POR_DEFECTO = 120;
private static final String SEPARADOR = " ";
private final List<T> filas;
private final List<Columna<T>> columnas = new ArrayList<>();
private boolean totales = false;
private TablaConsola(List<T> filas) { this.filas = filas; }
public static <T> TablaConsola<T> de(List<T> filas) { return new TablaConsola<>(filas); }
// --- Definición de columnas ---
public TablaConsola<T> columna(String titulo, Function<T, String> extractor) {
return columna(titulo, extractor, Integer.MAX_VALUE);
}
public TablaConsola<T> columna(String titulo, Function<T, String> extractor, int anchoMaximo) {
columnas.add(new Columna<>(titulo, extractor, Alineacion.IZQUIERDA, anchoMaximo, null));
return this;
}
public TablaConsola<T> columnaNumerica(String titulo, ToLongFunction<T> extractor) {
columnas.add(new Columna<>(titulo,
t -> String.valueOf(extractor.applyAsLong(t)),
Alineacion.DERECHA, 15, extractor));
return this;
}
public TablaConsola<T> conTotales() { this.totales = true; return this; }
// --- Impresión ---
public void imprimir(Salida salida) {
if (columnas.isEmpty()) throw new IllegalStateException("Define al menos una columna");
int[] anchos = calcularAnchos();
ajustarAlTerminal(anchos);
PrintStream out = salida.out();
if (salida.mostrarDecoracion()) {
out.println(fila(anchos, i -> columnas.get(i).titulo()));
out.println("-".repeat(anchoTotal(anchos)));
}
for (T elemento : filas) {
out.println(fila(anchos, i -> valor(columnas.get(i), elemento, anchos[i])));
}
if (totales && salida.mostrarDecoracion()) {
out.println("-".repeat(anchoTotal(anchos)));
out.println(fila(anchos, this::totalDeColumna));
out.printf("%d filas%n", filas.size());
}
}
// --- Cálculo de anchos ---
private int[] calcularAnchos() {
int[] anchos = new int[columnas.size()];
for (int i = 0; i < columnas.size(); i++) {
Columna<T> c = columnas.get(i);
int anchoContenido = filas.stream()
.map(c.extractor())
.mapToInt(String::length)
.max().orElse(0);
// El ancho es el mayor entre el título y el contenido, limitado por el máximo
anchos[i] = Math.min(c.anchoMaximo(), Math.max(c.titulo().length(), anchoContenido));
}
return anchos;
}
/**
* Si la tabla no cabe, recorta proporcionalmente las columnas de texto
* más anchas, respetando un mínimo de 8 caracteres.
*/
private void ajustarAlTerminal(int[] anchos) {
int disponible = anchoTerminal();
int total = anchoTotal(anchos);
if (total <= disponible) return;
int exceso = total - disponible;
// Recortar de mayor a menor hasta absorber el exceso
List<Integer> candidatas = IntStream.range(0, anchos.length)
.boxed()
.filter(i -> columnas.get(i).alineacion() == Alineacion.IZQUIERDA)
.sorted(Comparator.comparingInt((Integer i) -> anchos[i]).reversed())
.toList();
for (int i : candidatas) {
if (exceso <= 0) break;
int recortable = Math.max(0, anchos[i] - 8);
int recorte = Math.min(recortable, exceso);
anchos[i] -= recorte;
exceso -= recorte;
}
}
private int anchoTerminal() {
// COLUMNS la exporta el shell; si no está, usar el valor por defecto
String columnas = System.getenv("COLUMNS");
try {
return columnas != null ? Integer.parseInt(columnas) : ANCHO_TERMINAL_POR_DEFECTO;
} catch (NumberFormatException e) {
return ANCHO_TERMINAL_POR_DEFECTO;
}
}
private int anchoTotal(int[] anchos) {
return Arrays.stream(anchos).sum() + SEPARADOR.length() * (anchos.length - 1);
}
// --- Formato de celdas ---
private String fila(int[] anchos, IntFunction<String> celda) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < anchos.length; i++) {
if (i > 0) sb.append(SEPARADOR);
String texto = recortar(celda.apply(i), anchos[i]);
sb.append(columnas.get(i).alineacion() == Alineacion.DERECHA
? " ".repeat(anchos[i] - texto.length()) + texto
: texto + " ".repeat(anchos[i] - texto.length()));
}
return sb.toString().stripTrailing(); // sin espacios al final: ensucian al copiar
}
private String valor(Columna<T> c, T elemento, int ancho) {
return recortar(c.extractor().apply(elemento), ancho);
}
private static String recortar(String texto, int max) {
if (texto == null) return "";
return texto.length() <= max ? texto : texto.substring(0, Math.max(0, max - 1)) + "…";
}
private String totalDeColumna(int i) {
Columna<T> c = columnas.get(i);
if (c.sumador() == null) return i == 0 ? "TOTAL" : "";
long suma = filas.stream().mapToLong(c.sumador()).sum();
return String.valueOf(suma);
}
// --- Tipos auxiliares ---
private enum Alineacion { IZQUIERDA, DERECHA }
private record Columna<T>(String titulo,
Function<T, String> extractor,
Alineacion alineacion,
int anchoMaximo,
ToLongFunction<T> sumador) { }
}Uso y salida:
TablaConsola.de(materiales)
.columna("ISBN", m -> m.getIsbn().valor())
.columna("TÍTULO", Material::getTitulo, 45)
.columna("TIPO", m -> m.tipo().name())
.columnaNumerica("LIBRES", Material::unidadesDisponibles)
.conTotales()
.imprimir(salida);ISBN TÍTULO TIPO LIBRES --------------------------------------------------------------- 978-0000000001 Java Efectivo LIBRO 2 978-0000000002 Patrones de Diseño LIBRO 0 978-0000000003 Refactorización LIBRO 1 --------------------------------------------------------------- TOTAL 3 3 filas
Ventajas del diseño: es genérico (sirve para materiales, préstamos, empleados o cualquier cosa), usa el Builder de 12-02, respeta mostrarDecoracion() de modo que en una tubería solo salen los datos, y se adapta al ancho del terminal sin descuadrarse.
Conclusión
BiblioTech ya se puede usar.
Tienes claro cuándo una CLI es la interfaz correcta —tareas repetibles, automatizables o para gente técnica— y las cuatro propiedades que la hacen buena: predecible, componible, con errores útiles y honesta sobre lo que hace. Y sabes por qué el menú con Scanner del módulo 2 no era esto: aquello lo usaba una persona; esto lo usa una persona y un script.
Viste los límites reales del parseo a mano —la forma --opcion=valor, las opciones agrupadas, la conversión de tipos, el ++i que revienta con el índice— y por eso adoptaste Picocli: anotaciones para comandos, opciones y posicionales; conversión automática a más de cuarenta tipos incluidos los de java.time; conversores propios que hacen que el campo sea Isbn y no String, con el error apareciendo en el parseo y no a mitad de ejecución; grupos exclusivos; ayuda generada con ejemplos en el pie; y autocompletado para bash y zsh por una línea de configuración.
Lo integraste con Spring Boot de la forma correcta: los comandos son beans que reciben los casos de uso por constructor, sin Service Locator, con web-application-type: none y banner-mode: off —porque un banner ASCII rompe una tubería de JSON—. Y diseñaste la superficie completa de la herramienta, con subcomandos por área, opciones heredadas con ScopeType.INHERIT, y comandos que no imprimen nada por su cuenta: delegan en Salida.
Interiorizaste la regla que separa una CLI profesional de un programa que escribe cosas: el resultado va a la salida estándar; todo lo demás, a la de error. De ahí salen las tuberías que funcionan, las redirecciones que no se contaminan y las pruebas que verifican que el JSON es JSON válido. Y le pusiste al lado los códigos de salida con significado, con la distinción que de verdad importa —CONFLICTO no se reintenta, NO_DISPONIBLE sí—, que es lo que permite escribir el script de cron con reintentos del ejercicio 2.
Formateas la salida en tabla con anchos calculados, en CSV con el escapado que 07-07 enseñó a no improvisar y en JSON con el Jackson del módulo 11, con tres niveles de verbosidad y decoración que desaparece sola cuando la salida no va a un terminal. Gestionas operaciones largas con una barra de progreso que se pinta en la salida de error, se limita a un repintado cada 100 ms y se desactiva sin terminal; y con hilos virtuales de Java 21 para la importación concurrente que empezó en el módulo 8. Cancelas limpiamente con Ctrl+C mediante un shutdown hook que retoma el cierre ordenado del módulo 7, devolviendo el 130 del convenio POSIX. Usas colores respetando NO_COLOR, TERM=dumb y la ausencia de terminal, sin que el color sea nunca el único portador de información.
Tus errores tienen cuatro partes —qué, por qué, qué hacer y un identificador de incidencia— traducidos desde la jerarquía BiblioTechException del módulo 6 con el pattern matching de Java 21, dejando la traza completa en el log correlacionado con MDC de 11-07 y nunca en la pantalla del usuario. Empaquetas en jar ejecutable con su script de arranque y conoces las opciones para el arranque instantáneo: CDS, jpackage y GraalVM Native Image. Y pruebas la CLI en los tres niveles: la lógica del comando con Mockito, el parseo con parseArgs, y de extremo a extremo capturando la salida con setOut/setErr y comprobando el código devuelto.
Queda una limitación evidente, y no es técnica: Marta Ruiz no va a abrir un terminal para consultar si «Refactorización» está disponible. La CLI resuelve la automatización y a la gente técnica; no resuelve el acceso del resto de la empresa, ni la integración con otras aplicaciones, ni una futura app móvil.
La siguiente lección construye el segundo adaptador de entrada: el módulo bibliotech-web, una API REST completa con Spring Boot. Verás el ciclo petición-respuesta, el servidor embebido y el DispatcherServlet —y descubrirás que todo eso es exactamente lo que tú resolviste a mano con sockets en el módulo 9—, el diseño REST con sus verbos y sus códigos de estado, la validación, el manejo global de errores con Problem Details, la paginación, la documentación automática con OpenAPI y las pruebas de la capa web. Con los mismos casos de uso de siempre debajo.
Curso de Programación en Java
Módulo 1: Introducción a Java
- Introducción a Java
- Configuración del Entorno de Desarrollo
- Sintaxis y Estructura Básica
- Variables y Tipos de Datos
- Operadores
- Entrada y Salida por Consola
- Tu Primer Programa Completo: BiblioTech
Módulo 2: Flujo de Control
- Sentencias Condicionales
- Bucles
- Sentencias Switch
- Break y Continue
- Depuración y Trazas de Ejecución
- Proyecto: Menú Interactivo de BiblioTech
Módulo 3: Programación Orientada a Objetos
- Introducción a la POO
- Clases y Objetos
- Métodos
- Constructores
- Herencia
- Polimorfismo
- Encapsulamiento
- Abstracción
- La Clase Object: equals, hashCode y toString
Módulo 4: Programación Orientada a Objetos Avanzada
- Interfaces
- Clases Abstractas
- Clases Internas
- Clases Anónimas
- Expresiones Lambda
- Interfaces Funcionales y Referencias a Métodos
- Enumeraciones y Registros
Módulo 5: Estructuras de Datos y Colecciones
- Arreglos
- El Framework de Colecciones
- ArrayList
- LinkedList
- HashMap
- HashSet
- Cola y Deque
- Pila
- Ordenación y Búsqueda en Colecciones
Módulo 6: Manejo de Excepciones
- Introducción a las Excepciones
- Bloque Try-Catch
- Throw y Throws
- Excepciones Personalizadas
- Bloque Finally
- Try-with-resources y AutoCloseable
- Estrategias de Manejo de Errores y Logging
Módulo 7: Entrada/Salida de Archivos
- Lectura de Archivos
- Escritura de Archivos
- Flujos de Archivos
- BufferedReader y BufferedWriter
- Serialización
- La API NIO.2: Path y Files
- Formatos de Intercambio: CSV y Properties
Módulo 8: Multihilo y Concurrencia
- Introducción al Multihilo
- Creación de Hilos
- Ciclo de Vida de un Hilo
- Sincronización
- Utilidades de Concurrencia
- Colecciones Concurrentes y Variables Atómicas
- Tareas Asíncronas con CompletableFuture
Módulo 9: Redes
- Introducción a las Redes
- Sockets
- ServerSocket
- DatagramSocket y DatagramPacket
- URL y HttpURLConnection
- El Cliente HTTP Moderno
Módulo 10: Temas Avanzados
- Genéricos
- Anotaciones
- Reflexión
- Características de Java 8: Streams y Optional
- Fechas y Horas con java.time
- Java 9 y Más Allá
- Memoria, Recolección de Basura y Rendimiento
Módulo 11: Frameworks y Librerías de Java
- Introducción a los Frameworks de Java
- Spring Framework
- Hibernate
- JUnit
- Maven
- Pruebas Avanzadas con Mockito
- Librerías Esenciales del Ecosistema
