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

  1. Quan una CLI és la interfície correcta
  2. Què fa bona una CLI
  3. Arguments de línia d'ordres a mà, i els seus límits
  4. Picocli: el model d'anotacions
  5. Opcions, paràmetres i tipus
  6. Validació i conversió de tipus
  7. Subordres i jerarquia
  8. Ajuda automàtica i autocompletat
  9. Integració amb Spring Boot
  10. Disseny de la CLI de BiblioTech
  11. Mode d'una sola ordre enfront del mode interactiu
  12. Entrada i sortida estàndard: compondre amb canonades
  13. Codis de sortida
  14. Formatar la sortida: taula, CSV i JSON
  15. Nivell de detall: --silencios i --detallat
  16. Operacions llargues: progrés i senyals de vida
  17. Cancel·lació neta amb Ctrl+C
  18. Colors ANSI i quan desactivar-los
  19. Errors orientats a l'usuari
  20. Empaquetatge i distribució
  21. Provar una aplicació de consola
  22. Errors Comuns i Consells
  23. Exercicis
  24. Conclusió

  1. Quan una CLI és la interfície correcta

Situació CLI? Per què
Tasca programada nocturna (avisos de venciment) cron no sap prémer botons
Importació massiva del catàleg 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 Ràpida d'escriure, ràpida de fer servir, componible
Pas d'un pipeline de desplegament Codis de sortida que el pipeline interpreta
Diagnòstic en producció a les 3 de la matinada É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.

  1. 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:

Error: NullPointerException

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.

  1. 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.

  1. 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

  1. 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ó.

  1. 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».

  1. 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.

  1. 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  JSON

Aquest detall és dels que més agraeixen els qui fan servir l'eina cada dia, i costa una línia al POM.

  1. 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: INFO

Que banner-mode estigui a off no és cosmètic: si la sortida s'ha d'encadenar amb jq, el bàner trenca el JSON.

  1. 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.

  1. 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 No
Componible amb canonades 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.

  1. 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 | jq i > 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: ídem

I 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

  1. 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:

bibliotech avisos enviar || echo "FALLADA: revisa el log"    # || s'executa si el codi != 0

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

  1. 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ó

  1. Nivell de detall: --silencios i --detallat

Tres 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);
    }
}

  1. 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
    }
}
[========================>               ] 3120/5000 (62%) ETA 47s

Per a operacions sense total conegut, un giravolt (|, /, -, \) o un simple punt cada N elements compleix la mateixa funció: dir «continuo viu».

  1. Cancel·lació neta amb Ctrl+C

Ctrl+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:

  1. Ha de ser ràpid. El sistema pot matar el procés si triga massa (SIGKILL no es pot interceptar).
  2. No pot dependre del context de Spring. Pot ser que ja s'estigui tancant.
  3. Ha de ser idempotent. Un segon Ctrl+C no ha de trencar res.

  1. 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
TERM=dumb No
Sortida redirigida o en canonada No
Terminal interactiu

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.

  1. 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: a7f3e91c

El 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).

  1. 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 llistar

Script 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 espais

Aquest "$@" 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 ms

El 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.

  1. 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.
  • --data opcional (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-antelacio dies (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 --simular per 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 7

Fixa'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

Mòdul 2: Flux de control

Mòdul 3: Programació orientada a objectes

Mòdul 4: Programació orientada a objectes avançada

Mòdul 5: Estructures de dades i col·leccions

Mòdul 6: Gestió d'excepcions

Mòdul 7: Entrada/sortida de fitxers

Mòdul 8: Multifil i concurrència

Mòdul 9: Xarxes

Mòdul 10: Temes avançats

Mòdul 11: Frameworks i llibreries de Java

Mòdul 12: Construcció d'aplicacions del món real

© Copyright 2026. Tots els drets reservats