BiblioTech té arquitectura, mòduls, patrons i quaranta-una proves. I continua sense que ningú el pugui fer servir.
Aquesta lliçó construeix la primera interfície real del projecte: el mòdul bibliotech-consola. I convé desactivar d'entrada un prejudici molt estès: la consola no és «la interfície de segona, mentre no arriba el web». En una empresa com Nexus Software, la CLI és la interfície que més es fa servir sense que ningú la vegi, perquè és l'única que es pot ficar en un cron, encadenar amb altres eines, executar per SSH en un servidor sense entorn gràfic i cridar des d'un script de desplegament.
Al mòdul 2 vas fer un menú interactiu amb Scanner i un switch. Allò era un exercici de flux de control. El que construirem aquí és una altra cosa: una eina amb subordres, opcions tipades i validades, ajuda generada automàticament, autocompletat al terminal, codis de sortida amb significat, sortida en diversos formats, canonades, colors que es desactiven sols quan no toca, barres de progrés, cancel·lació neta amb Ctrl+C i un jar executable amb el seu script d'arrencada.
La diferència entre les dues coses es resumeix en una frase: el menú del mòdul 2 el fa servir una persona; aquesta CLI la fa servir una persona i també un script. I dissenyar per a totes dues alhora és el que la fa professional.
En acabar sabràs quan una CLI és la interfície correcta, dominaràs Picocli integrat amb Spring Boot, dissenyaràs una jerarquia de subordres coherent, entendràs per què la separació entre sortida estàndard i sortida d'error importa de debò, faràs servir codis de sortida que els scripts puguin interpretar, formataràs la sortida per a humans i per a màquines, gestionaràs operacions llargues amb progrés i cancel·lació, empaquetaràs l'aplicació i —una cosa que gairebé ningú fa— provaràs una aplicació de consola.
Contingut
- Quan una CLI és la interfície correcta
- Què fa bona una CLI
- Arguments de línia d'ordres a mà, i els seus límits
- Picocli: el model d'anotacions
- Opcions, paràmetres i tipus
- Validació i conversió de tipus
- Subordres i jerarquia
- Ajuda automàtica i autocompletat
- Integració amb Spring Boot
- Disseny de la CLI de BiblioTech
- Mode d'una sola ordre enfront del mode interactiu
- Entrada i sortida estàndard: compondre amb canonades
- Codis de sortida
- Formatar la sortida: taula, CSV i JSON
- Nivell de detall:
--silenciosi--detallat - Operacions llargues: progrés i senyals de vida
- Cancel·lació neta amb
Ctrl+C - Colors ANSI i quan desactivar-los
- Errors orientats a l'usuari
- Empaquetatge i distribució
- Provar una aplicació de consola
- Errors Comuns i Consells
- Exercicis
- Conclusió
- Quan una CLI és la interfície correcta
| Situació | CLI? | Per què |
|---|---|---|
| Tasca programada nocturna (avisos de venciment) | Sí | cron no sap prémer botons |
| Importació massiva del catàleg | Sí | S'executa per SSH, sense entorn gràfic, i es pot redirigir la sortida a un fitxer |
| Eina interna per a l'equip tècnic | Sí | Ràpida d'escriure, ràpida de fer servir, componible |
| Pas d'un pipeline de desplegament | Sí | Codis de sortida que el pipeline interpreta |
| Diagnòstic en producció a les 3 de la matinada | Sí | És l'únic que hi ha en un servidor |
| Consulta del catàleg per part de qualsevol empleat | No | Necessiten cercar visualment; web |
| Alta d'un material amb quinze camps | No | Formulari amb validació en viu |
| Panell d'indicadors | No | Gràfics |
La regla útil: una CLI guanya quan la tasca és repetible, automatitzable o l'executa algú tècnic. Perd quan la tasca és exploratòria o l'executa algú que no viu en un terminal.
I totes dues conviuen perfectament. BiblioTech tindrà CLI (aquesta lliçó) i API REST (12-04), compartint exactament els mateixos casos d'ús del mòdul bibliotech-aplicacio. Aquesta és la recompensa de l'arquitectura de 12-01: dos adaptadors d'entrada, un sol nucli.
- Què fa bona una CLI
Quatre propietats, i cap no és opcional:
Predictible. Les mateixes convencions que la resta d'eines del sistema: -v i --detallat, --help, --version, verb abans que substantiu o al revés però sempre igual. Una CLI que s'inventa la seva pròpia sintaxi obliga a llegir la documentació cada vegada.
Componible. Llegeix de l'entrada estàndard, escriu a la sortida estàndard, i els diagnòstics van a la sortida d'error. Això permet:
bibliotech cataleg llistar --format=csv | grep "Java" | wc -l
cat isbns.txt | bibliotech cataleg importar --des-de-stdin
bibliotech informe multes --format=json | jq '.[] | select(.quantitat > 10)'Amb bons missatges d'error. Un error ha de dir què ha passat, per què i què fer. Compara:
amb:
Error: no s'ha trobat cap material amb ISBN 978-0000000009.
Causa: l'ISBN no existeix al cataleg.
Suggeriment: comprova l'ISBN amb 'bibliotech cataleg cercar --titol="..."'
o importa'l amb 'bibliotech cataleg importar'.Honesta amb l'estat. Si trigarà, ho diu. Si modificarà dades, ho adverteix. Si té un mode de simulació (--simular), l'ofereix per a les operacions destructives.
- Arguments de línia d'ordres a mà, i els seus límits
Java et lliura els arguments en String[] args. Analitzar-los a mà sembla trivial:
public static void main(String[] args) {
String format = "taula";
boolean detallat = false;
String isbn = null;
for (int i = 0; i < args.length; i++) {
switch (args[i]) {
case "--format" -> format = args[++i];
case "-v", "--detallat" -> detallat = true;
case "--isbn" -> isbn = args[++i];
default -> {
System.err.println("Opcio desconeguda: " + args[i]);
System.exit(2);
}
}
}
// …
}Funciona per a tres opcions. Ara la llista del que no fa, i que un usuari de terminal espera que funcioni:
| Falta | Exemple que falla |
|---|---|
Forma --opcio=valor |
--format=json es llegeix com una opció desconeguda |
| Opcions curtes agrupades | -vq en lloc de -v -q |
-- per separar opcions d'arguments |
bibliotech cercar -- --titol-estrany |
| Conversió de tipus | Tot és String; convertir i validar a mà |
| Opcions obligatòries | Res no comprova que --isbn hi sigui |
| Grups exclusius | --format=json --format=csv no dona error |
| Ajuda | Cal escriure-la i mantenir-la sincronitzada a mà |
| Índex fora de rang | --format al final: ArrayIndexOutOfBoundsException |
| Subordres | cataleg llistar requereix un altre nivell d'anàlisi |
| Autocompletat | Impossible |
Aquell últim ++i sense comprovar el límit és un error real esperant que algú escrigui bibliotech llistar --format i premi Retorn.
Conclusió: analitzar a mà està bé per a un script de vint línies. Per a una eina de debò, es fa servir una biblioteca. En Java hi ha tres candidates serioses:
| Biblioteca | Avantatges | Inconvenients |
|---|---|---|
| Picocli | Anotacions, subordres, colors, autocompletat, sense dependències, suport GraalVM | — |
| Apache Commons CLI | Molt estable, veterana | API imperativa i verbosa; sense subordres natives |
| JCommander | Senzilla, amb anotacions | Menys activa; menys funcionalitats |
Farem servir Picocli: és l'opció estàndard de facto en Java modern, i és la que integra Spring Boot amb un starter oficial.
- Picocli: el model d'anotacions
Dependència a bibliotech-consola/pom.xml:
<dependencies>
<dependency>
<groupId>com.nexussoftware</groupId>
<artifactId>bibliotech-aplicacio</artifactId>
</dependency>
<dependency>
<groupId>com.nexussoftware</groupId>
<artifactId>bibliotech-infraestructura</artifactId>
</dependency>
<dependency>
<groupId>info.picocli</groupId>
<artifactId>picocli-spring-boot-starter</artifactId> <!-- porta picocli + integracio -->
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId> <!-- sense 'web': aixo no es un servidor -->
</dependency>
</dependencies>L'«hola món» de Picocli, amb tot el que és essencial:
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", // com s'invoca
mixinStandardHelpOptions = true, // afegeix --help i --version de franc
version = "BiblioTech CLI 1.0.0",
description = "Eina de gestio de la biblioteca tecnica de Nexus Software.")
public class Exemple implements Callable<Integer> { // Callable<Integer>: l'int es el codi de sortida
@Parameters(index = "0", description = "ISBN del material a consultar.")
private String isbn;
@Option(names = {"-f", "--format"}, defaultValue = "taula",
description = "Format de sortida: ${COMPLETION-CANDIDATES}. Per defecte: ${DEFAULT-VALUE}")
private Format format;
@Override
public Integer call() {
System.out.println("Consultant " + isbn + " en format " + format);
return 0; // 0 = exit
}
public static void main(String[] args) {
int codi = new CommandLine(new Exemple()).execute(args);
System.exit(codi);
}
}Amb aquestes vint línies ja tens:
$ java -jar exemple.jar --help
Usage: bibliotech [-hV] [-f=<format>] <isbn>
Eina de gestio de la biblioteca tecnica de Nexus Software.
<isbn> ISBN del material a consultar.
-f, --format=<format>
Format de sortida: TAULA, CSV, JSON. Per defecte: TAULA
-h, --help Show this help message and exit.
-V, --version Print version information and exit.Els tres elements del model:
| Anotació | Què marca | Exemple a la línia d'ordres |
|---|---|---|
@Command |
La classe (o mètode) que és una ordre | bibliotech |
@Option |
Un argument amb nom | --format=json, -v |
@Parameters |
Un argument posicional | el 978-0000000001 de bibliotech fitxa 978-0000000001 |
- Opcions, paràmetres i tipus
Picocli converteix a més de 40 tipus automàticament, inclosos els de java.time (mòdul 10):
@Command(name = "prestec")
class OpcionsExemple {
// Booleana: la seva sola presencia la posa a true
@Option(names = {"-v", "--detallat"}, description = "Mostra el detall de l'execucio.")
boolean detallat;
// Obligatoria: si falta, Picocli produeix un error clar
@Option(names = "--isbn", required = true, description = "ISBN del material.")
String isbn;
// Amb valor per defecte
@Option(names = "--dies", defaultValue = "15", description = "Durada en dies.")
int dies;
// Tipus de java.time: conversio automatica des d'ISO-8601
@Option(names = "--des-de", description = "Data d'inici (yyyy-MM-dd).")
LocalDate desDe;
// Enum: Picocli valida els valors permesos i els llista a l'ajuda
@Option(names = "--format", defaultValue = "TAULA")
Format format;
// Repetible: --etiqueta java --etiqueta disseny
@Option(names = "--etiqueta", description = "Etiqueta (repetible).")
List<String> etiquetes = new ArrayList<>();
// Amb aritat: exactament dos valors
@Option(names = "--rang", arity = "2", paramLabel = "<des-de> <fins-a>")
int[] rang;
// Mapa: --propietat clau=valor
@Option(names = "-D", description = "Propietat addicional.")
Map<String, String> propietats = new LinkedHashMap<>();
// Sensible: Picocli la demana per teclat sense mostrar-la si no es passa
@Option(names = "--clau", interactive = true, arity = "0..1")
char[] clau;
// Posicionals: tots els restants
@Parameters(index = "0", description = "Fitxer d'entrada.")
Path fitxer;
@Parameters(index = "1..*", description = "ISBN addicionals.")
List<String> isbnsExtra;
}Sobre interactive = true i char[]: és la manera correcta de demanar una contrasenya. char[] en lloc de String per poder sobreescriure-la en memòria després de fer-la servir; una String queda al pool fins que el recol·lector la retiri, i pot aparèixer en un bolcat de memòria (es reprèn a 12-07).
Grups exclusius, per a opcions incompatibles entre si:
static class OrigenDades {
@Option(names = "--fitxer", required = true) Path fitxer;
@Option(names = "--url", required = true) URI url;
@Option(names = "--stdin", required = true) boolean stdin;
}
@ArgGroup(exclusive = true, multiplicity = "1") // exactament un dels tres
OrigenDades origen;Si l'usuari en passa dos, Picocli ho rebutja amb un missatge clar, sense que escriguis cap comprovació.
- Validació i conversió de tipus
Picocli valida tipus; la validació de negoci l'escrius tu, i hi ha un lloc correcte per fer-la: un conversor propi, perquè l'error aparegui en l'anàlisi i no a mitja execució.
/** Converteix el text de la linia d'ordres en l'objecte de valor del domini. */
public class ConversorIsbn implements CommandLine.ITypeConverter<Isbn> {
@Override
public Isbn convert(String valor) {
try {
return Isbn.de(valor); // l'objecte de valor ja valida (12-02)
} catch (IsbnInvalidException e) {
// TypeConversionException produeix un missatge d'us, no una traca
throw new CommandLine.TypeConversionException(
"'" + valor + "' no es un ISBN-13 valid. Format esperat: 978-XXXXXXXXXX");
}
}
}@Option(names = "--isbn", required = true, converter = ConversorIsbn.class)
private Isbn isbn; // el camp es del tipus del domini, no String!Resultat al terminal:
$ bibliotech prestec crear --isbn=1234 --empleat=1
Invalid value for option '--isbn': '1234' no es un ISBN-13 valid.
Format esperat: 978-XXXXXXXXXX
Usage: bibliotech prestec crear [-hV] --empleat=<id> --isbn=<isbn> [--dies=<n>]
…Per a regles que depenen de diverses opcions, es fa servir l'especificació d'ordre:
@Spec CommandLine.Model.CommandSpec spec;
private void validar() {
if (desDe != null && finsA != null && desDe.isAfter(finsA)) {
throw new CommandLine.ParameterException(spec.commandLine(),
"--des-de (" + desDe + ") no pot ser posterior a --fins-a (" + finsA + ")");
}
}ParameterException és important: fa que Picocli imprimeixi el missatge i l'ús, i retorni el codi de sortida d'error d'ús (2), que és el que un script espera per a «m'has cridat malament».
- Subordres i jerarquia
Una CLI amb més de cinc operacions necessita subordres. El patró que ha guanyat a la indústria és substantiu + verb (git remote add, docker container run, kubectl get pods), perquè agrupa per àrea i escala millor.
flowchart TD
R["bibliotech"]
C["cataleg"]
P["prestec"]
A["avisos"]
I["informe"]
R --> C
R --> P
R --> A
R --> I
C --> C1["llistar"]
C --> C2["cercar"]
C --> C3["importar"]
C --> C4["fitxa"]
P --> P1["crear"]
P --> P2["retornar"]
P --> P3["renovar"]
P --> P4["llistar"]
A --> A1["enviar"]
A --> A2["previsualitzar"]
I --> I1["multes"]
I --> I2["us"]
L'ordre arrel declara els seus fills:
@Command(
name = "bibliotech",
mixinStandardHelpOptions = true,
version = "BiblioTech CLI 1.0.0",
description = "Gestio de la biblioteca tecnica de Nexus Software.",
subcommands = {
OrdreCataleg.class,
OrdrePrestec.class,
OrdreAvisos.class,
OrdreInforme.class,
CommandLine.HelpCommand.class // 'bibliotech help cataleg'
})
@Component
public class OrdreArrel implements Callable<Integer> {
@Override
public Integer call() {
// Sense subordre, mostrar l'ajuda i retornar error d'us:
// aixi un script sap que la invocacio estava incompleta.
CommandLine.usage(this, System.out);
return CodiSortida.ERROR_US;
}
}I una ordre intermèdia, que només agrupa:
@Command(name = "cataleg",
description = "Operacions sobre el cataleg de materials.",
subcommands = {OrdreCatalegLlistar.class, OrdreCatalegCercar.class,
OrdreCatalegImportar.class, OrdreCatalegFitxa.class})
@Component
public class OrdreCataleg implements Callable<Integer> {
@Override public Integer call() {
CommandLine.usage(this, System.out);
return CodiSortida.ERROR_US;
}
}Opcions heretades. Les globals (--format, --detallat) es declaren una vegada amb scope = ScopeType.INHERIT:
@Command(name = "bibliotech", …)
public class OrdreArrel {
@Option(names = {"-f", "--format"}, scope = CommandLine.ScopeType.INHERIT,
defaultValue = "TAULA", description = "Format de sortida: ${COMPLETION-CANDIDATES}")
Format format;
@Option(names = {"-q", "--silencios"}, scope = CommandLine.ScopeType.INHERIT,
description = "Nomes errors.")
boolean silencios;
@Option(names = {"-v", "--detallat"}, scope = CommandLine.ScopeType.INHERIT,
description = "Detall de l'execucio.")
boolean detallat;
}Ara bibliotech cataleg llistar --format=json funciona sense declarar --format a cada subordre.
- Ajuda automàtica i autocompletat
mixinStandardHelpOptions = true genera --help i --version. La qualitat d'aquesta ajuda depèn del que escriguis a description, i hi ha variables útils:
| Variable | Se substitueix per |
|---|---|
${DEFAULT-VALUE} |
El valor per defecte de l'opció |
${COMPLETION-CANDIDATES} |
Els valors vàlids (útil amb enum) |
${sys:usuari} |
Una propietat del sistema |
I es poden afegir seccions completes, que és el que separa una ajuda útil d'una llista d'opcions:
@Command(name = "importar",
description = "Importa materials al cataleg des d'un fitxer o des de l'entrada estandard.",
footerHeading = "%nExemples:%n",
footer = {
" bibliotech cataleg importar cataleg.csv",
" bibliotech cataleg importar --format-entrada=json dades.json --simular",
" cat isbns.txt | bibliotech cataleg importar --des-de-stdin --enriquir",
"",
"Codis de sortida: 0 correcte, 1 error, 2 us incorrecte, 4 importacio parcial."
})
class OrdreCatalegImportar implements Callable<Integer> { … }Autocompletat. Picocli genera un script de compleció per a bash i zsh:
# Generar l'script (una vegada, en la construccio)
java -cp bibliotech-consola.jar picocli.AutoComplete \
-n bibliotech com.nexussoftware.bibliotech.consola.OrdreArrel
# Activar-lo a la sessio
source bibliotech_completion
# Ara funciona el tabulador
$ bibliotech cat<TAB> → bibliotech cataleg
$ bibliotech cataleg <TAB> → cercar fitxa importar llistar
$ bibliotech cataleg llistar --format=<TAB> → TAULA CSV JSONAquest detall és dels que més agraeixen els qui fan servir l'eina cada dia, i costa una línia al POM.
- Integració amb Spring Boot
El problema a resoldre: les ordres necessiten els serveis d'aplicació (GestionarPrestecs, ConsultarCataleg), que són beans de Spring. Però Picocli instancia les ordres per reflexió. Sense integració, acabaries amb new OrdrePrestec(context.getBean(...)), que és Service Locator, un antipatró (12-02).
picocli-spring-boot-starter ho resol amb una fàbrica que delega en el context:
package com.nexussoftware.bibliotech.consola;
@SpringBootApplication(scanBasePackages = "com.nexussoftware.bibliotech")
public class BiblioTechCli implements CommandLineRunner, ExitCodeGenerator {
private final OrdreArrel ordreArrel;
private final CommandLine.IFactory fabrica; // l'aporta l'starter de Picocli
private int codiSortida;
public BiblioTechCli(OrdreArrel ordreArrel, CommandLine.IFactory fabrica) {
this.ordreArrel = ordreArrel;
this.fabrica = fabrica;
}
@Override
public void run(String... args) {
this.codiSortida = new CommandLine(ordreArrel, fabrica)
.setCaseInsensitiveEnumValuesAllowed(true) // --format=json i JSON
.setExecutionExceptionHandler(new GestorErrorsCli())
.execute(args);
}
@Override
public int getExitCode() { return codiSortida; }
public static void main(String[] args) {
// SpringApplication.exit retorna el codi de l'ExitCodeGenerator,
// i tanca el context ordenadament abans de sortir.
System.exit(SpringApplication.exit(SpringApplication.run(BiblioTechCli.class, args)));
}
}Amb això, les ordres són beans normals i reben les seves dependències per constructor:
@Command(name = "crear", description = "Crea un prestec d'un material a un empleat.")
@Component
public class OrdrePrestecCrear implements Callable<Integer> {
private final GestionarPrestecs gestor; // injeccio per constructor, com sempre!
private final Sortida sortida;
public OrdrePrestecCrear(GestionarPrestecs gestor, Sortida sortida) {
this.gestor = gestor;
this.sortida = sortida;
}
@Option(names = "--isbn", required = true, converter = ConversorIsbn.class)
private Isbn isbn;
@Option(names = "--empleat", required = true, paramLabel = "<id>")
private Long idEmpleat;
@Option(names = "--dies", description = "Durada. Per defecte, la del tipus de material.")
private Integer dies;
@Override
public Integer call() {
Prestec prestec = gestor.prestar(isbn, idEmpleat, dies);
sortida.correcte("Prestec #%d creat. Venc el %s."
.formatted(prestec.getId(), prestec.getDataVenciment()));
return CodiSortida.OK;
}
}Un detall de configuració important. La CLI no ha d'arrencar un servidor web ni imprimir el bàner de Spring. A bibliotech-consola/src/main/resources/application.yml:
spring:
main:
web-application-type: none # sense Tomcat
banner-mode: off # sense baner ASCII contaminant la sortida
output:
ansi:
enabled: detect # colors nomes si el terminal els admet
logging:
pattern:
console: "%d{HH:mm:ss} %-5level %msg%n"
level:
root: WARN # una CLI silenciosa per defecte
com.nexussoftware.bibliotech: INFOQue banner-mode estigui a off no és cosmètic: si la sortida s'ha d'encadenar amb jq, el bàner trenca el JSON.
- Disseny de la CLI de BiblioTech
La superfície completa de l'eina:
| Ordre | Descripció | Opcions principals |
|---|---|---|
cataleg llistar |
Llista materials | --tipus, --disponibles, --limit, --ordenar-per |
cataleg cercar |
Cerca per criteris | --titol, --autor, --des-de, --fins-a |
cataleg fitxa |
Detall d'un material | <isbn> posicional, --amb-historial |
cataleg importar |
Importa des de fitxer o stdin | --des-de-stdin, --format-entrada, --simular, --enriquir, --fils |
prestec crear |
Crea un préstec | --isbn, --empleat, --dies |
prestec retornar |
Registra una devolució | <idPrestec>, --data |
prestec renovar |
Renova un préstec | <idPrestec>, --dies |
prestec llistar |
Llista préstecs | --empleat, --estat, --vencuts |
avisos enviar |
Envia avisos de venciment | --dies-antelacio, --simular |
informe multes |
Informe de multes | --des-de, --fins-a, --empleat |
informe us |
Informe d'ús mensual | --periode |
Opcions globals, disponibles a totes:
| Opció | Efecte |
|---|---|
-f, --format=<TAULA|CSV|JSON> |
Format de sortida |
-q, --silencios |
Només errors; sense capçaleres ni missatges de progrés |
-v, --detallat |
Detall de l'execució (repetible: -vv per a traces) |
--sense-color |
Desactiva els colors ANSI |
-h, --help / -V, --version |
Ajuda i versió |
Una ordre completa, amb tot el que hem vist junt:
@Command(name = "llistar",
description = "Llista els materials del cataleg.",
footerHeading = "%nExemples:%n",
footer = {
" bibliotech cataleg llistar --tipus=LLIBRE --disponibles",
" bibliotech cataleg llistar --format=csv > cataleg.csv",
" bibliotech cataleg llistar --format=json | jq '.[].titol'"
})
@Component
public class OrdreCatalegLlistar implements Callable<Integer> {
private final ConsultarCataleg cataleg;
private final Sortida sortida;
public OrdreCatalegLlistar(ConsultarCataleg cataleg, Sortida sortida) {
this.cataleg = cataleg;
this.sortida = sortida;
}
@Option(names = "--tipus", description = "Filtrar per tipus: ${COMPLETION-CANDIDATES}")
private TipusMaterial tipus;
@Option(names = "--disponibles", description = "Nomes materials amb unitats lliures.")
private boolean nomesDisponibles;
@Option(names = "--limit", defaultValue = "50",
description = "Nombre maxim de resultats. Per defecte: ${DEFAULT-VALUE}")
private int limit;
@Option(names = "--ordenar-per", defaultValue = "TITOL",
description = "Criteri d'ordre: ${COMPLETION-CANDIDATES}")
private CriteriOrdre ordre;
@Override
public Integer call() {
var criteri = CriteriCerca.builder() // Builder de 12-02
.tipus(tipus)
.nomesDisponibles(nomesDisponibles)
.construir();
List<Material> resultats = cataleg.cercar(criteri, ordre, limit);
if (resultats.isEmpty()) {
sortida.avis("No s'ha trobat cap material amb aquests criteris.");
return CodiSortida.SENSE_RESULTATS; // 3: diferent d'error
}
sortida.escriureMaterials(resultats); // el format el decideix Sortida
sortida.info("%d materials.".formatted(resultats.size()));
return CodiSortida.OK;
}
}Observa que l'ordre no imprimeix res directament. Delega en Sortida, que és qui sap de formats, colors, nivell de detall i a quin flux va cada cosa. És responsabilitat única aplicada a la consola.
- Mode d'una sola ordre enfront del mode interactiu
| Aspecte | Una sola ordre | Interactiu (REPL) |
|---|---|---|
| Invocació | bibliotech prestec crear --isbn=… |
bibliotech shell, i després ordres |
| Automatitzable | Sí | No |
| Componible amb canonades | Sí | No |
| Cost per operació | Arrencada de la JVM cada vegada (~1 s) | Només la primera |
| Adequat per a | Scripts, cron, CI |
Exploració, moltes operacions seguides |
Poden conviure, i la clau perquè convisquin bé és que el mode interactiu no dupliqui lògica: es limita a llegir una línia, trossejar-la i passar-la al mateix CommandLine.
@Command(name = "shell", description = "Mode interactiu. Escriu 'sortir' per acabar.")
@Component
public class OrdreShell implements Callable<Integer> {
private final OrdreArrel arrel;
private final CommandLine.IFactory fabrica;
@Override
public Integer call() {
// Console es null si l'entrada esta redirigida: llavors el shell no te sentit
Console consola = System.console();
if (consola == null) {
System.err.println("El mode interactiu requereix un terminal.");
return CodiSortida.ERROR_US;
}
System.out.println("BiblioTech 1.0.0 — escriu 'ajuda' o 'sortir'.");
CommandLine cl = new CommandLine(arrel, fabrica);
while (true) {
String linia = consola.readLine("bibliotech> ");
if (linia == null || linia.isBlank()) continue;
if (linia.equals("sortir") || linia.equals("exit")) return CodiSortida.OK;
if (linia.equals("ajuda")) { cl.usage(System.out); continue; }
// Reutilitza EXACTAMENT la mateixa analisi i les mateixes ordres
cl.execute(trossejar(linia));
}
}
/** Trosseja respectant les cometes: cercar --titol="Java Eficac" */
private String[] trossejar(String linia) {
List<String> parts = new ArrayList<>();
Matcher m = Pattern.compile("\"([^\"]*)\"|(\\S+)").matcher(linia);
while (m.find()) {
parts.add(m.group(1) != null ? m.group(1) : m.group(2));
}
return parts.toArray(String[]::new);
}
}Per a un REPL seriós (historial, edició de línia, autocompletat en viu), la biblioteca és JLine, que a més Picocli integra oficialment. Aquí n'hi ha prou amb l'anterior.
- Entrada i sortida estàndard: compondre amb canonades
Això reprèn 01-06 i 06-07, i és el que converteix una CLI en una peça del sistema en lloc d'una illa.
Els tres fluxos, i la seva regla d'ús:
| Flux | Java | Què hi va | Es redirigeix amb |
|---|---|---|---|
| stdin (0) | System.in |
Dades d'entrada | < o | |
| stdout (1) | System.out |
El resultat, i només el resultat | > o | |
| stderr (2) | System.err |
Diagnòstics: avisos, progrés, errors | 2> |
La regla d'or: si una cosa no forma part del resultat, no va a
System.out. Un missatge de progrés, una capçalera decorativa o un «Processant…» a la sortida estàndard trenquen| jqi> fitxer.csv.
Llegir de l'entrada estàndard:
@Option(names = "--des-de-stdin", description = "Llegeix els ISBN de l'entrada estandard, un per linia.")
private boolean desDeStdin;
@Parameters(index = "0", arity = "0..1", description = "Fitxer d'entrada.")
private Path fitxer;
private List<String> llegirEntrada() throws IOException {
if (desDeStdin) {
// Compte amb la codificacio: en Java 18+ el defecte es UTF-8, pero ser explicit no fa mal
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 comentaris
.toList();
}
}
if (fitxer != null) {
return Files.readAllLines(fitxer, StandardCharsets.UTF_8);
}
throw new CommandLine.ParameterException(spec.commandLine(),
"Indica un fitxer d'entrada o fes servir --des-de-stdin.");
}Detectar si la sortida és un terminal. Això governa colors, progrés i capçaleres:
/** true si stdout va a un terminal; false si va a un fitxer o a un altre proces. */
public static boolean esTerminal() {
return System.console() != null;
}Amb això, la CLI s'adapta sola:
$ bibliotech cataleg llistar # terminal: colors, capçaleres, totals
$ bibliotech cataleg llistar > cataleg.txt # fitxer: sense colors ni adorns
$ bibliotech cataleg llistar | grep Java # canonada: ídemI el resultat pràctic d'haver respectat la regla d'or:
# El resultat va al fitxer; els avisos se segueixen veient a la pantalla
bibliotech cataleg importar dades.csv > resultat.json 2> importacio.log
# Encadenar sense que res es contamini
bibliotech informe multes --format=json | jq '[.[] | select(.quantitat > 10)] | length'
# Fer servir la sortida d'una ordre com a entrada d'una altra
bibliotech prestec llistar --vencuts --format=csv | cut -d';' -f2 | \
bibliotech avisos enviar --des-de-stdin
- Codis de sortida
Tot procés retorna un enter al sistema operatiu. Per a una persona és invisible; per a un script ho és tot:
Conveni de BiblioTech:
| Codi | Constant | Significat | Reacció típica de l'script |
|---|---|---|---|
| 0 | OK |
Èxit | Continuar |
| 1 | ERROR |
Error general d'execució | Avortar i avisar |
| 2 | ERROR_US |
Arguments no vàlids | Corregir la invocació |
| 3 | SENSE_RESULTATS |
S'ha executat bé, però no hi havia res | Continuar, sense alarma |
| 4 | PARCIAL |
Ha acabat amb errors parcials | Revisar el detall |
| 5 | NO_TROBAT |
El recurs sol·licitat no existeix | Depèn |
| 6 | CONFLICTE |
Regla de negoci violada | No reintentar |
| 7 | NO_DISPONIBLE |
Dependència externa caiguda | Reintentar més tard |
| 130 | — | Interromput amb Ctrl+C |
Conveni POSIX: 128 + SIGINT(2) |
public final class CodiSortida {
public static final int OK = 0;
public static final int ERROR = 1;
public static final int ERROR_US = 2;
public static final int SENSE_RESULTATS = 3;
public static final int PARCIAL = 4;
public static final int NO_TROBAT = 5;
public static final int CONFLICTE = 6;
public static final int NO_DISPONIBLE = 7;
public static final int INTERROMPUT = 130;
private CodiSortida() { }
}La distinció entre 6 i 7 és la que més valor aporta a la pràctica: un script de reintents ha de reintentar davant de NO_DISPONIBLE (l'API de metadades està caiguda) i no davant de CONFLICTE (l'empleat ja té tres préstecs: reintentar-ho mil vegades no ho arreglarà).
#!/usr/bin/env bash
# Reintentar nomes quan te sentit
for intent in 1 2 3; do
bibliotech cataleg importar --enriquir dades.csv
codi=$?
case $codi in
0) echo "Importacio correcta"; exit 0 ;;
7) echo "Servei no disponible; reintent $intent"; sleep $((intent * 30)) ;;
*) echo "Error no recuperable (codi $codi)"; exit $codi ;;
esac
done
exit 7
- Formatar la sortida: taula, CSV i JSON
La classe Sortida centralitza tot el que fa referència a la presentació. És una façana (12-02) sobre el format, el color i el nivell de detall.
@Component
public class Sortida {
private final ObjectMapper json; // Jackson, del modul 11: bean unic
private final PrintStream out;
private final PrintStream err;
private Format format = Format.TAULA;
private Nivell nivell = Nivell.NORMAL;
private boolean color = true;
public Sortida(ObjectMapper json) {
this.json = json;
// Codificacio explicita: sense aixo, els accents es trenquen en redirigir a 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 escriureMaterials(List<Material> materials) {
switch (format) {
case TAULA -> taula(materials);
case CSV -> csv(materials);
case JSON -> json(materials.stream().map(MaterialDto::desDe).toList());
}
}
// …
}Format taula, amb columnes que s'ajusten al contingut:
private void taula(List<Material> materials) {
// 1. Calcular l'amplada de cada columna: la del contingut mes llarg, amb un maxim
int ampladaTitol = Math.min(45, Math.max(6,
materials.stream().mapToInt(m -> m.getTitol().length()).max().orElse(6)));
String formatFila = "%-17s %-" + ampladaTitol + "s %-8s %5s%n";
// 2. Capcalera nomes si NO es una canonada i no estem en mode silencios
if (mostrarDecoracio()) {
out.printf(formatFila, "ISBN", "TITOL", "TIPUS", "LLIURES");
out.println("-".repeat(17 + ampladaTitol + 8 + 5 + 6));
}
// 3. Files
for (Material m : materials) {
out.printf(formatFila,
m.getIsbn().valor(),
retallar(m.getTitol(), ampladaTitol),
m.tipus(),
acolorirDisponibilitat(m.unitatsDisponibles()));
}
}
/** Retalla amb punts suspensius per no descompensar la taula. */
private String retallar(String text, int max) {
return text.length() <= max ? text : text.substring(0, max - 1) + "…";
}ISBN TITOL TIPUS LLIURES ------------------------------------------------------------------------- 978-0000000001 Java Eficac LLIBRE 2 978-0000000002 Patrons de Disseny LLIBRE 0 978-0000000003 Refactoritzacio LLIBRE 1
Format CSV, amb l'escapament que 07-07 va ensenyar a no improvisar:
private void csv(List<Material> materials) {
if (mostrarDecoracio()) out.println("isbn;titol;tipus;disponibles");
for (Material m : materials) {
out.printf("%s;%s;%s;%d%n",
m.getIsbn().valor(), escapar(m.getTitol()), m.tipus(), m.unitatsDisponibles());
}
}
private String escapar(String valor) {
// Si conte separador, cometes o salts de linia, cal encometar i duplicar les cometes
if (valor.contains(";") || valor.contains("\"") || valor.contains("\n")) {
return '"' + valor.replace("\"", "\"\"") + '"';
}
return valor;
}Format JSON, amb Jackson:
private void json(Object valor) {
try {
// Sense sagnar si es una canonada (mes compacte); sagnat si ho llegeix una persona
ObjectWriter escriptor = esTerminal()
? json.writerWithDefaultPrettyPrinter()
: json.writer();
out.println(escriptor.writeValueAsString(valor));
} catch (JsonProcessingException e) {
throw new SortidaFallidaException("No s'ha pogut serialitzar el resultat", e);
}
}Comparativa d'ús:
| Format | Per a qui | Quan |
|---|---|---|
taula |
Persones | Ús interactiu (per defecte) |
csv |
Fulls de càlcul, cut, awk |
Informes, importació a Excel |
json |
jq, altres programes |
Automatització, integració |
- Nivell de detall:
--silencios i --detallat
--silencios i --detallatTres nivells, i una regla clara sobre a quin flux va cadascun:
| Nivell | Opció | Què s'imprimeix | Flux |
|---|---|---|---|
| Silenciós | -q |
Només el resultat i els errors | out / err |
| Normal | (cap) | Resultat, capçaleres, totals, avisos | out / err |
| Detallat | -v |
A més, passos intermedis i temps | err |
| Traça | -vv |
A més, el log de l'aplicació en DEBUG | err |
public enum Nivell { SILENCIOS, NORMAL, DETALLAT, TRACA }
@Component
public class Sortida {
/** Resultat: SEMPRE a stdout, fins i tot en mode silencios. Es el que ha demanat l'usuari. */
public void resultat(String text) { out.println(text); }
/** Informacio contextual: a stderr, per no contaminar la canonada. */
public void info(String text) {
if (nivell.ordinal() >= Nivell.NORMAL.ordinal()) err.println(text);
}
/** Detall d'execucio: nomes amb -v. */
public void detall(String text) {
if (nivell.ordinal() >= Nivell.DETALLAT.ordinal()) err.println(gris(" " + text));
}
public void avis(String text) { err.println(groc("Avis: ") + text); }
public void error(String text) { err.println(vermell("Error: ") + text); }
public void correcte(String text) { if (nivell != Nivell.SILENCIOS) err.println(verd("✓ ") + text); }
}El nivell s'aplica en arrencar, i -vv puja també el nivell de registre de l'aplicació:
@Option(names = {"-v", "--detallat"}, scope = ScopeType.INHERIT)
void setDetallat(boolean[] vegades) {
Nivell nivell = vegades.length >= 2 ? Nivell.TRACA : Nivell.DETALLAT;
sortida.setNivell(nivell);
if (nivell == Nivell.TRACA) {
// Pujar el nivell de Logback en calent (11-07)
((ch.qos.logback.classic.Logger) LoggerFactory.getLogger("com.nexussoftware.bibliotech"))
.setLevel(ch.qos.logback.classic.Level.DEBUG);
}
}
- Operacions llargues: progrés i senyals de vida
La importació del catàleg amb enriquiment consulta l'API de metadades per a cada material. Amb 5.000 materials, això són uns quants minuts. Sense senyals de vida, l'usuari assumeix que s'ha penjat i prem Ctrl+C.
Aquí es reprèn la importació concurrent del mòdul 8: un ExecutorService amb fils virtuals (10-06), que per a tasques dominades per E/S és exactament el cas d'ús ideal.
@Command(name = "importar", description = "Importa materials al cataleg.")
@Component
public class OrdreCatalegImportar implements Callable<Integer> {
private final ImportadorCataleg importador;
private final Sortida sortida;
@Parameters(index = "0", arity = "0..1") private Path fitxer;
@Option(names = "--des-de-stdin") private boolean desDeStdin;
@Option(names = "--enriquir", description = "Completa les metadades des de l'API externa.")
private boolean enriquir;
@Option(names = "--simular", description = "No escriu res; mostra el que faria.")
private boolean simular;
@Override
public Integer call() throws Exception {
List<RegistreImportacio> registres = llegirEntrada();
sortida.info("Important %d registres%s…"
.formatted(registres.size(), simular ? " (SIMULACIO)" : ""));
var progres = new BarraProgres(registres.size(), sortida);
var correctes = new AtomicInteger(); // modul 8
var errors = Collections.synchronizedList(new ArrayList<ErrorImportacio>());
// Fils virtuals: milers de tasques bloquejants d'E/S sense esgotar el sistema
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (RegistreImportacio registre : registres) {
executor.submit(() -> {
try {
if (!simular) importador.importar(registre, enriquir);
correctes.incrementAndGet();
} catch (BiblioTechException e) {
errors.add(ErrorImportacio.de(registre, e));
} finally {
progres.avancar();
}
});
}
} // el tancament del try-with-resources espera que TOTES acabin
progres.acabar();
sortida.escriureResultatImportacio(correctes.get(), errors);
if (errors.isEmpty()) return CodiSortida.OK;
if (correctes.get() == 0) return CodiSortida.ERROR;
return CodiSortida.PARCIAL; // el 4 de la taula
}
}La barra de progrés, amb les tres decisions que la fan correcta:
public class BarraProgres {
private static final int AMPLADA = 40;
private final int total;
private final Sortida sortida;
private final AtomicInteger actual = new AtomicInteger();
private final long inici = System.nanoTime();
private final boolean activa;
private volatile long ultimRepintat;
public BarraProgres(int total, Sortida sortida) {
this.total = total;
this.sortida = sortida;
// DECISIO 1: nomes si hi ha terminal i no estem en silencios.
// Una barra de progres en un fitxer de log es brossa illegible.
this.activa = sortida.esTerminal() && sortida.nivell() != Nivell.SILENCIOS;
}
public void avancar() {
int n = actual.incrementAndGet();
if (!activa) return;
// DECISIO 2: limitar el repintat. Repintar 5.000 vegades per segon
// consumeix mes CPU que la feina mateixa.
long ara = System.nanoTime();
if (n < total && ara - ultimRepintat < 100_000_000L) return; // 100 ms
ultimRepintat = ara;
pintar(n);
}
private void pintar(int n) {
int plens = (int) ((double) n / total * AMPLADA);
long segons = (System.nanoTime() - inici) / 1_000_000_000L;
long restants = n > 0 ? segons * (total - n) / n : 0;
// DECISIO 3: a stderr, no a stdout. El progres NO es el resultat.
// \r torna al principi de la linia sense saltar: la barra se sobreescriu.
sortida.err().printf("\r[%s%s] %d/%d (%d%%) ETA %ds ",
"=".repeat(plens), " ".repeat(AMPLADA - plens),
n, total, n * 100 / total, restants);
}
public void acabar() {
if (activa) sortida.err().println(); // tancar la linia de la barra
}
}Per a operacions sense total conegut, un giravolt (|, /, -, \) o un simple punt cada N elements compleix la mateixa funció: dir «continuo viu».
- Cancel·lació neta amb
Ctrl+C
Ctrl+CCtrl+C envia SIGINT. Per defecte, la JVM acaba immediatament: transaccions a mitges, fitxers a mig escriure, context de Spring sense tancar.
La solució és un shutdown hook, que reprèn el que vam veure al mòdul 7 sobre tancament ordenat de recursos:
@Component
public class GestorCancellacio {
private static final Logger log = LoggerFactory.getLogger(GestorCancellacio.class);
private final AtomicBoolean cancellat = new AtomicBoolean(false);
private final CountDownLatch treballAcabat = new CountDownLatch(1);
@PostConstruct
void registrar() {
Runtime.getRuntime().addShutdownHook(new Thread(this::enRebreSenyal, "cancellacio"));
}
private void enRebreSenyal() {
cancellat.set(true);
System.err.println("\nCancellant… (prem Ctrl+C de nou per forcar)");
try {
// Donar un marge per acabar ordenadament, pero NO esperar indefinidament:
// un hook que no acaba deixa el proces penjat.
if (!treballAcabat.await(10, TimeUnit.SECONDS)) {
System.err.println("La feina no ha acabat a temps; surto igualment.");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
/** Els bucles llargs consulten aixo a cada iteracio. */
public boolean cancellat() { return cancellat.get(); }
public void treballCompletat() { treballAcabat.countDown(); }
}Ús a l'ordre:
@Override
public Integer call() {
try {
for (RegistreImportacio registre : registres) {
if (cancellacio.cancellat()) {
sortida.avis("Importacio cancellada per l'usuari. "
+ "Processats %d de %d registres.".formatted(processats, registres.size()));
return CodiSortida.INTERROMPUT; // 130, conveni POSIX
}
importador.importar(registre, enriquir);
processats++;
}
return CodiSortida.OK;
} finally {
cancellacio.treballCompletat(); // allibera el hook
}
}Tres regles del shutdown hook que convé no oblidar:
- Ha de ser ràpid. El sistema pot matar el procés si triga massa (
SIGKILLno es pot interceptar). - No pot dependre del context de Spring. Pot ser que ja s'estigui tancant.
- Ha de ser idempotent. Un segon
Ctrl+Cno ha de trencar res.
- Colors ANSI i quan desactivar-los
Els colors es produeixen amb seqüències d'escapament ANSI:
public final class Ansi {
public static final String RESET = "[0m";
public static final String VERMELL = "[31m";
public static final String VERD = "[32m";
public static final String GROC = "[33m";
public static final String GRIS = "[90m";
public static final String NEGRETA = "[1m";
}El problema és que, si la sortida no va a un terminal, aquestes seqüències s'escriuen literalment:
$ bibliotech cataleg llistar > sortida.txt
$ cat sortida.txt
^[[32m978-0000000001^[[0m Java Eficac …Lògica de decisió, en ordre de precedència:
public class DetectorColor {
public static boolean calUsarColor(boolean opcioSenseColor) {
// 1. L'opcio explicita de l'usuari mana
if (opcioSenseColor) return false;
// 2. NO_COLOR: conveni universal (no-color.org). Si existeix, amb qualsevol valor, es respecta
if (System.getenv("NO_COLOR") != null) return false;
// 3. FORCE_COLOR: per forcar colors en un pipeline de CI que si que els representa
if (System.getenv("FORCE_COLOR") != null) return true;
// 4. Terminal "ximple" (alguns entorns de CI, Emacs shell)
String term = System.getenv("TERM");
if ("dumb".equals(term)) return false;
// 5. Sense terminal (canonada o redireccio): sense color
return System.console() != null;
}
}| Condició | Color |
|---|---|
--sense-color |
No |
NO_COLOR definida |
No |
FORCE_COLOR definida |
Sí |
TERM=dumb |
No |
| Sortida redirigida o en canonada | No |
| Terminal interactiu | Sí |
I dos consells d'ús: el color no ha de ser mai l'únic portador d'informació (per accessibilitat i per daltonisme: acompanya'l d'un símbol o una paraula), i menys és més — vermell per als errors, groc per als avisos, verd per a l'èxit, gris per al que és secundari. Res més.
- Errors orientats a l'usuari
Una traça de pila a la consola d'un usuari és una confessió que no s'ha pensat en ell. L'estructura d'un bon error té quatre parts:
Error: no es pot prestar "Java Eficac" a Diego Alonso.
Causa: l'empleat ja te 3 prestecs actius (maxim permes: 3).
Suggeriment: consulta els seus prestecs amb
bibliotech prestec llistar --empleat=2
i retorna'n algun abans de crear-ne un de nou.
Detall tecnic registrat amb id: a7f3e91cEl gestor centralitzat de Picocli, que tradueix la jerarquia BiblioTechException del mòdul 6:
public class GestorErrorsCli implements CommandLine.IExecutionExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GestorErrorsCli.class);
@Override
public int handleExecutionException(Exception e, CommandLine cl, CommandLine.ParseResult analisi) {
String idIncidencia = UUID.randomUUID().toString().substring(0, 8);
// El detall tecnic COMPLET va al log, no a la pantalla de l'usuari (06-07)
log.error("Error en l'execucio de l'ordre [id={}]", idIncidencia, e);
PrintWriter err = cl.getErr();
return switch (e) {
case MaterialNoTrobatException ex -> {
err.println(vermell("Error: ") + "no existeix cap material amb ISBN " + ex.getIsbn() + ".");
err.println(" Suggeriment: cerca'l amb 'bibliotech cataleg cercar --titol=\"...\"'");
yield CodiSortida.NO_TROBAT;
}
case LimitPrestecsExceditException ex -> {
err.println(vermell("Error: ") + "l'empleat " + ex.getNom()
+ " ja te " + ex.getMaxim() + " prestecs actius.");
err.println(" Suggeriment: 'bibliotech prestec llistar --empleat=" + ex.getId() + "'");
yield CodiSortida.CONFLICTE;
}
case ServeiExternNoDisponibleException ex -> {
err.println(vermell("Error: ") + "el servei de metadades no respon.");
err.println(" Suggeriment: reintenta-ho mes tard, o fes servir --sense-enriquir.");
yield CodiSortida.NO_DISPONIBLE; // 7: l'script SI que ha de reintentar
}
case BiblioTechException ex -> {
err.println(vermell("Error: ") + ex.getMessage());
yield CodiSortida.ERROR;
}
default -> {
// L'inesperat: missatge generic + identificador per correlacionar amb el log
err.println(vermell("Error inesperat. ") + "Incidencia " + idIncidencia + ".");
err.println(" Executa amb -vv per veure el detall, o envia aquest identificador a suport.");
yield CodiSortida.ERROR;
}
};
}
}El switch amb patrons sobre tipus és el pattern matching de Java 21 (10-06), i aquí llueix especialment: substitueix una escala d'instanceof.
L'identificador d'incidència és el detall que més agraeix el suport tècnic: l'usuari veu vuit caràcters, i amb ells es localitza la traça completa al log (que ja està correlacionat amb MDC des de 11-07).
- Empaquetatge i distribució
Jar executable amb 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> <!-- afegeix un script d'arrencada al jar mateix -->
</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 cataleg llistarScript d'arrencada, perquè s'invoqui com a bibliotech i no com a java -jar …:
#!/usr/bin/env bash
# scripts/bibliotech — installa'l a /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}"
# Opcions de JVM pensades per a un arrencada rapida, no per a un servidor de vida llarga:
# -XX:TieredStopAtLevel=1 no compilar a fons: el proces dura segons
# -XX:+UseSerialGC el GC mes barat d'inicialitzar
# -Xshare:auto fer servir l'arxiu de classes compartides
exec "$JAVA_BIN" \
-XX:TieredStopAtLevel=1 \
-XX:+UseSerialGC \
-Xshare:auto \
-Dfile.encoding=UTF-8 \
${BIBLIOTECH_OPTS:-} \
-jar "$BIBLIOTECH_HOME/bibliotech-cli.jar" "$@" # "$@" preserva els arguments amb espaisAquest "$@" amb cometes és important: sense elles, bibliotech cataleg cercar --titol="Java Eficac" es trenca en dos arguments.
El problema de l'arrencada. Una CLI amb Spring Boot triga entre 1 i 3 segons a arrencar. Per a un ús ocasional és tolerable; per a una ordre que s'invoca mil vegades en un bucle, no.
| Opció | Arrencada | Cost |
|---|---|---|
| Jar normal | 1-3 s | Cap |
CDS (-XX:SharedArchiveFile) |
0,7-2 s | Un pas extra en la construcció |
AppCDS + -Xshare |
~0,6 s | Ídem |
jpackage (instal·lador natiu amb JRE inclosa) |
Igual que el jar | No cal tenir Java instal·lat |
| GraalVM Native Image | ~0,05 s | Construcció lenta; la reflexió s'ha de declarar |
jpackage (inclòs al JDK des de Java 14) produeix un .deb, .rpm, .msi o .dmg amb la JRE a dins:
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 és l'opció quan l'arrencada instantània importa de debò. Picocli hi té suport de primera classe (genera els metadades de reflexió automàticament), i Spring Boot 3 també:
./mvnw -pl bibliotech-consola -Pnative native:compile
./bibliotech-consola/target/bibliotech cataleg llistar # arrenca en ~50 msEl preu: la construcció triga uns quants minuts, i tot el que faci servir reflexió (Jackson, JPA) necessita metadades declarades. Per a una CLI petita compensa; per a una aplicació gran cal valorar-ho.
- Provar una aplicació de consola
És la part que gairebé tots els projectes se salten, i no hi ha motiu: una CLI es prova en tres nivells.
Nivell 1: la lògica de l'ordre, sense Picocli. L'ordre és un bean normal; es prova amb Mockito (11-06):
@ExtendWith(MockitoExtension.class)
class OrdrePrestecCrearTest {
@Mock GestionarPrestecs gestor;
@Mock Sortida sortida;
@InjectMocks OrdrePrestecCrear ordre;
@Test
void retornaOkIAvisaQuanElPrestecEsCrea() {
var prestec = unPrestecDe("978-0000000001", "Marta Ruiz");
when(gestor.prestar(any(), eq(1L), isNull())).thenReturn(prestec);
ReflectionTestUtils.setField(ordre, "isbn", Isbn.de("978-0000000001"));
ReflectionTestUtils.setField(ordre, "idEmpleat", 1L);
Integer codi = ordre.call();
assertThat(codi).isEqualTo(CodiSortida.OK);
verify(sortida).correcte(contains("Prestec #" + prestec.getId()));
}
}Nivell 2: l'anàlisi d'arguments. Es comprova que les opcions es converteixen bé, sense executar res:
class ParseigArgumentsTest {
@Test
void analitzaLesOpcionsDeLordreLlistar() {
var ordre = new OrdreCatalegLlistar(mock(ConsultarCataleg.class), mock(Sortida.class));
var cl = new CommandLine(ordre);
cl.parseArgs("--tipus=LLIBRE", "--disponibles", "--limit=10");
assertThat(ReflectionTestUtils.getField(ordre, "tipus")).isEqualTo(TipusMaterial.LLIBRE);
assertThat(ReflectionTestUtils.getField(ordre, "nomesDisponibles")).isEqualTo(true);
assertThat(ReflectionTestUtils.getField(ordre, "limit")).isEqualTo(10);
}
@Test
void rebutjaUnIsbnInvalidAmbMissatgeUtil() {
var cl = new CommandLine(new OrdrePrestecCrear(mock(…), mock(…)));
assertThatThrownBy(() -> cl.parseArgs("--isbn=1234", "--empleat=1"))
.isInstanceOf(CommandLine.ParameterException.class)
.hasMessageContaining("no es un ISBN-13 valid");
}
@Test
void exigeixLesOpcionsObligatories() {
var cl = new CommandLine(new OrdrePrestecCrear(mock(…), mock(…)));
assertThatThrownBy(() -> cl.parseArgs("--empleat=1"))
.isInstanceOf(CommandLine.MissingParameterException.class)
.hasMessageContaining("--isbn");
}
}Nivell 3: extrem a extrem, capturant la sortida. S'executa l'ordre completa i es comprova el que escriu i quin codi retorna:
@SpringBootTest
class BiblioTechCliIT {
@Autowired OrdreArrel arrel;
@Autowired CommandLine.IFactory fabrica;
@Test
void llistarCatalegEnJsonProdueixJsonValid() throws Exception {
var sortidaCapturada = new StringWriter();
var errorCapturat = new StringWriter();
int codi = new CommandLine(arrel, fabrica)
.setOut(new PrintWriter(sortidaCapturada)) // Picocli permet redirigir
.setErr(new PrintWriter(errorCapturat))
.execute("cataleg", "llistar", "--format=json");
assertThat(codi).isEqualTo(CodiSortida.OK);
// El resultat ha de ser JSON VALID: res de baners ni missatges contaminant-lo
JsonNode arbre = new ObjectMapper().readTree(sortidaCapturada.toString());
assertThat(arbre.isArray()).isTrue();
assertThat(arbre).hasSize(3);
assertThat(arbre.get(0).get("titol").asText()).isEqualTo("Java Eficac");
}
@Test
void retornaCodi5QuanElMaterialNoExisteix() {
int codi = new CommandLine(arrel, fabrica)
.setExecutionExceptionHandler(new GestorErrorsCli())
.execute("cataleg", "fitxa", "978-9999999999");
assertThat(codi).isEqualTo(CodiSortida.NO_TROBAT);
}
@Test
void retornaCodi2QuanFaltaUnaOpcioObligatoria() {
int codi = new CommandLine(arrel, fabrica).execute("prestec", "crear", "--empleat=1");
assertThat(codi).isEqualTo(2); // error d'us, el que Picocli fa servir per defecte
}
}La prova que el JSON és vàlid és especialment valuosa: detecta a l'instant que algú ha ficat un System.out.println("Processant…") on no tocava.
Errors Comuns i Consells
1. Barrejar resultat i diagnòstics a System.out. És l'error més freqüent i el que més trenca l'automatització. Un sol println de progrés invalida | jq. Regla: si no és el resultat, va a System.err.
2. Retornar sempre 0. Un script no pot distingir l'èxit de la fallada, i una fallada nocturna passa desapercebuda durant setmanes. Retorna codis amb significat.
3. Imprimir traces de pila a l'usuari. L'usuari no pot fer res amb NullPointerException. La traça va al log; a la pantalla hi va un missatge amb causa, suggeriment i identificador d'incidència.
4. No detectar si hi ha terminal. Colors, barres de progrés i capçaleres decoratives s'han de desactivar sols quan la sortida es redirigeix. Comprova System.console() != null.
5. Ignorar NO_COLOR. És un conveni establert. Si no el respectes, la teva eina serà la que espatlli la sortida al terminal d'algú.
6. Barres de progrés que repinten sense control. Repintar 5.000 vegades per segon consumeix més CPU que la feina real i satura el terminal. Limita-ho a un repintat cada 100 ms.
7. Oblidar la codificació. Sense -Dfile.encoding=UTF-8 o un PrintStream explícit, «Refactorització» surt com a «Refactoritzaci?» en redirigir en alguns sistemes.
8. Un --help que no ajuda. «Mostra materials» no explica res. Escriu descripcions útils i afegeix exemples al footer: és el primer que es llegeix i l'últim que s'escriu.
9. Operacions destructives sense confirmació ni simulació. Tota ordre que esborra o modifica en massa ha de tenir --simular i, en mode interactiu, demanar confirmació. En mode no interactiu, --si per saltar-se-la.
10. Duplicar lògica entre CLI i web. Si OrdrePrestecCrear valida regles de negoci que el controlador REST també valida, tard o d'hora divergeixen. Tots dos s'han de limitar a cridar el mateix cas d'ús.
Consell final: prova la teva CLI dins d'una canonada des del primer dia. bibliotech cataleg llistar --format=json | jq . detecta a l'instant gairebé tots els errors d'aquesta llista.
Exercicis
Exercici 1: ordre prestec retornar completa
Implementa bibliotech prestec retornar amb:
- Un paràmetre posicional obligatori: l'identificador del préstec.
--dataopcional (per defecte, avui), validant que no sigui futura.--simular: calcula i mostra la multa sense registrar la devolució.- Sortida en els tres formats.
- Codis: 0 correcte, 5 préstec no trobat, 6 ja retornat, 2 data no vàlida.
- Un missatge d'èxit que indiqui la multa si n'hi ha.
Exercici 2: entrada estàndard i codis de sortida
Implementa bibliotech avisos enviar que:
- Per defecte, enviï avisos dels préstecs que vencen d'aquí a
--dies-antelaciodies (per defecte 3). - Amb
--des-de-stdin, llegeixi identificadors d'empleat de l'entrada estàndard (un per línia) i avisi només aquests. - Tingui
--simularper mostrar a qui s'avisaria sense enviar res. - Retorni 0 si s'han enviat tots, 3 si no hi havia res a enviar, 4 si alguns han fallat i 7 si el servidor de correu no respon.
- Mostri progrés només si hi ha terminal.
Escriu a més l'script de shell que el faria servir en un cron amb reintents.
Exercici 3: formatador de taula reutilitzable
Escriu una classe TaulaConsola genèrica que:
- Accepti columnes amb nom, una funció extractora i una alineació.
- Calculi les amplades automàticament, amb un màxim per columna i retall amb «…».
- Admeti separador de capçalera i totals opcionals al peu.
- S'adapti a l'amplada del terminal si és possible.
- Sigui usable així:
TaulaConsola.de(materials)
.columna("ISBN", m -> m.getIsbn().valor())
.columna("TITOL", Material::getTitol, 45)
.columna("TIPUS", m -> m.tipus().name())
.columnaNumerica("LLIURES", m -> m.unitatsDisponibles())
.ambTotals()
.imprimir(sortida);Solucions
Solució 1
@Command(name = "retornar",
description = "Registra la devolucio d'un prestec.",
footerHeading = "%nExemples:%n",
footer = {
" bibliotech prestec retornar 42",
" bibliotech prestec retornar 42 --data=2026-03-15",
" bibliotech prestec retornar 42 --simular --format=json"
})
@Component
public class OrdrePrestecRetornar implements Callable<Integer> {
private final GestionarPrestecs gestor;
private final CalculadoraMultes calculadora;
private final Sortida sortida;
private final Clock rellotge;
@Spec CommandLine.Model.CommandSpec spec;
public OrdrePrestecRetornar(GestionarPrestecs gestor, CalculadoraMultes calculadora,
Sortida sortida, Clock rellotge) {
this.gestor = gestor;
this.calculadora = calculadora;
this.sortida = sortida;
this.rellotge = rellotge;
}
@Parameters(index = "0", paramLabel = "<idPrestec>",
description = "Identificador del prestec a retornar.")
private Long idPrestec;
@Option(names = "--data", description = "Data de devolucio (yyyy-MM-dd). Per defecte, avui.")
private LocalDate data;
@Option(names = "--simular",
description = "Calcula la multa sense registrar la devolucio.")
private boolean simular;
@Override
public Integer call() {
LocalDate avui = LocalDate.now(rellotge);
LocalDate dataEfectiva = (data != null) ? data : avui;
// Validacio creuada: ParameterException produeix missatge + us + codi 2
if (dataEfectiva.isAfter(avui)) {
throw new CommandLine.ParameterException(spec.commandLine(),
"--data (%s) no pot ser posterior a avui (%s)."
.formatted(dataEfectiva, avui));
}
Prestec prestec = gestor.cercar(idPrestec)
.orElseThrow(() -> new PrestecNoTrobatException(idPrestec));
if (prestec.getDataDevolucio().isPresent()) {
// L'excepcio la tradueix GestorErrorsCli a codi 6 (CONFLICTE)
throw new PrestecJaRetornatException(idPrestec,
prestec.getDataDevolucio().orElseThrow());
}
if (dataEfectiva.isBefore(prestec.getDataPrestec())) {
throw new CommandLine.ParameterException(spec.commandLine(),
"--data (%s) es anterior a la data del prestec (%s)."
.formatted(dataEfectiva, prestec.getDataPrestec()));
}
if (simular) {
Diner multa = calculadora.calcular(prestec, dataEfectiva);
sortida.escriureDevolucio(new ResultatDevolucio(
prestec.getId(), prestec.titolDelMaterial(), prestec.nomDeLempleat(),
dataEfectiva, multa, true));
sortida.avis("SIMULACIO: no s'ha registrat res.");
return CodiSortida.OK;
}
ResultatDevolucio resultat = gestor.retornar(idPrestec, dataEfectiva);
sortida.escriureDevolucio(resultat);
if (resultat.multa().esPositiva()) {
sortida.correcte("Devolucio registrada. Multa: %s (%d dies de retard)."
.formatted(resultat.multa(), resultat.diesDeRetard()));
} else {
sortida.correcte("Devolucio registrada en termini. Sense multa.");
}
return CodiSortida.OK;
}
}El formatatge, a Sortida:
public void escriureDevolucio(ResultatDevolucio r) {
switch (format) {
case TAULA -> {
out.printf("%-14s %s%n", "Prestec:", "#" + r.idPrestec());
out.printf("%-14s %s%n", "Material:", r.titolMaterial());
out.printf("%-14s %s%n", "Empleat:", r.nomEmpleat());
out.printf("%-14s %s%n", "Devolucio:", r.data());
out.printf("%-14s %s%n", "Multa:",
r.multa().esPositiva() ? vermell(r.multa().toString()) : verd("sense multa"));
}
case CSV -> {
if (mostrarDecoracio()) out.println("id;material;empleat;data;multa");
out.printf("%d;%s;%s;%s;%s%n", r.idPrestec(), escapar(r.titolMaterial()),
escapar(r.nomEmpleat()), r.data(), r.multa().quantitat());
}
case JSON -> json(r);
}
}Comprovació dels codis de sortida:
$ bibliotech prestec retornar 42; echo $? # 0
$ bibliotech prestec retornar 9999; echo $? # 5 (no trobat)
$ bibliotech prestec retornar 42; echo $? # 6 (ja retornat)
$ bibliotech prestec retornar 42 --data=2099-01-01; echo $? # 2 (us incorrecte)Solució 2
@Command(name = "enviar",
description = "Envia avisos de venciment proxim als empleats afectats.",
footerHeading = "%nCodis de sortida:%n",
footer = {
" 0 tots els avisos s'han enviat",
" 3 no hi havia cap avis per enviar",
" 4 alguns avisos han fallat",
" 7 el servidor de correu no respon"
})
@Component
public class OrdreAvisosEnviar implements Callable<Integer> {
private final ServeiAvisos avisos;
private final RepositoriPrestecs prestecs;
private final Sortida sortida;
private final GestorCancellacio cancellacio;
private final Clock rellotge;
@Option(names = "--dies-antelacio", defaultValue = "3",
description = "Avisar dels venciments d'aqui a N dies. Per defecte: ${DEFAULT-VALUE}")
private int diesAntelacio;
@Option(names = "--des-de-stdin",
description = "Llegeix identificadors d'empleat de l'entrada estandard, un per linia.")
private boolean desDeStdin;
@Option(names = "--simular", description = "Mostra a qui s'avisaria, sense enviar res.")
private boolean simular;
@Override
public Integer call() throws IOException {
List<Prestec> objectiu = seleccionarPrestecs();
if (objectiu.isEmpty()) {
sortida.info("No hi ha cap prestec que requereixi avis.");
return CodiSortida.SENSE_RESULTATS; // 3: NO es un error
}
sortida.info("Preparant %d avisos%s…".formatted(objectiu.size(), simular ? " (SIMULACIO)" : ""));
var progres = new BarraProgres(objectiu.size(), sortida); // s'autodesactiva sense terminal
int enviats = 0;
List<FalladaEnviament> fallades = new ArrayList<>();
for (Prestec p : objectiu) {
if (cancellacio.cancellat()) {
progres.acabar();
sortida.avis("Cancellat. Enviats %d de %d.".formatted(enviats, objectiu.size()));
return CodiSortida.INTERROMPUT;
}
try {
if (simular) {
// El resultat de la simulacio SI que es resultat: va a stdout
sortida.resultat("%s <%s> — \"%s\" venc el %s"
.formatted(p.nomDeLempleat(), p.correuDeLempleat(),
p.titolDelMaterial(), p.getDataVenciment()));
} else {
avisos.enviar(Avis.perVenciment(p));
}
enviats++;
} catch (ServeiExternNoDisponibleException e) {
// El servidor de correu caigut afecta TOTS: no te sentit continuar
progres.acabar();
throw e; // el gestor el tradueix a 7
} catch (BiblioTechException e) {
fallades.add(new FalladaEnviament(p.getId(), p.correuDeLempleat(), e.getMessage()));
} finally {
progres.avancar();
}
}
progres.acabar();
if (!fallades.isEmpty()) {
sortida.avis("%d avisos han fallat:".formatted(fallades.size()));
fallades.forEach(f -> sortida.avis(" prestec #%d (%s): %s"
.formatted(f.idPrestec(), f.desti(), f.motiu())));
sortida.info("Enviats %d de %d.".formatted(enviats, objectiu.size()));
return CodiSortida.PARCIAL; // 4
}
sortida.correcte("Enviats %d avisos.".formatted(enviats));
return CodiSortida.OK;
}
private List<Prestec> seleccionarPrestecs() throws IOException {
LocalDate limit = LocalDate.now(rellotge).plusDays(diesAntelacio);
if (!desDeStdin) {
return prestecs.actiusQueVencenAbansDe(limit);
}
List<Long> ids = llegirIdsDeStdin();
if (ids.isEmpty()) {
sortida.avis("No s'ha llegit cap identificador de l'entrada estandard.");
return List.of();
}
sortida.detall("Filtrant per %d empleats llegits de stdin".formatted(ids.size()));
return prestecs.actiusQueVencenAbansDe(limit).stream()
.filter(p -> ids.contains(p.getIdEmpleat()))
.toList();
}
private List<Long> llegirIdsDeStdin() 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) {
sortida.avis("S'ignora la linia no numerica: '%s'".formatted(l));
return null;
}
})
.filter(Objects::nonNull)
.distinct()
.toList();
}
}
}Ús i composició:
# Tots els venciments dels propers 3 dies
bibliotech avisos enviar
# Nomes als empleats del departament d'Arquitectura
bibliotech empleat llistar --departament=Arquitectura --format=csv \
| cut -d';' -f1 | tail -n +2 \
| bibliotech avisos enviar --des-de-stdin --dies-antelacio=7
# Veure a qui s'avisaria, sense enviar res
bibliotech avisos enviar --simular --format=json | jq -r '.[].correu'Script per a cron:
#!/usr/bin/env bash
# /opt/bibliotech/scripts/avisos-diaris.sh
# Executar amb: 0 8 * * * /opt/bibliotech/scripts/avisos-diaris.sh
set -uo pipefail # sense -e: volem gestionar els codis nosaltres
LOG="/var/log/bibliotech/avisos-$(date +%F).log"
MAX_INTENTS=3
for intent in $(seq 1 $MAX_INTENTS); do
# El resultat al log; els diagnostics tambe, pero separables per si de cas
bibliotech avisos enviar --dies-antelacio=3 --silencios >>"$LOG" 2>&1
codi=$?
case $codi in
0) echo "$(date -Is) OK: avisos enviats" >>"$LOG"; exit 0 ;;
3) echo "$(date -Is) Res per enviar avui" >>"$LOG"; exit 0 ;; # NO es una fallada
4) echo "$(date -Is) AVIS: enviament parcial; revisa el log" >>"$LOG"
mail -s "BiblioTech: avisos parcials" [email protected] <"$LOG"
exit 0 ;;
7) echo "$(date -Is) Correu no disponible; reintent $intent de $MAX_INTENTS" >>"$LOG"
sleep $((intent * 120)) ;; # 2, 4, 6 minuts
*) echo "$(date -Is) ERROR irrecuperable (codi $codi)" >>"$LOG"
mail -s "BiblioTech: fallada en els avisos" [email protected] <"$LOG"
exit "$codi" ;;
esac
done
echo "$(date -Is) ERROR: reintents esgotats" >>"$LOG"
mail -s "BiblioTech: correu caigut despres de 3 intents" [email protected] <"$LOG"
exit 7Fixa't en la decisió de disseny que fa útil tot això: el codi 3 no dispara alarma (que un dimarts no hi hagi venciments és normal) i el 7 sí que dispara reintent. Amb un únic codi d'error genèric, aquest script no es podria escriure.
Solució 3
/**
* Formatador de taules per a consola, amb amplades automatiques.
* Us:
* TaulaConsola.de(materials)
* .columna("ISBN", m -> m.getIsbn().valor())
* .columna("TITOL", Material::getTitol, 45)
* .columnaNumerica("LLIURES", Material::unitatsDisponibles)
* .ambTotals()
* .imprimir(sortida);
*/
public class TaulaConsola<T> {
private static final int AMPLADA_TERMINAL_PER_DEFECTE = 120;
private static final String SEPARADOR = " ";
private final List<T> files;
private final List<Columna<T>> columnes = new ArrayList<>();
private boolean totals = false;
private TaulaConsola(List<T> files) { this.files = files; }
public static <T> TaulaConsola<T> de(List<T> files) { return new TaulaConsola<>(files); }
// --- Definicio de columnes ---
public TaulaConsola<T> columna(String titol, Function<T, String> extractor) {
return columna(titol, extractor, Integer.MAX_VALUE);
}
public TaulaConsola<T> columna(String titol, Function<T, String> extractor, int ampladaMaxima) {
columnes.add(new Columna<>(titol, extractor, Alineacio.ESQUERRA, ampladaMaxima, null));
return this;
}
public TaulaConsola<T> columnaNumerica(String titol, ToLongFunction<T> extractor) {
columnes.add(new Columna<>(titol,
t -> String.valueOf(extractor.applyAsLong(t)),
Alineacio.DRETA, 15, extractor));
return this;
}
public TaulaConsola<T> ambTotals() { this.totals = true; return this; }
// --- Impressio ---
public void imprimir(Sortida sortida) {
if (columnes.isEmpty()) throw new IllegalStateException("Defineix almenys una columna");
int[] amplades = calcularAmplades();
ajustarAlTerminal(amplades);
PrintStream out = sortida.out();
if (sortida.mostrarDecoracio()) {
out.println(fila(amplades, i -> columnes.get(i).titol()));
out.println("-".repeat(ampladaTotal(amplades)));
}
for (T element : files) {
out.println(fila(amplades, i -> valor(columnes.get(i), element, amplades[i])));
}
if (totals && sortida.mostrarDecoracio()) {
out.println("-".repeat(ampladaTotal(amplades)));
out.println(fila(amplades, this::totalDeColumna));
out.printf("%d files%n", files.size());
}
}
// --- Calcul d'amplades ---
private int[] calcularAmplades() {
int[] amplades = new int[columnes.size()];
for (int i = 0; i < columnes.size(); i++) {
Columna<T> c = columnes.get(i);
int ampladaContingut = files.stream()
.map(c.extractor())
.mapToInt(String::length)
.max().orElse(0);
// L'amplada es la major entre el titol i el contingut, limitada pel maxim
amplades[i] = Math.min(c.ampladaMaxima(), Math.max(c.titol().length(), ampladaContingut));
}
return amplades;
}
/**
* Si la taula no hi cap, retalla proporcionalment les columnes de text
* mes amples, respectant un minim de 8 caracters.
*/
private void ajustarAlTerminal(int[] amplades) {
int disponible = ampladaTerminal();
int total = ampladaTotal(amplades);
if (total <= disponible) return;
int exces = total - disponible;
// Retallar de major a menor fins a absorbir l'exces
List<Integer> candidates = IntStream.range(0, amplades.length)
.boxed()
.filter(i -> columnes.get(i).alineacio() == Alineacio.ESQUERRA)
.sorted(Comparator.comparingInt((Integer i) -> amplades[i]).reversed())
.toList();
for (int i : candidates) {
if (exces <= 0) break;
int retallable = Math.max(0, amplades[i] - 8);
int retall = Math.min(retallable, exces);
amplades[i] -= retall;
exces -= retall;
}
}
private int ampladaTerminal() {
// COLUMNS l'exporta el shell; si no hi es, fer servir el valor per defecte
String columnes = System.getenv("COLUMNS");
try {
return columnes != null ? Integer.parseInt(columnes) : AMPLADA_TERMINAL_PER_DEFECTE;
} catch (NumberFormatException e) {
return AMPLADA_TERMINAL_PER_DEFECTE;
}
}
private int ampladaTotal(int[] amplades) {
return Arrays.stream(amplades).sum() + SEPARADOR.length() * (amplades.length - 1);
}
// --- Format de celles ---
private String fila(int[] amplades, IntFunction<String> cella) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < amplades.length; i++) {
if (i > 0) sb.append(SEPARADOR);
String text = retallar(cella.apply(i), amplades[i]);
sb.append(columnes.get(i).alineacio() == Alineacio.DRETA
? " ".repeat(amplades[i] - text.length()) + text
: text + " ".repeat(amplades[i] - text.length()));
}
return sb.toString().stripTrailing(); // sense espais al final: embruten en copiar
}
private String valor(Columna<T> c, T element, int amplada) {
return retallar(c.extractor().apply(element), amplada);
}
private static String retallar(String text, int max) {
if (text == null) return "";
return text.length() <= max ? text : text.substring(0, Math.max(0, max - 1)) + "…";
}
private String totalDeColumna(int i) {
Columna<T> c = columnes.get(i);
if (c.sumador() == null) return i == 0 ? "TOTAL" : "";
long suma = files.stream().mapToLong(c.sumador()).sum();
return String.valueOf(suma);
}
// --- Tipus auxiliars ---
private enum Alineacio { ESQUERRA, DRETA }
private record Columna<T>(String titol,
Function<T, String> extractor,
Alineacio alineacio,
int ampladaMaxima,
ToLongFunction<T> sumador) { }
}Ús i sortida:
TaulaConsola.de(materials)
.columna("ISBN", m -> m.getIsbn().valor())
.columna("TITOL", Material::getTitol, 45)
.columna("TIPUS", m -> m.tipus().name())
.columnaNumerica("LLIURES", Material::unitatsDisponibles)
.ambTotals()
.imprimir(sortida);ISBN TITOL TIPUS LLIURES ----------------------------------------------------------------- 978-0000000001 Java Eficac LLIBRE 2 978-0000000002 Patrons de Disseny LLIBRE 0 978-0000000003 Refactoritzacio LLIBRE 1 ----------------------------------------------------------------- TOTAL 3 3 files
Avantatges del disseny: és genèric (serveix per a materials, préstecs, empleats o qualsevol cosa), fa servir el Builder de 12-02, respecta mostrarDecoracio() de manera que en una canonada només surten les dades, i s'adapta a l'amplada del terminal sense descompensar-se.
Conclusió
BiblioTech ja es pot fer servir.
Tens clar quan una CLI és la interfície correcta —tasques repetibles, automatitzables o per a gent tècnica— i les quatre propietats que la fan bona: predictible, componible, amb errors útils i honesta sobre el que fa. I saps per què el menú amb Scanner del mòdul 2 no era això: allò ho feia servir una persona; això ho fa servir una persona i un script.
Vas veure els límits reals de l'anàlisi a mà —la forma --opcio=valor, les opcions agrupades, la conversió de tipus, el ++i que peta amb l'índex— i per això vas adoptar Picocli: anotacions per a ordres, opcions i posicionals; conversió automàtica a més de quaranta tipus inclosos els de java.time; conversors propis que fan que el camp sigui Isbn i no String, amb l'error apareixent en l'anàlisi i no a mitja execució; grups exclusius; ajuda generada amb exemples al peu; i autocompletat per a bash i zsh amb una línia de configuració.
El vas integrar amb Spring Boot de la manera correcta: les ordres són beans que reben els casos d'ús per constructor, sense Service Locator, amb web-application-type: none i banner-mode: off —perquè un bàner ASCII trenca una canonada de JSON—. I vas dissenyar la superfície completa de l'eina, amb subordres per àrea, opcions heretades amb ScopeType.INHERIT, i ordres que no imprimeixen res pel seu compte: deleguen en Sortida.
Vas interioritzar la regla que separa una CLI professional d'un programa que escriu coses: el resultat va a la sortida estàndard; tota la resta, a la d'error. D'aquí surten les canonades que funcionen, les redireccions que no es contaminen i les proves que verifiquen que el JSON és JSON vàlid. I hi vas posar al costat els codis de sortida amb significat, amb la distinció que de debò importa —CONFLICTE no es reintenta, NO_DISPONIBLE sí—, que és el que permet escriure l'script de cron amb reintents de l'exercici 2.
Formates la sortida en taula amb amplades calculades, en CSV amb l'escapament que 07-07 va ensenyar a no improvisar i en JSON amb el Jackson del mòdul 11, amb tres nivells de detall i decoració que desapareix sola quan la sortida no va a un terminal. Gestiones operacions llargues amb una barra de progrés que es pinta a la sortida d'error, es limita a un repintat cada 100 ms i es desactiva sense terminal; i amb fils virtuals de Java 21 per a la importació concurrent que va començar al mòdul 8. Canceles netament amb Ctrl+C mitjançant un shutdown hook que reprèn el tancament ordenat del mòdul 7, retornant el 130 del conveni POSIX. Fas servir colors respectant NO_COLOR, TERM=dumb i l'absència de terminal, sense que el color sigui mai l'únic portador d'informació.
Els teus errors tenen quatre parts —què, per què, què fer i un identificador d'incidència— traduïts des de la jerarquia BiblioTechException del mòdul 6 amb el pattern matching de Java 21, deixant la traça completa al log correlacionat amb MDC d'11-07 i mai a la pantalla de l'usuari. Empaquetes en jar executable amb el seu script d'arrencada i coneixes les opcions per a l'arrencada instantània: CDS, jpackage i GraalVM Native Image. I proves la CLI en els tres nivells: la lògica de l'ordre amb Mockito, l'anàlisi amb parseArgs, i d'extrem a extrem capturant la sortida amb setOut/setErr i comprovant el codi retornat.
Queda una limitació evident, i no és tècnica: la Marta Ruiz no obrirà un terminal per consultar si «Refactorització» està disponible. La CLI resol l'automatització i la gent tècnica; no resol l'accés de la resta de l'empresa, ni la integració amb altres aplicacions, ni una futura app mòbil.
La lliçó següent construeix el segon adaptador d'entrada: el mòdul bibliotech-web, una API REST completa amb Spring Boot. Hi veuràs el cicle petició-resposta, el servidor incrustat i el DispatcherServlet —i descobriràs que tot això és exactament el que tu vas resoldre a mà amb sockets al mòdul 9—, el disseny REST amb els seus verbs i els seus codis d'estat, la validació, la gestió global d'errors amb Problem Details, la paginació, la documentació automàtica amb OpenAPI i les proves de la capa web. Amb els mateixos casos d'ús de sempre a sota.
Curs de Programació en Java
Mòdul 1: Introducció a Java
- Introducció a Java
- Configuració de l'entorn de desenvolupament
- Sintaxi i estructura bàsica
- Variables i tipus de dades
- Operadors
- Entrada i sortida per consola
- El teu primer programa complet: BiblioTech
Mòdul 2: Flux de control
- Sentències condicionals
- Bucles
- Sentències switch
- Break i continue
- Depuració i traces d'execució
- Projecte: menú interactiu de BiblioTech
Mòdul 3: Programació orientada a objectes
- Introducció a la POO
- Classes i objectes
- Mètodes
- Constructors
- Herència
- Polimorfisme
- Encapsulament
- Abstracció
- La classe Object: equals, hashCode i toString
Mòdul 4: Programació orientada a objectes avançada
- Interfícies
- Classes abstractes
- Classes internes
- Classes anònimes
- Expressions lambda
- Interfícies funcionals i referències a mètodes
- Enumeracions i registres
Mòdul 5: Estructures de dades i col·leccions
- Arrays
- El framework de col·leccions
- ArrayList
- LinkedList
- HashMap
- HashSet
- Cua i Deque
- Pila
- Ordenació i cerca en col·leccions
Mòdul 6: Gestió d'excepcions
- Introducció a les excepcions
- Bloc try-catch
- Throw i throws
- Excepcions personalitzades
- Bloc finally
- Try-with-resources i AutoCloseable
- Estratègies de gestió d'errors i logging
Mòdul 7: Entrada/sortida de fitxers
- Lectura de fitxers
- Escriptura de fitxers
- Fluxos de fitxers
- BufferedReader i BufferedWriter
- Serialització
- L'API NIO.2: Path i Files
- Formats d'intercanvi: CSV i Properties
Mòdul 8: Multifil i concurrència
- Introducció al multifil
- Creació de fils
- Cicle de vida d'un fil
- Sincronització
- Utilitats de concurrència
- Col·leccions concurrents i variables atòmiques
- Tasques asíncrones amb CompletableFuture
Mòdul 9: Xarxes
- Introducció a les xarxes
- Sockets
- ServerSocket
- DatagramSocket i DatagramPacket
- URL i HttpURLConnection
- El client HTTP modern
Mòdul 10: Temes avançats
- Genèrics
- Anotacions
- Reflexió
- Característiques de Java 8: Streams i Optional
- Dates i hores amb java.time
- Java 9 i més enllà
- Memòria, recol·lecció de brossa i rendiment
Mòdul 11: Frameworks i llibreries de Java
- Introducció als frameworks de Java
- Spring Framework
- Hibernate
- JUnit
- Maven
- Proves avançades amb Mockito
- Llibreries essencials de l'ecosistema
