Esta lección cierra el módulo, y salda dos deudas.
La primera se remonta a 07-02. El ExportadorCatalogo tiene un método llamado sanear() que sustituye los puntos y coma de los títulos por comas para que no rompan el fichero. Lo marcaste entonces como un apaño consciente, y lo es: un libro titulado "Java: el lenguaje, la máquina y el ecosistema" vuelve del fichero con el título alterado. El dato se pierde en la exportación. Hoy se arregla, implementando un lector y un escritor CSV que hacen bien lo que el split(";") ingenuo hace mal.
La segunda deuda viene del módulo 1. DIAS_PRESTAMO = 15, TARIFA_DIARIA = 0.25, MULTA_MAXIMA = 20.0, UMBRAL_LEVE = 7. Llevan siete módulos escritas a fuego en el código, y cambiar la tarifa de las multas —una decisión de negocio que puede tomarse en una reunión de media hora— exige editar el código fuente, recompilar, empaquetar y volver a desplegar. Hoy se acaba: esas cuatro constantes se leerán de un fichero externo, con validación y valores por defecto.
Al terminar, BiblioTech recordará entre ejecuciones y se configurará sin recompilar.
try-with-resourcesen todo, y toda la persistencia sobrePathyFilesde 07-06.
Contenido
- Por qué texto y no binario
- CSV: la estructura y sus reglas
- El
splitingenuo y por qué falla - Escapado y desescapado: hacerlo bien
- Un lector CSV correcto para BiblioTech
- Un escritor CSV correcto
- El separador y el decimal en español
- Exportar el catálogo y los préstamos a CSV
- Recomendación honesta: cuándo usar una librería
Properties: qué es y cómo funcionaloadystore, con charset y sin sorpresas- Propiedades encadenadas por defecto
- La jerarquía de configuración y su precedencia
- La clase
Configuracionde BiblioTech - Las cuatro constantes dejan de ser constantes
- Nunca guardes credenciales en el repositorio
- Otros formatos: XML, JSON y YAML
- Errores Comunes y Consejos
- Ejercicios
- Conclusión y cierre del módulo
- Por qué texto y no binario
Ya conoces las dos alternativas: el formato binario de DataOutputStream (07-03) y la serialización nativa (07-05), frente al texto plano. La elección no es de gusto.
| Formato de texto (CSV, Properties, JSON) | Formato binario (serialización, DataStream) |
|
|---|---|---|
| Legible por una persona | Sí, con cualquier editor | No |
| Diferenciable en control de versiones | Sí: git diff muestra qué cambió |
No: un blob que cambió entero |
| Editable a mano | Sí, en una emergencia | No |
| Independiente del lenguaje | Sí: Python, Excel, grep |
Solo Java |
| Resistente a cambios de clase | Sí | No: InvalidClassException |
| Seguro con datos ajenos | Sí: produce cadenas, no instancia clases | No |
| Tamaño | Mayor | Menor |
| Velocidad | Menor: hay que parsear | Mayor |
| Tipos | Todo es texto: hay que convertir y validar | Los tipos viajan |
| Estructuras complejas | Difíciles: referencias, anidamiento | Automáticas |
Las tres filas que deciden, en orden de importancia:
1. La diferenciabilidad en control de versiones. Si el catálogo de BiblioTech está en CSV y alguien cambia una tarifa, el git diff muestra exactamente qué línea cambió y quién la cambió. Con un fichero binario, el diff dice "el fichero cambió" y ahí se acaba la investigación. Para datos que se versionan, esto no tiene precio.
2. La independencia del lenguaje. El fichero CSV del catálogo se abre en una hoja de cálculo, se procesa con un script de Python, se filtra con grep y se envía a un proveedor que no usa Java. El fichero serializado solo lo lee tu aplicación, y solo mientras no cambies las clases.
3. La seguridad. Un CSV produce cadenas; tú decides qué objeto construir con ellas, pasando por tus constructores y tus validaciones de 06-03. La serialización construye objetos a partir de nombres de clase que vienen en el fichero, con las consecuencias del apartado 12 de 07-05.
Qué se paga a cambio:
- Tamaño: un
intque ocupa 4 bytes en binario ocupa hasta 11 en texto. - Velocidad: hay que parsear cada campo.
- Tipos: todo llega como
Stringy hay que convertirlo y validarlo. - Estructuras: las relaciones entre objetos hay que representarlas a mano, con identificadores.
Para el catálogo de BiblioTech —unos miles de líneas que se abren en Excel, se versionan y se envían a proveedores— el intercambio es claramente favorable. Para una caché de un millón de objetos con referencias cruzadas, no lo sería.
- CSV: la estructura y sus reglas
CSV significa Comma-Separated Values. Su estructura básica cabe en tres frases: una línea por registro, campos separados por comas, primera línea opcional con los nombres de las columnas.
tipo,referencia,titulo,autor,anio,disponible
LIBRO,978-0000000001,Java Efectivo,Joshua Bloch,2018,true
LIBRO,978-0000000002,Patrones de Diseno,Erich Gamma,1994,false
LIBRO,978-0000000003,Refactorizacion,Martin Fowler,1999,trueY ahí acaba la parte fácil. Porque CSV no es un formato bien definido. Durante décadas fue una convención informal, con variantes por herramienta y por país. El intento de normalización —el RFC 4180, del año 2005— llegó tarde y no todo el mundo lo sigue.
Las reglas del RFC 4180, que son las que implementaremos:
- Cada registro en una línea, terminada en CRLF.
- La cabecera es opcional, con el mismo número de campos que los datos.
- Los campos se separan por comas. El último no lleva coma detrás.
- Un campo puede ir entre comillas dobles.
- Un campo que contenga una coma, una comilla o un salto de línea DEBE ir entre comillas dobles.
- Una comilla dentro de un campo entrecomillado se escribe DUPLICADA.
Las reglas 4, 5 y 6 son las que el split ingenuo ignora, y son las que provocan todos los problemas.
Ejemplos de cada caso:
titulo,autor,notas
Java Efectivo,Bloch,Sin notas
"Java: el lenguaje, la maquina y el ecosistema",Bloch,"Contiene una coma"
"El libro llamado ""Refactorizacion""",Fowler,"Contiene comillas duplicadas"
"Titulo con
salto de linea",Autor,"Un campo puede ocupar varias lineas fisicas"
Sin comillas,,"Campo vacio en medio"Fíjate en la anteúltima línea: un solo registro lógico ocupa dos líneas físicas. Esto rompe por completo la idea de "un readLine() por registro", y es la razón de que un lector CSV correcto no pueda escribirse con el bucle canónico de 07-04 sin más.
- El
split ingenuo y por qué falla
split ingenuo y por qué fallaEste es el código que casi todo el mundo escribe la primera vez:
Los seis casos donde falla, con lo que produce cada uno:
| Línea CSV | Lo que debería dar | Lo que da split(",") |
|---|---|---|
"Java: el lenguaje, la maquina",Bloch,2018 |
3 campos | 4 campos, el título partido |
"El libro ""Refactorizacion""",Fowler |
2 campos | 2 campos, pero con comillas de más |
"Titulo con\nsalto",Autor |
1 registro de 2 campos | 2 registros rotos |
Java Efectivo,,2018 |
3 campos, el 2º vacío | 3 campos. Correcto por casualidad |
Java Efectivo,Bloch, |
3 campos, el 3º vacío | 2 campos: descarta el último vacío |
\uFEFFtipo,referencia (con BOM) |
tipo |
\uFEFFtipo: no coincide con nada |
Los tres más graves:
El campo con separador dentro. Es el caso que rompe el catálogo de BiblioTech, y es el motivo del sanear() de 07-02:
String linea = "\"Java: el lenguaje, la maquina y el ecosistema\",Bloch,2018";
String[] campos = linea.split(",");
// campos.length == 4, no 3
// campos[0] == "\"Java: el lenguaje"
// campos[1] == " la maquina y el ecosistema\""
// campos[2] == "Bloch"
// campos[3] == "2018"Y lo peor no es que dé cuatro campos: es que si tu código comprueba campos.length != 3 y descarta la línea, pierdes el libro sin más aviso que una línea en el informe. Si no lo comprueba, guardas "Java: el lenguaje como título y Bloch como año.
El campo vacío al final. Ya lo viste en 07-01:
"Java Efectivo,Bloch,".split(","); // 2 elementos: descarta los vacios finales
"Java Efectivo,Bloch,".split(",", -1); // 3 elementos: correctoEl -1 es obligatorio, y se olvida constantemente.
El BOM. Un fichero UTF-8 guardado por Excel en Windows suele empezar con tres bytes invisibles —EF BB BF, la marca de orden de bytes— que se decodifican como el carácter \uFEFF. Es invisible en cualquier editor, y hace que la primera columna de la cabecera no coincida con nada:
String primera = campos[0]; // "\uFEFFtipo", no "tipo"
primera.equals("tipo"); // false
primera.length(); // 5, no 4Es uno de los fallos más desconcertantes que existen, porque el fichero se ve perfecto. La solución es quitarlo al leer la primera línea, y está en el apartado 5.
Y una pregunta que surge siempre: ¿no se puede resolver con una expresión regular? Circulan varias, del estilo ,(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$). Funcionan para el caso simple y fallan con comillas escapadas y con saltos de línea dentro de campos, además de ser ilegibles e ineficientes. La solución correcta es un pequeño autómata de estados, y ocupa cuarenta líneas.
- Escapado y desescapado: hacerlo bien
Antes del lector completo, las dos operaciones elementales.
Escapar (al escribir): decidir si un campo necesita comillas y ponerlas.
/**
* Escapa un campo segun el RFC 4180.
*
* Un campo necesita comillas si contiene el separador, una comilla, un
* salto de linea, o espacios al principio o al final (que muchas
* herramientas recortan sin avisar).
*
* Las comillas interiores se DUPLICAN, no se preceden de barra invertida:
* CSV no usa barras invertidas.
*/
public static String escapar(String campo, char separador) {
if (campo == null) {
return ""; // null y vacio se escriben igual (apartado 5)
}
boolean necesitaComillas =
campo.indexOf(separador) >= 0
|| campo.indexOf('"') >= 0
|| campo.indexOf('\n') >= 0
|| campo.indexOf('\r') >= 0
|| campo.startsWith(" ")
|| campo.endsWith(" ");
if (!necesitaComillas) {
return campo;
}
// Duplicar las comillas interiores y envolver en comillas
return '"' + campo.replace("\"", "\"\"") + '"';
}Desescapar (al leer): quitar las comillas envolventes y deshacer la duplicación.
/** Deshace el escapado de un campo ya extraido. */
public static String desescapar(String campo) {
if (campo == null || campo.isEmpty()) {
return "";
}
String limpio = campo.trim();
if (limpio.length() >= 2 && limpio.startsWith("\"") && limpio.endsWith("\"")) {
limpio = limpio.substring(1, limpio.length() - 1);
limpio = limpio.replace("\"\"", "\"");
}
return limpio;
}La tabla de casos límite completa, que es la especificación de lo que hay que cumplir:
| Valor en memoria | Escrito en CSV | Leído de vuelta |
|---|---|---|
Java Efectivo |
Java Efectivo |
Java Efectivo |
Java: el lenguaje, la maquina |
"Java: el lenguaje, la maquina" |
igual |
El libro "Refactorizacion" |
"El libro ""Refactorizacion""" |
igual |
Linea 1\nLinea 2 |
"Linea 1\nLinea 2" |
igual |
| `` (cadena vacía) | (nada) | `` |
null |
(nada) | `` (se pierde la distinción) |
con espacios |
" con espacios " |
igual |
campo;con;puntoycoma con separador , |
campo;con;puntoycoma |
igual |
campo;con;puntoycoma con separador ; |
"campo;con;puntoycoma" |
igual |
Fíjate en la fila del null. CSV no distingue el nulo de la cadena vacía: ambos se escriben igual y ambos vuelven como cadena vacía. Es una limitación real del formato, no un defecto de la implementación. Si esa distinción importa en tu dominio, hay dos salidas: usar un valor centinela documentado —\N es la convención de PostgreSQL— o usar un formato que sí tenga nulos, como JSON (11-07). Lo que no puedes hacer es fingir que el problema no existe.
- Un lector CSV correcto para BiblioTech
La implementación completa, con un autómata de dos estados: dentro de comillas y fuera de comillas.
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 al RFC 4180.
*
* Maneja correctamente:
* - separadores dentro de campos entrecomillados
* - comillas escapadas por duplicacion
* - saltos de linea dentro de un campo (un registro ocupa varias lineas)
* - campos vacios, incluido el ultimo
* - la marca BOM al principio del fichero
*
* NO maneja (y lo dice honestamente):
* - la distincion entre null y cadena vacia: CSV no la tiene
* - codificaciones distintas de la que se le pase
*
* Es Closeable: cierra el Reader que envuelve (06-06).
*/
public class LectorCsv implements AutoCloseable {
/** Marca de orden de bytes que Excel escribe al principio de los UTF-8. */
private static final char BOM = '\uFEFF';
private static final char COMILLA = '"';
private final BufferedReader lector;
private final char separador;
private boolean primeraLectura = true;
private int numeroRegistro = 0;
private int numeroLineaFisica = 0;
public LectorCsv(Reader origen, char separador) {
Objects.requireNonNull(origen, "El origen no puede ser nulo");
this.lector = (origen instanceof BufferedReader br)
? br
: new BufferedReader(origen); // no envolver dos veces (07-04)
this.separador = separador;
}
public LectorCsv(Reader origen) {
this(origen, ',');
}
/**
* Lee el siguiente registro.
*
* Un registro puede ocupar VARIAS lineas fisicas si algun campo tiene un
* salto de linea dentro. Por eso no basta con un readLine() por registro.
*
* @return los campos ya desescapados, o null si no hay mas registros
* @throws CsvFormatoException si el registro esta mal formado
*/
public List<String> siguiente() throws IOException {
String linea = lector.readLine();
if (linea == null) {
return null; // fin (07-04)
}
numeroLineaFisica++;
// Quitar el BOM SOLO de la primera linea
if (primeraLectura) {
primeraLectura = false;
if (!linea.isEmpty() && linea.charAt(0) == BOM) {
linea = linea.substring(1);
}
}
List<String> campos = new ArrayList<>();
StringBuilder campo = new StringBuilder();
boolean dentroDeComillas = false;
int indice = 0;
while (true) {
if (indice >= linea.length()) {
if (!dentroDeComillas) {
break; // fin del registro
}
// Estamos DENTRO de comillas: el campo continua en la
// siguiente linea fisica. Se repone el salto de linea.
String continuacion = lector.readLine();
if (continuacion == null) {
throw new CsvFormatoException(numeroLineaFisica,
"El fichero termina con unas comillas sin cerrar");
}
numeroLineaFisica++;
campo.append('\n');
linea = continuacion;
indice = 0;
continue;
}
char c = linea.charAt(indice);
if (dentroDeComillas) {
if (c == COMILLA) {
// Una comilla dentro de comillas: o cierra, o esta duplicada
if (indice + 1 < linea.length() && linea.charAt(indice + 1) == COMILLA) {
campo.append(COMILLA); // duplicada: una comilla literal
indice += 2;
} else {
dentroDeComillas = false; // cierra el campo
indice++;
}
} else {
campo.append(c);
indice++;
}
} else {
if (c == COMILLA && campo.isEmpty()) {
// Comillas al PRINCIPIO del campo: abren
dentroDeComillas = true;
indice++;
} else if (c == separador) {
campos.add(campo.toString());
campo.setLength(0);
indice++;
} else {
campo.append(c);
indice++;
}
}
}
campos.add(campo.toString()); // el ultimo campo, siempre
numeroRegistro++;
return campos;
}
/**
* Lee todos los registros restantes.
*
* AVISO: carga el fichero entero en memoria (07-01). Para ficheros
* grandes, usa siguiente() en un bucle.
*/
public List<List<String>> leerTodo() throws IOException {
List<List<String>> registros = new ArrayList<>();
List<String> registro;
while ((registro = siguiente()) != null) {
registros.add(registro);
}
return registros;
}
public int getNumeroRegistro() { return numeroRegistro; }
public int getNumeroLineaFisica() { return numeroLineaFisica; }
@Override
public void close() throws IOException {
lector.close();
}
/** Fallo de formato con la linea fisica exacta (06-04). */
public static class CsvFormatoException extends IOException {
private static final long serialVersionUID = 1L;
private final int linea;
public CsvFormatoException(int linea, String mensaje) {
super("Linea " + linea + ": " + mensaje);
this.linea = linea;
}
public int getLinea() { return linea; }
}
}Comprobación con los casos difíciles:
public class PruebaLectorCsv {
public static void main(String[] args) throws IOException {
String contenido = String.join("\n",
"titulo,autor,anio",
"Java Efectivo,Joshua Bloch,2018",
"\"Java: el lenguaje, la maquina\",Bloch,2018",
"\"El libro \"\"Refactorizacion\"\"\",Fowler,1999",
"\"Titulo con",
"salto de linea\",Autor,2020",
"Sin comillas,,2021",
"Ultimo campo vacio,Autor,");
try (LectorCsv csv = new LectorCsv(new java.io.StringReader(contenido))) {
List<String> registro;
while ((registro = csv.siguiente()) != null) {
System.out.printf("[%d] %d campos: %s%n",
csv.getNumeroRegistro(), registro.size(), registro);
}
}
}
}Salida:
[1] 3 campos: [titulo, autor, anio]
[2] 3 campos: [Java Efectivo, Joshua Bloch, 2018]
[3] 3 campos: [Java: el lenguaje, la maquina, Bloch, 2018]
[4] 3 campos: [El libro "Refactorizacion", Fowler, 1999]
[5] 3 campos: [Titulo con
salto de linea, Autor, 2020]
[6] 3 campos: [Sin comillas, , 2021]
[7] 3 campos: [Ultimo campo vacio, Autor, ]Los siete registros dan tres campos. Compara con el split(","), que habría dado 4, 3, 2 (mal partido), 3 y 2 respectivamente. Fíjate especialmente en el registro 5: ocupa dos líneas físicas y se lee como un solo registro, con el salto de línea conservado dentro del campo.
- Un escritor CSV correcto
El lado simétrico:
package com.nexussoftware.bibliotech.infraestructura;
import java.io.IOException;
import java.io.Writer;
import java.util.List;
import java.util.Objects;
/**
* Escritor CSV conforme al RFC 4180.
*
* Entrecomilla y escapa lo que haga falta, de modo que lo escrito se puede
* volver a leer con LectorCsv sin perder nada.
*/
public class EscritorCsv implements AutoCloseable {
private static final char COMILLA = '"';
private final Writer salida;
private final char separador;
private final String finDeLinea;
private int registrosEscritos = 0;
/**
* @param salida destino, normalmente un BufferedWriter (07-04)
* @param separador ',' o ';'
* @param finDeLinea "\n" para ficheros que se versionan; CRLF si lo exige
* el consumidor. El RFC 4180 pide CRLF.
*/
public EscritorCsv(Writer salida, char separador, String finDeLinea) {
this.salida = Objects.requireNonNull(salida, "La salida no puede ser nula");
this.separador = separador;
this.finDeLinea = Objects.requireNonNull(finDeLinea);
}
public EscritorCsv(Writer salida) {
this(salida, ',', "\n");
}
/** Escribe un registro completo. */
public void escribirRegistro(List<String> campos) throws IOException {
Objects.requireNonNull(campos, "Los campos no pueden ser nulos");
for (int i = 0; i < campos.size(); i++) {
if (i > 0) {
salida.write(separador);
}
salida.write(escapar(campos.get(i)));
}
salida.write(finDeLinea);
registrosEscritos++;
}
/** Variante con argumentos variables, cómoda para llamadas cortas. */
public void escribirRegistro(String... campos) throws IOException {
escribirRegistro(List.of(campos));
}
/** Escribe la cabecera. Es un registro normal; el nombre es documentacion. */
public void escribirCabecera(String... nombres) throws IOException {
escribirRegistro(nombres);
}
/**
* Escapa un campo segun el RFC 4180.
*
* Se entrecomilla si contiene el separador, una comilla, un salto de
* linea, o espacios en los extremos (que muchas herramientas recortan).
*/
private String escapar(String campo) {
if (campo == null || campo.isEmpty()) {
return "";
}
boolean necesita = campo.indexOf(separador) >= 0
|| campo.indexOf(COMILLA) >= 0
|| campo.indexOf('\n') >= 0
|| campo.indexOf('\r') >= 0
|| campo.charAt(0) == ' '
|| campo.charAt(campo.length() - 1) == ' ';
if (!necesita) {
return campo;
}
return COMILLA + campo.replace("\"", "\"\"") + COMILLA;
}
public int getRegistrosEscritos() { return registrosEscritos; }
/** flush explicito: util si el consumidor lee mientras escribimos (07-02). */
public void flush() throws IOException {
salida.flush();
}
@Override
public void close() throws IOException {
salida.close(); // el close del Writer hace flush (07-02)
}
}Prueba de ida y vuelta, que es la comprobación que de verdad importa:
public class PruebaIdaYVuelta {
public static void main(String[] args) throws IOException {
List<List<String>> originales = List.of(
List.of("titulo", "autor", "notas"),
List.of("Java Efectivo", "Bloch", ""),
List.of("Java: el lenguaje, la maquina", "Bloch", "con coma"),
List.of("El libro \"Refactorizacion\"", "Fowler", "con comillas"),
List.of("Titulo con\nsalto", "Autor", "con salto"),
List.of(" espacios ", "Autor", "con espacios"));
// Escribir a memoria (07-03)
java.io.StringWriter memoria = new java.io.StringWriter();
try (EscritorCsv escritor = new EscritorCsv(memoria)) {
for (List<String> r : originales) {
escritor.escribirRegistro(r);
}
}
System.out.println("=== CSV GENERADO ===");
System.out.println(memoria);
// Leer de vuelta
System.out.println("=== COMPROBACION DE IDA Y VUELTA ===");
try (LectorCsv lector = new LectorCsv(new java.io.StringReader(memoria.toString()))) {
int i = 0;
List<String> leido;
boolean todoBien = true;
while ((leido = lector.siguiente()) != null) {
boolean igual = leido.equals(originales.get(i));
todoBien &= igual;
System.out.printf(" [%d] %s %s%n", i, igual ? "OK " : "FALLO", leido);
i++;
}
System.out.println(todoBien
? " Todos los registros vuelven IDENTICOS."
: " HAY PERDIDA DE DATOS.");
}
}
}Salida:
=== CSV GENERADO ===
titulo,autor,notas
Java Efectivo,Bloch,
"Java: el lenguaje, la maquina",Bloch,con coma
"El libro ""Refactorizacion""",Fowler,con comillas
"Titulo con
salto",Autor,con salto
" espacios ",Autor,con espacios
=== COMPROBACION DE IDA Y VUELTA ===
[0] OK [titulo, autor, notas]
[1] OK [Java Efectivo, Bloch, ]
[2] OK [Java: el lenguaje, la maquina, Bloch, con coma]
[3] OK [El libro "Refactorizacion", Fowler, con comillas]
[4] OK [Titulo con
salto, Autor, con salto]
[5] OK [ espacios , Autor, con espacios]
Todos los registros vuelven IDENTICOS.La prueba de ida y vuelta es la única forma seria de validar un formato. Escribir, leer y comparar con el original. Si algo vuelve distinto, el formato pierde datos, y lo mejor que puede pasar es que lo descubras aquí y no en producción. Esta prueba es lo que faltaba en el
sanear()de 07-02: si la hubieras hecho entonces, el fallo habría saltado a la primera.
- El separador y el decimal en español
Un problema práctico que aparece en cuanto el fichero se abre en una hoja de cálculo configurada en español.
El separador. CSV dice "coma", pero Excel en configuración española usa punto y coma por defecto, porque la coma está ocupada como separador decimal. Un CSV con comas se abre en Excel español como una sola columna.
El decimal. En español, 0,25. En inglés, 0.25. Y si el separador de campos es la coma y el decimal también, el conflicto es directo:
¿Son dos campos con la tarifa 0,25, o tres campos con 0 y 25? No hay forma de saberlo. El fichero es ambiguo.
Las tres estrategias, con sus consecuencias:
| Estrategia | Separador | Decimal | Se abre en Excel español | Portable |
|---|---|---|---|---|
| Estándar internacional | , |
. |
Mal: una columna | Sí |
| Convención española | ; |
, |
Bien | No |
| Mixta | ; |
. |
Bien | Sí, con aviso |
La decisión de BiblioTech, y la recomendación general:
Para ficheros que consume otro programa: separador
,, decimal.,Locale.ROOT. Es el estándar y no se discute. Para ficheros que abre una persona en Excel en España: separador;. Y para el decimal, si el fichero lo va a releer tu programa, mantén el punto y documéntalo; si es solo para leerlo, la coma.
Cómo se controla en el código, retomando lo de 07-01 y 07-02:
import java.util.Locale;
// ESCRIBIR: fijar el Locale para que el decimal sea SIEMPRE el punto
String tarifa = String.format(Locale.ROOT, "%.2f", 0.25); // "0.25"
// Sin Locale, en una maquina espanola:
String malo = String.format("%.2f", 0.25); // "0,25" <-- ambiguo
// LEER: parsear siempre con punto
double valor = Double.parseDouble("0.25"); // Double.parseDouble usa SIEMPRE punto
// Si el fichero tiene coma decimal, hay que normalizarlo antes
double valorEs = Double.parseDouble("0,25".replace(',', '.'));Un detalle útil: Double.parseDouble usa siempre el punto, independientemente del Locale del sistema. Es String.format y Scanner los que son sensibles a la configuración regional. Esa asimetría causa el fallo clásico de escribir 0,25 con format y no poder releerlo con parseDouble.
Y una defensa práctica al leer:
/** Parsea un decimal aceptando punto o coma. Tolerante al leer, estricto al escribir. */
private static double parsearDecimal(String texto, String campo, int linea) {
if (texto == null || texto.isBlank()) {
return 0.0;
}
try {
return Double.parseDouble(texto.trim().replace(',', '.'));
} catch (NumberFormatException e) {
throw new IllegalArgumentException(String.format(
"Linea %d: el campo '%s' no es un numero valido: '%s'",
linea, campo, texto), e); // encadenar la causa (06-03)
}
}Tolerante al leer, estricto al escribir. Es un principio general de diseño de formatos, y aquí ahorra muchos problemas: acepta lo que te llegue si puedes entenderlo, pero escribe siempre en la forma canónica.
- Exportar el catálogo y los préstamos a CSV
Ahora sí: ExportadorCatalogo sin apaños.
package com.nexussoftware.bibliotech.servicio;
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.dominio.Libro;
import com.nexussoftware.bibliotech.dominio.Material;
import com.nexussoftware.bibliotech.dominio.ReferenciaDuplicadaException;
import com.nexussoftware.bibliotech.infraestructura.EscritorCsv;
import com.nexussoftware.bibliotech.infraestructura.LectorCsv;
/**
* Exporta e importa el catalogo de BiblioTech en CSV conforme al RFC 4180.
*
* Sustituye a la version de 07-02, que "saneaba" los titulos sustituyendo
* los separadores por comas y PERDIA EL DATO. Ahora un titulo como
* "Java: el lenguaje, la maquina y el ecosistema" se exporta y se vuelve a
* importar identico.
*
* Persistencia sobre NIO.2 (07-06), con escritura atomica.
*/
public class ExportadorCatalogoCsv {
private static final Logger LOG =
Logger.getLogger(ExportadorCatalogoCsv.class.getName());
private static final java.nio.charset.Charset CHARSET = StandardCharsets.UTF_8;
/** Coma y punto decimal: el estandar. Para Excel espanol, ';' (apartado 7). */
private static final char SEPARADOR = ',';
private static final String[] CABECERA_CATALOGO =
{ "tipo", "referencia", "titulo", "autor", "anio", "disponible" };
private static final String[] CABECERA_PRESTAMOS =
{ "referencia", "material", "empleado", "dia_inicio",
"dia_devolucion", "multa" };
private final Path directorio;
public ExportadorCatalogoCsv(Path directorio) throws IOException {
this.directorio = Objects.requireNonNull(directorio).toAbsolutePath().normalize();
Files.createDirectories(this.directorio);
}
// ---------------------- EXPORTAR ----------------------
/**
* Exporta el catalogo a CSV, de forma atomica (07-06).
*
* @return numero de materiales exportados
*/
public int exportarCatalogo(Catalogo catalogo) throws IOException {
Objects.requireNonNull(catalogo, "El catalogo no puede ser nulo");
List<Material> materiales = catalogo.listar();
Path destino = directorio.resolve("catalogo.csv");
Path temporal = destino.resolveSibling(destino.getFileName() + ".tmp");
boolean completado = false;
try {
try (BufferedWriter salida = Files.newBufferedWriter(temporal, CHARSET,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE);
EscritorCsv csv = new EscritorCsv(salida, SEPARADOR, "\n")) {
csv.escribirCabecera(CABECERA_CATALOGO);
for (Material m : materiales) {
csv.escribirRegistro(aRegistro(m));
}
}
Files.move(temporal, destino,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
completado = true;
LOG.info(() -> String.format("Catalogo exportado: %d materiales en %s",
materiales.size(), destino));
} catch (AtomicMoveNotSupportedException e) {
LOG.warning(() -> "Sin ATOMIC_MOVE; sustitucion no atomica");
Files.move(temporal, destino, StandardCopyOption.REPLACE_EXISTING);
completado = true;
} finally {
if (!completado) {
Files.deleteIfExists(temporal); // compensacion (06-05)
}
}
return materiales.size();
}
/**
* Convierte un material a registro CSV.
*
* SIN saneado: el escritor entrecomilla lo que haga falta. El titulo
* viaja INTACTO, con sus comas, sus comillas y sus dos puntos.
*/
private List<String> aRegistro(Material m) {
List<String> campos = new ArrayList<>(6);
if (m instanceof Libro libro) { // 03-06
campos.add("LIBRO");
campos.add(libro.getIsbn());
campos.add(libro.getTitulo());
campos.add(libro.getAutor());
campos.add(String.valueOf(libro.getAnioPublicacion()));
} else {
campos.add(m.getTipo().toUpperCase(Locale.ROOT));
campos.add(m.getReferencia());
campos.add(m.getTitulo());
campos.add("");
campos.add("");
}
campos.add(String.valueOf(m.estaDisponible()));
return campos;
}
// ---------------------- IMPORTAR ----------------------
/** Resultado de una importacion (06-07, 07-04). */
public record ResultadoImportacion(int leidos, int importados, int descartados,
List<String> errores) {
public String resumen() {
StringBuilder sb = new StringBuilder();
sb.append(String.format(
"Importacion CSV: %d registros leidos, %d importados, %d descartados%n",
leidos, importados, descartados));
int n = Math.min(10, errores.size());
for (int i = 0; i < n; i++) {
sb.append(" ").append(errores.get(i)).append('\n');
}
if (errores.size() > n) {
sb.append(String.format(" ... y %d mas%n", errores.size() - n));
}
return sb.toString();
}
}
/**
* Importa el catalogo desde CSV.
*
* Politica de 06-07 y 07-04: una linea mala se descarta y se cuenta;
* el fichero ausente degrada a resultado vacio sin excepcion.
*/
public ResultadoImportacion importarCatalogo(Catalogo catalogo) throws IOException {
Objects.requireNonNull(catalogo, "El catalogo no puede ser nulo");
Path origen = directorio.resolve("catalogo.csv");
List<String> errores = new ArrayList<>();
int leidos = 0, importados = 0;
if (Files.notExists(origen)) {
LOG.warning(() -> "No hay catalogo CSV en " + origen
+ "; se arranca con catalogo vacio");
return new ResultadoImportacion(0, 0, 0, errores);
}
try (BufferedReader lector = Files.newBufferedReader(origen, CHARSET);
LectorCsv csv = new LectorCsv(lector, SEPARADOR)) {
List<String> cabecera = csv.siguiente();
if (cabecera == null) {
LOG.warning(() -> "El fichero " + origen + " esta vacio");
return new ResultadoImportacion(0, 0, 0, errores);
}
comprobarCabecera(cabecera, errores);
List<String> registro;
while ((registro = csv.siguiente()) != null) {
leidos++;
int numero = csv.getNumeroRegistro();
try {
Material material = aMaterial(registro, numero);
if (material != null) {
catalogo.registrar(material);
importados++;
}
} catch (IllegalArgumentException e) {
errores.add(e.getMessage());
} catch (ReferenciaDuplicadaException e) {
errores.add("Registro " + numero + ": " + e.getMessage());
}
}
}
int descartados = leidos - importados;
LOG.info(() -> String.format("Catalogo importado: %d de %d registros",
importados, leidos));
return new ResultadoImportacion(leidos, importados, descartados, errores);
}
/** Un cambio de columnas es un aviso, no un fallo fatal. */
private void comprobarCabecera(List<String> cabecera, List<String> errores) {
if (cabecera.size() != CABECERA_CATALOGO.length) {
errores.add(String.format(
"Cabecera con %d columnas; se esperaban %d (%s). "
+ "Se intenta importar igualmente.",
cabecera.size(), CABECERA_CATALOGO.length,
String.join(", ", CABECERA_CATALOGO)));
}
}
/** Construye el material validando cada campo. Lanza si el registro es invalido. */
private Material aMaterial(List<String> campos, int numero) {
if (campos.size() < 5) {
throw new IllegalArgumentException(String.format(
"Registro %d: %d campos, se esperaban al menos 5", numero, campos.size()));
}
String tipo = campos.get(0).trim().toUpperCase(Locale.ROOT);
if (!"LIBRO".equals(tipo)) {
throw new IllegalArgumentException(
"Registro " + numero + ": tipo no soportado '" + tipo + "'");
}
String isbn = campos.get(1).trim();
String titulo = campos.get(2).trim();
String autor = campos.get(3).trim();
if (isbn.isEmpty() || titulo.isEmpty()) {
throw new IllegalArgumentException(
"Registro " + numero + ": ISBN o titulo vacios");
}
int anio;
try {
anio = Integer.parseInt(campos.get(4).trim());
} catch (NumberFormatException e) {
throw new IllegalArgumentException(String.format(
"Registro %d: el anio '%s' no es un numero",
numero, campos.get(4)), e); // con la causa (06-03)
}
boolean disponible = campos.size() < 6
|| !"false".equalsIgnoreCase(campos.get(5).trim());
return new Libro(titulo, autor.isEmpty() ? "Desconocido" : autor,
isbn, anio, disponible);
}
// ---------------------- PRESTAMOS ----------------------
/** Exporta el registro de prestamos, con los decimales en Locale.ROOT. */
public int exportarPrestamos(List<Prestamo> prestamos) throws IOException {
Path destino = directorio.resolve("prestamos.csv");
try (BufferedWriter salida = Files.newBufferedWriter(destino, CHARSET,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE);
EscritorCsv csv = new EscritorCsv(salida, SEPARADOR, "\n")) {
csv.escribirCabecera(CABECERA_PRESTAMOS);
for (Prestamo p : prestamos) {
csv.escribirRegistro(
p.getReferencia(),
p.getMaterial().getReferencia(),
p.getTitular().getIdentificador(),
String.valueOf(p.getDiaInicio()),
String.valueOf(p.estaDevuelto() ? p.getDiaDevolucion() : -1),
// Locale.ROOT: PUNTO decimal SIEMPRE, para poder releerlo
String.format(Locale.ROOT, "%.2f", p.calcularMulta(p.getDiaInicio())));
}
}
LOG.info(() -> "Prestamos exportados: " + prestamos.size() + " en " + destino);
return prestamos.size();
}
}El fichero resultante, con el título problemático incluido:
tipo,referencia,titulo,autor,anio,disponible
LIBRO,978-0000000001,Java Efectivo,Joshua Bloch,2018,true
LIBRO,978-0000000002,Patrones de Diseno,Erich Gamma,1994,false
LIBRO,978-0000000003,Refactorizacion,Martin Fowler,1999,true
LIBRO,978-0000000004,"Java: el lenguaje, la maquina y el ecosistema",Bloch,2024,trueEl título de la última línea viaja íntegro, con su coma, y vuelve idéntico. Esa es la deuda de 07-02 saldada.
- Recomendación honesta: cuándo usar una librería
Acabas de escribir un lector CSV correcto en unas cien líneas, y funciona. Ahora la recomendación profesional, que puede sonar contradictoria:
En producción, usa una librería de CSV. OpenCSV o Apache Commons CSV son las habituales en Java, y cubren casos que tu implementación no cubre.
Qué hacen ellas y no la tuya:
| Aspecto | Tu implementación | Librería madura |
|---|---|---|
| RFC 4180 básico | Sí | Sí |
| Detección automática del separador | No | Sí |
| Correspondencia con objetos por anotaciones | No | Sí |
| Cabeceras con acceso por nombre | No | Sí |
| Detección y eliminación del BOM | Sí, la primera línea | Sí, completa |
| Formatos de dialecto (Excel, MySQL, TDF) | No | Sí |
| Rendimiento afinado | Razonable | Muy optimizado |
| Casos límite exóticos | Los que has previsto | Miles de casos reales probados |
| Mantenimiento | Tuyo | De la comunidad |
Entonces, ¿para qué implementarlo? Por tres razones que valen la pena:
- Ahora entiendes el problema. Cuando una librería te dé un resultado raro, sabrás si el fallo está en tu fichero, en la configuración del separador o en un caso límite del formato. Quien nunca lo ha implementado no puede diagnosticar eso.
- Sabes qué preguntarle a la librería. ¿Cómo maneja el BOM? ¿Qué hace con los campos vacíos? ¿Distingue
nullde""? Ahora sabes que esas preguntas existen y por qué importan. - A veces no puedes usar una librería. Un entorno restringido, una aplicación que no puede añadir dependencias, un formato propio ligeramente distinto. Entonces escribes esto, y sabes que funciona porque has hecho la prueba de ida y vuelta.
Las librerías esenciales de Java —incluidas Jackson para JSON, OpenCSV y Commons CSV— se ven en 11-07. Y hay una lección general que va más allá del CSV:
Implementa una vez para entenderlo; usa la librería para producción. Es aplicable a casi todo: parseadores, cachés, colas, algoritmos de ordenación. Entender el mecanismo te hace mejor profesional; reimplementarlo en producción te hace responsable de mantenerlo.
Properties: qué es y cómo funciona
Properties: qué es y cómo funcionajava.util.Properties es la forma clásica de configuración en Java. Es un Map<String, String> con capacidad de cargarse y guardarse en un fichero.
# Configuracion de BiblioTech - Nexus Software
# Este fichero se carga al arrancar y NO requiere recompilar la aplicacion
# --- Reglas de prestamo ---
bibliotech.prestamo.dias = 15
bibliotech.prestamo.maximo.simultaneos = 3
# --- Multas ---
bibliotech.multa.tarifa.diaria = 0.25
bibliotech.multa.maxima = 20.0
bibliotech.multa.umbral.leve = 7
# --- Rutas ---
bibliotech.datos.directorio = datos
bibliotech.datos.catalogo = catalogo.csv
# --- Registro ---
bibliotech.log.nivel = INFOLas reglas del formato:
| Elemento | Regla |
|---|---|
| Separador clave-valor | =, : o espacio. = es lo habitual |
Espacios alrededor del = |
Se ignoran |
| Comentarios | Líneas que empiezan por # o ! |
| Líneas vacías | Se ignoran |
| Continuación de línea | \ al final |
| Escapes | \n, \t, \\, \:, \=, \uXXXX |
| Clave con espacios | Hay que escaparlos: mi\ clave = valor |
| Todo es texto | No hay tipos: hay que convertir y validar |
Un detalle que sorprende: los espacios finales del valor SÍ se conservan. clave = valor guarda "valor " con tres espacios. Como son invisibles, el fallo desconcierta. Por eso conviene hacer siempre trim() al leer.
Y la trampa de las rutas en Windows:
# MAL: \d se interpreta como un escape
ruta = C:\datos\catalogo.csv
# BIEN: barra invertida duplicada
ruta = C:\\datos\\catalogo.csv
# MEJOR: barras normales, que Java acepta en Windows (07-01)
ruta = C:/datos/catalogo.csvLa API básica:
import java.util.Properties;
Properties props = new Properties();
props.setProperty("bibliotech.prestamo.dias", "15");
String dias = props.getProperty("bibliotech.prestamo.dias"); // "15"
String falta = props.getProperty("no.existe"); // null
String conDefecto = props.getProperty("no.existe", "valor por defecto"); // el defecto
props.containsKey("bibliotech.prestamo.dias"); // true
props.stringPropertyNames(); // Set<String> con todas las claves
props.size();Usa siempre
getPropertycon valor por defecto. La versión de un argumento devuelvenull, y esenullacaba en unInteger.parseIntque lanzaNumberFormatExceptioncon el mensajenull, que no dice absolutamente nada sobre qué clave faltaba. Es elnullcomo valor de retorno que 06-07 desterró.
Y una advertencia sobre la herencia de Properties: extiende Hashtable<Object, Object>, lo que le da métodos get/put que aceptan cualquier objeto y se saltan el mecanismo de valores por defecto. Usa siempre getProperty/setProperty, nunca get/put. Es un defecto de diseño de 1996 que sigue ahí.
load y store, con charset y sin sorpresas
load y store, con charset y sin sorpresasEl problema histórico de la codificación
Hasta Java 8, Properties.load(InputStream) leía en ISO-8859-1, sin posibilidad de cambiarlo. Los caracteres fuera de ese juego había que escribirlos como escapes Unicode:
# Antes de Java 9, la unica forma de escribir "Espanol" con enye:
mensaje.bienvenida = Bienvenido a BiblioTech en Espa\u00f1olIlegible e incómodo, hasta el punto de que el JDK traía una herramienta (native2ascii) para convertir ficheros automáticamente.
Desde Java 9, Properties.load(InputStream) detecta UTF-8 y solo cae a ISO-8859-1 si los bytes no son UTF-8 válido. Y desde Java 6 existen las sobrecargas que aceptan Reader, que son las que hay que usar:
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 CargarProperties {
/** Carga con charset EXPLICITO. La forma correcta, sin ambiguedades. */
public static Properties cargar(Path fichero) throws IOException {
Properties props = new Properties();
try (BufferedReader lector = Files.newBufferedReader(
fichero, StandardCharsets.UTF_8)) { // 07-06
props.load(lector); // load(Reader): el charset lo decide el Reader
}
return props;
}
/** Guarda con charset explicito. */
public static void guardar(Properties props, Path fichero, String comentario)
throws IOException {
try (java.io.BufferedWriter salida = Files.newBufferedWriter(
fichero, StandardCharsets.UTF_8,
java.nio.file.StandardOpenOption.CREATE,
java.nio.file.StandardOpenOption.TRUNCATE_EXISTING,
java.nio.file.StandardOpenOption.WRITE)) {
props.store(salida, comentario);
}
}
}Usa siempre las sobrecargas de
Reader/Writer, no las deInputStream/OutputStream. ConReaderel charset lo decides tú explícitamente, igual que en 07-01. ConInputStreamdependes de la heurística de la JVM.
Qué escribe store:
#Configuracion generada por BiblioTech
#Wed Aug 05 09:14:22 CEST 2026
bibliotech.multa.tarifa.diaria=0.25
bibliotech.prestamo.dias=15
bibliotech.datos.directorio=datosDos observaciones sobre esa salida:
storeañade una línea con la fecha. Eso hace que el fichero cambie en cada guardado aunque el contenido sea idéntico, lo que genera ruido en el control de versiones. Si el fichero se versiona, considera escribirlo tú con elEscritorCsv... o más bien con unBufferedWritery tu propio formato.storeno conserva el orden ni los comentarios originales.Propertieses unHashtable: sin orden. Si cargas un fichero cuidadosamente comentado y lo vuelves a guardar, pierdes todos los comentarios y el orden. Por eso los ficheros de configuración de un proyecto se editan a mano y se leen conload;storese reserva para configuración generada por la aplicación.
Y una alternativa muy útil para configuración que viaja dentro del .jar:
/** Carga desde el classpath: el fichero va dentro del jar y no se puede perder. */
public static Properties cargarDelClasspath(String recurso) throws IOException {
Properties props = new Properties();
try (java.io.InputStream entrada =
Configuracion.class.getResourceAsStream(recurso)) {
if (entrada == null) {
throw new java.io.FileNotFoundException(
"No se encontro el recurso '" + recurso + "' en el classpath");
}
try (java.io.Reader lector = new java.io.InputStreamReader(
entrada, StandardCharsets.UTF_8)) { // el PUENTE de 07-03
props.load(lector);
}
}
return props;
}Es el patrón habitual para los valores por defecto: van dentro del .jar, siempre están, y el fichero externo solo los sobrescribe.
- Propiedades encadenadas por defecto
Properties tiene un mecanismo poco conocido y muy útil: una instancia puede tener otra como respaldo.
// Nivel 1: valores por defecto, en el codigo
Properties porDefecto = new Properties();
porDefecto.setProperty("bibliotech.prestamo.dias", "15");
porDefecto.setProperty("bibliotech.multa.tarifa.diaria", "0.25");
porDefecto.setProperty("bibliotech.multa.maxima", "20.0");
// Nivel 2: el fichero, CON los defectos como respaldo
Properties configuracion = new Properties(porDefecto);
configuracion.load(lector); // el fichero solo trae 'dias'
configuracion.getProperty("bibliotech.prestamo.dias"); // del fichero
configuracion.getProperty("bibliotech.multa.tarifa.diaria"); // "0.25", del defecto
configuracion.getProperty("bibliotech.multa.maxima"); // "20.0", del defectogetProperty busca primero en la instancia y, si no encuentra la clave, en la de respaldo, recursivamente. El fichero solo necesita declarar lo que cambia.
Es limpio, pero tiene dos trampas serias que hay que conocer:
1. stringPropertyNames() sí incluye los valores por defecto, pero keySet() y size() NO.
configuracion.size(); // 1: solo lo del fichero
configuracion.stringPropertyNames().size(); // 3: incluye los defectosIterar con keySet() se salta los valores por defecto sin avisar. Usa siempre stringPropertyNames().
2. store() no guarda los valores por defecto. Si cargas con respaldo y guardas, el fichero resultante solo tiene lo explícito. Eso es lo correcto —el fichero declara lo que cambia— pero sorprende si esperabas un volcado completo.
Por estas dos trampas, y porque la jerarquía real necesita más de dos niveles, BiblioTech implementará su propia clase Configuracion en lugar de usar el encadenamiento directamente.
- La jerarquía de configuración y su precedencia
Una aplicación profesional obtiene su configuración de varias fuentes, con una precedencia definida:
flowchart TD
A["1. Valores por defecto<br/>en el codigo<br/>(menor prioridad)"] --> B["2. Fichero del classpath<br/>bibliotech-defecto.properties"]
B --> C["3. Fichero externo<br/>bibliotech.properties"]
C --> D["4. Variables de entorno<br/>BIBLIOTECH_PRESTAMO_DIAS"]
D --> E["5. Propiedades del sistema<br/>-Dbibliotech.prestamo.dias=20<br/>(mayor prioridad)"]
style A fill:#e3f2fd
style E fill:#c8e6c9
Cada nivel sobrescribe al anterior. La tabla de precedencia, de menor a mayor:
| Nivel | Fuente | Quién la controla | Cuándo se usa |
|---|---|---|---|
| 1 | Constantes del código | El desarrollador | Siempre hay un valor: la aplicación nunca se queda sin |
| 2 | Fichero del classpath | El desarrollador | Los valores de fábrica, dentro del .jar |
| 3 | Fichero externo | El administrador | La configuración del despliegue |
| 4 | Variables de entorno | El sistema o el contenedor | Despliegues en contenedores y en la nube |
| 5 | -D en la línea de comandos |
Quien ejecuta | Pruebas y ajustes puntuales sin tocar nada |
Por qué este orden exacto:
- Los defectos del código van los últimos en prioridad porque son la red de seguridad: garantizan que la aplicación arranca aunque no haya ningún fichero. Es la degradación elegante de 06-07 aplicada a la configuración.
- Las variables de entorno están por encima del fichero porque en un contenedor no siempre se puede montar un fichero, y son la forma estándar de configurar en Docker y Kubernetes.
-Dgana a todo porque es la forma explícita e inmediata de decir "esta vez, esto", sin tocar nada permanente. Es lo que usa quien depura un problema en producción.
Convención de nombres entre -D y las variables de entorno:
| Propiedad | Variable de entorno |
|---|---|
bibliotech.prestamo.dias |
BIBLIOTECH_PRESTAMO_DIAS |
bibliotech.multa.tarifa.diaria |
BIBLIOTECH_MULTA_TARIFA_DIARIA |
La transformación es mecánica: mayúsculas y los puntos por guiones bajos. Es la convención de facto en el ecosistema —la usa Spring Boot, que verás en 11-02— y hay que respetarla para que nadie tenga que aprenderse dos nombres.
- La clase
Configuracion de BiblioTech
Configuracion 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;
/**
* Configuracion de BiblioTech con jerarquia de fuentes.
*
* Precedencia, de menor a mayor:
* 1. valores por defecto del codigo
* 2. bibliotech-defecto.properties del classpath
* 3. fichero externo bibliotech.properties
* 4. variables de entorno (BIBLIOTECH_XXX_YYY)
* 5. propiedades del sistema (-Dbibliotech.xxx.yyy)
*
* DEGRADACION ELEGANTE (06-07): si no hay ningun fichero, la aplicacion
* arranca con los valores por defecto y lo registra. Configurar mal no debe
* impedir arrancar; lo que SI debe impedirlo es un valor INVALIDO, porque
* calcular multas con una tarifa desconocida produciria cobros incorrectos.
*/
public final class Configuracion {
private static final Logger LOG = Logger.getLogger(Configuracion.class.getName());
private static final String RECURSO_DEFECTO = "/bibliotech-defecto.properties";
private static final String FICHERO_EXTERNO = "bibliotech.properties";
private static final String PREFIJO = "bibliotech.";
// ---------- Claves ----------
public static final String PRESTAMO_DIAS = "bibliotech.prestamo.dias";
public static final String PRESTAMO_MAXIMO = "bibliotech.prestamo.maximo.simultaneos";
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_UMBRAL_LEVE = "bibliotech.multa.umbral.leve";
public static final String DATOS_DIRECTORIO = "bibliotech.datos.directorio";
public static final String DATOS_CATALOGO = "bibliotech.datos.catalogo";
public static final String CSV_SEPARADOR = "bibliotech.csv.separador";
/** Las propiedades ya resueltas por la jerarquia. */
private final Properties resueltas = new Properties();
/** De donde salio cada valor. Sirve para diagnosticar. */
private final TreeMap<String, String> origen = new TreeMap<>();
private static Configuracion instancia;
private Configuracion() { }
/**
* Carga la configuracion. Idempotente.
*
* @param rutaFichero fichero externo, o null para el nombre por defecto
*/
public static synchronized Configuracion cargar(Path rutaFichero) {
if (instancia != null) {
return instancia;
}
Configuracion c = new Configuracion();
c.aplicarDefectosDelCodigo();
c.aplicarRecursoDelClasspath();
c.aplicarFicheroExterno(rutaFichero);
c.aplicarVariablesDeEntorno();
c.aplicarPropiedadesDelSistema();
c.validar();
instancia = c;
LOG.info(() -> "Configuracion cargada: " + c.resueltas.size() + " propiedades");
return instancia;
}
public static synchronized Configuracion get() {
if (instancia == null) {
return cargar(null);
}
return instancia;
}
/** Solo para pruebas: permite recargar entre casos. */
static synchronized void reiniciar() { instancia = null; }
// ---------------------- LOS CINCO NIVELES ----------------------
/**
* NIVEL 1: los defectos del codigo.
*
* Son los valores que llevan siete modulos escritos a fuego. Ahora estan
* en un solo sitio y son solo el ULTIMO recurso, no la unica opcion.
*/
private void aplicarDefectosDelCodigo() {
poner(PRESTAMO_DIAS, "15", "defecto del codigo");
poner(PRESTAMO_MAXIMO, "3", "defecto del codigo");
poner(MULTA_TARIFA_DIARIA, "0.25", "defecto del codigo");
poner(MULTA_MAXIMA, "20.0", "defecto del codigo");
poner(MULTA_UMBRAL_LEVE, "7", "defecto del codigo");
poner(DATOS_DIRECTORIO, "datos", "defecto del codigo");
poner(DATOS_CATALOGO, "catalogo.csv", "defecto del codigo");
poner(CSV_SEPARADOR, ",", "defecto del codigo");
}
/** NIVEL 2: el fichero de fabrica, dentro del jar. */
private void aplicarRecursoDelClasspath() {
try (InputStream entrada = Configuracion.class.getResourceAsStream(RECURSO_DEFECTO)) {
if (entrada == null) {
LOG.fine(() -> "Sin recurso " + RECURSO_DEFECTO + " en el classpath");
return;
}
// El PUENTE de 07-03 con charset explicito
try (Reader lector = new InputStreamReader(entrada, StandardCharsets.UTF_8)) {
volcar(leer(lector), "classpath " + RECURSO_DEFECTO);
}
} catch (IOException e) {
LOG.log(Level.WARNING, "No se pudo leer " + RECURSO_DEFECTO, e);
}
}
/**
* NIVEL 3: el fichero externo.
*
* Su ausencia NO es un error: la primera ejecucion no lo tiene.
* Degradacion elegante (06-07, 07-01).
*/
private void aplicarFicheroExterno(Path ruta) {
Path fichero = (ruta != null) ? ruta : Path.of(FICHERO_EXTERNO);
if (Files.notExists(fichero)) {
LOG.info(() -> "No hay fichero de configuracion en "
+ fichero.toAbsolutePath()
+ "; se usan los valores por defecto");
return;
}
try (BufferedReader lector = Files.newBufferedReader(
fichero, StandardCharsets.UTF_8)) { // 07-06
volcar(leer(lector), "fichero " + fichero.toAbsolutePath());
LOG.info(() -> "Configuracion leida de " + fichero.toAbsolutePath());
} catch (IOException e) {
// Un fichero ILEGIBLE si es un aviso serio: alguien lo puso ahi
// esperando que se aplicara, y no se esta aplicando.
LOG.log(Level.SEVERE, "No se pudo leer " + fichero.toAbsolutePath()
+ "; se sigue con los valores anteriores", e);
}
}
/**
* NIVEL 4: variables de entorno.
*
* BIBLIOTECH_PRESTAMO_DIAS -> bibliotech.prestamo.dias
*/
private void aplicarVariablesDeEntorno() {
for (var entrada : System.getenv().entrySet()) {
String variable = entrada.getKey();
if (!variable.startsWith("BIBLIOTECH_")) {
continue;
}
String clave = variable.toLowerCase(Locale.ROOT).replace('_', '.');
poner(clave, entrada.getValue(), "variable de entorno " + variable);
}
}
/**
* NIVEL 5: propiedades del sistema (-D). La mayor prioridad.
*/
private void aplicarPropiedadesDelSistema() {
Properties sistema = System.getProperties();
for (String clave : sistema.stringPropertyNames()) {
if (clave.startsWith(PREFIJO)) {
poner(clave, sistema.getProperty(clave), "-D" + clave);
}
}
}
private Properties leer(Reader lector) throws IOException {
Properties p = new Properties();
p.load(lector);
return p;
}
private void volcar(Properties origenProps, String descripcion) {
// stringPropertyNames, NO keySet: incluye los valores por defecto (apartado 12)
for (String clave : origenProps.stringPropertyNames()) {
poner(clave, origenProps.getProperty(clave), descripcion);
}
}
private void poner(String clave, String valor, String descripcionOrigen) {
if (valor == null) {
return;
}
// trim: los espacios finales del valor SI se conservan en .properties
resueltas.setProperty(clave, valor.trim());
origen.put(clave, descripcionOrigen);
}
// ---------------------- VALIDACION ----------------------
/**
* Comprueba que los valores son coherentes.
*
* Distincion de 06-07: un fichero AUSENTE degrada (se usan los defectos);
* un valor INVALIDO ABORTA, porque calcular multas con una tarifa
* desconocida produciria cobros incorrectos, y eso es peor que no arrancar.
*/
private void validar() {
int dias = getEntero(PRESTAMO_DIAS, 15);
if (dias < 1 || dias > 365) {
throw new ConfiguracionInvalidaException(PRESTAMO_DIAS,
String.valueOf(dias), "debe estar entre 1 y 365");
}
int maximo = getEntero(PRESTAMO_MAXIMO, 3);
if (maximo < 1 || maximo > 50) {
throw new ConfiguracionInvalidaException(PRESTAMO_MAXIMO,
String.valueOf(maximo), "debe estar entre 1 y 50");
}
double tarifa = getDecimal(MULTA_TARIFA_DIARIA, 0.25);
if (tarifa < 0 || tarifa > 100) {
throw new ConfiguracionInvalidaException(MULTA_TARIFA_DIARIA,
String.valueOf(tarifa), "debe estar entre 0 y 100 EUR/dia");
}
double maxima = getDecimal(MULTA_MAXIMA, 20.0);
if (maxima < 0) {
throw new ConfiguracionInvalidaException(MULTA_MAXIMA,
String.valueOf(maxima), "no puede ser negativa");
}
if (maxima < tarifa) {
throw new ConfiguracionInvalidaException(MULTA_MAXIMA,
String.valueOf(maxima),
"no puede ser menor que la tarifa diaria (" + tarifa + ")");
}
int umbral = getEntero(MULTA_UMBRAL_LEVE, 7);
if (umbral < 0 || umbral > dias) {
throw new ConfiguracionInvalidaException(MULTA_UMBRAL_LEVE,
String.valueOf(umbral),
"debe estar entre 0 y el plazo de prestamo (" + dias + ")");
}
}
// ---------------------- ACCESO TIPADO ----------------------
public String getTexto(String clave, String porDefecto) {
return resueltas.getProperty(clave, porDefecto);
}
/**
* Entero con valor por defecto.
*
* Un valor no numerico NO revienta la aplicacion: se avisa y se usa el
* defecto. La validacion posterior comprobara el rango.
*/
public int getEntero(String clave, int porDefecto) {
String valor = resueltas.getProperty(clave);
if (valor == null || valor.isBlank()) {
return porDefecto;
}
try {
return Integer.parseInt(valor.trim());
} catch (NumberFormatException e) {
LOG.warning(() -> String.format(
"La propiedad %s tiene el valor no numerico '%s' (origen: %s); "
+ "se usa %d", clave, valor, origen.get(clave), porDefecto));
return porDefecto;
}
}
/** Decimal. Acepta coma o punto al leer, escribe siempre punto (apartado 7). */
public double getDecimal(String clave, double porDefecto) {
String valor = resueltas.getProperty(clave);
if (valor == null || valor.isBlank()) {
return porDefecto;
}
try {
return Double.parseDouble(valor.trim().replace(',', '.'));
} catch (NumberFormatException e) {
LOG.warning(() -> String.format(
"La propiedad %s tiene el valor no numerico '%s' (origen: %s); "
+ "se usa %s", clave, valor, origen.get(clave), porDefecto));
return porDefecto;
}
}
public boolean getBooleano(String clave, boolean porDefecto) {
String valor = resueltas.getProperty(clave);
if (valor == null || valor.isBlank()) {
return porDefecto;
}
String limpio = valor.trim().toLowerCase(Locale.ROOT);
return switch (limpio) { // switch de expresion (02-03)
case "true", "si", "yes", "1", "on" -> true;
case "false", "no", "0", "off" -> false;
default -> {
LOG.warning(() -> "Valor booleano no reconocido en " + clave
+ ": '" + valor + "'; se usa " + porDefecto);
yield porDefecto;
}
};
}
public char getCaracter(String clave, char porDefecto) {
String valor = resueltas.getProperty(clave);
return (valor == null || valor.isEmpty()) ? porDefecto : valor.charAt(0);
}
public Path getRuta(String clave, String porDefecto) {
return Path.of(getTexto(clave, porDefecto));
}
/**
* Volcado de diagnostico: cada propiedad con su valor y su ORIGEN.
*
* Saber DE DONDE viene cada valor es lo que permite resolver en un
* minuto el clasico "he cambiado el fichero y no hace caso": casi
* siempre hay una variable de entorno o un -D pisandolo.
*/
public String volcado() {
StringBuilder sb = new StringBuilder();
sb.append("=== CONFIGURACION EFECTIVA ===\n");
for (String clave : new TreeMap<>(origen).keySet()) {
sb.append(String.format(" %-42s = %-16s [%s]%n",
clave, resueltas.getProperty(clave), origen.get(clave)));
}
return sb.toString();
}
/** Configuracion invalida: la aplicacion NO debe arrancar asi. */
public static class ConfiguracionInvalidaException extends RuntimeException {
private static final long serialVersionUID = 1L; // 07-05
private final String clave;
private final String valor;
public ConfiguracionInvalidaException(String clave, String valor, String motivo) {
super(String.format(
"Configuracion invalida: %s = '%s'. %s. "
+ "Corrige el fichero de configuracion y vuelve a arrancar.",
clave, valor, motivo));
this.clave = clave;
this.valor = valor;
}
public String getClave() { return clave; }
public String getValor() { return valor; }
}
}
- Las cuatro constantes dejan de ser constantes
El momento prometido desde el módulo 1. Así estaban las cuatro constantes:
// ANTES: escritas a fuego, repartidas por tres clases
public class Prestamo {
public static final int DIAS_PRESTAMO = 15; // 06-03
}
public abstract class Material {
public static final double MULTA_MAXIMA = 20.0; // 04-02
public static final int UMBRAL_LEVE = 7; // 04-02
}
public class Libro extends Material {
public static final double TARIFA_DIARIA_LIBRO = 0.25; // 04-02
}
public class Empleado {
public static final int MAX_PRESTAMOS_SIMULTANEOS = 3; // 03-03
}Cambiar la tarifa exigía editar el código, recompilar, empaquetar y desplegar. Ahora:
package com.nexussoftware.bibliotech.dominio;
import com.nexussoftware.bibliotech.infraestructura.Configuracion;
/**
* Reglas de negocio de BiblioTech, leidas de la configuracion.
*
* Sustituye a las constantes repartidas por el dominio desde el modulo 3.
* Los valores se leen UNA VEZ al arrancar y quedan fijos durante la
* ejecucion: cambiarlos a mitad haria que dos prestamos del mismo dia se
* calcularan con reglas distintas, que es inaceptable en un sistema que
* cobra dinero.
*
* Para recargar la configuracion hay que reiniciar la aplicacion. Es una
* decision DELIBERADA, no una limitacion.
*/
public final class ReglasNegocio {
private static final Configuracion CONFIG = Configuracion.get();
/** Dias de plazo de prestamo. Antes: Prestamo.DIAS_PRESTAMO = 15 */
public static final int DIAS_PRESTAMO =
CONFIG.getEntero(Configuracion.PRESTAMO_DIAS, 15);
/** Prestamos simultaneos por empleado. Antes: Empleado.MAX_PRESTAMOS = 3 */
public static final int MAX_PRESTAMOS_SIMULTANEOS =
CONFIG.getEntero(Configuracion.PRESTAMO_MAXIMO, 3);
/** EUR por dia de retraso. Antes: Libro.TARIFA_DIARIA_LIBRO = 0.25 */
public static final double TARIFA_DIARIA =
CONFIG.getDecimal(Configuracion.MULTA_TARIFA_DIARIA, 0.25);
/** Tope de multa. Antes: Material.MULTA_MAXIMA = 20.0 */
public static final double MULTA_MAXIMA =
CONFIG.getDecimal(Configuracion.MULTA_MAXIMA, 20.0);
/** Dias hasta que el retraso deja de ser leve. Antes: Material.UMBRAL_LEVE = 7 */
public static final int UMBRAL_LEVE =
CONFIG.getEntero(Configuracion.MULTA_UMBRAL_LEVE, 7);
private ReglasNegocio() { }
/** Resumen para el arranque, para que quede constancia de con que reglas se opera. */
public static String resumen() {
return String.format(
"Reglas: %d dias de prestamo, maximo %d simultaneos, "
+ "%.2f EUR/dia, tope %.2f EUR, umbral leve %d dias",
DIAS_PRESTAMO, MAX_PRESTAMOS_SIMULTANEOS,
TARIFA_DIARIA, MULTA_MAXIMA, UMBRAL_LEVE);
}
}Y el dominio pasa a usarlas:
public abstract class Material implements Prestable, Notificable {
// Antes: constantes literales. Ahora: leidas de la configuracion.
// El Template Method de 04-02 no cambia ni una linea: solo cambia de
// donde salen los numeros.
public final double calcularMulta(int diasTranscurridos) {
int retraso = calcularDiasRetraso(diasTranscurridos);
double bruta = retraso * getTarifaDiaria();
return Math.min(bruta, ReglasNegocio.MULTA_MAXIMA);
}
public final Gravedad clasificarGravedad(int diasTranscurridos) {
int retraso = calcularDiasRetraso(diasTranscurridos);
if (retraso == 0) { return Gravedad.SIN_RETRASO; }
if (retraso <= ReglasNegocio.UMBRAL_LEVE) { return Gravedad.LEVE; }
return Gravedad.GRAVE;
}
}El arranque completo:
package com.nexussoftware.bibliotech.presentacion;
import java.io.IOException;
import java.nio.file.Path;
import java.util.logging.Level;
import java.util.logging.Logger;
import com.nexussoftware.bibliotech.dominio.ReglasNegocio;
import com.nexussoftware.bibliotech.infraestructura.ConfiguracionLog;
import com.nexussoftware.bibliotech.infraestructura.Configuracion;
import com.nexussoftware.bibliotech.servicio.Catalogo;
import com.nexussoftware.bibliotech.servicio.ExportadorCatalogoCsv;
public class BiblioTechApp {
private static final Logger LOG = Logger.getLogger(BiblioTechApp.class.getName());
public static void main(String[] args) {
ConfiguracionLog.inicializar(); // 06-07
Configuracion config;
try {
Path ruta = (args.length > 0) ? Path.of(args[0]) : null;
config = Configuracion.cargar(ruta);
} catch (Configuracion.ConfiguracionInvalidaException e) {
// Frontera de errores del main (06-07). Un valor invalido ABORTA:
// arrancar con una tarifa absurda produciria cobros incorrectos.
System.err.println("ERROR DE CONFIGURACION");
System.err.println(" " + e.getMessage());
LOG.log(Level.SEVERE, "Arranque abortado por configuracion invalida", e);
System.exit(2);
return;
}
LOG.config(config::volcado);
System.out.println("BiblioTech - Nexus Software");
System.out.println(ReglasNegocio.resumen());
try {
Path directorio = config.getRuta(Configuracion.DATOS_DIRECTORIO, "datos");
ExportadorCatalogoCsv persistencia = new ExportadorCatalogoCsv(directorio);
Catalogo catalogo = new Catalogo();
var resultado = persistencia.importarCatalogo(catalogo);
System.out.print(resultado.resumen());
// Guardado al salir (07-02)
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
int n = persistencia.exportarCatalogo(catalogo);
LOG.info("Catalogo guardado al salir: " + n + " materiales");
} catch (IOException e) {
LOG.log(Level.SEVERE, "NO SE PUDO GUARDAR EL CATALOGO", e);
}
}, "guardado-final"));
new MenuBiblioTech(catalogo, persistencia).ejecutar();
} catch (IOException e) {
System.err.println("No se pudo acceder al almacen de datos: " + e.getMessage());
LOG.log(Level.SEVERE, "Fallo de E/S en el arranque", e);
System.exit(3);
}
}
}La demostración del cambio, sin recompilar nada:
# 1. Sin fichero de configuracion: valores por defecto
$ java -cp bibliotech.jar com.nexussoftware.bibliotech.presentacion.BiblioTechApp
BiblioTech - Nexus Software
Reglas: 15 dias de prestamo, maximo 3 simultaneos, 0,25 EUR/dia, tope 20,00 EUR, umbral leve 7 dias
# 2. Con fichero de configuracion
$ cat bibliotech.properties
bibliotech.prestamo.dias = 21
bibliotech.multa.tarifa.diaria = 0.50
$ java -cp bibliotech.jar com.nexussoftware.bibliotech.presentacion.BiblioTechApp
BiblioTech - Nexus Software
Reglas: 21 dias de prestamo, maximo 3 simultaneos, 0,50 EUR/dia, tope 20,00 EUR, umbral leve 7 dias
# 3. Variable de entorno: pisa al fichero
$ BIBLIOTECH_MULTA_MAXIMA=50.0 java -cp bibliotech.jar ...
Reglas: 21 dias de prestamo, maximo 3 simultaneos, 0,50 EUR/dia, tope 50,00 EUR, umbral leve 7 dias
# 4. -D: pisa a todo
$ java -Dbibliotech.prestamo.dias=7 -cp bibliotech.jar ...
Reglas: 7 dias de prestamo, maximo 3 simultaneos, 0,50 EUR/dia, tope 50,00 EUR, umbral leve 7 dias
# 5. Valor invalido: la aplicacion NO arranca
$ java -Dbibliotech.multa.maxima=-5 -cp bibliotech.jar ...
ERROR DE CONFIGURACION
Configuracion invalida: bibliotech.multa.maxima = '-5.0'. no puede ser negativa.
Corrige el fichero de configuracion y vuelve a arrancar.
$ echo $?
2El caso 5 es el que demuestra que el diseño es correcto. Un fichero ausente degrada; un valor inválido aborta con un código de salida distinguible. Es exactamente la distinción de 06-07: degrada cuando el servicio reducido siga siendo correcto; aborta cuando continuar produciría resultados incorrectos. Una biblioteca sin catálogo es correcta; una multa máxima negativa produce cobros absurdos.
Y el volcado de diagnóstico, que resuelve el clásico "he cambiado el fichero y no hace caso":
=== CONFIGURACION EFECTIVA ===
bibliotech.csv.separador = , [defecto del codigo]
bibliotech.datos.catalogo = catalogo.csv [defecto del codigo]
bibliotech.datos.directorio = datos [fichero /home/marta/bibliotech.properties]
bibliotech.multa.maxima = 50.0 [variable de entorno BIBLIOTECH_MULTA_MAXIMA]
bibliotech.multa.tarifa.diaria = 0.50 [fichero /home/marta/bibliotech.properties]
bibliotech.multa.umbral.leve = 7 [defecto del codigo]
bibliotech.prestamo.dias = 7 [-Dbibliotech.prestamo.dias]
bibliotech.prestamo.maximo.simultaneos = 3 [defecto del codigo]Cada valor con su origen. Es una de esas funcionalidades pequeñas que ahorran horas de desconcierto.
- Nunca guardes credenciales en el repositorio
Una advertencia que hay que dar con toda la claridad posible.
NUNCA pongas contraseñas, claves de API, tokens ni cadenas de conexión con credenciales en un fichero de propiedades que esté en el control de versiones.
Por qué, más allá de lo evidente:
- El historial de Git es permanente. Borrar la contraseña en un commit posterior no la elimina: sigue en el historial y cualquiera con acceso al repositorio puede recuperarla. Reescribir el historial es doloroso y no siempre posible.
- Los repositorios se clonan y se comparten. Cada persona que ha tenido acceso alguna vez tiene una copia completa del historial en su portátil.
- Los repositorios se hacen públicos por error. Ocurre constantemente. Existen robots que rastrean permanentemente los repositorios públicos buscando claves, y el tiempo entre publicar una clave y que se use suele medirse en minutos.
- Las copias de seguridad multiplican las copias del fichero por todas partes.
Qué hacer en su lugar:
| Enfoque | Cómo | Cuándo |
|---|---|---|
| Variables de entorno | BIBLIOTECH_BD_CLAVE |
Contenedores, nube. Lo más habitual |
| Fichero fuera del repositorio | /etc/bibliotech/secretos.properties, con permisos 600 |
Servidores propios |
| Gestor de secretos | Vault, AWS Secrets Manager, Azure Key Vault | Producción seria |
| Fichero local ignorado | bibliotech-local.properties en .gitignore |
Desarrollo |
El patrón que usa BiblioTech:
# bibliotech.properties: SI va en el repositorio.
# Configuracion funcional, sin ningun secreto.
bibliotech.prestamo.dias = 15
bibliotech.multa.tarifa.diaria = 0.25
# Los secretos NO van aqui. Se leen de variables de entorno:
# BIBLIOTECH_BD_USUARIO
# BIBLIOTECH_BD_CLAVE
# En desarrollo, ponlos en bibliotech-local.properties, que esta en .gitignore./**
* Lee un secreto. NUNCA del fichero del repositorio, y NUNCA con un valor
* por defecto: un secreto ausente debe fallar, no funcionar a medias.
*/
public String getSecreto(String variableDeEntorno) {
String valor = System.getenv(variableDeEntorno);
if (valor == null || valor.isBlank()) {
throw new IllegalStateException(String.format(
"Falta la variable de entorno %s. Los secretos no se leen del "
+ "fichero de configuracion. Consulta la documentacion "
+ "de despliegue.", variableDeEntorno));
}
// Y NUNCA registrarlo (06-07): ni el valor, ni una parte, ni su longitud.
LOG.config(() -> "Secreto " + variableDeEntorno + " cargado correctamente");
return valor;
}Fíjate en las dos decisiones: sin valor por defecto —un secreto ausente debe fallar ruidosamente, no dejar la aplicación funcionando de forma insegura— y sin registrar el valor, aplicando lo de 06-07 sobre qué no debe aparecer nunca en el log.
Y el aviso formal, como en 07-05: esto es una introducción, no una guía de seguridad. La gestión de secretos en un sistema real la define y la revisa el responsable de seguridad de tu organización. La seguridad de aplicaciones se trata, en lo que a este curso corresponde, en 12-07.
- Otros formatos: XML, JSON y YAML
Para cerrar el mapa de formatos de intercambio:
| Formato | Estructura | Legible | Tipos | Comentarios | En Java |
|---|---|---|---|---|---|
| Properties | Plana clave-valor | Sí | No: todo texto | Sí (#) |
java.util.Properties |
| CSV | Tabular | Sí | No: todo texto | No estándar | A mano o librería |
| XML | Jerárquica con atributos | Regular | Con esquema | Sí | JAXP, JAXB, en el JDK |
| JSON | Jerárquica | Sí | Sí: número, texto, booleano, nulo | No | Jackson, Gson (11-07) |
| YAML | Jerárquica, por indentación | Muy | Sí | Sí | SnakeYAML |
XML está en el JDK y no necesita dependencias. Es verboso y ha caído en desuso para intercambio, pero sigue vivo en configuración empresarial, en SOAP y en formatos de documentos. Tiene un riesgo de seguridad conocido, las entidades externas (XXE), que exige desactivar explícitamente el procesamiento de entidades al parsear datos ajenos.
JSON es el estándar actual para APIs web. Tiene tipos —a diferencia de CSV y Properties—, es jerárquico y todos los lenguajes lo entienden. Su gran ausencia son los comentarios, lo que lo hace incómodo como formato de configuración. Se trata en 11-07, con Jackson.
YAML es el formato de configuración de moda: legible, jerárquico, con comentarios. Su gran defecto es la sensibilidad a la indentación, que produce errores sutiles, y su especificación es sorprendentemente compleja.
Qué elegir:
| Necesitas | Formato |
|---|---|
| Configuración plana de una aplicación | Properties |
| Datos tabulares para una hoja de cálculo | CSV |
| API web | JSON |
| Configuración jerárquica compleja | YAML |
| Documentos con esquema y validación estricta | XML |
| Máximo rendimiento y solo Java | Binario (07-03) |
Errores Comunes y Consejos
- Partir un CSV con
split(","). Falla con separadores dentro de campos, comillas escapadas y saltos de línea. Es el error que perdía el título de los libros de BiblioTech. - Olvidar el
-1ensplit."a,b,".split(",")da 2 elementos, no 3. Descarta los campos vacíos finales. - No quitar el BOM. La primera columna de la cabecera no coincide con nada y el fichero se ve perfecto en el editor. Uno de los fallos más desconcertantes que existen.
- Intentar parsear CSV con una expresión regular. Funciona para el caso simple e ilegible, y falla con lo demás. Un autómata de dos estados son cuarenta líneas y sí funciona.
- Escapar las comillas con barra invertida. CSV duplica la comilla:
"", no\". - Suponer que un registro es una línea. Un campo con salto de línea dentro ocupa varias líneas físicas.
- Confundir
nullcon cadena vacía en CSV. El formato no los distingue. Si tu dominio los distingue, usa un centinela documentado o cambia de formato. - Usar coma como separador y esperar que se abra bien en Excel español. Se abre como una sola columna.
;para consumo humano en España,,para intercambio. - Escribir decimales sin
Locale.ROOT. En una máquina española,String.format("%.2f", 0.25)da0,25, que con separador coma hace el fichero ambiguo y no se puede releer. - No hacer la prueba de ida y vuelta. Escribir, leer y comparar con el original es la única forma seria de validar un formato. Es lo que faltaba en el
sanear()de 07-02. - Usar
props.get()en lugar degetProperty().getviene deHashtabley se salta el mecanismo de valores por defecto. - Usar
keySet()en lugar destringPropertyNames(). El primero no incluye los valores por defecto encadenados. - No hacer
trim()a los valores leídos. Los espacios finales sí se conservan en.properties, y son invisibles. - Barras invertidas sin escapar en las rutas de Windows.
C:\datosinterpreta\dcomo escape. Usa/o duplica la barra. - Cargar
.propertiessin charset explícito. Usa las sobrecargas deReader, no las deInputStream. - Usar
store()sobre un fichero cuidadosamente comentado.Propertiesno conserva ni el orden ni los comentarios: los pierdes todos. getPropertysin valor por defecto. Devuelvenull, que acaba en unparseIntcon el mensajenull, que no dice qué clave faltaba.- No validar los valores leídos. Una tarifa de −5 EUR/día pasa sin más y produce multas negativas. Validar es obligatorio.
- Abortar porque falta el fichero de configuración. Es lo contrario de lo correcto: la ausencia degrada a los defectos; lo que aborta es un valor inválido.
- Guardar contraseñas en el fichero del repositorio. El historial de Git es permanente y los robots rastrean los repositorios públicos constantemente.
- Registrar el valor de un secreto en el log. Ni el valor, ni una parte, ni su longitud.
- Consejo: haz siempre la prueba de ida y vuelta con los casos difíciles. Comas, comillas, saltos de línea, campos vacíos y espacios en los extremos. Si vuelven idénticos, el formato es correcto.
- Consejo: registra el origen de cada valor de configuración. El volcado con
[fichero],[variable de entorno],[-D]resuelve en un minuto el clásico "he cambiado el fichero y no hace caso". - Consejo: tolerante al leer, estricto al escribir. Acepta coma o punto decimal al leer; escribe siempre punto.
- Consejo: implementa una vez para entenderlo, usa la librería en producción. Vale para CSV y para casi todo lo demás.
Ejercicios
Ejercicio 1: batería de pruebas del CSV
Escribe PruebaCsvCompleta que valide LectorCsv y EscritorCsv con una batería sistemática:
- Una tabla de al menos doce casos: campo normal, con separador, con comillas, con salto de línea, vacío, solo espacios, con espacios en los extremos, con acentos y eñes, con el separador alternativo, con comillas al principio, con comillas en medio sin entrecomillar el campo, y un campo muy largo.
- Para cada caso: escribir, leer y comparar con el original, informando OK o FALLO.
- Un contador final de casos superados.
- Pruebas de los fallos: un fichero con comillas sin cerrar debe lanzar
CsvFormatoExceptionindicando la línea. - Una prueba con BOM: crea el contenido empezando por
\uFEFFy comprueba que la primera columna se lee limpia.
Ejercicio 2: conversor de formatos
Escribe ConversorFormatos que convierta entre los formatos que conoces:
csvAProperties(Path csv, Path properties): toma un CSV de dos columnasclave,valory genera un.properties.propertiesACsv(Path properties, Path csv): la inversa, con las claves ordenadas alfabéticamente.csvACsv(Path origen, Path destino, char sepOrigen, char sepDestino): cambia el separador respetando el escapado, para pasar de formato internacional a formato Excel español.- Todas las operaciones con escritura atómica (07-06) y charset explícito.
- Un
mainque demuestre las tres conversiones e incluya en los datos un valor con coma, uno con punto y coma y uno con comillas, para comprobar que nada se pierde.
Ejercicio 3: configuración con recarga y validación
Amplía Configuracion de BiblioTech:
recargar()que vuelva a leer todas las fuentes y devuelva un informe de qué propiedades han cambiado, con su valor anterior y el nuevo.- Un mecanismo de notificación: una interfaz funcional
OyenteConfiguracionconcambiado(String clave, String anterior, String nuevo), y un registro de oyentes al que se avisa en cada recarga (retomaOyenteDevolucionde 04-06). exportarPlantilla(Path destino)que genere un.propertiescomentado con todas las claves conocidas, su valor actual, su origen y una descripción, para que un administrador tenga una plantilla completa.- Marca las propiedades que no admiten recarga en caliente —las reglas de negocio— y avisa si cambian, explicando que hace falta reiniciar.
- Un
mainque cargue, exporte la plantilla, modifique el fichero, recargue y muestre el informe de cambios.
Soluciones
Solución 1
package com.nexussoftware.bibliotech.pruebas;
import java.io.IOException;
import java.io.StringReader;
import java.io.StringWriter;
import java.util.List;
import com.nexussoftware.bibliotech.infraestructura.EscritorCsv;
import com.nexussoftware.bibliotech.infraestructura.LectorCsv;
/**
* Bateria de pruebas de ida y vuelta del CSV.
*
* La prueba de ida y vuelta —escribir, leer y comparar con el original— es
* la unica forma seria de validar un formato: si algo vuelve distinto, hay
* perdida de datos.
*
* Trabaja EN MEMORIA con StringWriter y StringReader (07-03): sin ficheros,
* sin limpieza, sin dependencias del entorno.
*/
public class PruebaCsvCompleta {
private static int superados = 0;
private static int fallidos = 0;
public static void main(String[] args) throws IOException {
System.out.println("=== IDA Y VUELTA CON SEPARADOR ',' ===");
char sep = ',';
probar(sep, "1. Campo normal", "Java Efectivo", "Bloch", "2018");
probar(sep, "2. Con separador dentro", "Java: el lenguaje, la maquina", "Bloch", "2018");
probar(sep, "3. Con comillas", "El libro \"Refactorizacion\"", "Fowler", "1999");
probar(sep, "4. Con salto de linea", "Titulo con\nsalto de linea", "Autor", "2020");
probar(sep, "5. Campo vacio en medio", "Titulo", "", "2021");
probar(sep, "6. Campo vacio al final", "Titulo", "Autor", "");
probar(sep, "7. Todos vacios", "", "", "");
probar(sep, "8. Solo espacios", " ", "Autor", "2022");
probar(sep, "9. Espacios en extremos", " con espacios ", "Autor", "2022");
probar(sep, "10. Acentos y enyes", "Patrones de Diseño", "Martín Ñuñez", "1994");
probar(sep, "11. Punto y coma dentro", "campo;con;puntoycoma", "Autor", "2023");
probar(sep, "12. Comillas en medio", "El \"mejor\" libro de Java", "Autor", "2024");
probar(sep, "13. Solo una comilla", "Comilla suelta \" aqui", "Autor", "2024");
probar(sep, "14. Campo muy largo", "x".repeat(5000), "Autor", "2024");
probar(sep, "15. Separador y comillas", "a,b\"c,d", "Autor", "2024");
System.out.println();
System.out.println("=== IDA Y VUELTA CON SEPARADOR ';' ===");
char sep2 = ';';
probar(sep2, "16. Coma dentro con sep ;", "Java: el lenguaje, la maquina", "Bloch", "2018");
probar(sep2, "17. Punto y coma dentro", "campo;con;puntoycoma", "Autor", "2023");
// ---- Fallo controlado: comillas sin cerrar ----
System.out.println();
System.out.println("=== FALLOS CONTROLADOS ===");
probarComillasSinCerrar();
// ---- BOM ----
System.out.println();
System.out.println("=== MARCA BOM ===");
probarBom();
System.out.println();
System.out.printf("=== RESULTADO: %d superados, %d fallidos ===%n",
superados, fallidos);
if (fallidos > 0) {
System.out.println("HAY PERDIDA DE DATOS. Revisa el escapado.");
}
}
/** Escribe los campos, los vuelve a leer y compara con el original. */
private static void probar(char separador, String nombre, String... campos)
throws IOException {
List<String> originales = List.of(campos);
StringWriter memoria = new StringWriter();
try (EscritorCsv escritor = new EscritorCsv(memoria, separador, "\n")) {
escritor.escribirRegistro(originales);
}
String generado = memoria.toString();
List<String> leidos;
try (LectorCsv lector = new LectorCsv(new StringReader(generado), separador)) {
leidos = lector.siguiente();
}
boolean ok = originales.equals(leidos);
if (ok) { superados++; } else { fallidos++; }
System.out.printf(" %-30s %s%n", nombre, ok ? "OK" : "FALLO");
if (!ok) {
System.out.println(" original: " + originales);
System.out.println(" leido : " + leidos);
System.out.println(" csv : " + generado.replace("\n", "\\n"));
}
}
/** Un fichero con comillas sin cerrar debe fallar indicando la linea. */
private static void probarComillasSinCerrar() {
String malo = "titulo,autor\n\"Sin cerrar,Bloch";
try (LectorCsv lector = new LectorCsv(new StringReader(malo))) {
lector.siguiente(); // la cabecera, correcta
lector.siguiente(); // esta debe fallar
System.out.println(" Comillas sin cerrar FALLO (deberia lanzar)");
fallidos++;
} catch (LectorCsv.CsvFormatoException e) {
System.out.println(" Comillas sin cerrar OK");
System.out.println(" " + e.getMessage());
superados++;
} catch (IOException e) {
System.out.println(" Comillas sin cerrar FALLO (excepcion inesperada)");
fallidos++;
}
}
/**
* La marca BOM que escribe Excel al principio de los ficheros UTF-8.
*
* Sin tratarla, la primera columna se lee como "\uFEFFtipo" y NO coincide
* con "tipo", pese a que el fichero se ve perfecto en cualquier editor.
*/
private static void probarBom() throws IOException {
String conBom = "\uFEFFtipo,referencia,titulo\nLIBRO,978-0000000001,Java Efectivo";
try (LectorCsv lector = new LectorCsv(new StringReader(conBom))) {
List<String> cabecera = lector.siguiente();
boolean ok = "tipo".equals(cabecera.get(0));
if (ok) { superados++; } else { fallidos++; }
System.out.printf(" BOM eliminado %s%n", ok ? "OK" : "FALLO");
System.out.println(" primera columna: '" + cabecera.get(0)
+ "' (longitud " + cabecera.get(0).length() + ")");
if (!ok) {
System.out.println(" El BOM sigue ahi: la comparacion con 'tipo' falla");
}
}
}
}Salida:
=== IDA Y VUELTA CON SEPARADOR ',' ===
1. Campo normal OK
2. Con separador dentro OK
3. Con comillas OK
4. Con salto de linea OK
5. Campo vacio en medio OK
6. Campo vacio al final OK
7. Todos vacios OK
8. Solo espacios OK
9. Espacios en extremos OK
10. Acentos y enyes OK
11. Punto y coma dentro OK
12. Comillas en medio OK
13. Solo una comilla OK
14. Campo muy largo OK
15. Separador y comillas OK
=== IDA Y VUELTA CON SEPARADOR ';' ===
16. Coma dentro con sep ; OK
17. Punto y coma dentro OK
=== FALLOS CONTROLADOS ===
Comillas sin cerrar OK
Linea 2: El fichero termina con unas comillas sin cerrar
=== MARCA BOM ===
BOM eliminado OK
primera columna: 'tipo' (longitud 4)
=== RESULTADO: 19 superados, 0 fallidos ===Los tres puntos didácticos:
- Los casos 8 y 9 son los que más se olvidan. Un campo de solo espacios y uno con espacios en los extremos: si el escritor no los entrecomilla, el lector los recorta al leer y el dato vuelve distinto. Es una pérdida silenciosa.
- El caso 15 combina separador y comillas en el mismo campo, que es donde fallan la mayoría de las implementaciones caseras que se encuentran en internet.
- Todo se prueba en memoria.
StringWriteryStringReaderde 07-03 permiten una batería completa sin crear ni un fichero, sin limpieza posterior y sin depender del sistema de ficheros. Es exactamente la lección de diseño de 07-03: acepta la abstracción más general que te sirva.
Solución 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.EscritorCsv;
import com.nexussoftware.bibliotech.infraestructura.LectorCsv;
/**
* Conversiones entre CSV y Properties, y entre dialectos de CSV.
*
* Todas las escrituras son ATOMICAS (07-06): si la conversion falla a mitad,
* el fichero destino anterior queda intacto.
*/
public class ConversorFormatos {
private static final Logger LOG = Logger.getLogger(ConversorFormatos.class.getName());
private static final java.nio.charset.Charset CHARSET = StandardCharsets.UTF_8;
// ---------------------- CSV -> PROPERTIES ----------------------
/**
* Convierte un CSV de dos columnas 'clave,valor' en un .properties.
*
* OJO: Properties escapa por su cuenta los caracteres especiales, asi
* que un valor con '=' o ':' se guarda escapado y vuelve correcto.
*/
public int csvAProperties(Path csv, Path destino, char separador) throws IOException {
Properties props = new Properties();
int convertidas = 0;
try (BufferedReader lector = Files.newBufferedReader(csv, CHARSET);
LectorCsv entrada = new LectorCsv(lector, separador)) {
List<String> registro = entrada.siguiente(); // cabecera
if (registro == null) {
throw new IOException("El fichero " + csv + " esta vacio");
}
while ((registro = entrada.siguiente()) != null) {
if (registro.size() < 2) {
LOG.warning(() -> "Registro con menos de 2 campos; se ignora");
continue;
}
String clave = registro.get(0).trim();
if (clave.isEmpty()) {
continue;
}
props.setProperty(clave, registro.get(1));
convertidas++;
}
}
escribirPropertiesAtomico(props, destino,
"Generado desde " + csv.getFileName());
final int total = convertidas;
LOG.info(() -> String.format("CSV -> Properties: %d claves en %s",
total, destino));
return convertidas;
}
// ---------------------- PROPERTIES -> CSV ----------------------
/** Convierte un .properties en CSV, con las claves ordenadas. */
public int propertiesACsv(Path properties, Path destino, char separador)
throws IOException {
Properties props = new Properties();
try (BufferedReader lector = Files.newBufferedReader(properties, CHARSET)) {
props.load(lector);
}
// stringPropertyNames, no keySet (apartado 12). TreeSet para ordenar.
TreeSet<String> claves = new TreeSet<>(props.stringPropertyNames());
escribirAtomico(destino, salida -> {
try (EscritorCsv csv = new EscritorCsv(salida, separador, "\n")) {
csv.escribirCabecera("clave", "valor");
for (String clave : claves) {
csv.escribirRegistro(clave, props.getProperty(clave));
}
}
});
LOG.info(() -> String.format("Properties -> CSV: %d claves en %s",
claves.size(), destino));
return claves.size();
}
// ---------------------- CSV -> CSV ----------------------
/**
* Cambia el separador de un CSV respetando el escapado.
*
* NO es una sustitucion de texto: un campo que contenia el separador
* NUEVO y no el viejo pasa a necesitar comillas, y al reves. Solo se
* puede hacer bien parseando y reescribiendo.
*/
public int csvACsv(Path origen, Path destino, char sepOrigen, char sepDestino)
throws IOException {
List<List<String>> registros = new ArrayList<>();
try (BufferedReader lector = Files.newBufferedReader(origen, CHARSET);
LectorCsv entrada = new LectorCsv(lector, sepOrigen)) {
List<String> registro;
while ((registro = entrada.siguiente()) != null) {
registros.add(registro);
}
}
escribirAtomico(destino, salida -> {
try (EscritorCsv csv = new EscritorCsv(salida, sepDestino, "\n")) {
for (List<String> r : registros) {
csv.escribirRegistro(r);
}
}
});
LOG.info(() -> String.format("CSV '%c' -> CSV '%c': %d registros en %s",
sepOrigen, sepDestino, registros.size(), destino));
return registros.size();
}
// ---------------------- ESCRITURA ATOMICA ----------------------
@FunctionalInterface
private interface Escritura {
void escribirEn(BufferedWriter salida) throws IOException;
}
private void escribirAtomico(Path destino, Escritura contenido) throws IOException {
Files.createDirectories(destino.toAbsolutePath().getParent());
Path temporal = destino.resolveSibling(destino.getFileName() + ".tmp");
boolean completado = false;
try {
try (BufferedWriter salida = Files.newBufferedWriter(temporal, CHARSET,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE)) {
contenido.escribirEn(salida);
}
Files.move(temporal, destino,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
completado = true;
} catch (AtomicMoveNotSupportedException e) {
Files.move(temporal, destino, StandardCopyOption.REPLACE_EXISTING);
completado = true;
} finally {
if (!completado) {
Files.deleteIfExists(temporal); // compensacion (06-05)
}
}
}
private void escribirPropertiesAtomico(Properties props, Path destino, String comentario)
throws IOException {
escribirAtomico(destino, salida -> props.store(salida, comentario));
}
// ---------------------- DEMOSTRACION ----------------------
public static void main(String[] args) throws IOException {
Path dir = Path.of("datos/conversiones");
Files.createDirectories(dir);
ConversorFormatos conversor = new ConversorFormatos();
// 1. CSV de partida con los casos dificiles
Path csvOriginal = dir.resolve("configuracion.csv");
Files.writeString(csvOriginal, String.join("\n",
"clave,valor",
"bibliotech.prestamo.dias,15",
"bibliotech.multa.tarifa.diaria,0.25",
"bibliotech.mensaje.bienvenida,\"Bienvenido a BiblioTech, de Nexus Software\"",
"bibliotech.separadores,\"punto;y;coma\"",
"bibliotech.cita,\"El libro \"\"Refactorizacion\"\" de Fowler\"",
""), CHARSET);
System.out.println("=== CSV ORIGINAL ===");
System.out.println(Files.readString(csvOriginal, CHARSET));
// 2. CSV -> Properties
Path props = dir.resolve("configuracion.properties");
int n1 = conversor.csvAProperties(csvOriginal, props, ',');
System.out.println("=== PROPERTIES GENERADO (" + n1 + " claves) ===");
System.out.println(Files.readString(props, CHARSET));
// 3. Properties -> CSV, con separador ';' para Excel espanol
Path csvExcel = dir.resolve("configuracion-excel.csv");
int n2 = conversor.propertiesACsv(props, csvExcel, ';');
System.out.println("=== CSV PARA EXCEL ESPANOL (" + n2 + " claves) ===");
System.out.println(Files.readString(csvExcel, CHARSET));
// 4. CSV ',' -> CSV ';' directamente
Path csvPuntoYComa = dir.resolve("configuracion-pyc.csv");
int n3 = conversor.csvACsv(csvOriginal, csvPuntoYComa, ',', ';');
System.out.println("=== CSV CON SEPARADOR ';' (" + n3 + " registros) ===");
System.out.println(Files.readString(csvPuntoYComa, CHARSET));
// 5. Comprobacion: ida y vuelta completa
Path vuelta = dir.resolve("vuelta.csv");
conversor.csvACsv(csvPuntoYComa, vuelta, ';', ',');
boolean identicos = Files.readString(csvOriginal, CHARSET).trim()
.equals(Files.readString(vuelta, CHARSET).trim());
System.out.println("=== IDA Y VUELTA ',' -> ';' -> ',' ===");
System.out.println(" ¿Identico al original? " + identicos);
}
}Salida (fragmento):
=== CSV CON SEPARADOR ';' (6 registros) ===
clave;valor
bibliotech.prestamo.dias;15
bibliotech.multa.tarifa.diaria;0.25
bibliotech.mensaje.bienvenida;Bienvenido a BiblioTech, de Nexus Software
bibliotech.separadores;"punto;y;coma"
bibliotech.cita;"El libro ""Refactorizacion"" de Fowler"
=== IDA Y VUELTA ',' -> ';' -> ',' ===
¿Identico al original? trueLos tres puntos que hay que ver:
- El escapado cambia al cambiar el separador, y eso demuestra que no es una sustitución de texto. El valor
Bienvenido a BiblioTech, de Nexus Softwarepierde las comillas al pasar a separador;—ya no las necesita, porque su coma es inofensiva— y el valorpunto;y;comalas gana. Unreplace(',', ';')habría destrozado los dos. - La ida y vuelta devuelve el fichero idéntico. Es la prueba de que las dos conversiones son inversas exactas.
Properties.storeescribe con sus propios escapes, así que un valor con=o:se guarda escapado y vuelve correcto. Ese es el motivo de convertir parseando y no copiando texto.
Solución 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;
/**
* Configuracion de BiblioTech con recarga, notificacion de cambios y
* generacion de plantilla comentada.
*
* Amplia la clase del apartado 14 con lo que hace falta para operar el
* sistema sin pararlo del todo.
*/
public class ConfiguracionRecargable {
private static final Logger LOG =
Logger.getLogger(ConfiguracionRecargable.class.getName());
private static final java.nio.charset.Charset CHARSET = StandardCharsets.UTF_8;
/**
* Descripcion de una propiedad conocida.
*
* 'recargable' distingue lo que se puede cambiar en caliente de lo que
* exige reiniciar. Las REGLAS DE NEGOCIO no son recargables: cambiar la
* tarifa a mitad de ejecucion haria que dos prestamos del mismo dia se
* calcularan con reglas distintas.
*/
public record DescripcionPropiedad(String clave, String porDefecto,
String descripcion, boolean recargable) { }
private static final List<DescripcionPropiedad> CONOCIDAS = List.of(
new DescripcionPropiedad("bibliotech.prestamo.dias", "15",
"Dias de plazo de prestamo", false),
new DescripcionPropiedad("bibliotech.prestamo.maximo.simultaneos", "3",
"Prestamos simultaneos por empleado", false),
new DescripcionPropiedad("bibliotech.multa.tarifa.diaria", "0.25",
"EUR por dia de retraso", false),
new DescripcionPropiedad("bibliotech.multa.maxima", "20.0",
"Tope de multa por prestamo, en EUR", false),
new DescripcionPropiedad("bibliotech.multa.umbral.leve", "7",
"Dias hasta que el retraso deja de ser leve", false),
new DescripcionPropiedad("bibliotech.datos.directorio", "datos",
"Directorio de datos", true),
new DescripcionPropiedad("bibliotech.datos.catalogo", "catalogo.csv",
"Nombre del fichero de catalogo", true),
new DescripcionPropiedad("bibliotech.csv.separador", ",",
"Separador de campos del CSV: ',' o ';' para Excel espanol", true),
new DescripcionPropiedad("bibliotech.log.nivel", "INFO",
"Nivel de registro: SEVERE, WARNING, INFO, CONFIG, FINE", true));
/** Oyente de cambios de configuracion (interfaz funcional, 04-06). */
@FunctionalInterface
public interface OyenteConfiguracion {
void cambiado(String clave, String anterior, String nuevo);
}
/** Un cambio detectado en la recarga. */
public record Cambio(String clave, String anterior, String nuevo, boolean recargable) {
public String linea() {
return String.format("%-42s %-14s -> %-14s %s",
clave, anterior == null ? "(sin valor)" : anterior, nuevo,
recargable ? "" : " [REQUIERE REINICIAR]");
}
}
private final Path ficheroExterno;
private final Map<String, String> valores = new TreeMap<>();
private final Map<String, String> origen = new TreeMap<>();
private final List<OyenteConfiguracion> oyentes = new ArrayList<>();
public ConfiguracionRecargable(Path ficheroExterno) {
this.ficheroExterno = Objects.requireNonNull(ficheroExterno);
cargarTodo();
}
public void registrarOyente(OyenteConfiguracion oyente) {
oyentes.add(Objects.requireNonNull(oyente, "El oyente no puede ser nulo"));
}
// ---------------------- CARGA ----------------------
private void cargarTodo() {
valores.clear();
origen.clear();
// 1. Defectos del codigo
for (DescripcionPropiedad d : CONOCIDAS) {
valores.put(d.clave(), d.porDefecto());
origen.put(d.clave(), "defecto del codigo");
}
// 2. Fichero externo
if (Files.exists(ficheroExterno)) {
Properties props = new Properties();
try (var lector = Files.newBufferedReader(ficheroExterno, CHARSET)) {
props.load(lector);
for (String clave : props.stringPropertyNames()) { // no keySet
valores.put(clave, props.getProperty(clave).trim());
origen.put(clave, "fichero " + ficheroExterno.getFileName());
}
} catch (IOException e) {
LOG.warning(() -> "No se pudo leer " + ficheroExterno + ": " + e.getMessage());
}
}
// 3. Variables de entorno
for (var e : System.getenv().entrySet()) {
if (e.getKey().startsWith("BIBLIOTECH_")) {
String clave = e.getKey().toLowerCase(java.util.Locale.ROOT).replace('_', '.');
valores.put(clave, e.getValue().trim());
origen.put(clave, "entorno " + e.getKey());
}
}
// 4. Propiedades del sistema
for (String clave : System.getProperties().stringPropertyNames()) {
if (clave.startsWith("bibliotech.")) {
valores.put(clave, System.getProperty(clave).trim());
origen.put(clave, "-D" + clave);
}
}
}
// ---------------------- RECARGA ----------------------
/**
* Vuelve a leer todas las fuentes y devuelve los cambios detectados.
*
* Notifica a los oyentes de cada cambio, y AVISA de los que necesitan
* un reinicio para tener efecto.
*/
public List<Cambio> recargar() {
Map<String, String> anteriores = new LinkedHashMap<>(valores);
cargarTodo();
List<Cambio> cambios = new ArrayList<>();
Set<String> todas = new java.util.TreeSet<>(anteriores.keySet());
todas.addAll(valores.keySet());
for (String clave : todas) {
String antes = anteriores.get(clave);
String ahora = valores.get(clave);
if (Objects.equals(antes, ahora)) {
continue;
}
boolean recargable = esRecargable(clave);
cambios.add(new Cambio(clave, antes, ahora, recargable));
// Notificar. Un oyente que falle NO debe romper la recarga.
for (OyenteConfiguracion oyente : oyentes) {
try {
oyente.cambiado(clave, antes, ahora);
} catch (RuntimeException e) {
LOG.warning(() -> "Un oyente fallo al notificar " + clave
+ ": " + e.getMessage());
}
}
if (!recargable) {
LOG.warning(() -> String.format(
"La propiedad %s ha cambiado de '%s' a '%s', pero NO se aplica "
+ "en caliente: hay que reiniciar la aplicacion.",
clave, antes, ahora));
}
}
LOG.info(() -> "Recarga: " + cambios.size() + " propiedades cambiadas");
return cambios;
}
private boolean esRecargable(String clave) {
for (DescripcionPropiedad d : CONOCIDAS) {
if (d.clave().equals(clave)) {
return d.recargable();
}
}
return true; // desconocida: se asume recargable
}
// ---------------------- PLANTILLA ----------------------
/**
* Genera un .properties comentado con todas las claves conocidas.
*
* Se escribe a mano en lugar de con Properties.store porque store NO
* conserva ni el orden ni los comentarios (apartado 11), y una plantilla
* sin comentarios ni orden no sirve para nada.
*/
public void exportarPlantilla(Path destino) throws IOException {
Files.createDirectories(destino.toAbsolutePath().getParent());
Path temporal = destino.resolveSibling(destino.getFileName() + ".tmp");
boolean completado = false;
try {
try (BufferedWriter salida = Files.newBufferedWriter(temporal, CHARSET,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE)) {
salida.write("# ============================================================");
salida.newLine();
salida.write("# Configuracion de BiblioTech - Nexus Software");
salida.newLine();
salida.write("# Plantilla generada automaticamente.");
salida.newLine();
salida.write("#");
salida.newLine();
salida.write("# Precedencia, de menor a mayor:");
salida.newLine();
salida.write("# 1. defectos del codigo");
salida.newLine();
salida.write("# 2. este fichero");
salida.newLine();
salida.write("# 3. variables de entorno (BIBLIOTECH_XXX_YYY)");
salida.newLine();
salida.write("# 4. propiedades del sistema (-Dbibliotech.xxx.yyy)");
salida.newLine();
salida.write("#");
salida.newLine();
salida.write("# ATENCION: NO pongas contrasenas ni claves en este fichero");
salida.newLine();
salida.write("# si va al control de versiones. Usa variables de entorno.");
salida.newLine();
salida.write("# ============================================================");
salida.newLine();
salida.newLine();
for (DescripcionPropiedad d : CONOCIDAS) {
String actual = valores.getOrDefault(d.clave(), d.porDefecto());
String de = origen.getOrDefault(d.clave(), "defecto del codigo");
salida.write("# " + d.descripcion());
salida.newLine();
salida.write("# por defecto : " + d.porDefecto());
salida.newLine();
salida.write("# valor actual: " + actual + " (origen: " + de + ")");
salida.newLine();
salida.write("# recarga en caliente: " + (d.recargable()
? "SI" : "NO, requiere reiniciar"));
salida.newLine();
salida.write(d.clave() + " = " + actual);
salida.newLine();
salida.newLine();
}
}
Files.move(temporal, destino,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
completado = true;
LOG.info(() -> "Plantilla de configuracion generada en " + destino);
} catch (AtomicMoveNotSupportedException e) {
Files.move(temporal, destino, StandardCopyOption.REPLACE_EXISTING);
completado = true;
} finally {
if (!completado) {
Files.deleteIfExists(temporal);
}
}
}
// ---------------------- ACCESO ----------------------
public String getTexto(String clave, String porDefecto) {
return valores.getOrDefault(clave, porDefecto);
}
public String volcado() {
StringBuilder sb = new StringBuilder("=== CONFIGURACION EFECTIVA ===\n");
for (var e : valores.entrySet()) {
sb.append(String.format(" %-42s = %-16s [%s]%n",
e.getKey(), e.getValue(), origen.get(e.getKey())));
}
return sb.toString();
}
// ---------------------- DEMOSTRACION ----------------------
public static void main(String[] args) throws IOException {
Path fichero = Path.of("datos/bibliotech.properties");
Files.createDirectories(fichero.getParent());
Files.writeString(fichero,
"bibliotech.prestamo.dias = 15\n"
+ "bibliotech.csv.separador = ,\n", CHARSET);
ConfiguracionRecargable config = new ConfiguracionRecargable(fichero);
// Oyente que reacciona a los cambios
config.registrarOyente((clave, antes, ahora) ->
System.out.printf(" [OYENTE] %s: '%s' -> '%s'%n", clave, antes, ahora));
System.out.println(config.volcado());
// Plantilla comentada para el administrador
Path plantilla = Path.of("datos/bibliotech-plantilla.properties");
config.exportarPlantilla(plantilla);
System.out.println("=== PLANTILLA (primeras lineas) ===");
try (var lineas = Files.lines(plantilla, CHARSET)) { // cerrar el flujo (07-06)
int[] n = { 0 };
lineas.forEach(l -> {
if (n[0]++ < 20) {
System.out.println(" " + l);
}
});
}
// Cambiar el fichero y recargar
System.out.println();
System.out.println("=== MODIFICANDO EL FICHERO Y RECARGANDO ===");
Files.writeString(fichero,
"bibliotech.prestamo.dias = 21\n"
+ "bibliotech.csv.separador = ;\n"
+ "bibliotech.log.nivel = FINE\n", CHARSET);
List<Cambio> cambios = config.recargar();
System.out.println();
System.out.println("=== CAMBIOS DETECTADOS ===");
for (Cambio c : cambios) {
System.out.println(" " + c.linea());
}
}
}Salida (fragmento):
=== MODIFICANDO EL FICHERO Y RECARGANDO ===
[OYENTE] bibliotech.csv.separador: ',' -> ';'
[OYENTE] bibliotech.log.nivel: 'INFO' -> 'FINE'
[OYENTE] bibliotech.prestamo.dias: '15' -> '21'
=== CAMBIOS DETECTADOS ===
bibliotech.csv.separador , -> ;
bibliotech.log.nivel INFO -> FINE
bibliotech.prestamo.dias 15 -> 21 [REQUIERE REINICIAR]Los cuatro puntos didácticos:
- La distinción entre recargable y no recargable es de negocio, no técnica. Cambiar el separador CSV en caliente es inofensivo. Cambiar la tarifa de las multas a mitad de ejecución haría que dos préstamos del mismo día se calcularan con reglas distintas, y eso, en un sistema que cobra dinero, es inaceptable. La configuración marca la diferencia y avisa en lugar de aplicarla silenciosamente.
- Un oyente que falle no rompe la recarga. El
try/catchalrededor de la notificación es la política de 06-07: un fallo en una parte accesoria no debe tumbar la operación principal. - La plantilla se escribe a mano, no con
store().Properties.storeno conserva ni el orden ni los comentarios, y una plantilla sin comentarios ni orden no sirve para nada. Es la limitación del apartado 11 en acción. - La plantilla incluye la advertencia sobre las credenciales. Documentar la política de seguridad en el sitio donde alguien va a estar tentado de saltársela es más eficaz que documentarla en un wiki que nadie abre.
Conclusión
Has cerrado el módulo de entrada/salida, y con él una parte muy grande de lo que hace falta para escribir software real.
En esta lección has visto por qué un formato de texto vence a uno binario para intercambiar —legible, diferenciable en control de versiones, independiente del lenguaje y seguro con datos ajenos— y qué se paga a cambio: tamaño, velocidad, tipos y estructuras complejas.
Conoces el CSV de verdad: sus seis reglas del RFC 4180, y los seis casos donde el split(",") ingenuo falla —separadores dentro de campos entrecomillados, comillas duplicadas, saltos de línea dentro de un campo, campos vacíos al final que split descarta si olvidas el -1, y el BOM invisible que hace que la primera columna no coincida con nada—. Sabes que una expresión regular no resuelve esto y que un autómata de dos estados sí, en cuarenta líneas. Tienes tu LectorCsv y tu EscritorCsv con su escapar/desescapar, su tabla de casos límite y la limitación honesta que ningún escapado arregla: CSV no distingue el nulo de la cadena vacía. Y tienes la única forma seria de validar un formato: la prueba de ida y vuelta, escribir, leer y comparar —lo que faltaba en el sanear() de 07-02 y habría delatado el fallo a la primera—.
Sabes navegar el conflicto del separador y el decimal en español: coma y punto para intercambio, punto y coma para Excel español, Locale.ROOT al escribir, y Double.parseDouble que siempre usa el punto mientras String.format obedece al sistema. Con el principio que lo resuelve: tolerante al leer, estricto al escribir. Y tienes la recomendación honesta sobre las librerías: implementa una vez para entenderlo, usa la librería en producción —OpenCSV, Commons CSV y Jackson, en 11-07—, porque ahora sabes qué preguntarles y sabrás diagnosticar cuando den un resultado raro.
Conoces Properties: su formato clave-valor, sus escapes, sus comentarios, el problema histórico de ISO-8859-1 y por qué hay que usar las sobrecargas de Reader/Writer con charset explícito; el getProperty con valor por defecto que evita un null inútil; el encadenamiento por defecto con sus dos trampas —keySet() frente a stringPropertyNames(), y el store que no guarda los defectos ni conserva orden ni comentarios—; y la advertencia de usar getProperty/setProperty y nunca los get/put heredados de Hashtable.
Y tienes la jerarquía de configuración completa, con sus cinco niveles y su precedencia razonada: defectos del código como red de seguridad, fichero del classpath como valores de fábrica, fichero externo para el despliegue, variables de entorno para contenedores y -D para el ajuste inmediato. Con la convención BIBLIOTECH_PRESTAMO_DIAS ↔ bibliotech.prestamo.dias, y con el volcado de diagnóstico que dice de dónde viene cada valor y resuelve en un minuto el clásico "he cambiado el fichero y no hace caso".
Las cuatro constantes ya no son constantes. DIAS_PRESTAMO, TARIFA_DIARIA, MULTA_MAXIMA y UMBRAL_LEVE se leen de un fichero externo, con validación, con valores por defecto y con la distinción de 06-07 aplicada al arranque: un fichero ausente degrada —la aplicación arranca con los defectos y lo registra— y un valor inválido aborta con un código de salida distinguible, porque calcular multas con una tarifa negativa produce cobros incorrectos, y eso es peor que no arrancar. Cambiar la tarifa de las multas es ahora editar una línea de texto, no recompilar y desplegar.
Y sabes que las credenciales nunca van en el fichero del repositorio, porque el historial de Git es permanente, los repositorios se clonan y se hacen públicos por error, y los robots rastrean constantemente. Con las cuatro alternativas —variables de entorno, fichero externo con permisos restringidos, gestor de secretos, fichero local ignorado— y con las dos reglas de código: sin valor por defecto para un secreto, y nunca registrarlo. Con la advertencia formal, otra vez, de que esto lo define y lo revisa el responsable de seguridad de la organización, y de que la seguridad de aplicaciones se trata en 12-07.
Y el módulo entero. Empezaste sin saber qué era un fichero más allá de un icono; ahora sabes que es una secuencia de bytes con un nombre, que llegar hasta él cruza una frontera cara —la llamada al sistema— y que todo el diseño de la E/S de Java existe para cruzarla menos veces. Sabes localizar ficheros con rutas absolutas y relativas y sabes por qué el mismo programa "no los encuentra" según desde dónde se lance. Sabes leer y escribir texto con el charset explícito siempre, porque la codificación no se puede detectar y equivocarse corrompe en silencio. Sabes que el parámetro append olvidado destruye ficheros enteros sin avisar, y que la escritura atómica —temporal y renombrado— es la única forma de regenerar un fichero sin arriesgarse a perderlo.
Entiendes la arquitectura de java.io: el flujo como abstracción unidireccional y agnóstica, las dos jerarquías de bytes y caracteres y por qué tenían que ser dos, las clases de nodo frente a las de filtro, el patrón Decorador que convierte doce clases en cinco, y los puentes donde se decide la codificación. Sabes qué hace un búfer y por qué da un factor de sesenta, dominas el contrato de readLine() y el bucle canónico, y sabes procesar un fichero de un gigabyte con memoria constante. Conoces la serialización y —con el mismo énfasis— sus riesgos reales y cuándo no usarla. Y dominas NIO.2, con Path, Files, ATOMIC_MOVE, StandardOpenOption y los recorridos de árboles, que es lo que escribirás a partir de ahora.
BiblioTech, al cerrar el módulo 7, recuerda. Carga su catálogo al arrancar desde un CSV correcto que no pierde ni una coma de ningún título, y lo guarda al salir de forma atómica con copia de seguridad rotativa. Importa inventarios de miles de líneas validando cada una y devolviendo un informe de lo cargado y lo descartado con el motivo de cada descarte. Guarda y restaura sesiones de trabajo entre ejecuciones, con validación en la deserialización y filtro de seguridad. Gestiona las portadas binarias de sus libros sin corromperlas. Organiza sus informes en un árbol de directorios y los localiza recorriéndolo. Registra cada operación en una auditoría que crece en lugar de sobrescribirse. Y se configura sin recompilar, con cinco fuentes de configuración, precedencia definida, validación al arranque y un volcado que dice de dónde sale cada valor. De las cinco fragilidades que declaraste al cerrar el módulo 5, no queda ninguna.
Pero hay algo que BiblioTech sigue haciendo de una única manera: una cosa detrás de otra. Cuando importa un catálogo de cincuenta mil líneas, la aplicación se queda muda hasta que termina: no puedes cancelarla, no sabes por dónde va, y el menú no responde. Cuando envía los avisos de vencimiento a doscientos empleados, los envía uno detrás de otro, y si el segundo tarda tres segundos, el número doscientos espera diez minutos. Cuando está esperando a que alguien teclee una opción del menú, el procesador está completamente parado, sin hacer absolutamente nada, aunque haya cuatro informes pendientes de generar. Y si mañana dos empleados usaran BiblioTech a la vez sobre el mismo catálogo, no hay nada en el código que impida que uno pise el trabajo del otro.
Todo eso tiene un nombre: el programa es secuencial, y las máquinas en las que se ejecuta llevan quince años sin serlo. Tu portátil tiene ocho núcleos y BiblioTech usa uno.
En el módulo 8, Multihilo y Concurrencia, se resuelve. Verás qué es un hilo y en qué se diferencia de un proceso; cómo se crean con Thread y Runnable, y por qué casi nunca deberías crearlos tú directamente; el ciclo de vida completo de un hilo y qué significa exactamente cada estado; la sincronización con synchronized, volatile y el modelo de memoria de Java, que explica por qué un contador incrementado por dos hilos puede acabar valiendo menos de lo que debería; las utilidades de concurrencia —ExecutorService, CountDownLatch, Semaphore— que sustituyen la gestión manual de hilos por algo que se puede razonar; las colecciones concurrentes y atómicas que hacen seguro lo que HashMap no puede garantizar; y CompletableFuture, la forma moderna de encadenar trabajo asíncrono sin bloquear a nadie. Al terminarlo, BiblioTech importará su catálogo mostrando el progreso y pudiendo cancelarse, enviará sus doscientos avisos en paralelo, atenderá el menú mientras genera informes de fondo, y protegerá su catálogo de dos empleados que trabajen a la vez. Dejará de esperar.
Curso de Programación en Java
Módulo 1: Introducción a Java
- Introducción a Java
- Configuración del Entorno de Desarrollo
- Sintaxis y Estructura Básica
- Variables y Tipos de Datos
- Operadores
- Entrada y Salida por Consola
- Tu Primer Programa Completo: BiblioTech
Módulo 2: Flujo de Control
- Sentencias Condicionales
- Bucles
- Sentencias Switch
- Break y Continue
- Depuración y Trazas de Ejecución
- Proyecto: Menú Interactivo de BiblioTech
Módulo 3: Programación Orientada a Objetos
- Introducción a la POO
- Clases y Objetos
- Métodos
- Constructores
- Herencia
- Polimorfismo
- Encapsulamiento
- Abstracción
- La Clase Object: equals, hashCode y toString
Módulo 4: Programación Orientada a Objetos Avanzada
- Interfaces
- Clases Abstractas
- Clases Internas
- Clases Anónimas
- Expresiones Lambda
- Interfaces Funcionales y Referencias a Métodos
- Enumeraciones y Registros
Módulo 5: Estructuras de Datos y Colecciones
- Arreglos
- El Framework de Colecciones
- ArrayList
- LinkedList
- HashMap
- HashSet
- Cola y Deque
- Pila
- Ordenación y Búsqueda en Colecciones
Módulo 6: Manejo de Excepciones
- Introducción a las Excepciones
- Bloque Try-Catch
- Throw y Throws
- Excepciones Personalizadas
- Bloque Finally
- Try-with-resources y AutoCloseable
- Estrategias de Manejo de Errores y Logging
Módulo 7: Entrada/Salida de Archivos
- Lectura de Archivos
- Escritura de Archivos
- Flujos de Archivos
- BufferedReader y BufferedWriter
- Serialización
- La API NIO.2: Path y Files
- Formatos de Intercambio: CSV y Properties
Módulo 8: Multihilo y Concurrencia
- Introducción al Multihilo
- Creación de Hilos
- Ciclo de Vida de un Hilo
- Sincronización
- Utilidades de Concurrencia
- Colecciones Concurrentes y Variables Atómicas
- Tareas Asíncronas con CompletableFuture
Módulo 9: Redes
- Introducción a las Redes
- Sockets
- ServerSocket
- DatagramSocket y DatagramPacket
- URL y HttpURLConnection
- El Cliente HTTP Moderno
Módulo 10: Temas Avanzados
- Genéricos
- Anotaciones
- Reflexión
- Características de Java 8: Streams y Optional
- Fechas y Horas con java.time
- Java 9 y Más Allá
- Memoria, Recolección de Basura y Rendimiento
Módulo 11: Frameworks y Librerías de Java
- Introducción a los Frameworks de Java
- Spring Framework
- Hibernate
- JUnit
- Maven
- Pruebas Avanzadas con Mockito
- Librerías Esenciales del Ecosistema
