Aquesta lliçó tanca el mòdul, i salda dos deutes.

El primer es remunta a 07-02. L'ExportadorCataleg té un mètode anomenat sanejar() que substitueix els punts i comes dels títols per comes perquè no trenquin el fitxer. El vas marcar llavors com un pedaç conscient, i ho és: un llibre titulat "Java: el llenguatge, la màquina i l'ecosistema" torna del fitxer amb el títol alterat. La dada es perd en l'exportació. Avui s'arregla, implementant un lector i un escriptor CSV que fan bé el que el split(";") ingenu fa malament.

El segon deute ve del mòdul 1. DIES_PRESTEC = 15, TARIFA_DIARIA = 0.25, MULTA_MAXIMA = 20.0, LLINDAR_LLEU = 7. Porten set mòduls escrites a foc al codi, i canviar la tarifa de les multes —una decisió de negoci que es pot prendre en una reunió de mitja hora— exigeix editar el codi font, recompilar, empaquetar i tornar a desplegar. Avui s'acaba: aquestes quatre constants es llegiran d'un fitxer extern, amb validació i valors per defecte.

En acabar, BiblioTech recordarà entre execucions i es configurarà sense recompilar.

try-with-resources a tot, i tota la persistència sobre Path i Files de 07-06.

Contingut

  1. Per què text i no binari
  2. CSV: l'estructura i les seves regles
  3. El split ingenu i per què falla
  4. Escapada i desescapada: fer-ho bé
  5. Un lector CSV correcte per a BiblioTech
  6. Un escriptor CSV correcte
  7. El separador i el decimal en català
  8. Exportar el catàleg i els préstecs a CSV
  9. Recomanació honesta: quan fer servir una llibreria
  10. Properties: què és i com funciona
  11. load i store, amb charset i sense sorpreses
  12. Propietats encadenades per defecte
  13. La jerarquia de configuració i la seva precedència
  14. La classe Configuracio de BiblioTech
  15. Les quatre constants deixen de ser constants
  16. No desis mai credencials al repositori
  17. Altres formats: XML, JSON i YAML
  18. Errors Comuns i Consells
  19. Exercicis
  20. Conclusió i tancament del mòdul

  1. Per què text i no binari

Ja coneixes les dues alternatives: el format binari de DataOutputStream (07-03) i la serialització nativa (07-05), davant del text pla. L'elecció no és de gust.

Format de text (CSV, Properties, JSON) Format binari (serialització, DataStream)
Llegible per una persona , amb qualsevol editor No
Diferenciable en control de versions : git diff mostra què ha canviat No: un blob que ha canviat sencer
Editable a mà , en una emergència No
Independent del llenguatge : Python, Excel, grep Només Java
Resistent a canvis de classe No: InvalidClassException
Segur amb dades alienes : produeix cadenes, no instancia classes No
Mida Més gran Menor
Velocitat Menor: cal analitzar Major
Tipus Tot és text: cal convertir i validar Els tipus viatgen
Estructures complexes Difícils: referències, imbricació Automàtiques

Les tres files que decideixen, en ordre d'importància:

1. La diferenciabilitat en control de versions. Si el catàleg de BiblioTech és en CSV i algú canvia una tarifa, el git diff mostra exactament quina línia ha canviat i qui l'ha canviada. Amb un fitxer binari, el diff diu "el fitxer ha canviat" i allà s'acaba la investigació. Per a dades que es versionen, això no té preu.

2. La independència del llenguatge. El fitxer CSV del catàleg s'obre en un full de càlcul, es processa amb un script de Python, es filtra amb grep i s'envia a un proveïdor que no fa servir Java. El fitxer serialitzat només el llegeix la teva aplicació, i només mentre no canviïs les classes.

3. La seguretat. Un CSV produeix cadenes; tu decideixes quin objecte construir amb elles, passant pels teus constructors i les teves validacions de 06-03. La serialització construeix objectes a partir de noms de classe que vénen al fitxer, amb les conseqüències de l'apartat 12 de 07-05.

Què es paga a canvi:

  • Mida: un int que ocupa 4 bytes en binari n'ocupa fins a 11 en text.
  • Velocitat: cal analitzar cada camp.
  • Tipus: tot arriba com a String i cal convertir-ho i validar-ho.
  • Estructures: les relacions entre objectes cal representar-les a mà, amb identificadors.

Per al catàleg de BiblioTech —uns quants milers de línies que s'obren a Excel, es versionen i s'envien a proveïdors— l'intercanvi és clarament favorable. Per a una memòria cau d'un milió d'objectes amb referències creuades, no ho seria.

  1. CSV: l'estructura i les seves regles

CSV significa Comma-Separated Values. La seva estructura bàsica cap en tres frases: una línia per registre, camps separats per comes, primera línia opcional amb els noms de les columnes.

tipus,referencia,titol,autor,any,disponible
LLIBRE,978-0000000001,Java Eficac,Joshua Bloch,2018,true
LLIBRE,978-0000000002,Patrons de Disseny,Erich Gamma,1994,false
LLIBRE,978-0000000003,Refactoritzacio,Martin Fowler,1999,true

I aquí s'acaba la part fàcil. Perquè CSV no és un format ben definit. Durant dècades va ser una convenció informal, amb variants per eina i per país. L'intent de normalització —l'RFC 4180, de l'any 2005— va arribar tard i no tothom el segueix.

Les regles de l'RFC 4180, que són les que implementarem:

  1. Cada registre en una línia, acabada en CRLF.
  2. La capçalera és opcional, amb el mateix nombre de camps que les dades.
  3. Els camps se separen per comes. L'últim no porta coma al darrere.
  4. Un camp pot anar entre cometes dobles.
  5. Un camp que contingui una coma, una cometa o un salt de línia HA d'anar entre cometes dobles.
  6. Una cometa dins d'un camp entrecometat s'escriu DUPLICADA.

Les regles 4, 5 i 6 són les que el split ingenu ignora, i són les que provoquen tots els problemes.

Exemples de cada cas:

titol,autor,notes
Java Eficac,Bloch,Sense notes
"Java: el llenguatge, la maquina i l'ecosistema",Bloch,"Conte una coma"
"El llibre anomenat ""Refactoritzacio""",Fowler,"Conte cometes duplicades"
"Titol amb
salt de linia",Autor,"Un camp pot ocupar diverses linies fisiques"
Sense cometes,,"Camp buit al mig"

Fixa't en la penúltima línia: un sol registre lògic ocupa dues línies físiques. Això trenca del tot la idea d'"un readLine() per registre", i és la raó que un lector CSV correcte no es pugui escriure amb el bucle canònic de 07-04 sense més.

  1. El split ingenu i per què falla

Aquest és el codi que gairebé tothom escriu la primera vegada:

// INGENU: funciona amb dades simples i falla amb dades reals
String[] camps = linia.split(",");

Els sis casos on falla, amb el que produeix cadascun:

Línia CSV El que hauria de donar El que dona split(",")
"Java: el llenguatge, la maquina",Bloch,2018 3 camps 4 camps, el títol partit
"El llibre ""Refactoritzacio""",Fowler 2 camps 2 camps, però amb cometes de més
"Titol amb\nsalt",Autor 1 registre de 2 camps 2 registres trencats
Java Eficac,,2018 3 camps, el 2n buit 3 camps. Correcte per casualitat
Java Eficac,Bloch, 3 camps, el 3r buit 2 camps: descarta l'últim buit
\uFEFFtipus,referencia (amb BOM) tipus \uFEFFtipus: no coincideix amb res

Els tres més greus:

El camp amb separador dins. És el cas que trenca el catàleg de BiblioTech, i és el motiu del sanejar() de 07-02:

String linia = "\"Java: el llenguatge, la maquina i l'ecosistema\",Bloch,2018";
String[] camps = linia.split(",");

// camps.length == 4, no 3
// camps[0] == "\"Java: el llenguatge"
// camps[1] == " la maquina i l'ecosistema\""
// camps[2] == "Bloch"
// camps[3] == "2018"

I el pitjor no és que doni quatre camps: és que si el teu codi comprova camps.length != 3 i descarta la línia, perds el llibre sense més avís que una línia a l'informe. Si no ho comprova, deses "Java: el llenguatge com a títol i Bloch com a any.

El camp buit al final. Ja ho vas veure a 07-01:

"Java Eficac,Bloch,".split(",");        // 2 elements: descarta els buits finals
"Java Eficac,Bloch,".split(",", -1);    // 3 elements: correcte

El -1 és obligatori, i s'oblida constantment.

El BOM. Un fitxer UTF-8 desat per Excel a Windows sol començar amb tres bytes invisibles —EF BB BF, la marca d'ordre de bytes— que es descodifiquen com el caràcter \uFEFF. És invisible en qualsevol editor, i fa que la primera columna de la capçalera no coincideixi amb res:

String primera = camps[0];                // "\uFEFFtipus", no "tipus"
primera.equals("tipus");                  // false
primera.length();                         // 6, no 5

És una de les fallades més desconcertants que existeixen, perquè el fitxer es veu perfecte. La solució és treure'l en llegir la primera línia, i és a l'apartat 5.

I una pregunta que sorgeix sempre: no es pot resoldre amb una expressió regular? En circulen diverses, de l'estil ,(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$). Funcionen per al cas simple i fallen amb cometes escapades i amb salts de línia dins de camps, a més de ser illegibles i ineficients. La solució correcta és un petit autòmat d'estats, i ocupa quaranta línies.

  1. Escapada i desescapada: fer-ho bé

Abans del lector complet, les dues operacions elementals.

Escapar (en escriure): decidir si un camp necessita cometes i posar-les-hi.

/**
 * Escapa un camp segons l'RFC 4180.
 *
 * Un camp necessita cometes si conte el separador, una cometa, un
 * salt de linia, o espais al principi o al final (que moltes
 * eines retallen sense avisar).
 *
 * Les cometes interiors es DUPLIQUEN, no es precedeixen de barra invertida:
 * CSV no fa servir barres invertides.
 */
public static String escapar(String camp, char separador) {
    if (camp == null) {
        return "";                     // null i buit s'escriuen igual (apartat 5)
    }

    boolean necessitaCometes =
            camp.indexOf(separador) >= 0
            || camp.indexOf('"') >= 0
            || camp.indexOf('\n') >= 0
            || camp.indexOf('\r') >= 0
            || camp.startsWith(" ")
            || camp.endsWith(" ");

    if (!necessitaCometes) {
        return camp;
    }
    // Duplicar les cometes interiors i embolcallar en cometes
    return '"' + camp.replace("\"", "\"\"") + '"';
}

Desescapar (en llegir): treure les cometes envoltants i desfer la duplicació.

/** Desfa l'escapada d'un camp ja extret. */
public static String desescapar(String camp) {
    if (camp == null || camp.isEmpty()) {
        return "";
    }
    String net = camp.trim();

    if (net.length() >= 2 && net.startsWith("\"") && net.endsWith("\"")) {
        net = net.substring(1, net.length() - 1);
        net = net.replace("\"\"", "\"");
    }
    return net;
}

La taula de casos límit completa, que és l'especificació del que cal complir:

Valor en memòria Escrit en CSV Llegit de tornada
Java Eficac Java Eficac Java Eficac
Java: el llenguatge, la maquina "Java: el llenguatge, la maquina" igual
El llibre "Refactoritzacio" "El llibre ""Refactoritzacio""" igual
Linia 1\nLinia 2 "Linia 1\nLinia 2" igual
`` (cadena buida) (res) ``
null (res) `` (es perd la distinció)
amb espais " amb espais " igual
camp;amb;punticoma amb separador , camp;amb;punticoma igual
camp;amb;punticoma amb separador ; "camp;amb;punticoma" igual

Fixa't en la fila del null. CSV no distingeix el nul de la cadena buida: tots dos s'escriuen igual i tots dos tornen com a cadena buida. És una limitació real del format, no un defecte de la implementació. Si aquesta distinció importa al teu domini, hi ha dues sortides: fer servir un valor sentinella documentat —\N és la convenció de PostgreSQL— o fer servir un format que sí que tingui nuls, com JSON (11-07). El que no pots fer és fingir que el problema no existeix.

  1. Un lector CSV correcte per a BiblioTech

La implementació completa, amb un autòmat de dos estats: dins de cometes i fora de cometes.

package com.nexussoftware.bibliotech.infraestructura;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

/**
 * Lector CSV conforme a l'RFC 4180.
 *
 * Gestiona correctament:
 *   - separadors dins de camps entrecometats
 *   - cometes escapades per duplicacio
 *   - salts de linia dins d'un camp (un registre ocupa diverses linies)
 *   - camps buits, inclos l'ultim
 *   - la marca BOM al principi del fitxer
 *
 * NO gestiona (i ho diu honestament):
 *   - la distincio entre null i cadena buida: CSV no la te
 *   - codificacions diferents de la que se li passi
 *
 * Es Closeable: tanca el Reader que embolcalla (06-06).
 */
public class LectorCsv implements AutoCloseable {

    /** Marca d'ordre de bytes que Excel escriu al principi dels UTF-8. */
    private static final char BOM = '\uFEFF';

    private static final char COMETA = '"';

    private final BufferedReader lector;
    private final char separador;

    private boolean primeraLectura = true;
    private int numeroRegistre = 0;
    private int numeroLiniaFisica = 0;

    public LectorCsv(Reader origen, char separador) {
        Objects.requireNonNull(origen, "L'origen no pot ser nul");
        this.lector = (origen instanceof BufferedReader br)
                ? br
                : new BufferedReader(origen);       // no embolcallar dues vegades (07-04)
        this.separador = separador;
    }

    public LectorCsv(Reader origen) {
        this(origen, ',');
    }

    /**
     * Llegeix el registre seguent.
     *
     * Un registre pot ocupar DIVERSES linies fisiques si algun camp te un
     * salt de linia dins. Per aixo no n'hi ha prou amb un readLine() per registre.
     *
     * @return els camps ja desescapats, o null si no hi ha mes registres
     * @throws CsvFormatException si el registre esta mal format
     */
    public List<String> seguent() throws IOException {
        String linia = lector.readLine();
        if (linia == null) {
            return null;                             // final (07-04)
        }
        numeroLiniaFisica++;

        // Treure el BOM NOMES de la primera linia
        if (primeraLectura) {
            primeraLectura = false;
            if (!linia.isEmpty() && linia.charAt(0) == BOM) {
                linia = linia.substring(1);
            }
        }

        List<String> camps = new ArrayList<>();
        StringBuilder camp = new StringBuilder();
        boolean dinsDeCometes = false;
        int index = 0;

        while (true) {
            if (index >= linia.length()) {
                if (!dinsDeCometes) {
                    break;                           // final del registre
                }
                // Estem DINS de cometes: el camp continua a la
                // linia fisica seguent. Es reposa el salt de linia.
                String continuacio = lector.readLine();
                if (continuacio == null) {
                    throw new CsvFormatException(numeroLiniaFisica,
                            "El fitxer acaba amb unes cometes sense tancar");
                }
                numeroLiniaFisica++;
                camp.append('\n');
                linia = continuacio;
                index = 0;
                continue;
            }

            char c = linia.charAt(index);

            if (dinsDeCometes) {
                if (c == COMETA) {
                    // Una cometa dins de cometes: o tanca, o esta duplicada
                    if (index + 1 < linia.length() && linia.charAt(index + 1) == COMETA) {
                        camp.append(COMETA);         // duplicada: una cometa literal
                        index += 2;
                    } else {
                        dinsDeCometes = false;       // tanca el camp
                        index++;
                    }
                } else {
                    camp.append(c);
                    index++;
                }

            } else {
                if (c == COMETA && camp.isEmpty()) {
                    // Cometes al PRINCIPI del camp: obren
                    dinsDeCometes = true;
                    index++;
                } else if (c == separador) {
                    camps.add(camp.toString());
                    camp.setLength(0);
                    index++;
                } else {
                    camp.append(c);
                    index++;
                }
            }
        }

        camps.add(camp.toString());                  // l'ultim camp, sempre
        numeroRegistre++;
        return camps;
    }

    /**
     * Llegeix tots els registres restants.
     *
     * AVIS: carrega el fitxer sencer en memoria (07-01). Per a fitxers
     * grans, fes servir seguent() en un bucle.
     */
    public List<List<String>> llegirTot() throws IOException {
        List<List<String>> registres = new ArrayList<>();
        List<String> registre;
        while ((registre = seguent()) != null) {
            registres.add(registre);
        }
        return registres;
    }

    public int getNumeroRegistre()    { return numeroRegistre; }
    public int getNumeroLiniaFisica() { return numeroLiniaFisica; }

    @Override
    public void close() throws IOException {
        lector.close();
    }

    /** Fallada de format amb la linia fisica exacta (06-04). */
    public static class CsvFormatException extends IOException {

        private static final long serialVersionUID = 1L;

        private final int linia;

        public CsvFormatException(int linia, String missatge) {
            super("Linia " + linia + ": " + missatge);
            this.linia = linia;
        }

        public int getLinia() { return linia; }
    }
}

Comprovació amb els casos difícils:

public class ProvaLectorCsv {

    public static void main(String[] args) throws IOException {
        String contingut = String.join("\n",
                "titol,autor,any",
                "Java Eficac,Joshua Bloch,2018",
                "\"Java: el llenguatge, la maquina\",Bloch,2018",
                "\"El llibre \"\"Refactoritzacio\"\"\",Fowler,1999",
                "\"Titol amb",
                "salt de linia\",Autor,2020",
                "Sense cometes,,2021",
                "Ultim camp buit,Autor,");

        try (LectorCsv csv = new LectorCsv(new java.io.StringReader(contingut))) {
            List<String> registre;
            while ((registre = csv.seguent()) != null) {
                System.out.printf("[%d] %d camps: %s%n",
                        csv.getNumeroRegistre(), registre.size(), registre);
            }
        }
    }
}

Sortida:

[1] 3 camps: [titol, autor, any]
[2] 3 camps: [Java Eficac, Joshua Bloch, 2018]
[3] 3 camps: [Java: el llenguatge, la maquina, Bloch, 2018]
[4] 3 camps: [El llibre "Refactoritzacio", Fowler, 1999]
[5] 3 camps: [Titol amb
salt de linia, Autor, 2020]
[6] 3 camps: [Sense cometes, , 2021]
[7] 3 camps: [Ultim camp buit, Autor, ]

Els set registres donen tres camps. Compara amb el split(","), que hauria donat 4, 3, 2 (mal partit), 3 i 2 respectivament. Fixa't especialment en el registre 5: ocupa dues línies físiques i es llegeix com un sol registre, amb el salt de línia conservat dins del camp.

  1. Un escriptor CSV correcte

El costat simètric:

package com.nexussoftware.bibliotech.infraestructura;

import java.io.IOException;
import java.io.Writer;
import java.util.List;
import java.util.Objects;

/**
 * Escriptor CSV conforme a l'RFC 4180.
 *
 * Entrecometa i escapa el que calgui, de manera que el que s'escriu es pot
 * tornar a llegir amb LectorCsv sense perdre res.
 */
public class EscriptorCsv implements AutoCloseable {

    private static final char COMETA = '"';

    private final Writer sortida;
    private final char separador;
    private final String fiDeLinia;

    private int registresEscrits = 0;

    /**
     * @param sortida   desti, normalment un BufferedWriter (07-04)
     * @param separador ',' o ';'
     * @param fiDeLinia "\n" per a fitxers que es versionen; CRLF si ho exigeix
     *                  el consumidor. L'RFC 4180 demana CRLF.
     */
    public EscriptorCsv(Writer sortida, char separador, String fiDeLinia) {
        this.sortida = Objects.requireNonNull(sortida, "La sortida no pot ser nulla");
        this.separador = separador;
        this.fiDeLinia = Objects.requireNonNull(fiDeLinia);
    }

    public EscriptorCsv(Writer sortida) {
        this(sortida, ',', "\n");
    }

    /** Escriu un registre complet. */
    public void escriureRegistre(List<String> camps) throws IOException {
        Objects.requireNonNull(camps, "Els camps no poden ser nuls");

        for (int i = 0; i < camps.size(); i++) {
            if (i > 0) {
                sortida.write(separador);
            }
            sortida.write(escapar(camps.get(i)));
        }
        sortida.write(fiDeLinia);
        registresEscrits++;
    }

    /** Variant amb arguments variables, còmoda per a crides curtes. */
    public void escriureRegistre(String... camps) throws IOException {
        escriureRegistre(List.of(camps));
    }

    /** Escriu la capcalera. Es un registre normal; el nom es documentacio. */
    public void escriureCapcalera(String... noms) throws IOException {
        escriureRegistre(noms);
    }

    /**
     * Escapa un camp segons l'RFC 4180.
     *
     * S'entrecometa si conte el separador, una cometa, un salt de
     * linia, o espais als extrems (que moltes eines retallen).
     */
    private String escapar(String camp) {
        if (camp == null || camp.isEmpty()) {
            return "";
        }
        boolean necessita = camp.indexOf(separador) >= 0
                || camp.indexOf(COMETA) >= 0
                || camp.indexOf('\n') >= 0
                || camp.indexOf('\r') >= 0
                || camp.charAt(0) == ' '
                || camp.charAt(camp.length() - 1) == ' ';

        if (!necessita) {
            return camp;
        }
        return COMETA + camp.replace("\"", "\"\"") + COMETA;
    }

    public int getRegistresEscrits() { return registresEscrits; }

    /** flush explicit: util si el consumidor llegeix mentre escrivim (07-02). */
    public void flush() throws IOException {
        sortida.flush();
    }

    @Override
    public void close() throws IOException {
        sortida.close();         // el close del Writer fa flush (07-02)
    }
}

Prova d'anada i tornada, que és la comprovació que de veritat importa:

public class ProvaAnadaITornada {

    public static void main(String[] args) throws IOException {
        List<List<String>> originals = List.of(
                List.of("titol", "autor", "notes"),
                List.of("Java Eficac", "Bloch", ""),
                List.of("Java: el llenguatge, la maquina", "Bloch", "amb coma"),
                List.of("El llibre \"Refactoritzacio\"", "Fowler", "amb cometes"),
                List.of("Titol amb\nsalt", "Autor", "amb salt"),
                List.of("  espais  ", "Autor", "amb espais"));

        // Escriure a memoria (07-03)
        java.io.StringWriter memoria = new java.io.StringWriter();
        try (EscriptorCsv escriptor = new EscriptorCsv(memoria)) {
            for (List<String> r : originals) {
                escriptor.escriureRegistre(r);
            }
        }

        System.out.println("=== CSV GENERAT ===");
        System.out.println(memoria);

        // Llegir de tornada
        System.out.println("=== COMPROVACIO D'ANADA I TORNADA ===");
        try (LectorCsv lector = new LectorCsv(new java.io.StringReader(memoria.toString()))) {
            int i = 0;
            List<String> llegit;
            boolean totBe = true;

            while ((llegit = lector.seguent()) != null) {
                boolean igual = llegit.equals(originals.get(i));
                totBe &= igual;
                System.out.printf("  [%d] %s  %s%n", i, igual ? "OK  " : "FALLA", llegit);
                i++;
            }
            System.out.println(totBe
                    ? "  Tots els registres tornen IDENTICS."
                    : "  HI HA PERDUA DE DADES.");
        }
    }
}

Sortida:

=== CSV GENERAT ===
titol,autor,notes
Java Eficac,Bloch,
"Java: el llenguatge, la maquina",Bloch,amb coma
"El llibre ""Refactoritzacio""",Fowler,amb cometes
"Titol amb
salt",Autor,amb salt
"  espais  ",Autor,amb espais

=== COMPROVACIO D'ANADA I TORNADA ===
  [0] OK   [titol, autor, notes]
  [1] OK   [Java Eficac, Bloch, ]
  [2] OK   [Java: el llenguatge, la maquina, Bloch, amb coma]
  [3] OK   [El llibre "Refactoritzacio", Fowler, amb cometes]
  [4] OK   [Titol amb
salt, Autor, amb salt]
  [5] OK   [  espais  , Autor, amb espais]
  Tots els registres tornen IDENTICS.

La prova d'anada i tornada és l'única forma seriosa de validar un format. Escriure, llegir i comparar amb l'original. Si alguna cosa torna diferent, el format perd dades, i el millor que pot passar és que ho descobreixis aquí i no en producció. Aquesta prova és el que faltava al sanejar() de 07-02: si l'haguessis feta llavors, la fallada hauria saltat a la primera.

  1. El separador i el decimal en català

Un problema pràctic que apareix tan bon punt el fitxer s'obre en un full de càlcul configurat en català.

El separador. CSV diu "coma", però Excel en configuració catalana fa servir punt i coma per defecte, perquè la coma està ocupada com a separador decimal. Un CSV amb comes s'obre a l'Excel català com una sola columna.

El decimal. En català, 0,25. En anglès, 0.25. I si el separador de camps és la coma i el decimal també, el conflicte és directe:

titol,tarifa
Java Eficac,0,25

Són dos camps amb la tarifa 0,25, o tres camps amb 0 i 25? No hi ha manera de saber-ho. El fitxer és ambigu.

Les tres estratègies, amb les seves conseqüències:

Estratègia Separador Decimal S'obre a l'Excel català Portable
Estàndard internacional , . Malament: una columna
Convenció catalana ; , No
Mixta ; . Sí, amb avís

La decisió de BiblioTech, i la recomanació general:

Per a fitxers que consumeix un altre programa: separador ,, decimal ., Locale.ROOT. És l'estàndard i no es discuteix. Per a fitxers que obre una persona a Excel a Catalunya: separador ;. I per al decimal, si el fitxer l'ha de rellegir el teu programa, mantén el punt i documenta-ho; si és només per llegir-lo, la coma.

Com es controla al codi, reprenent el de 07-01 i 07-02:

import java.util.Locale;

// ESCRIURE: fixar el Locale perque el decimal sigui SEMPRE el punt
String tarifa = String.format(Locale.ROOT, "%.2f", 0.25);      // "0.25"

// Sense Locale, en una maquina catalana:
String dolent = String.format("%.2f", 0.25);                   // "0,25"  <-- ambigu

// LLEGIR: analitzar sempre amb punt
double valor = Double.parseDouble("0.25");     // Double.parseDouble fa servir SEMPRE punt

// Si el fitxer te coma decimal, cal normalitzar-lo abans
double valorCa = Double.parseDouble("0,25".replace(',', '.'));

Un detall útil: Double.parseDouble fa servir sempre el punt, independentment del Locale del sistema. Són String.format i Scanner els que són sensibles a la configuració regional. Aquesta asimetria causa la fallada clàssica d'escriure 0,25 amb format i no poder rellegir-ho amb parseDouble.

I una defensa pràctica en llegir:

/** Analitza un decimal acceptant punt o coma. Tolerant en llegir, estricte en escriure. */
private static double parsejarDecimal(String text, String camp, int linia) {
    if (text == null || text.isBlank()) {
        return 0.0;
    }
    try {
        return Double.parseDouble(text.trim().replace(',', '.'));
    } catch (NumberFormatException e) {
        throw new IllegalArgumentException(String.format(
                "Linia %d: el camp '%s' no es un numero valid: '%s'",
                linia, camp, text), e);            // encadenar la causa (06-03)
    }
}

Tolerant en llegir, estricte en escriure. És un principi general de disseny de formats, i aquí estalvia molts problemes: accepta el que t'arribi si el pots entendre, però escriu sempre en la forma canònica.

  1. Exportar el catàleg i els préstecs a CSV

Ara sí: ExportadorCataleg sense pedaços.

package com.nexussoftware.bibliotech.servei;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;

import com.nexussoftware.bibliotech.domini.Llibre;
import com.nexussoftware.bibliotech.domini.Material;
import com.nexussoftware.bibliotech.domini.ReferenciaDuplicadaException;
import com.nexussoftware.bibliotech.infraestructura.EscriptorCsv;
import com.nexussoftware.bibliotech.infraestructura.LectorCsv;

/**
 * Exporta i importa el cataleg de BiblioTech en CSV conforme a l'RFC 4180.
 *
 * Substitueix la versio de 07-02, que "sanejava" els titols substituint
 * els separadors per comes i PERDIA LA DADA. Ara un titol com
 * "Java: el llenguatge, la maquina i l'ecosistema" s'exporta i es torna a
 * importar identic.
 *
 * Persistencia sobre NIO.2 (07-06), amb escriptura atomica.
 */
public class ExportadorCatalegCsv {

    private static final Logger LOG =
            Logger.getLogger(ExportadorCatalegCsv.class.getName());

    private static final java.nio.charset.Charset CHARSET = StandardCharsets.UTF_8;

    /** Coma i punt decimal: l'estandard. Per a l'Excel catala, ';' (apartat 7). */
    private static final char SEPARADOR = ',';

    private static final String[] CAPCALERA_CATALEG =
            { "tipus", "referencia", "titol", "autor", "any", "disponible" };

    private static final String[] CAPCALERA_PRESTECS =
            { "referencia", "material", "empleat", "dia_inici",
              "dia_devolucio", "multa" };

    private final Path directori;

    public ExportadorCatalegCsv(Path directori) throws IOException {
        this.directori = Objects.requireNonNull(directori).toAbsolutePath().normalize();
        Files.createDirectories(this.directori);
    }

    // ---------------------- EXPORTAR ----------------------

    /**
     * Exporta el cataleg a CSV, de forma atomica (07-06).
     *
     * @return nombre de materials exportats
     */
    public int exportarCataleg(Cataleg cataleg) throws IOException {
        Objects.requireNonNull(cataleg, "El cataleg no pot ser nul");

        List<Material> materials = cataleg.llistar();
        Path desti = directori.resolve("cataleg.csv");
        Path temporal = desti.resolveSibling(desti.getFileName() + ".tmp");

        boolean completat = false;
        try {
            try (BufferedWriter sortida = Files.newBufferedWriter(temporal, CHARSET,
                         StandardOpenOption.CREATE,
                         StandardOpenOption.TRUNCATE_EXISTING,
                         StandardOpenOption.WRITE);
                 EscriptorCsv csv = new EscriptorCsv(sortida, SEPARADOR, "\n")) {

                csv.escriureCapcalera(CAPCALERA_CATALEG);

                for (Material m : materials) {
                    csv.escriureRegistre(aRegistre(m));
                }
            }

            Files.move(temporal, desti,
                    StandardCopyOption.REPLACE_EXISTING,
                    StandardCopyOption.ATOMIC_MOVE);
            completat = true;

            LOG.info(() -> String.format("Cataleg exportat: %d materials a %s",
                    materials.size(), desti));

        } catch (AtomicMoveNotSupportedException e) {
            LOG.warning(() -> "Sense ATOMIC_MOVE; substitucio no atomica");
            Files.move(temporal, desti, StandardCopyOption.REPLACE_EXISTING);
            completat = true;

        } finally {
            if (!completat) {
                Files.deleteIfExists(temporal);           // compensacio (06-05)
            }
        }
        return materials.size();
    }

    /**
     * Converteix un material a registre CSV.
     *
     * SENSE sanejat: l'escriptor entrecometa el que calgui. El titol
     * viatja INTACTE, amb les seves comes, les seves cometes i els seus dos punts.
     */
    private List<String> aRegistre(Material m) {
        List<String> camps = new ArrayList<>(6);

        if (m instanceof Llibre llibre) {                  // 03-06
            camps.add("LLIBRE");
            camps.add(llibre.getIsbn());
            camps.add(llibre.getTitol());
            camps.add(llibre.getAutor());
            camps.add(String.valueOf(llibre.getAnyPublicacio()));
        } else {
            camps.add(m.getTipus().toUpperCase(Locale.ROOT));
            camps.add(m.getReferencia());
            camps.add(m.getTitol());
            camps.add("");
            camps.add("");
        }
        camps.add(String.valueOf(m.estaDisponible()));
        return camps;
    }

    // ---------------------- IMPORTAR ----------------------

    /** Resultat d'una importacio (06-07, 07-04). */
    public record ResultatImportacio(int llegits, int importats, int descartats,
                                     List<String> errors) {

        public String resum() {
            StringBuilder sb = new StringBuilder();
            sb.append(String.format(
                    "Importacio CSV: %d registres llegits, %d importats, %d descartats%n",
                    llegits, importats, descartats));
            int n = Math.min(10, errors.size());
            for (int i = 0; i < n; i++) {
                sb.append("    ").append(errors.get(i)).append('\n');
            }
            if (errors.size() > n) {
                sb.append(String.format("    ... i %d mes%n", errors.size() - n));
            }
            return sb.toString();
        }
    }

    /**
     * Importa el cataleg des de CSV.
     *
     * Politica de 06-07 i 07-04: una linia dolenta es descarta i es compta;
     * el fitxer absent degrada a resultat buit sense excepcio.
     */
    public ResultatImportacio importarCataleg(Cataleg cataleg) throws IOException {
        Objects.requireNonNull(cataleg, "El cataleg no pot ser nul");

        Path origen = directori.resolve("cataleg.csv");
        List<String> errors = new ArrayList<>();
        int llegits = 0, importats = 0;

        if (Files.notExists(origen)) {
            LOG.warning(() -> "No hi ha cataleg CSV a " + origen
                    + "; s'arrenca amb el cataleg buit");
            return new ResultatImportacio(0, 0, 0, errors);
        }

        try (BufferedReader lector = Files.newBufferedReader(origen, CHARSET);
             LectorCsv csv = new LectorCsv(lector, SEPARADOR)) {

            List<String> capcalera = csv.seguent();
            if (capcalera == null) {
                LOG.warning(() -> "El fitxer " + origen + " es buit");
                return new ResultatImportacio(0, 0, 0, errors);
            }
            comprovarCapcalera(capcalera, errors);

            List<String> registre;
            while ((registre = csv.seguent()) != null) {
                llegits++;
                int numero = csv.getNumeroRegistre();

                try {
                    Material material = aMaterial(registre, numero);
                    if (material != null) {
                        cataleg.registrar(material);
                        importats++;
                    }
                } catch (IllegalArgumentException e) {
                    errors.add(e.getMessage());
                } catch (ReferenciaDuplicadaException e) {
                    errors.add("Registre " + numero + ": " + e.getMessage());
                }
            }
        }

        int descartats = llegits - importats;
        LOG.info(() -> String.format("Cataleg importat: %d de %d registres",
                importats, llegits));

        return new ResultatImportacio(llegits, importats, descartats, errors);
    }

    /** Un canvi de columnes es un avis, no una fallada fatal. */
    private void comprovarCapcalera(List<String> capcalera, List<String> errors) {
        if (capcalera.size() != CAPCALERA_CATALEG.length) {
            errors.add(String.format(
                    "Capcalera amb %d columnes; se n'esperaven %d (%s). "
                            + "S'intenta importar igualment.",
                    capcalera.size(), CAPCALERA_CATALEG.length,
                    String.join(", ", CAPCALERA_CATALEG)));
        }
    }

    /** Construeix el material validant cada camp. Llanca si el registre es invalid. */
    private Material aMaterial(List<String> camps, int numero) {
        if (camps.size() < 5) {
            throw new IllegalArgumentException(String.format(
                    "Registre %d: %d camps, se n'esperaven com a minim 5", numero, camps.size()));
        }

        String tipus = camps.get(0).trim().toUpperCase(Locale.ROOT);
        if (!"LLIBRE".equals(tipus)) {
            throw new IllegalArgumentException(
                    "Registre " + numero + ": tipus no suportat '" + tipus + "'");
        }

        String isbn  = camps.get(1).trim();
        String titol = camps.get(2).trim();
        String autor = camps.get(3).trim();

        if (isbn.isEmpty() || titol.isEmpty()) {
            throw new IllegalArgumentException(
                    "Registre " + numero + ": ISBN o titol buits");
        }

        int any;
        try {
            any = Integer.parseInt(camps.get(4).trim());
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException(String.format(
                    "Registre %d: l'any '%s' no es un numero",
                    numero, camps.get(4)), e);           // amb la causa (06-03)
        }

        boolean disponible = camps.size() < 6
                || !"false".equalsIgnoreCase(camps.get(5).trim());

        return new Llibre(titol, autor.isEmpty() ? "Desconegut" : autor,
                isbn, any, disponible);
    }

    // ---------------------- PRESTECS ----------------------

    /** Exporta el registre de prestecs, amb els decimals en Locale.ROOT. */
    public int exportarPrestecs(List<Prestec> prestecs) throws IOException {
        Path desti = directori.resolve("prestecs.csv");

        try (BufferedWriter sortida = Files.newBufferedWriter(desti, CHARSET,
                     StandardOpenOption.CREATE,
                     StandardOpenOption.TRUNCATE_EXISTING,
                     StandardOpenOption.WRITE);
             EscriptorCsv csv = new EscriptorCsv(sortida, SEPARADOR, "\n")) {

            csv.escriureCapcalera(CAPCALERA_PRESTECS);

            for (Prestec p : prestecs) {
                csv.escriureRegistre(
                        p.getReferencia(),
                        p.getMaterial().getReferencia(),
                        p.getTitular().getIdentificador(),
                        String.valueOf(p.getDiaInici()),
                        String.valueOf(p.estaRetornat() ? p.getDiaDevolucio() : -1),
                        // Locale.ROOT: PUNT decimal SEMPRE, per poder rellegir-ho
                        String.format(Locale.ROOT, "%.2f", p.calcularMulta(p.getDiaInici())));
            }
        }
        LOG.info(() -> "Prestecs exportats: " + prestecs.size() + " a " + desti);
        return prestecs.size();
    }
}

El fitxer resultant, amb el títol problemàtic inclòs:

tipus,referencia,titol,autor,any,disponible
LLIBRE,978-0000000001,Java Eficac,Joshua Bloch,2018,true
LLIBRE,978-0000000002,Patrons de Disseny,Erich Gamma,1994,false
LLIBRE,978-0000000003,Refactoritzacio,Martin Fowler,1999,true
LLIBRE,978-0000000004,"Java: el llenguatge, la maquina i l'ecosistema",Bloch,2024,true

El títol de l'última línia viatja íntegre, amb la seva coma, i torna idèntic. Aquest és el deute de 07-02 saldat.

  1. Recomanació honesta: quan fer servir una llibreria

Acabes d'escriure un lector CSV correcte en unes cent línies, i funciona. Ara la recomanació professional, que pot sonar contradictòria:

En producció, fes servir una llibreria de CSV. OpenCSV o Apache Commons CSV són les habituals en Java, i cobreixen casos que la teva implementació no cobreix.

Què fan elles i no la teva:

Aspecte La teva implementació Llibreria madura
RFC 4180 bàsic
Detecció automàtica del separador No
Correspondència amb objectes per anotacions No
Capçaleres amb accés per nom No
Detecció i eliminació del BOM Sí, la primera línia Sí, completa
Formats de dialecte (Excel, MySQL, TDF) No
Rendiment afinat Raonable Molt optimitzat
Casos límit exòtics Els que has previst Milers de casos reals provats
Manteniment Teu De la comunitat

Aleshores, per a què implementar-lo? Per tres raons que valen la pena:

  1. Ara entens el problema. Quan una llibreria et doni un resultat estrany, sabràs si la fallada és al teu fitxer, a la configuració del separador o en un cas límit del format. Qui no ho ha implementat mai no pot diagnosticar això.
  2. Saps què preguntar-li a la llibreria. Com gestiona el BOM? Què fa amb els camps buits? Distingeix null de ""? Ara saps que aquestes preguntes existeixen i per què importen.
  3. De vegades no pots fer servir una llibreria. Un entorn restringit, una aplicació que no pot afegir dependències, un format propi lleugerament diferent. Llavors escrius això, i saps que funciona perquè has fet la prova d'anada i tornada.

Les llibreries essencials de Java —incloses Jackson per a JSON, OpenCSV i Commons CSV— es veuen a 11-07. I hi ha una lliçó general que va més enllà del CSV:

Implementa una vegada per entendre-ho; fes servir la llibreria per a producció. És aplicable a gairebé tot: analitzadors, memòries cau, cues, algorismes d'ordenació. Entendre el mecanisme et fa millor professional; reimplementar-lo en producció et fa responsable de mantenir-lo.

  1. Properties: què és i com funciona

java.util.Properties és la forma clàssica de configuració en Java. És un Map<String, String> amb capacitat de carregar-se i desar-se en un fitxer.

# Configuracio de BiblioTech - Nexus Software
# Aquest fitxer es carrega en arrencar i NO requereix recompilar l'aplicacio

# --- Regles de prestec ---
bibliotech.dies.prestec = 15
bibliotech.max.prestecs.simultanis = 3

# --- Multes ---
bibliotech.multa.tarifa.diaria = 0.25
bibliotech.multa.maxima = 20.0
bibliotech.multa.llindar.lleu = 7

# --- Camins ---
bibliotech.dades.directori = dades
bibliotech.dades.cataleg = cataleg.csv

# --- Registre ---
bibliotech.log.nivell = INFO

Les regles del format:

Element Regla
Separador clau-valor =, : o espai. = és l'habitual
Espais al voltant del = S'ignoren
Comentaris Línies que comencen per # o !
Línies buides S'ignoren
Continuació de línia \ al final
Escapades \n, \t, \\, \:, \=, \uXXXX
Clau amb espais Cal escapar-los: la\ meva\ clau = valor
Tot és text No hi ha tipus: cal convertir i validar

Un detall que sorprèn: els espais finals del valor SÍ que es conserven. clau = valor desa "valor " amb tres espais. Com que són invisibles, la fallada desconcerta. Per això convé fer sempre trim() en llegir.

I el parany dels camins a Windows:

# MALAMENT: \d s'interpreta com una escapada
cami = C:\dades\cataleg.csv

# BE: barra invertida duplicada
cami = C:\\dades\\cataleg.csv

# MILLOR: barres normals, que Java accepta a Windows (07-01)
cami = C:/dades/cataleg.csv

L'API bàsica:

import java.util.Properties;

Properties props = new Properties();

props.setProperty("bibliotech.dies.prestec", "15");

String dies = props.getProperty("bibliotech.dies.prestec");            // "15"
String falta = props.getProperty("no.existeix");                       // null
String ambDefecte = props.getProperty("no.existeix", "valor per defecte"); // el defecte

props.containsKey("bibliotech.dies.prestec");    // true
props.stringPropertyNames();                     // Set<String> amb totes les claus
props.size();

Fes servir sempre getProperty amb valor per defecte. La versió d'un argument retorna null, i aquest null acaba en un Integer.parseInt que llança NumberFormatException amb el missatge null, que no diu absolutament res sobre quina clau faltava. És el null com a valor de retorn que 06-07 va desterrar.

I un advertiment sobre l'herència de Properties: estén Hashtable<Object, Object>, cosa que li dona mètodes get/put que accepten qualsevol objecte i se salten el mecanisme de valors per defecte. Fes servir sempre getProperty/setProperty, mai get/put. És un defecte de disseny de 1996 que continua allà.

  1. load i store, amb charset i sense sorpreses

El problema històric de la codificació

Fins a Java 8, Properties.load(InputStream) llegia en ISO-8859-1, sense possibilitat de canviar-ho. Els caràcters fora d'aquell joc s'havien d'escriure com a escapades Unicode:

# Abans de Java 9, l'unica forma d'escriure "Catala" amb accent:
missatge.benvinguda = Benvingut a BiblioTech en Catal\u00e0

Illegible i incòmode, fins al punt que el JDK portava una eina (native2ascii) per convertir fitxers automàticament.

Des de Java 9, Properties.load(InputStream) detecta UTF-8 i només cau a ISO-8859-1 si els bytes no són UTF-8 vàlid. I des de Java 6 existeixen les sobrecàrregues que accepten Reader, que són les que cal fer servir:

import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Properties;

public class CarregarProperties {

    /** Carrega amb charset EXPLICIT. La forma correcta, sense ambiguitats. */
    public static Properties carregar(Path fitxer) throws IOException {
        Properties props = new Properties();

        try (BufferedReader lector = Files.newBufferedReader(
                fitxer, StandardCharsets.UTF_8)) {      // 07-06

            props.load(lector);          // load(Reader): el charset el decideix el Reader
        }
        return props;
    }

    /** Desa amb charset explicit. */
    public static void desar(Properties props, Path fitxer, String comentari)
            throws IOException {

        try (java.io.BufferedWriter sortida = Files.newBufferedWriter(
                fitxer, StandardCharsets.UTF_8,
                java.nio.file.StandardOpenOption.CREATE,
                java.nio.file.StandardOpenOption.TRUNCATE_EXISTING,
                java.nio.file.StandardOpenOption.WRITE)) {

            props.store(sortida, comentari);
        }
    }
}

Fes servir sempre les sobrecàrregues de Reader/Writer, no les d'InputStream/OutputStream. Amb Reader el charset el decideixes tu explícitament, igual que a 07-01. Amb InputStream depens de l'heurística de la JVM.

Què escriu store:

#Configuracio generada per BiblioTech
#Wed Aug 05 09:14:22 CEST 2026
bibliotech.multa.tarifa.diaria=0.25
bibliotech.dies.prestec=15
bibliotech.dades.directori=dades

Dues observacions sobre aquesta sortida:

  1. store afegeix una línia amb la data. Això fa que el fitxer canviï a cada desada encara que el contingut sigui idèntic, cosa que genera soroll al control de versions. Si el fitxer es versiona, considera escriure'l tu amb l'EscriptorCsv... o més aviat amb un BufferedWriter i el teu propi format.
  2. store no conserva l'ordre ni els comentaris originals. Properties és un Hashtable: sense ordre. Si carregues un fitxer acuradament comentat i el tornes a desar, perds tots els comentaris i l'ordre. Per això els fitxers de configuració d'un projecte s'editen a mà i es llegeixen amb load; store es reserva per a configuració generada per l'aplicació.

I una alternativa molt útil per a configuració que viatja dins del .jar:

/** Carrega des del classpath: el fitxer va dins del jar i no es pot perdre. */
public static Properties carregarDelClasspath(String recurs) throws IOException {
    Properties props = new Properties();

    try (java.io.InputStream entrada =
                 Configuracio.class.getResourceAsStream(recurs)) {

        if (entrada == null) {
            throw new java.io.FileNotFoundException(
                    "No s'ha trobat el recurs '" + recurs + "' al classpath");
        }
        try (java.io.Reader lector = new java.io.InputStreamReader(
                entrada, StandardCharsets.UTF_8)) {     // el PONT de 07-03
            props.load(lector);
        }
    }
    return props;
}

És el patró habitual per als valors per defecte: van dins del .jar, sempre hi són, i el fitxer extern només els sobreescriu.

  1. Propietats encadenades per defecte

Properties té un mecanisme poc conegut i molt útil: una instància pot tenir-ne una altra com a suport.

// Nivell 1: valors per defecte, al codi
Properties perDefecte = new Properties();
perDefecte.setProperty("bibliotech.dies.prestec", "15");
perDefecte.setProperty("bibliotech.multa.tarifa.diaria", "0.25");
perDefecte.setProperty("bibliotech.multa.maxima", "20.0");

// Nivell 2: el fitxer, AMB els defectes com a suport
Properties configuracio = new Properties(perDefecte);
configuracio.load(lector);         // el fitxer nomes porta 'dies'

configuracio.getProperty("bibliotech.dies.prestec");             // del fitxer
configuracio.getProperty("bibliotech.multa.tarifa.diaria");      // "0.25", del defecte
configuracio.getProperty("bibliotech.multa.maxima");             // "20.0", del defecte

getProperty busca primer a la instància i, si no troba la clau, a la de suport, recursivament. El fitxer només necessita declarar el que canvia.

És net, però té dos paranys seriosos que cal conèixer:

1. stringPropertyNames() sí que inclou els valors per defecte, però keySet() i size() NO.

configuracio.size();                        // 1: nomes el del fitxer
configuracio.stringPropertyNames().size();  // 3: inclou els defectes

Iterar amb keySet() se salta els valors per defecte sense avisar. Fes servir sempre stringPropertyNames().

2. store() no desa els valors per defecte. Si carregues amb suport i deses, el fitxer resultant només té l'explícit. Això és el correcte —el fitxer declara el que canvia— però sorprèn si esperaves un bolcat complet.

Per aquests dos paranys, i perquè la jerarquia real necessita més de dos nivells, BiblioTech implementarà la seva pròpia classe Configuracio en lloc de fer servir l'encadenament directament.

  1. La jerarquia de configuració i la seva precedència

Una aplicació professional obté la seva configuració de diverses fonts, amb una precedència definida:

flowchart TD
    A["1. Valors per defecte<br/>al codi<br/>(menor prioritat)"] --> B["2. Fitxer del classpath<br/>bibliotech-defecte.properties"]
    B --> C["3. Fitxer extern<br/>bibliotech.properties"]
    C --> D["4. Variables d'entorn<br/>BIBLIOTECH_DIES_PRESTEC"]
    D --> E["5. Propietats del sistema<br/>-Dbibliotech.dies.prestec=20<br/>(major prioritat)"]

    style A fill:#e3f2fd
    style E fill:#c8e6c9

Cada nivell sobreescriu l'anterior. La taula de precedència, de menor a major:

Nivell Font Qui la controla Quan es fa servir
1 Constants del codi El desenvolupador Sempre hi ha un valor: l'aplicació no es queda mai sense
2 Fitxer del classpath El desenvolupador Els valors de fàbrica, dins del .jar
3 Fitxer extern L'administrador La configuració del desplegament
4 Variables d'entorn El sistema o el contenidor Desplegaments en contenidors i al núvol
5 -D a la línia d'ordres Qui executa Proves i ajustos puntuals sense tocar res

Per què aquest ordre exacte:

  • Els defectes del codi van els últims en prioritat perquè són la xarxa de seguretat: garanteixen que l'aplicació arrenca encara que no hi hagi cap fitxer. És la degradació elegant de 06-07 aplicada a la configuració.
  • Les variables d'entorn estan per damunt del fitxer perquè en un contenidor no sempre es pot muntar un fitxer, i són la forma estàndard de configurar a Docker i Kubernetes.
  • -D guanya a tot perquè és la forma explícita i immediata de dir "aquesta vegada, això", sense tocar res permanent. És el que fa servir qui depura un problema en producció.

Convenció de noms entre -D i les variables d'entorn:

Propietat Variable d'entorn
bibliotech.dies.prestec BIBLIOTECH_DIES_PRESTEC
bibliotech.multa.tarifa.diaria BIBLIOTECH_MULTA_TARIFA_DIARIA

La transformació és mecànica: majúscules i els punts per guions baixos. És la convenció de facto a l'ecosistema —la fa servir Spring Boot, que veuràs a 11-02— i cal respectar-la perquè ningú no hagi d'aprendre's dos noms.

  1. La classe Configuracio de BiblioTech

package com.nexussoftware.bibliotech.infraestructura;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;
import java.util.Objects;
import java.util.Properties;
import java.util.TreeMap;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Configuracio de BiblioTech amb jerarquia de fonts.
 *
 * Precedencia, de menor a major:
 *   1. valors per defecte del codi
 *   2. bibliotech-defecte.properties del classpath
 *   3. fitxer extern bibliotech.properties
 *   4. variables d'entorn (BIBLIOTECH_XXX_YYY)
 *   5. propietats del sistema (-Dbibliotech.xxx.yyy)
 *
 * DEGRADACIO ELEGANT (06-07): si no hi ha cap fitxer, l'aplicacio
 * arrenca amb els valors per defecte i ho registra. Configurar malament no ha
 * d'impedir arrencar; el que SI que ho ha d'impedir es un valor INVALID, perque
 * calcular multes amb una tarifa desconeguda produiria cobraments incorrectes.
 */
public final class Configuracio {

    private static final Logger LOG = Logger.getLogger(Configuracio.class.getName());

    private static final String RECURS_DEFECTE = "/bibliotech-defecte.properties";
    private static final String FITXER_EXTERN = "bibliotech.properties";
    private static final String PREFIX = "bibliotech.";

    // ---------- Claus ----------
    public static final String DIES_PRESTEC        = "bibliotech.dies.prestec";
    public static final String MAX_PRESTECS        = "bibliotech.max.prestecs.simultanis";
    public static final String MULTA_TARIFA_DIARIA = "bibliotech.multa.tarifa.diaria";
    public static final String MULTA_MAXIMA        = "bibliotech.multa.maxima";
    public static final String MULTA_LLINDAR_LLEU  = "bibliotech.multa.llindar.lleu";
    public static final String DADES_DIRECTORI     = "bibliotech.dades.directori";
    public static final String DADES_CATALEG       = "bibliotech.dades.cataleg";
    public static final String CSV_SEPARADOR       = "bibliotech.csv.separador";

    /** Les propietats ja resoltes per la jerarquia. */
    private final Properties resoltes = new Properties();

    /** D'on ha sortit cada valor. Serveix per diagnosticar. */
    private final TreeMap<String, String> origen = new TreeMap<>();

    private static Configuracio instancia;

    private Configuracio() { }

    /**
     * Carrega la configuracio. Idempotent.
     *
     * @param camiFitxer fitxer extern, o null per al nom per defecte
     */
    public static synchronized Configuracio carregar(Path camiFitxer) {
        if (instancia != null) {
            return instancia;
        }
        Configuracio c = new Configuracio();

        c.aplicarDefectesDelCodi();
        c.aplicarRecursDelClasspath();
        c.aplicarFitxerExtern(camiFitxer);
        c.aplicarVariablesDEntorn();
        c.aplicarPropietatsDelSistema();
        c.validar();

        instancia = c;
        LOG.info(() -> "Configuracio carregada: " + c.resoltes.size() + " propietats");
        return instancia;
    }

    public static synchronized Configuracio get() {
        if (instancia == null) {
            return carregar(null);
        }
        return instancia;
    }

    /** Nomes per a proves: permet recarregar entre casos. */
    static synchronized void reiniciar() { instancia = null; }

    // ---------------------- ELS CINC NIVELLS ----------------------

    /**
     * NIVELL 1: els defectes del codi.
     *
     * Son els valors que porten set moduls escrits a foc. Ara son
     * en un sol lloc i son nomes l'ULTIM recurs, no l'unica opcio.
     */
    private void aplicarDefectesDelCodi() {
        posar(DIES_PRESTEC,        "15",          "defecte del codi");
        posar(MAX_PRESTECS,        "3",           "defecte del codi");
        posar(MULTA_TARIFA_DIARIA, "0.25",        "defecte del codi");
        posar(MULTA_MAXIMA,        "20.0",        "defecte del codi");
        posar(MULTA_LLINDAR_LLEU,  "7",           "defecte del codi");
        posar(DADES_DIRECTORI,     "dades",       "defecte del codi");
        posar(DADES_CATALEG,       "cataleg.csv", "defecte del codi");
        posar(CSV_SEPARADOR,       ",",           "defecte del codi");
    }

    /** NIVELL 2: el fitxer de fabrica, dins del jar. */
    private void aplicarRecursDelClasspath() {
        try (InputStream entrada = Configuracio.class.getResourceAsStream(RECURS_DEFECTE)) {
            if (entrada == null) {
                LOG.fine(() -> "Sense recurs " + RECURS_DEFECTE + " al classpath");
                return;
            }
            // El PONT de 07-03 amb charset explicit
            try (Reader lector = new InputStreamReader(entrada, StandardCharsets.UTF_8)) {
                bolcar(llegir(lector), "classpath " + RECURS_DEFECTE);
            }
        } catch (IOException e) {
            LOG.log(Level.WARNING, "No s'ha pogut llegir " + RECURS_DEFECTE, e);
        }
    }

    /**
     * NIVELL 3: el fitxer extern.
     *
     * La seva absencia NO es un error: la primera execucio no el te.
     * Degradacio elegant (06-07, 07-01).
     */
    private void aplicarFitxerExtern(Path cami) {
        Path fitxer = (cami != null) ? cami : Path.of(FITXER_EXTERN);

        if (Files.notExists(fitxer)) {
            LOG.info(() -> "No hi ha fitxer de configuracio a "
                    + fitxer.toAbsolutePath()
                    + "; es fan servir els valors per defecte");
            return;
        }

        try (BufferedReader lector = Files.newBufferedReader(
                fitxer, StandardCharsets.UTF_8)) {           // 07-06

            bolcar(llegir(lector), "fitxer " + fitxer.toAbsolutePath());
            LOG.info(() -> "Configuracio llegida de " + fitxer.toAbsolutePath());

        } catch (IOException e) {
            // Un fitxer ILLEGIBLE si que es un avis serios: algu el va posar alla
            // esperant que s'apliques, i no s'esta aplicant.
            LOG.log(Level.SEVERE, "No s'ha pogut llegir " + fitxer.toAbsolutePath()
                    + "; es continua amb els valors anteriors", e);
        }
    }

    /**
     * NIVELL 4: variables d'entorn.
     *
     * BIBLIOTECH_DIES_PRESTEC -> bibliotech.dies.prestec
     */
    private void aplicarVariablesDEntorn() {
        for (var entrada : System.getenv().entrySet()) {
            String variable = entrada.getKey();
            if (!variable.startsWith("BIBLIOTECH_")) {
                continue;
            }
            String clau = variable.toLowerCase(Locale.ROOT).replace('_', '.');
            posar(clau, entrada.getValue(), "variable d'entorn " + variable);
        }
    }

    /**
     * NIVELL 5: propietats del sistema (-D). La prioritat mes alta.
     */
    private void aplicarPropietatsDelSistema() {
        Properties sistema = System.getProperties();
        for (String clau : sistema.stringPropertyNames()) {
            if (clau.startsWith(PREFIX)) {
                posar(clau, sistema.getProperty(clau), "-D" + clau);
            }
        }
    }

    private Properties llegir(Reader lector) throws IOException {
        Properties p = new Properties();
        p.load(lector);
        return p;
    }

    private void bolcar(Properties origenProps, String descripcio) {
        // stringPropertyNames, NO keySet: inclou els valors per defecte (apartat 12)
        for (String clau : origenProps.stringPropertyNames()) {
            posar(clau, origenProps.getProperty(clau), descripcio);
        }
    }

    private void posar(String clau, String valor, String descripcioOrigen) {
        if (valor == null) {
            return;
        }
        // trim: els espais finals del valor SI que es conserven a .properties
        resoltes.setProperty(clau, valor.trim());
        origen.put(clau, descripcioOrigen);
    }

    // ---------------------- VALIDACIO ----------------------

    /**
     * Comprova que els valors son coherents.
     *
     * Distincio de 06-07: un fitxer ABSENT degrada (es fan servir els defectes);
     * un valor INVALID AVORTA, perque calcular multes amb una tarifa
     * desconeguda produiria cobraments incorrectes, i aixo es pitjor que no arrencar.
     */
    private void validar() {
        int dies = getEnter(DIES_PRESTEC, 15);
        if (dies < 1 || dies > 365) {
            throw new ConfiguracioInvalidaException(DIES_PRESTEC,
                    String.valueOf(dies), "ha d'estar entre 1 i 365");
        }

        int maxim = getEnter(MAX_PRESTECS, 3);
        if (maxim < 1 || maxim > 50) {
            throw new ConfiguracioInvalidaException(MAX_PRESTECS,
                    String.valueOf(maxim), "ha d'estar entre 1 i 50");
        }

        double tarifa = getDecimal(MULTA_TARIFA_DIARIA, 0.25);
        if (tarifa < 0 || tarifa > 100) {
            throw new ConfiguracioInvalidaException(MULTA_TARIFA_DIARIA,
                    String.valueOf(tarifa), "ha d'estar entre 0 i 100 EUR/dia");
        }

        double maxima = getDecimal(MULTA_MAXIMA, 20.0);
        if (maxima < 0) {
            throw new ConfiguracioInvalidaException(MULTA_MAXIMA,
                    String.valueOf(maxima), "no pot ser negativa");
        }
        if (maxima < tarifa) {
            throw new ConfiguracioInvalidaException(MULTA_MAXIMA,
                    String.valueOf(maxima),
                    "no pot ser menor que la tarifa diaria (" + tarifa + ")");
        }

        int llindar = getEnter(MULTA_LLINDAR_LLEU, 7);
        if (llindar < 0 || llindar > dies) {
            throw new ConfiguracioInvalidaException(MULTA_LLINDAR_LLEU,
                    String.valueOf(llindar),
                    "ha d'estar entre 0 i el termini de prestec (" + dies + ")");
        }
    }

    // ---------------------- ACCES TIPAT ----------------------

    public String getText(String clau, String perDefecte) {
        return resoltes.getProperty(clau, perDefecte);
    }

    /**
     * Enter amb valor per defecte.
     *
     * Un valor no numeric NO peta l'aplicacio: s'avisa i es fa servir el
     * defecte. La validacio posterior comprovara el rang.
     */
    public int getEnter(String clau, int perDefecte) {
        String valor = resoltes.getProperty(clau);
        if (valor == null || valor.isBlank()) {
            return perDefecte;
        }
        try {
            return Integer.parseInt(valor.trim());
        } catch (NumberFormatException e) {
            LOG.warning(() -> String.format(
                    "La propietat %s te el valor no numeric '%s' (origen: %s); "
                            + "es fa servir %d", clau, valor, origen.get(clau), perDefecte));
            return perDefecte;
        }
    }

    /** Decimal. Accepta coma o punt en llegir, escriu sempre punt (apartat 7). */
    public double getDecimal(String clau, double perDefecte) {
        String valor = resoltes.getProperty(clau);
        if (valor == null || valor.isBlank()) {
            return perDefecte;
        }
        try {
            return Double.parseDouble(valor.trim().replace(',', '.'));
        } catch (NumberFormatException e) {
            LOG.warning(() -> String.format(
                    "La propietat %s te el valor no numeric '%s' (origen: %s); "
                            + "es fa servir %s", clau, valor, origen.get(clau), perDefecte));
            return perDefecte;
        }
    }

    public boolean getBoolea(String clau, boolean perDefecte) {
        String valor = resoltes.getProperty(clau);
        if (valor == null || valor.isBlank()) {
            return perDefecte;
        }
        String net = valor.trim().toLowerCase(Locale.ROOT);
        return switch (net) {                       // switch d'expressio (02-03)
            case "true", "si", "yes", "1", "on"  -> true;
            case "false", "no", "0", "off"       -> false;
            default -> {
                LOG.warning(() -> "Valor boolea no reconegut a " + clau
                        + ": '" + valor + "'; es fa servir " + perDefecte);
                yield perDefecte;
            }
        };
    }

    public char getCaracter(String clau, char perDefecte) {
        String valor = resoltes.getProperty(clau);
        return (valor == null || valor.isEmpty()) ? perDefecte : valor.charAt(0);
    }

    public Path getCami(String clau, String perDefecte) {
        return Path.of(getText(clau, perDefecte));
    }

    /**
     * Bolcat de diagnostic: cada propietat amb el seu valor i el seu ORIGEN.
     *
     * Saber D'ON ve cada valor es el que permet resoldre en un
     * minut el classic "he canviat el fitxer i no en fa cas": gairebe
     * sempre hi ha una variable d'entorn o un -D trepitjant-lo.
     */
    public String bolcat() {
        StringBuilder sb = new StringBuilder();
        sb.append("=== CONFIGURACIO EFECTIVA ===\n");

        for (String clau : new TreeMap<>(origen).keySet()) {
            sb.append(String.format("  %-42s = %-16s [%s]%n",
                    clau, resoltes.getProperty(clau), origen.get(clau)));
        }
        return sb.toString();
    }

    /** Configuracio invalida: l'aplicacio NO ha d'arrencar aixi. */
    public static class ConfiguracioInvalidaException extends RuntimeException {

        private static final long serialVersionUID = 1L;      // 07-05

        private final String clau;
        private final String valor;

        public ConfiguracioInvalidaException(String clau, String valor, String motiu) {
            super(String.format(
                    "Configuracio invalida: %s = '%s'. %s. "
                            + "Corregeix el fitxer de configuracio i torna a arrencar.",
                    clau, valor, motiu));
            this.clau = clau;
            this.valor = valor;
        }

        public String getClau()  { return clau; }
        public String getValor() { return valor; }
    }
}

  1. Les quatre constants deixen de ser constants

El moment promès des del mòdul 1. Així estaven les quatre constants:

// ABANS: escrites a foc, repartides per tres classes

public class Prestec {
    public static final int DIES_PRESTEC = 15;           // 06-03
}

public abstract class Material {
    public static final double MULTA_MAXIMA = 20.0;      // 04-02
    public static final int    LLINDAR_LLEU = 7;         // 04-02
}

public class Llibre extends Material {
    public static final double TARIFA_DIARIA_LLIBRE = 0.25;  // 04-02
}

public class Empleat {
    public static final int MAX_PRESTECS_SIMULTANIS = 3;     // 03-03
}

Canviar la tarifa exigia editar el codi, recompilar, empaquetar i desplegar. Ara:

package com.nexussoftware.bibliotech.domini;

import com.nexussoftware.bibliotech.infraestructura.Configuracio;

/**
 * Regles de negoci de BiblioTech, llegides de la configuracio.
 *
 * Substitueix les constants repartides pel domini des del modul 3.
 * Els valors es llegeixen UNA VEGADA en arrencar i queden fixos durant
 * l'execucio: canviar-los a mitges faria que dos prestecs del mateix dia es
 * calculessin amb regles diferents, cosa inacceptable en un sistema que
 * cobra diners.
 *
 * Per recarregar la configuracio cal reiniciar l'aplicacio. Es una
 * decisio DELIBERADA, no una limitacio.
 */
public final class ReglesNegoci {

    private static final Configuracio CONFIG = Configuracio.get();

    /** Dies de termini de prestec. Abans: Prestec.DIES_PRESTEC = 15 */
    public static final int DIES_PRESTEC =
            CONFIG.getEnter(Configuracio.DIES_PRESTEC, 15);

    /** Prestecs simultanis per empleat. Abans: Empleat.MAX_PRESTECS = 3 */
    public static final int MAX_PRESTECS_SIMULTANIS =
            CONFIG.getEnter(Configuracio.MAX_PRESTECS, 3);

    /** EUR per dia de retard. Abans: Llibre.TARIFA_DIARIA_LLIBRE = 0.25 */
    public static final double TARIFA_DIARIA =
            CONFIG.getDecimal(Configuracio.MULTA_TARIFA_DIARIA, 0.25);

    /** Sostre de multa. Abans: Material.MULTA_MAXIMA = 20.0 */
    public static final double MULTA_MAXIMA =
            CONFIG.getDecimal(Configuracio.MULTA_MAXIMA, 20.0);

    /** Dies fins que el retard deixa de ser lleu. Abans: Material.LLINDAR_LLEU = 7 */
    public static final int LLINDAR_LLEU =
            CONFIG.getEnter(Configuracio.MULTA_LLINDAR_LLEU, 7);

    private ReglesNegoci() { }

    /** Resum per a l'arrencada, perque quedi constancia amb quines regles s'opera. */
    public static String resum() {
        return String.format(
                "Regles: %d dies de prestec, maxim %d simultanis, "
                        + "%.2f EUR/dia, sostre %.2f EUR, llindar lleu %d dies",
                DIES_PRESTEC, MAX_PRESTECS_SIMULTANIS,
                TARIFA_DIARIA, MULTA_MAXIMA, LLINDAR_LLEU);
    }
}

I el domini passa a fer-les servir:

public abstract class Material implements Prestable, Notificable {

    // Abans: constants literals. Ara: llegides de la configuracio.
    // El Template Method de 04-02 no canvia ni una linia: nomes canvia d'on
    // surten els numeros.

    public final double calcularMulta(int diesTranscorreguts) {
        int    retard = calcularDiesRetard(diesTranscorreguts);
        double bruta  = retard * getTarifaDiaria();
        return Math.min(bruta, ReglesNegoci.MULTA_MAXIMA);
    }

    public final Gravetat classificarGravetat(int diesTranscorreguts) {
        int retard = calcularDiesRetard(diesTranscorreguts);
        if (retard == 0)                            { return Gravetat.SENSE_RETARD; }
        if (retard <= ReglesNegoci.LLINDAR_LLEU)    { return Gravetat.LLEU; }
        return Gravetat.GREU;
    }
}

L'arrencada completa:

package com.nexussoftware.bibliotech.presentacio;

import java.io.IOException;
import java.nio.file.Path;
import java.util.logging.Level;
import java.util.logging.Logger;

import com.nexussoftware.bibliotech.domini.ReglesNegoci;
import com.nexussoftware.bibliotech.infraestructura.ConfiguracioLog;
import com.nexussoftware.bibliotech.infraestructura.Configuracio;
import com.nexussoftware.bibliotech.servei.Cataleg;
import com.nexussoftware.bibliotech.servei.ExportadorCatalegCsv;

public class BiblioTechApp {

    private static final Logger LOG = Logger.getLogger(BiblioTechApp.class.getName());

    public static void main(String[] args) {
        ConfiguracioLog.inicialitzar();                      // 06-07

        Configuracio config;
        try {
            Path cami = (args.length > 0) ? Path.of(args[0]) : null;
            config = Configuracio.carregar(cami);

        } catch (Configuracio.ConfiguracioInvalidaException e) {
            // Frontera d'errors del main (06-07). Un valor invalid AVORTA:
            // arrencar amb una tarifa absurda produiria cobraments incorrectes.
            System.err.println("ERROR DE CONFIGURACIO");
            System.err.println("  " + e.getMessage());
            LOG.log(Level.SEVERE, "Arrencada avortada per configuracio invalida", e);
            System.exit(2);
            return;
        }

        LOG.config(config::bolcat);

        System.out.println("BiblioTech - Nexus Software");
        System.out.println(ReglesNegoci.resum());

        try {
            Path directori = config.getCami(Configuracio.DADES_DIRECTORI, "dades");
            ExportadorCatalegCsv persistencia = new ExportadorCatalegCsv(directori);

            Cataleg cataleg = new Cataleg();
            var resultat = persistencia.importarCataleg(cataleg);
            System.out.print(resultat.resum());

            // Desat en sortir (07-02)
            Runtime.getRuntime().addShutdownHook(new Thread(() -> {
                try {
                    int n = persistencia.exportarCataleg(cataleg);
                    LOG.info("Cataleg desat en sortir: " + n + " materials");
                } catch (IOException e) {
                    LOG.log(Level.SEVERE, "NO S'HA POGUT DESAR EL CATALEG", e);
                }
            }, "desat-final"));

            new MenuBiblioTech(cataleg, persistencia).executar();

        } catch (IOException e) {
            System.err.println("No s'ha pogut accedir al magatzem de dades: " + e.getMessage());
            LOG.log(Level.SEVERE, "Fallada d'E/S en l'arrencada", e);
            System.exit(3);
        }
    }
}

La demostració del canvi, sense recompilar res:

# 1. Sense fitxer de configuracio: valors per defecte
$ java -cp bibliotech.jar com.nexussoftware.bibliotech.presentacio.BiblioTechApp
BiblioTech - Nexus Software
Regles: 15 dies de prestec, maxim 3 simultanis, 0,25 EUR/dia, sostre 20,00 EUR, llindar lleu 7 dies

# 2. Amb fitxer de configuracio
$ cat bibliotech.properties
bibliotech.dies.prestec = 21
bibliotech.multa.tarifa.diaria = 0.50

$ java -cp bibliotech.jar com.nexussoftware.bibliotech.presentacio.BiblioTechApp
BiblioTech - Nexus Software
Regles: 21 dies de prestec, maxim 3 simultanis, 0,50 EUR/dia, sostre 20,00 EUR, llindar lleu 7 dies

# 3. Variable d'entorn: trepitja el fitxer
$ BIBLIOTECH_MULTA_MAXIMA=50.0 java -cp bibliotech.jar ...
Regles: 21 dies de prestec, maxim 3 simultanis, 0,50 EUR/dia, sostre 50,00 EUR, llindar lleu 7 dies

# 4. -D: trepitja tot
$ java -Dbibliotech.dies.prestec=7 -cp bibliotech.jar ...
Regles: 7 dies de prestec, maxim 3 simultanis, 0,50 EUR/dia, sostre 50,00 EUR, llindar lleu 7 dies

# 5. Valor invalid: l'aplicacio NO arrenca
$ java -Dbibliotech.multa.maxima=-5 -cp bibliotech.jar ...
ERROR DE CONFIGURACIO
  Configuracio invalida: bibliotech.multa.maxima = '-5.0'. no pot ser negativa.
  Corregeix el fitxer de configuracio i torna a arrencar.
$ echo $?
2

El cas 5 és el que demostra que el disseny és correcte. Un fitxer absent degrada; un valor invàlid avorta amb un codi de sortida distingible. És exactament la distinció de 06-07: degrada quan el servei reduït continuï sent correcte; avorta quan continuar produiria resultats incorrectes. Una biblioteca sense catàleg és correcta; una multa màxima negativa produeix cobraments absurds.

I el bolcat de diagnòstic, que resol el clàssic "he canviat el fitxer i no en fa cas":

=== CONFIGURACIO EFECTIVA ===
  bibliotech.csv.separador                   = ,                [defecte del codi]
  bibliotech.dades.cataleg                   = cataleg.csv      [defecte del codi]
  bibliotech.dades.directori                 = dades            [fitxer /home/marta/bibliotech.properties]
  bibliotech.dies.prestec                    = 7                [-Dbibliotech.dies.prestec]
  bibliotech.max.prestecs.simultanis         = 3                [defecte del codi]
  bibliotech.multa.llindar.lleu              = 7                [defecte del codi]
  bibliotech.multa.maxima                    = 50.0             [variable d'entorn BIBLIOTECH_MULTA_MAXIMA]
  bibliotech.multa.tarifa.diaria             = 0.50             [fitxer /home/marta/bibliotech.properties]

Cada valor amb el seu origen. És una d'aquelles funcionalitats petites que estalvien hores de desconcert.

  1. No desis mai credencials al repositori

Un advertiment que cal donar amb tota la claredat possible.

NO posis MAI contrasenyes, claus d'API, testimonis ni cadenes de connexió amb credencials en un fitxer de propietats que estigui al control de versions.

Per què, més enllà del que és evident:

  1. L'historial de Git és permanent. Esborrar la contrasenya en un commit posterior no l'elimina: continua a l'historial i qualsevol amb accés al repositori la pot recuperar. Reescriure l'historial és dolorós i no sempre possible.
  2. Els repositoris es clonen i es comparteixen. Cada persona que hi ha tingut accés alguna vegada té una còpia completa de l'historial al seu portàtil.
  3. Els repositoris es fan públics per error. Passa constantment. Existeixen robots que rastregen permanentment els repositoris públics buscant claus, i el temps entre publicar una clau i que es faci servir se sol mesurar en minuts.
  4. Les còpies de seguretat multipliquen les còpies del fitxer per tot arreu.

Què fer en el seu lloc:

Enfocament Com Quan
Variables d'entorn BIBLIOTECH_BD_CLAU Contenidors, núvol. El més habitual
Fitxer fora del repositori /etc/bibliotech/secrets.properties, amb permisos 600 Servidors propis
Gestor de secrets Vault, AWS Secrets Manager, Azure Key Vault Producció seriosa
Fitxer local ignorat bibliotech-local.properties a .gitignore Desenvolupament

El patró que fa servir BiblioTech:

# bibliotech.properties: SI que va al repositori.
# Configuracio funcional, sense cap secret.

bibliotech.dies.prestec = 15
bibliotech.multa.tarifa.diaria = 0.25

# Els secrets NO van aqui. Es llegeixen de variables d'entorn:
#   BIBLIOTECH_BD_USUARI
#   BIBLIOTECH_BD_CLAU
# En desenvolupament, posa'ls a bibliotech-local.properties, que es a .gitignore.
/**
 * Llegeix un secret. MAI del fitxer del repositori, i MAI amb un valor
 * per defecte: un secret absent ha de fallar, no funcionar a mitges.
 */
public String getSecret(String variableDEntorn) {
    String valor = System.getenv(variableDEntorn);

    if (valor == null || valor.isBlank()) {
        throw new IllegalStateException(String.format(
                "Falta la variable d'entorn %s. Els secrets no es llegeixen del "
                        + "fitxer de configuracio. Consulta la documentacio "
                        + "de desplegament.", variableDEntorn));
    }
    // I MAI registrar-lo (06-07): ni el valor, ni una part, ni la seva longitud.
    LOG.config(() -> "Secret " + variableDEntorn + " carregat correctament");
    return valor;
}

Fixa't en les dues decisions: sense valor per defecte —un secret absent ha de fallar sorollosament, no deixar l'aplicació funcionant de forma insegura— i sense registrar el valor, aplicant el de 06-07 sobre què no ha d'aparèixer mai al log.

I l'avís formal, com a 07-05: això és una introducció, no una guia de seguretat. La gestió de secrets en un sistema real la defineix i la revisa el responsable de seguretat de la teva organització. La seguretat d'aplicacions es tracta, pel que fa a aquest curs, a 12-07.

  1. Altres formats: XML, JSON i YAML

Per tancar el mapa de formats d'intercanvi:

Format Estructura Llegible Tipus Comentaris En Java
Properties Plana clau-valor No: tot text Sí (#) java.util.Properties
CSV Tabular No: tot text No estàndard A mà o llibreria
XML Jeràrquica amb atributs Regular Amb esquema JAXP, JAXB, al JDK
JSON Jeràrquica Sí: nombre, text, booleà, nul No Jackson, Gson (11-07)
YAML Jeràrquica, per sagnat Molt SnakeYAML

XML és al JDK i no necessita dependències. És verbós i ha caigut en desús per a intercanvi, però continua viu en configuració empresarial, a SOAP i en formats de documents. Té un risc de seguretat conegut, les entitats externes (XXE), que exigeix desactivar explícitament el processament d'entitats en analitzar dades alienes.

JSON és l'estàndard actual per a API web. Té tipus —a diferència de CSV i Properties—, és jeràrquic i tots els llenguatges l'entenen. La seva gran absència són els comentaris, cosa que el fa incòmode com a format de configuració. Es tracta a 11-07, amb Jackson.

YAML és el format de configuració de moda: llegible, jeràrquic, amb comentaris. El seu gran defecte és la sensibilitat al sagnat, que produeix errors subtils, i la seva especificació és sorprenentment complexa.

Què triar:

Necessites Format
Configuració plana d'una aplicació Properties
Dades tabulars per a un full de càlcul CSV
API web JSON
Configuració jeràrquica complexa YAML
Documents amb esquema i validació estricta XML
Màxim rendiment i només Java Binari (07-03)

Errors Comuns i Consells

  • Partir un CSV amb split(","). Falla amb separadors dins de camps, cometes escapades i salts de línia. És l'error que perdia el títol dels llibres de BiblioTech.
  • Oblidar el -1 a split. "a,b,".split(",") dona 2 elements, no 3. Descarta els camps buits finals.
  • No treure el BOM. La primera columna de la capçalera no coincideix amb res i el fitxer es veu perfecte a l'editor. Una de les fallades més desconcertants que existeixen.
  • Intentar analitzar CSV amb una expressió regular. Funciona per al cas simple i és illegible, i falla amb la resta. Un autòmat de dos estats són quaranta línies i sí que funciona.
  • Escapar les cometes amb barra invertida. CSV duplica la cometa: "", no \".
  • Suposar que un registre és una línia. Un camp amb salt de línia dins ocupa diverses línies físiques.
  • Confondre null amb cadena buida en CSV. El format no els distingeix. Si el teu domini els distingeix, fes servir un sentinella documentat o canvia de format.
  • Fer servir coma com a separador i esperar que s'obri bé a l'Excel català. S'obre com una sola columna. ; per a consum humà a Catalunya, , per a intercanvi.
  • Escriure decimals sense Locale.ROOT. En una màquina catalana, String.format("%.2f", 0.25) dona 0,25, que amb separador coma fa el fitxer ambigu i no es pot rellegir.
  • No fer la prova d'anada i tornada. Escriure, llegir i comparar amb l'original és l'única forma seriosa de validar un format. És el que faltava al sanejar() de 07-02.
  • Fer servir props.get() en lloc de getProperty(). get ve de Hashtable i se salta el mecanisme de valors per defecte.
  • Fer servir keySet() en lloc de stringPropertyNames(). El primer no inclou els valors per defecte encadenats.
  • No fer trim() als valors llegits. Els espais finals sí que es conserven a .properties, i són invisibles.
  • Barres invertides sense escapar als camins de Windows. C:\dades interpreta \d com una escapada. Fes servir / o duplica la barra.
  • Carregar .properties sense charset explícit. Fes servir les sobrecàrregues de Reader, no les d'InputStream.
  • Fer servir store() sobre un fitxer acuradament comentat. Properties no conserva ni l'ordre ni els comentaris: els perds tots.
  • getProperty sense valor per defecte. Retorna null, que acaba en un parseInt amb el missatge null, que no diu quina clau faltava.
  • No validar els valors llegits. Una tarifa de −5 EUR/dia passa sense més i produeix multes negatives. Validar és obligatori.
  • Avortar perquè falta el fitxer de configuració. És el contrari del que és correcte: l'absència degrada als defectes; el que avorta és un valor invàlid.
  • Desar contrasenyes al fitxer del repositori. L'historial de Git és permanent i els robots rastregen els repositoris públics constantment.
  • Registrar el valor d'un secret al log. Ni el valor, ni una part, ni la seva longitud.
  • Consell: fes sempre la prova d'anada i tornada amb els casos difícils. Comes, cometes, salts de línia, camps buits i espais als extrems. Si tornen idèntics, el format és correcte.
  • Consell: registra l'origen de cada valor de configuració. El bolcat amb [fitxer], [variable d'entorn], [-D] resol en un minut el clàssic "he canviat el fitxer i no en fa cas".
  • Consell: tolerant en llegir, estricte en escriure. Accepta coma o punt decimal en llegir; escriu sempre punt.
  • Consell: implementa una vegada per entendre-ho, fes servir la llibreria en producció. Val per a CSV i per a gairebé tota la resta.

Exercicis

Exercici 1: bateria de proves del CSV

Escriu ProvaCsvCompleta que validi LectorCsv i EscriptorCsv amb una bateria sistemàtica:

  1. Una taula d'almenys dotze casos: camp normal, amb separador, amb cometes, amb salt de línia, buit, només espais, amb espais als extrems, amb accents i ce trencades, amb el separador alternatiu, amb cometes al principi, amb cometes al mig sense entrecometar el camp, i un camp molt llarg.
  2. Per a cada cas: escriure, llegir i comparar amb l'original, informant OK o FALLA.
  3. Un comptador final de casos superats.
  4. Proves de les fallades: un fitxer amb cometes sense tancar ha de llançar CsvFormatException indicant la línia.
  5. Una prova amb BOM: crea el contingut començant per \uFEFF i comprova que la primera columna es llegeix neta.

Exercici 2: conversor de formats

Escriu ConversorFormats que converteixi entre els formats que coneixes:

  1. csvAProperties(Path csv, Path properties): pren un CSV de dues columnes clau,valor i genera un .properties.
  2. propertiesACsv(Path properties, Path csv): la inversa, amb les claus ordenades alfabèticament.
  3. csvACsv(Path origen, Path desti, char sepOrigen, char sepDesti): canvia el separador respectant l'escapada, per passar de format internacional a format Excel català.
  4. Totes les operacions amb escriptura atòmica (07-06) i charset explícit.
  5. Un main que demostri les tres conversions i inclogui a les dades un valor amb coma, un amb punt i coma i un amb cometes, per comprovar que no es perd res.

Exercici 3: configuració amb recàrrega i validació

Amplia Configuracio de BiblioTech:

  1. recarregar() que torni a llegir totes les fonts i retorni un informe de quines propietats han canviat, amb el seu valor anterior i el nou.
  2. Un mecanisme de notificació: una interfície funcional OientConfiguracio amb canviat(String clau, String anterior, String nou), i un registre d'oients al qual s'avisa a cada recàrrega (reprèn OientDevolucio de 04-06).
  3. exportarPlantilla(Path desti) que generi un .properties comentat amb totes les claus conegudes, el seu valor actual, el seu origen i una descripció, perquè un administrador tingui una plantilla completa.
  4. Marca les propietats que no admeten recàrrega en calent —les regles de negoci— i avisa si canvien, explicant que cal reiniciar.
  5. Un main que carregui, exporti la plantilla, modifiqui el fitxer, recarregui i mostri l'informe de canvis.

Solucions

Solució 1

package com.nexussoftware.bibliotech.proves;

import java.io.IOException;
import java.io.StringReader;
import java.io.StringWriter;
import java.util.List;

import com.nexussoftware.bibliotech.infraestructura.EscriptorCsv;
import com.nexussoftware.bibliotech.infraestructura.LectorCsv;

/**
 * Bateria de proves d'anada i tornada del CSV.
 *
 * La prova d'anada i tornada —escriure, llegir i comparar amb l'original— es
 * l'unica forma seriosa de validar un format: si alguna cosa torna diferent, hi
 * ha perdua de dades.
 *
 * Treballa EN MEMORIA amb StringWriter i StringReader (07-03): sense fitxers,
 * sense neteja, sense dependencies de l'entorn.
 */
public class ProvaCsvCompleta {

    private static int superats = 0;
    private static int fallits = 0;

    public static void main(String[] args) throws IOException {

        System.out.println("=== ANADA I TORNADA AMB SEPARADOR ',' ===");
        char sep = ',';

        provar(sep, "1. Camp normal",             "Java Eficac", "Bloch", "2018");
        provar(sep, "2. Amb separador dins",      "Java: el llenguatge, la maquina", "Bloch", "2018");
        provar(sep, "3. Amb cometes",             "El llibre \"Refactoritzacio\"", "Fowler", "1999");
        provar(sep, "4. Amb salt de linia",       "Titol amb\nsalt de linia", "Autor", "2020");
        provar(sep, "5. Camp buit al mig",        "Titol", "", "2021");
        provar(sep, "6. Camp buit al final",      "Titol", "Autor", "");
        provar(sep, "7. Tots buits",              "", "", "");
        provar(sep, "8. Nomes espais",            "   ", "Autor", "2022");
        provar(sep, "9. Espais als extrems",      "  amb espais  ", "Autor", "2022");
        provar(sep, "10. Accents i ce trencades", "Patrons de Disseny à ç", "Martí Peçó", "1994");
        provar(sep, "11. Punt i coma dins",       "camp;amb;punticoma", "Autor", "2023");
        provar(sep, "12. Cometes al mig",         "El \"millor\" llibre de Java", "Autor", "2024");
        provar(sep, "13. Nomes una cometa",       "Cometa solta \" aqui", "Autor", "2024");
        provar(sep, "14. Camp molt llarg",        "x".repeat(5000), "Autor", "2024");
        provar(sep, "15. Separador i cometes",    "a,b\"c,d", "Autor", "2024");

        System.out.println();
        System.out.println("=== ANADA I TORNADA AMB SEPARADOR ';' ===");
        char sep2 = ';';
        provar(sep2, "16. Coma dins amb sep ;",   "Java: el llenguatge, la maquina", "Bloch", "2018");
        provar(sep2, "17. Punt i coma dins",      "camp;amb;punticoma", "Autor", "2023");

        // ---- Fallada controlada: cometes sense tancar ----
        System.out.println();
        System.out.println("=== FALLADES CONTROLADES ===");
        provarCometesSenseTancar();

        // ---- BOM ----
        System.out.println();
        System.out.println("=== MARCA BOM ===");
        provarBom();

        System.out.println();
        System.out.printf("=== RESULTAT: %d superats, %d fallits ===%n",
                superats, fallits);

        if (fallits > 0) {
            System.out.println("HI HA PERDUA DE DADES. Revisa l'escapada.");
        }
    }

    /** Escriu els camps, els torna a llegir i els compara amb l'original. */
    private static void provar(char separador, String nom, String... camps)
            throws IOException {

        List<String> originals = List.of(camps);

        StringWriter memoria = new StringWriter();
        try (EscriptorCsv escriptor = new EscriptorCsv(memoria, separador, "\n")) {
            escriptor.escriureRegistre(originals);
        }

        String generat = memoria.toString();

        List<String> llegits;
        try (LectorCsv lector = new LectorCsv(new StringReader(generat), separador)) {
            llegits = lector.seguent();
        }

        boolean ok = originals.equals(llegits);
        if (ok) { superats++; } else { fallits++; }

        System.out.printf("  %-30s %s%n", nom, ok ? "OK" : "FALLA");

        if (!ok) {
            System.out.println("      original: " + originals);
            System.out.println("      llegit  : " + llegits);
            System.out.println("      csv     : " + generat.replace("\n", "\\n"));
        }
    }

    /** Un fitxer amb cometes sense tancar ha de fallar indicant la linia. */
    private static void provarCometesSenseTancar() {
        String dolent = "titol,autor\n\"Sense tancar,Bloch";

        try (LectorCsv lector = new LectorCsv(new StringReader(dolent))) {
            lector.seguent();         // la capcalera, correcta
            lector.seguent();         // aquesta ha de fallar
            System.out.println("  Cometes sense tancar         FALLA (hauria de llancar)");
            fallits++;

        } catch (LectorCsv.CsvFormatException e) {
            System.out.println("  Cometes sense tancar         OK");
            System.out.println("      " + e.getMessage());
            superats++;

        } catch (IOException e) {
            System.out.println("  Cometes sense tancar         FALLA (excepcio inesperada)");
            fallits++;
        }
    }

    /**
     * La marca BOM que escriu Excel al principi dels fitxers UTF-8.
     *
     * Sense tractar-la, la primera columna es llegeix com a "\uFEFFtipus" i NO
     * coincideix amb "tipus", tot i que el fitxer es veu perfecte a qualsevol editor.
     */
    private static void provarBom() throws IOException {
        String ambBom = "\uFEFFtipus,referencia,titol\nLLIBRE,978-0000000001,Java Eficac";

        try (LectorCsv lector = new LectorCsv(new StringReader(ambBom))) {
            List<String> capcalera = lector.seguent();

            boolean ok = "tipus".equals(capcalera.get(0));
            if (ok) { superats++; } else { fallits++; }

            System.out.printf("  BOM eliminat                 %s%n", ok ? "OK" : "FALLA");
            System.out.println("      primera columna: '" + capcalera.get(0)
                    + "' (longitud " + capcalera.get(0).length() + ")");

            if (!ok) {
                System.out.println("      El BOM continua alla: la comparacio amb 'tipus' falla");
            }
        }
    }
}

Sortida:

=== ANADA I TORNADA AMB SEPARADOR ',' ===
  1. Camp normal                 OK
  2. Amb separador dins          OK
  3. Amb cometes                 OK
  4. Amb salt de linia           OK
  5. Camp buit al mig            OK
  6. Camp buit al final          OK
  7. Tots buits                  OK
  8. Nomes espais                OK
  9. Espais als extrems          OK
  10. Accents i ce trencades     OK
  11. Punt i coma dins           OK
  12. Cometes al mig             OK
  13. Nomes una cometa           OK
  14. Camp molt llarg            OK
  15. Separador i cometes        OK

=== ANADA I TORNADA AMB SEPARADOR ';' ===
  16. Coma dins amb sep ;        OK
  17. Punt i coma dins           OK

=== FALLADES CONTROLADES ===
  Cometes sense tancar         OK
      Linia 2: El fitxer acaba amb unes cometes sense tancar

=== MARCA BOM ===
  BOM eliminat                 OK
      primera columna: 'tipus' (longitud 5)

=== RESULTAT: 19 superats, 0 fallits ===

Els tres punts didàctics:

  1. Els casos 8 i 9 són els que més s'obliden. Un camp de només espais i un amb espais als extrems: si l'escriptor no els entrecometa, el lector els retalla en llegir i la dada torna diferent. És una pèrdua silenciosa.
  2. El cas 15 combina separador i cometes al mateix camp, que és on fallen la majoria de les implementacions casolanes que es troben a internet.
  3. Tot es prova en memòria. StringWriter i StringReader de 07-03 permeten una bateria completa sense crear ni un fitxer, sense neteja posterior i sense dependre del sistema de fitxers. És exactament la lliçó de disseny de 07-03: accepta l'abstracció més general que et serveixi.

Solució 2

package com.nexussoftware.bibliotech.util;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
import java.util.TreeSet;
import java.util.logging.Logger;

import com.nexussoftware.bibliotech.infraestructura.EscriptorCsv;
import com.nexussoftware.bibliotech.infraestructura.LectorCsv;

/**
 * Conversions entre CSV i Properties, i entre dialectes de CSV.
 *
 * Totes les escriptures son ATOMIQUES (07-06): si la conversio falla a mitges,
 * el fitxer desti anterior queda intacte.
 */
public class ConversorFormats {

    private static final Logger LOG = Logger.getLogger(ConversorFormats.class.getName());
    private static final java.nio.charset.Charset CHARSET = StandardCharsets.UTF_8;

    // ---------------------- CSV -> PROPERTIES ----------------------

    /**
     * Converteix un CSV de dues columnes 'clau,valor' en un .properties.
     *
     * ATENCIO: Properties escapa pel seu compte els caracters especials, aixi
     * que un valor amb '=' o ':' es desa escapat i torna correcte.
     */
    public int csvAProperties(Path csv, Path desti, char separador) throws IOException {
        Properties props = new Properties();
        int convertides = 0;

        try (BufferedReader lector = Files.newBufferedReader(csv, CHARSET);
             LectorCsv entrada = new LectorCsv(lector, separador)) {

            List<String> registre = entrada.seguent();        // capcalera
            if (registre == null) {
                throw new IOException("El fitxer " + csv + " es buit");
            }

            while ((registre = entrada.seguent()) != null) {
                if (registre.size() < 2) {
                    LOG.warning(() -> "Registre amb menys de 2 camps; s'ignora");
                    continue;
                }
                String clau = registre.get(0).trim();
                if (clau.isEmpty()) {
                    continue;
                }
                props.setProperty(clau, registre.get(1));
                convertides++;
            }
        }

        escriurePropertiesAtomic(props, desti,
                "Generat des de " + csv.getFileName());

        final int total = convertides;
        LOG.info(() -> String.format("CSV -> Properties: %d claus a %s",
                total, desti));
        return convertides;
    }

    // ---------------------- PROPERTIES -> CSV ----------------------

    /** Converteix un .properties en CSV, amb les claus ordenades. */
    public int propertiesACsv(Path properties, Path desti, char separador)
            throws IOException {

        Properties props = new Properties();
        try (BufferedReader lector = Files.newBufferedReader(properties, CHARSET)) {
            props.load(lector);
        }

        // stringPropertyNames, no keySet (apartat 12). TreeSet per ordenar.
        TreeSet<String> claus = new TreeSet<>(props.stringPropertyNames());

        escriureAtomic(desti, sortida -> {
            try (EscriptorCsv csv = new EscriptorCsv(sortida, separador, "\n")) {
                csv.escriureCapcalera("clau", "valor");
                for (String clau : claus) {
                    csv.escriureRegistre(clau, props.getProperty(clau));
                }
            }
        });

        LOG.info(() -> String.format("Properties -> CSV: %d claus a %s",
                claus.size(), desti));
        return claus.size();
    }

    // ---------------------- CSV -> CSV ----------------------

    /**
     * Canvia el separador d'un CSV respectant l'escapada.
     *
     * NO es una substitucio de text: un camp que contenia el separador
     * NOU i no el vell passa a necessitar cometes, i a l'inreves. Nomes es
     * pot fer be analitzant i reescrivint.
     */
    public int csvACsv(Path origen, Path desti, char sepOrigen, char sepDesti)
            throws IOException {

        List<List<String>> registres = new ArrayList<>();

        try (BufferedReader lector = Files.newBufferedReader(origen, CHARSET);
             LectorCsv entrada = new LectorCsv(lector, sepOrigen)) {

            List<String> registre;
            while ((registre = entrada.seguent()) != null) {
                registres.add(registre);
            }
        }

        escriureAtomic(desti, sortida -> {
            try (EscriptorCsv csv = new EscriptorCsv(sortida, sepDesti, "\n")) {
                for (List<String> r : registres) {
                    csv.escriureRegistre(r);
                }
            }
        });

        LOG.info(() -> String.format("CSV '%c' -> CSV '%c': %d registres a %s",
                sepOrigen, sepDesti, registres.size(), desti));
        return registres.size();
    }

    // ---------------------- ESCRIPTURA ATOMICA ----------------------

    @FunctionalInterface
    private interface Escriptura {
        void escriureA(BufferedWriter sortida) throws IOException;
    }

    private void escriureAtomic(Path desti, Escriptura contingut) throws IOException {
        Files.createDirectories(desti.toAbsolutePath().getParent());
        Path temporal = desti.resolveSibling(desti.getFileName() + ".tmp");

        boolean completat = false;
        try {
            try (BufferedWriter sortida = Files.newBufferedWriter(temporal, CHARSET,
                    StandardOpenOption.CREATE,
                    StandardOpenOption.TRUNCATE_EXISTING,
                    StandardOpenOption.WRITE)) {

                contingut.escriureA(sortida);
            }
            Files.move(temporal, desti,
                    StandardCopyOption.REPLACE_EXISTING,
                    StandardCopyOption.ATOMIC_MOVE);
            completat = true;

        } catch (AtomicMoveNotSupportedException e) {
            Files.move(temporal, desti, StandardCopyOption.REPLACE_EXISTING);
            completat = true;

        } finally {
            if (!completat) {
                Files.deleteIfExists(temporal);          // compensacio (06-05)
            }
        }
    }

    private void escriurePropertiesAtomic(Properties props, Path desti, String comentari)
            throws IOException {

        escriureAtomic(desti, sortida -> props.store(sortida, comentari));
    }

    // ---------------------- DEMOSTRACIO ----------------------

    public static void main(String[] args) throws IOException {
        Path dir = Path.of("dades/conversions");
        Files.createDirectories(dir);

        ConversorFormats conversor = new ConversorFormats();

        // 1. CSV de partida amb els casos dificils
        Path csvOriginal = dir.resolve("configuracio.csv");
        Files.writeString(csvOriginal, String.join("\n",
                "clau,valor",
                "bibliotech.dies.prestec,15",
                "bibliotech.multa.tarifa.diaria,0.25",
                "bibliotech.missatge.benvinguda,\"Benvingut a BiblioTech, de Nexus Software\"",
                "bibliotech.separadors,\"punt;i;coma\"",
                "bibliotech.cita,\"El llibre \"\"Refactoritzacio\"\" de Fowler\"",
                ""), CHARSET);

        System.out.println("=== CSV ORIGINAL ===");
        System.out.println(Files.readString(csvOriginal, CHARSET));

        // 2. CSV -> Properties
        Path props = dir.resolve("configuracio.properties");
        int n1 = conversor.csvAProperties(csvOriginal, props, ',');
        System.out.println("=== PROPERTIES GENERAT (" + n1 + " claus) ===");
        System.out.println(Files.readString(props, CHARSET));

        // 3. Properties -> CSV, amb separador ';' per a l'Excel catala
        Path csvExcel = dir.resolve("configuracio-excel.csv");
        int n2 = conversor.propertiesACsv(props, csvExcel, ';');
        System.out.println("=== CSV PER A L'EXCEL CATALA (" + n2 + " claus) ===");
        System.out.println(Files.readString(csvExcel, CHARSET));

        // 4. CSV ',' -> CSV ';' directament
        Path csvPuntIComa = dir.resolve("configuracio-pic.csv");
        int n3 = conversor.csvACsv(csvOriginal, csvPuntIComa, ',', ';');
        System.out.println("=== CSV AMB SEPARADOR ';' (" + n3 + " registres) ===");
        System.out.println(Files.readString(csvPuntIComa, CHARSET));

        // 5. Comprovacio: anada i tornada completa
        Path tornada = dir.resolve("tornada.csv");
        conversor.csvACsv(csvPuntIComa, tornada, ';', ',');

        boolean identics = Files.readString(csvOriginal, CHARSET).trim()
                .equals(Files.readString(tornada, CHARSET).trim());

        System.out.println("=== ANADA I TORNADA ',' -> ';' -> ',' ===");
        System.out.println("  Identic a l'original? " + identics);
    }
}

Sortida (fragment):

=== CSV AMB SEPARADOR ';' (6 registres) ===
clau;valor
bibliotech.dies.prestec;15
bibliotech.multa.tarifa.diaria;0.25
bibliotech.missatge.benvinguda;Benvingut a BiblioTech, de Nexus Software
bibliotech.separadors;"punt;i;coma"
bibliotech.cita;"El llibre ""Refactoritzacio"" de Fowler"

=== ANADA I TORNADA ',' -> ';' -> ',' ===
  Identic a l'original? true

Els tres punts que cal veure:

  1. L'escapada canvia en canviar el separador, i això demostra que no és una substitució de text. El valor Benvingut a BiblioTech, de Nexus Software perd les cometes en passar a separador ; —ja no les necessita, perquè la seva coma és inofensiva— i el valor punt;i;coma les guanya. Un replace(',', ';') hauria destrossat tots dos.
  2. L'anada i tornada retorna el fitxer idèntic. És la prova que les dues conversions són inverses exactes.
  3. Properties.store escriu amb les seves pròpies escapades, així que un valor amb = o : es desa escapat i torna correcte. Aquest és el motiu de convertir analitzant i no copiant text.

Solució 3

package com.nexussoftware.bibliotech.infraestructura;

import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
import java.util.Set;
import java.util.TreeMap;
import java.util.logging.Logger;

/**
 * Configuracio de BiblioTech amb recarrega, notificacio de canvis i
 * generacio de plantilla comentada.
 *
 * Amplia la classe de l'apartat 14 amb el que cal per operar el
 * sistema sense aturar-lo del tot.
 */
public class ConfiguracioRecarregable {

    private static final Logger LOG =
            Logger.getLogger(ConfiguracioRecarregable.class.getName());

    private static final java.nio.charset.Charset CHARSET = StandardCharsets.UTF_8;

    /**
     * Descripcio d'una propietat coneguda.
     *
     * 'recarregable' distingeix el que es pot canviar en calent del que
     * exigeix reiniciar. Les REGLES DE NEGOCI no son recarregables: canviar la
     * tarifa a mitja execucio faria que dos prestecs del mateix dia es
     * calculessin amb regles diferents.
     */
    public record DescripcioPropietat(String clau, String perDefecte,
                                      String descripcio, boolean recarregable) { }

    private static final List<DescripcioPropietat> CONEGUDES = List.of(
            new DescripcioPropietat("bibliotech.dies.prestec", "15",
                    "Dies de termini de prestec", false),
            new DescripcioPropietat("bibliotech.max.prestecs.simultanis", "3",
                    "Prestecs simultanis per empleat", false),
            new DescripcioPropietat("bibliotech.multa.tarifa.diaria", "0.25",
                    "EUR per dia de retard", false),
            new DescripcioPropietat("bibliotech.multa.maxima", "20.0",
                    "Sostre de multa per prestec, en EUR", false),
            new DescripcioPropietat("bibliotech.multa.llindar.lleu", "7",
                    "Dies fins que el retard deixa de ser lleu", false),
            new DescripcioPropietat("bibliotech.dades.directori", "dades",
                    "Directori de dades", true),
            new DescripcioPropietat("bibliotech.dades.cataleg", "cataleg.csv",
                    "Nom del fitxer de cataleg", true),
            new DescripcioPropietat("bibliotech.csv.separador", ",",
                    "Separador de camps del CSV: ',' o ';' per a l'Excel catala", true),
            new DescripcioPropietat("bibliotech.log.nivell", "INFO",
                    "Nivell de registre: SEVERE, WARNING, INFO, CONFIG, FINE", true));

    /** Oient de canvis de configuracio (interficie funcional, 04-06). */
    @FunctionalInterface
    public interface OientConfiguracio {
        void canviat(String clau, String anterior, String nou);
    }

    /** Un canvi detectat a la recarrega. */
    public record Canvi(String clau, String anterior, String nou, boolean recarregable) {

        public String linia() {
            return String.format("%-42s %-14s -> %-14s %s",
                    clau, anterior == null ? "(sense valor)" : anterior, nou,
                    recarregable ? "" : "  [REQUEREIX REINICIAR]");
        }
    }

    private final Path fitxerExtern;
    private final Map<String, String> valors = new TreeMap<>();
    private final Map<String, String> origen = new TreeMap<>();
    private final List<OientConfiguracio> oients = new ArrayList<>();

    public ConfiguracioRecarregable(Path fitxerExtern) {
        this.fitxerExtern = Objects.requireNonNull(fitxerExtern);
        carregarTot();
    }

    public void registrarOient(OientConfiguracio oient) {
        oients.add(Objects.requireNonNull(oient, "L'oient no pot ser nul"));
    }

    // ---------------------- CARREGA ----------------------

    private void carregarTot() {
        valors.clear();
        origen.clear();

        // 1. Defectes del codi
        for (DescripcioPropietat d : CONEGUDES) {
            valors.put(d.clau(), d.perDefecte());
            origen.put(d.clau(), "defecte del codi");
        }

        // 2. Fitxer extern
        if (Files.exists(fitxerExtern)) {
            Properties props = new Properties();
            try (var lector = Files.newBufferedReader(fitxerExtern, CHARSET)) {
                props.load(lector);
                for (String clau : props.stringPropertyNames()) {      // no keySet
                    valors.put(clau, props.getProperty(clau).trim());
                    origen.put(clau, "fitxer " + fitxerExtern.getFileName());
                }
            } catch (IOException e) {
                LOG.warning(() -> "No s'ha pogut llegir " + fitxerExtern + ": " + e.getMessage());
            }
        }

        // 3. Variables d'entorn
        for (var e : System.getenv().entrySet()) {
            if (e.getKey().startsWith("BIBLIOTECH_")) {
                String clau = e.getKey().toLowerCase(java.util.Locale.ROOT).replace('_', '.');
                valors.put(clau, e.getValue().trim());
                origen.put(clau, "entorn " + e.getKey());
            }
        }

        // 4. Propietats del sistema
        for (String clau : System.getProperties().stringPropertyNames()) {
            if (clau.startsWith("bibliotech.")) {
                valors.put(clau, System.getProperty(clau).trim());
                origen.put(clau, "-D" + clau);
            }
        }
    }

    // ---------------------- RECARREGA ----------------------

    /**
     * Torna a llegir totes les fonts i retorna els canvis detectats.
     *
     * Notifica els oients de cada canvi, i AVISA dels que necessiten
     * un reinici per tenir efecte.
     */
    public List<Canvi> recarregar() {
        Map<String, String> anteriors = new LinkedHashMap<>(valors);

        carregarTot();

        List<Canvi> canvis = new ArrayList<>();
        Set<String> totes = new java.util.TreeSet<>(anteriors.keySet());
        totes.addAll(valors.keySet());

        for (String clau : totes) {
            String abans = anteriors.get(clau);
            String ara = valors.get(clau);

            if (Objects.equals(abans, ara)) {
                continue;
            }
            boolean recarregable = esRecarregable(clau);
            canvis.add(new Canvi(clau, abans, ara, recarregable));

            // Notificar. Un oient que falli NO ha de trencar la recarrega.
            for (OientConfiguracio oient : oients) {
                try {
                    oient.canviat(clau, abans, ara);
                } catch (RuntimeException e) {
                    LOG.warning(() -> "Un oient ha fallat en notificar " + clau
                            + ": " + e.getMessage());
                }
            }

            if (!recarregable) {
                LOG.warning(() -> String.format(
                        "La propietat %s ha canviat de '%s' a '%s', pero NO s'aplica "
                                + "en calent: cal reiniciar l'aplicacio.",
                        clau, abans, ara));
            }
        }

        LOG.info(() -> "Recarrega: " + canvis.size() + " propietats canviades");
        return canvis;
    }

    private boolean esRecarregable(String clau) {
        for (DescripcioPropietat d : CONEGUDES) {
            if (d.clau().equals(clau)) {
                return d.recarregable();
            }
        }
        return true;      // desconeguda: s'assumeix recarregable
    }

    // ---------------------- PLANTILLA ----------------------

    /**
     * Genera un .properties comentat amb totes les claus conegudes.
     *
     * S'escriu a ma en lloc d'amb Properties.store perque store NO
     * conserva ni l'ordre ni els comentaris (apartat 11), i una plantilla
     * sense comentaris ni ordre no serveix de res.
     */
    public void exportarPlantilla(Path desti) throws IOException {
        Files.createDirectories(desti.toAbsolutePath().getParent());
        Path temporal = desti.resolveSibling(desti.getFileName() + ".tmp");

        boolean completat = false;
        try {
            try (BufferedWriter sortida = Files.newBufferedWriter(temporal, CHARSET,
                    StandardOpenOption.CREATE,
                    StandardOpenOption.TRUNCATE_EXISTING,
                    StandardOpenOption.WRITE)) {

                sortida.write("# ============================================================");
                sortida.newLine();
                sortida.write("# Configuracio de BiblioTech - Nexus Software");
                sortida.newLine();
                sortida.write("# Plantilla generada automaticament.");
                sortida.newLine();
                sortida.write("#");
                sortida.newLine();
                sortida.write("# Precedencia, de menor a major:");
                sortida.newLine();
                sortida.write("#   1. defectes del codi");
                sortida.newLine();
                sortida.write("#   2. aquest fitxer");
                sortida.newLine();
                sortida.write("#   3. variables d'entorn (BIBLIOTECH_XXX_YYY)");
                sortida.newLine();
                sortida.write("#   4. propietats del sistema (-Dbibliotech.xxx.yyy)");
                sortida.newLine();
                sortida.write("#");
                sortida.newLine();
                sortida.write("# ATENCIO: NO posis contrasenyes ni claus en aquest fitxer");
                sortida.newLine();
                sortida.write("# si va al control de versions. Fes servir variables d'entorn.");
                sortida.newLine();
                sortida.write("# ============================================================");
                sortida.newLine();
                sortida.newLine();

                for (DescripcioPropietat d : CONEGUDES) {
                    String actual = valors.getOrDefault(d.clau(), d.perDefecte());
                    String de = origen.getOrDefault(d.clau(), "defecte del codi");

                    sortida.write("# " + d.descripcio());
                    sortida.newLine();
                    sortida.write("#   per defecte : " + d.perDefecte());
                    sortida.newLine();
                    sortida.write("#   valor actual: " + actual + "  (origen: " + de + ")");
                    sortida.newLine();
                    sortida.write("#   recarrega en calent: " + (d.recarregable()
                            ? "SI" : "NO, requereix reiniciar"));
                    sortida.newLine();
                    sortida.write(d.clau() + " = " + actual);
                    sortida.newLine();
                    sortida.newLine();
                }
            }

            Files.move(temporal, desti,
                    StandardCopyOption.REPLACE_EXISTING,
                    StandardCopyOption.ATOMIC_MOVE);
            completat = true;

            LOG.info(() -> "Plantilla de configuracio generada a " + desti);

        } catch (AtomicMoveNotSupportedException e) {
            Files.move(temporal, desti, StandardCopyOption.REPLACE_EXISTING);
            completat = true;

        } finally {
            if (!completat) {
                Files.deleteIfExists(temporal);
            }
        }
    }

    // ---------------------- ACCES ----------------------

    public String getText(String clau, String perDefecte) {
        return valors.getOrDefault(clau, perDefecte);
    }

    public String bolcat() {
        StringBuilder sb = new StringBuilder("=== CONFIGURACIO EFECTIVA ===\n");
        for (var e : valors.entrySet()) {
            sb.append(String.format("  %-42s = %-16s [%s]%n",
                    e.getKey(), e.getValue(), origen.get(e.getKey())));
        }
        return sb.toString();
    }

    // ---------------------- DEMOSTRACIO ----------------------

    public static void main(String[] args) throws IOException {
        Path fitxer = Path.of("dades/bibliotech.properties");
        Files.createDirectories(fitxer.getParent());

        Files.writeString(fitxer,
                "bibliotech.dies.prestec = 15\n"
                        + "bibliotech.csv.separador = ,\n", CHARSET);

        ConfiguracioRecarregable config = new ConfiguracioRecarregable(fitxer);

        // Oient que reacciona als canvis
        config.registrarOient((clau, abans, ara) ->
                System.out.printf("  [OIENT] %s: '%s' -> '%s'%n", clau, abans, ara));

        System.out.println(config.bolcat());

        // Plantilla comentada per a l'administrador
        Path plantilla = Path.of("dades/bibliotech-plantilla.properties");
        config.exportarPlantilla(plantilla);
        System.out.println("=== PLANTILLA (primeres linies) ===");
        try (var linies = Files.lines(plantilla, CHARSET)) {   // tancar el flux (07-06)
            int[] n = { 0 };
            linies.forEach(l -> {
                if (n[0]++ < 20) {
                    System.out.println("  " + l);
                }
            });
        }

        // Canviar el fitxer i recarregar
        System.out.println();
        System.out.println("=== MODIFICANT EL FITXER I RECARREGANT ===");
        Files.writeString(fitxer,
                "bibliotech.dies.prestec = 21\n"
                        + "bibliotech.csv.separador = ;\n"
                        + "bibliotech.log.nivell = FINE\n", CHARSET);

        List<Canvi> canvis = config.recarregar();

        System.out.println();
        System.out.println("=== CANVIS DETECTATS ===");
        for (Canvi c : canvis) {
            System.out.println("  " + c.linia());
        }
    }
}

Sortida (fragment):

=== MODIFICANT EL FITXER I RECARREGANT ===
  [OIENT] bibliotech.csv.separador: ',' -> ';'
  [OIENT] bibliotech.dies.prestec: '15' -> '21'
  [OIENT] bibliotech.log.nivell: 'INFO' -> 'FINE'

=== CANVIS DETECTATS ===
  bibliotech.csv.separador                   ,              -> ;
  bibliotech.dies.prestec                    15             -> 21               [REQUEREIX REINICIAR]
  bibliotech.log.nivell                      INFO           -> FINE

Els quatre punts didàctics:

  1. La distinció entre recarregable i no recarregable és de negoci, no tècnica. Canviar el separador CSV en calent és inofensiu. Canviar la tarifa de les multes a mitja execució faria que dos préstecs del mateix dia es calculessin amb regles diferents, i això, en un sistema que cobra diners, és inacceptable. La configuració marca la diferència i avisa en lloc d'aplicar-la silenciosament.
  2. Un oient que falli no trenca la recàrrega. El try/catch al voltant de la notificació és la política de 06-07: una fallada en una part accessòria no ha de tombar l'operació principal.
  3. La plantilla s'escriu a mà, no amb store(). Properties.store no conserva ni l'ordre ni els comentaris, i una plantilla sense comentaris ni ordre no serveix de res. És la limitació de l'apartat 11 en acció.
  4. La plantilla inclou l'advertiment sobre les credencials. Documentar la política de seguretat al lloc on algú estarà temptat de saltar-se-la és més eficaç que documentar-la en un wiki que ningú no obre.

Conclusió

Has tancat el mòdul d'entrada/sortida, i amb ell una part molt gran del que cal per escriure programari real.

En aquesta lliçó has vist per què un format de text venç un de binari per intercanviar —llegible, diferenciable en control de versions, independent del llenguatge i segur amb dades alienes— i què es paga a canvi: mida, velocitat, tipus i estructures complexes.

Coneixes el CSV de veritat: les seves sis regles de l'RFC 4180, i els sis casos on el split(",") ingenu falla —separadors dins de camps entrecometats, cometes duplicades, salts de línia dins d'un camp, camps buits al final que split descarta si oblides el -1, i el BOM invisible que fa que la primera columna no coincideixi amb res—. Saps que una expressió regular no resol això i que un autòmat de dos estats sí, en quaranta línies. Tens el teu LectorCsv i el teu EscriptorCsv amb el seu escapar/desescapar, la seva taula de casos límit i la limitació honesta que cap escapada no arregla: CSV no distingeix el nul de la cadena buida. I tens l'única forma seriosa de validar un format: la prova d'anada i tornada, escriure, llegir i comparar —el que faltava al sanejar() de 07-02 i hauria delatat la fallada a la primera—.

Saps navegar el conflicte del separador i el decimal en català: coma i punt per a intercanvi, punt i coma per a l'Excel català, Locale.ROOT en escriure, i Double.parseDouble que sempre fa servir el punt mentre String.format obeeix el sistema. Amb el principi que ho resol: tolerant en llegir, estricte en escriure. I tens la recomanació honesta sobre les llibreries: implementa una vegada per entendre-ho, fes servir la llibreria en producció —OpenCSV, Commons CSV i Jackson, a 11-07—, perquè ara saps què preguntar-los i sabràs diagnosticar quan donin un resultat estrany.

Coneixes Properties: el seu format clau-valor, les seves escapades, els seus comentaris, el problema històric d'ISO-8859-1 i per què cal fer servir les sobrecàrregues de Reader/Writer amb charset explícit; el getProperty amb valor per defecte que evita un null inútil; l'encadenament per defecte amb els seus dos paranys —keySet() davant de stringPropertyNames(), i el store que no desa els defectes ni conserva ordre ni comentaris—; i l'advertiment de fer servir getProperty/setProperty i mai els get/put heretats de Hashtable.

I tens la jerarquia de configuració completa, amb els seus cinc nivells i la seva precedència raonada: defectes del codi com a xarxa de seguretat, fitxer del classpath com a valors de fàbrica, fitxer extern per al desplegament, variables d'entorn per a contenidors i -D per a l'ajust immediat. Amb la convenció BIBLIOTECH_DIES_PRESTECbibliotech.dies.prestec, i amb el bolcat de diagnòstic que diu d'on ve cada valor i resol en un minut el clàssic "he canviat el fitxer i no en fa cas".

Les quatre constants ja no són constants. DIES_PRESTEC, TARIFA_DIARIA, MULTA_MAXIMA i LLINDAR_LLEU es llegeixen d'un fitxer extern, amb validació, amb valors per defecte i amb la distinció de 06-07 aplicada a l'arrencada: un fitxer absent degrada —l'aplicació arrenca amb els defectes i ho registra— i un valor invàlid avorta amb un codi de sortida distingible, perquè calcular multes amb una tarifa negativa produeix cobraments incorrectes, i això és pitjor que no arrencar. Canviar la tarifa de les multes és ara editar una línia de text, no recompilar i desplegar.

I saps que les credencials no van mai al fitxer del repositori, perquè l'historial de Git és permanent, els repositoris es clonen i es fan públics per error, i els robots rastregen constantment. Amb les quatre alternatives —variables d'entorn, fitxer extern amb permisos restringits, gestor de secrets, fitxer local ignorat— i amb les dues regles de codi: sense valor per defecte per a un secret, i mai registrar-lo. Amb l'advertiment formal, un altre cop, que això ho defineix i ho revisa el responsable de seguretat de l'organització, i que la seguretat d'aplicacions es tracta a 12-07.

I el mòdul sencer. Vas començar sense saber què era un fitxer més enllà d'una icona; ara saps que és una seqüència de bytes amb un nom, que arribar-hi creua una frontera cara —la crida al sistema— i que tot el disseny de l'E/S de Java existeix per creuar-la menys vegades. Saps localitzar fitxers amb camins absoluts i relatius i saps per què el mateix programa "no els troba" segons des d'on es llanci. Saps llegir i escriure text amb el charset explícit sempre, perquè la codificació no es pot detectar i equivocar-se corromp en silenci. Saps que el paràmetre append oblidat destrueix fitxers sencers sense avisar, i que l'escriptura atòmica —temporal i reanomenat— és l'única forma de regenerar un fitxer sense arriscar-se a perdre'l.

Entens l'arquitectura de java.io: el flux com a abstracció unidireccional i agnòstica, les dues jerarquies de bytes i caràcters i per què havien de ser dues, les classes de node davant de les de filtre, el patró Decorador que converteix dotze classes en cinc, i els ponts on es decideix la codificació. Saps què fa una memòria intermèdia i per què dona un factor de seixanta, domines el contracte de readLine() i el bucle canònic, i saps processar un fitxer d'un gigabyte amb memòria constant. Coneixes la serialització i —amb el mateix èmfasi— els seus riscos reals i quan no fer-la servir. I domines NIO.2, amb Path, Files, ATOMIC_MOVE, StandardOpenOption i els recorreguts d'arbres, que és el que escriuràs a partir d'ara.

BiblioTech, en tancar el mòdul 7, recorda. Carrega el seu catàleg en arrencar des d'un CSV correcte que no perd ni una coma de cap títol, i el desa en sortir de forma atòmica amb còpia de seguretat rotativa. Importa inventaris de milers de línies validant-ne cadascuna i retornant un informe del que s'ha carregat i del que s'ha descartat amb el motiu de cada descart. Desa i restaura sessions de treball entre execucions, amb validació a la deserialització i filtre de seguretat. Gestiona les portades binàries dels seus llibres sense corrompre-les. Organitza els seus informes en un arbre de directoris i els localitza recorrent-lo. Registra cada operació en una auditoria que creix en lloc de sobreescriure's. I es configura sense recompilar, amb cinc fonts de configuració, precedència definida, validació a l'arrencada i un bolcat que diu d'on surt cada valor. De les cinc fragilitats que vas declarar en tancar el mòdul 5, no en queda cap.

Però hi ha una cosa que BiblioTech continua fent d'una única manera: una cosa darrere de l'altra. Quan importa un catàleg de cinquanta mil línies, l'aplicació es queda muda fins que acaba: no la pots cancel·lar, no saps per on va, i el menú no respon. Quan envia els avisos de venciment a dos-cents empleats, els envia un darrere l'altre, i si el segon triga tres segons, el número dos-cents espera deu minuts. Quan està esperant que algú teclegi una opció del menú, el processador està completament aturat, sense fer absolutament res, encara que hi hagi quatre informes pendents de generar. I si demà dos empleats fessin servir BiblioTech alhora sobre el mateix catàleg, no hi ha res al codi que impedeixi que un trepitgi la feina de l'altre.

Tot això té un nom: el programa és seqüencial, i les màquines on s'executa fa quinze anys que no ho són. El teu portàtil té vuit nuclis i BiblioTech en fa servir un.

Al mòdul 8, Multifil i Concurrència, es resol. Veuràs què és un fil i en què es diferencia d'un procés; com es creen amb Thread i Runnable, i per què gairebé mai no els hauries de crear tu directament; el cicle de vida complet d'un fil i què significa exactament cada estat; la sincronització amb synchronized, volatile i el model de memòria de Java, que explica per què un comptador incrementat per dos fils pot acabar valent menys del que hauria; les utilitats de concurrència —ExecutorService, CountDownLatch, Semaphore— que substitueixen la gestió manual de fils per alguna cosa que es pot raonar; les col·leccions concurrents i atòmiques que fan segur el que HashMap no pot garantir; i CompletableFuture, la forma moderna d'encadenar feina asíncrona sense bloquejar ningú. En acabar-lo, BiblioTech importarà el seu catàleg mostrant el progrés i podent-se cancel·lar, enviarà els seus dos-cents avisos en paral·lel, atendrà el menú mentre genera informes de fons, i protegirà el seu catàleg de dos empleats que treballin alhora. Deixarà d'esperar.

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