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-resourcesa tot, i tota la persistència sobrePathiFilesde 07-06.
Contingut
- Per què text i no binari
- CSV: l'estructura i les seves regles
- El
splitingenu i per què falla - Escapada i desescapada: fer-ho bé
- Un lector CSV correcte per a BiblioTech
- Un escriptor CSV correcte
- El separador i el decimal en català
- Exportar el catàleg i els préstecs a CSV
- Recomanació honesta: quan fer servir una llibreria
Properties: què és i com funcionaloadistore, amb charset i sense sorpreses- Propietats encadenades per defecte
- La jerarquia de configuració i la seva precedència
- La classe
Configuraciode BiblioTech - Les quatre constants deixen de ser constants
- No desis mai credencials al repositori
- Altres formats: XML, JSON i YAML
- Errors Comuns i Consells
- Exercicis
- Conclusió i tancament del mòdul
- 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 | Sí, amb qualsevol editor | No |
| Diferenciable en control de versions | Sí: git diff mostra què ha canviat |
No: un blob que ha canviat sencer |
| Editable a mà | Sí, en una emergència | No |
| Independent del llenguatge | Sí: Python, Excel, grep |
Només Java |
| Resistent a canvis de classe | Sí | No: InvalidClassException |
| Segur amb dades alienes | Sí: 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
intque ocupa 4 bytes en binari n'ocupa fins a 11 en text. - Velocitat: cal analitzar cada camp.
- Tipus: tot arriba com a
Stringi 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.
- 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,trueI 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:
- Cada registre en una línia, acabada en CRLF.
- La capçalera és opcional, amb el mateix nombre de camps que les dades.
- Els camps se separen per comes. L'últim no porta coma al darrere.
- Un camp pot anar entre cometes dobles.
- Un camp que contingui una coma, una cometa o un salt de línia HA d'anar entre cometes dobles.
- 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.
- El
split ingenu i per què falla
split ingenu i per què fallaAquest és el codi que gairebé tothom escriu la primera vegada:
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: correcteEl -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.
- 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.
- 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.
- 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.
- 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:
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 | Sí |
| Convenció catalana | ; |
, |
Bé | No |
| Mixta | ; |
. |
Bé | 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.
- 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,trueEl 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.
- 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 | Sí | Sí |
| Detecció automàtica del separador | No | Sí |
| Correspondència amb objectes per anotacions | No | Sí |
| Capçaleres amb accés per nom | No | Sí |
| Detecció i eliminació del BOM | Sí, la primera línia | Sí, completa |
| Formats de dialecte (Excel, MySQL, TDF) | No | Sí |
| 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:
- 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ò.
- Saps què preguntar-li a la llibreria. Com gestiona el BOM? Què fa amb els camps buits? Distingeix
nullde""? Ara saps que aquestes preguntes existeixen i per què importen. - 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.
Properties: què és i com funciona
Properties: què és i com funcionajava.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 = INFOLes 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.csvL'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
getPropertyamb valor per defecte. La versió d'un argument retornanull, i aquestnullacaba en unInteger.parseIntque llançaNumberFormatExceptionamb el missatgenull, que no diu absolutament res sobre quina clau faltava. És elnullcom 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à.
load i store, amb charset i sense sorpreses
load i store, amb charset i sense sorpresesEl 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\u00e0Illegible 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. AmbReaderel charset el decideixes tu explícitament, igual que a 07-01. AmbInputStreamdepens 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=dadesDues observacions sobre aquesta sortida:
storeafegeix 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 unBufferedWriteri el teu propi format.storeno conserva l'ordre ni els comentaris originals.Propertiesés unHashtable: 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 ambload;storees 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.
- 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 defectegetProperty 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 defectesIterar 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.
- 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.
-Dguanya 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.
- La classe
Configuracio de BiblioTech
Configuracio de BiblioTechpackage 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; }
}
}
- 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 $?
2El 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.
- 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:
- 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.
- 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.
- 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.
- 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.
- 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 | Sí | No: tot text | Sí (#) |
java.util.Properties |
| CSV | Tabular | Sí | No: tot text | No estàndard | A mà o llibreria |
| XML | Jeràrquica amb atributs | Regular | Amb esquema | Sí | JAXP, JAXB, al JDK |
| JSON | Jeràrquica | Sí | Sí: nombre, text, booleà, nul | No | Jackson, Gson (11-07) |
| YAML | Jeràrquica, per sagnat | Molt | Sí | Sí | 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
-1asplit."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
nullamb 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)dona0,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 degetProperty().getve deHashtablei se salta el mecanisme de valors per defecte. - Fer servir
keySet()en lloc destringPropertyNames(). 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:\dadesinterpreta\dcom una escapada. Fes servir/o duplica la barra. - Carregar
.propertiessense charset explícit. Fes servir les sobrecàrregues deReader, no les d'InputStream. - Fer servir
store()sobre un fitxer acuradament comentat.Propertiesno conserva ni l'ordre ni els comentaris: els perds tots. getPropertysense valor per defecte. Retornanull, que acaba en unparseIntamb el missatgenull, 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:
- 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.
- Per a cada cas: escriure, llegir i comparar amb l'original, informant OK o FALLA.
- Un comptador final de casos superats.
- Proves de les fallades: un fitxer amb cometes sense tancar ha de llançar
CsvFormatExceptionindicant la línia. - Una prova amb BOM: crea el contingut començant per
\uFEFFi comprova que la primera columna es llegeix neta.
Exercici 2: conversor de formats
Escriu ConversorFormats que converteixi entre els formats que coneixes:
csvAProperties(Path csv, Path properties): pren un CSV de dues columnesclau,valori genera un.properties.propertiesACsv(Path properties, Path csv): la inversa, amb les claus ordenades alfabèticament.csvACsv(Path origen, Path desti, char sepOrigen, char sepDesti): canvia el separador respectant l'escapada, per passar de format internacional a format Excel català.- Totes les operacions amb escriptura atòmica (07-06) i charset explícit.
- Un
mainque 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:
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.- Un mecanisme de notificació: una interfície funcional
OientConfiguracioambcanviat(String clau, String anterior, String nou), i un registre d'oients al qual s'avisa a cada recàrrega (reprènOientDevoluciode 04-06). exportarPlantilla(Path desti)que generi un.propertiescomentat amb totes les claus conegudes, el seu valor actual, el seu origen i una descripció, perquè un administrador tingui una plantilla completa.- Marca les propietats que no admeten recàrrega en calent —les regles de negoci— i avisa si canvien, explicant que cal reiniciar.
- Un
mainque 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:
- 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.
- 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.
- Tot es prova en memòria.
StringWriteriStringReaderde 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? trueEls tres punts que cal veure:
- L'escapada canvia en canviar el separador, i això demostra que no és una substitució de text. El valor
Benvingut a BiblioTech, de Nexus Softwareperd les cometes en passar a separador;—ja no les necessita, perquè la seva coma és inofensiva— i el valorpunt;i;comales guanya. Unreplace(',', ';')hauria destrossat tots dos. - L'anada i tornada retorna el fitxer idèntic. És la prova que les dues conversions són inverses exactes.
Properties.storeescriu 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 -> FINEEls quatre punts didàctics:
- 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.
- Un oient que falli no trenca la recàrrega. El
try/catchal 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. - La plantilla s'escriu a mà, no amb
store().Properties.storeno 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ó. - 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_PRESTEC ↔ bibliotech.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
- Introducció a Java
- Configuració de l'entorn de desenvolupament
- Sintaxi i estructura bàsica
- Variables i tipus de dades
- Operadors
- Entrada i sortida per consola
- El teu primer programa complet: BiblioTech
Mòdul 2: Flux de control
- Sentències condicionals
- Bucles
- Sentències switch
- Break i continue
- Depuració i traces d'execució
- Projecte: menú interactiu de BiblioTech
Mòdul 3: Programació orientada a objectes
- Introducció a la POO
- Classes i objectes
- Mètodes
- Constructors
- Herència
- Polimorfisme
- Encapsulament
- Abstracció
- La classe Object: equals, hashCode i toString
Mòdul 4: Programació orientada a objectes avançada
- Interfícies
- Classes abstractes
- Classes internes
- Classes anònimes
- Expressions lambda
- Interfícies funcionals i referències a mètodes
- Enumeracions i registres
Mòdul 5: Estructures de dades i col·leccions
- Arrays
- El framework de col·leccions
- ArrayList
- LinkedList
- HashMap
- HashSet
- Cua i Deque
- Pila
- Ordenació i cerca en col·leccions
Mòdul 6: Gestió d'excepcions
- Introducció a les excepcions
- Bloc try-catch
- Throw i throws
- Excepcions personalitzades
- Bloc finally
- Try-with-resources i AutoCloseable
- Estratègies de gestió d'errors i logging
Mòdul 7: Entrada/sortida de fitxers
- Lectura de fitxers
- Escriptura de fitxers
- Fluxos de fitxers
- BufferedReader i BufferedWriter
- Serialització
- L'API NIO.2: Path i Files
- Formats d'intercanvi: CSV i Properties
Mòdul 8: Multifil i concurrència
- Introducció al multifil
- Creació de fils
- Cicle de vida d'un fil
- Sincronització
- Utilitats de concurrència
- Col·leccions concurrents i variables atòmiques
- Tasques asíncrones amb CompletableFuture
Mòdul 9: Xarxes
- Introducció a les xarxes
- Sockets
- ServerSocket
- DatagramSocket i DatagramPacket
- URL i HttpURLConnection
- El client HTTP modern
Mòdul 10: Temes avançats
- Genèrics
- Anotacions
- Reflexió
- Característiques de Java 8: Streams i Optional
- Dates i hores amb java.time
- Java 9 i més enllà
- Memòria, recol·lecció de brossa i rendiment
Mòdul 11: Frameworks i llibreries de Java
- Introducció als frameworks de Java
- Spring Framework
- Hibernate
- JUnit
- Maven
- Proves avançades amb Mockito
- Llibreries essencials de l'ecosistema
