El módulo 6 terminó con una promesa y una carencia. La carencia: nada se guarda al salir. Seis módulos de trabajo —el catálogo, los préstamos, las multas, las reservas, el historial— viven íntegramente en la memoria del proceso, y cuando el proceso termina, la memoria se libera y todo desaparece. La promesa: que este módulo lo resuelve.
Empezamos por la mitad más sencilla de la persistencia, que es leer. Leer es más sencillo que escribir por una razón práctica: si te equivocas leyendo, no destruyes nada. Si te equivocas escribiendo, puedes perder un fichero entero, y eso es exactamente lo que le va a pasar a alguien en la lección siguiente si no lee la tabla del parámetro append.
Esta lección cubre el modelo mental completo de qué es un fichero, cómo se localiza y cómo se lee texto de él con las dos APIs clásicas —FileReader y Scanner—, más el detalle que provoca más incidencias reales de todo el módulo: la codificación de caracteres. Al terminar, BiblioTech arrancará leyendo su catálogo de un fichero de texto, y si ese fichero no existe, arrancará igualmente con el catálogo vacío y dejando constancia en el registro, aplicando la degradación elegante que ya sabes diseñar.
Todo lo que se abra en esta lección se cierra con
try-with-resources. Es la construcción de 06-06 y se da por completamente conocida: orden de cierre inverso, excepciones suprimidas,close()idempotente. No se vuelve a explicar; se usa siempre.
Contenido
- Por qué un programa necesita persistencia
- Qué es un fichero: bytes en disco frente a objetos en memoria
- El recorrido de un dato desde la aplicación hasta el disco
- Rutas absolutas y relativas, y el directorio de trabajo
- Separadores de ruta y portabilidad
- La clase
File: la API heredada que todavía te encontrarás - Lectura con
FileReader: carácter a carácter - Lectura con
Scannersobre un fichero useDelimitery el parseo cómodo- Codificación de caracteres: UTF-8 y el desastre de las tildes
FileNotFoundExceptionfrente aIOException- Degradación elegante: BiblioTech arranca sin catálogo
- Ficheros grandes y por qué no leerlo todo en memoria
- Errores Comunes y Consejos
- Ejercicios
- Por qué un programa necesita persistencia
La memoria de un proceso es volátil: existe mientras el proceso existe. Cuando main retorna, cuando el usuario cierra la ventana o cuando el sistema operativo mata el proceso, la JVM libera su memoria y todo lo que había en ella deja de existir. No hay nada que recuperar.
Esto no es un defecto de Java: es la naturaleza de la RAM. Un programa que solo trabaja en memoria es un programa sin memoria a largo plazo, y eso lo descalifica para casi cualquier uso real:
| Necesidad | Sin persistencia | Con persistencia |
|---|---|---|
| Recordar datos entre ejecuciones | Imposible | Es el caso base |
| Cargar mil materiales | Tecleándolos uno a uno | Un fichero de mil líneas |
| Compartir datos con otro programa | Imposible | Un fichero que los dos entienden |
| Auditar qué pasó ayer | Imposible | Un fichero de registro |
| Configurar sin recompilar | Imposible | Un fichero de propiedades |
| Recuperarse de una caída | Se pierde todo | Se recupera el último estado guardado |
Fíjate en la última fila de la columna izquierda aplicada a BiblioTech: si la aplicación se cae con veinte préstamos activos, esos veinte préstamos no existieron nunca. La biblioteca real seguirá teniendo veinte libros fuera de la estantería, pero el sistema dirá que están todos disponibles. El sistema y el mundo dejan de coincidir, que es la peor cosa que le puede pasar a un sistema de gestión.
Persistir significa escribir el estado en un medio que sobrevive al proceso. En este módulo ese medio es el sistema de ficheros. En un sistema profesional grande suele ser una base de datos —lo verás con Hibernate en 11-03—, pero una base de datos es, por debajo, ficheros con mucha ingeniería encima. Todo empieza aquí.
- Qué es un fichero: bytes en disco frente a objetos en memoria
Esta es la idea que hay que interiorizar antes de escribir una línea de código:
Un fichero es una secuencia de bytes con un nombre. Nada más. No tiene tipos, no tiene objetos, no tiene campos. Solo bytes numerados del 0 en adelante.
Compara los dos mundos:
| Objeto en memoria | Fichero en disco | |
|---|---|---|
| Naturaleza | Estructura con campos tipados | Secuencia plana de bytes |
| Identidad | Referencia (dirección) | Ruta (nombre) |
| Vida | Mientras dure el proceso | Hasta que alguien lo borre |
| Acceso | Directo, nanosegundos | A través del sistema operativo, microsegundos o más |
| Referencias entre datos | Punteros nativos | Hay que inventárselas (identificadores, offsets) |
| Tipos | El compilador los conoce | No existen: todo son bytes |
| Coste de una operación | Barata | Cara: hay una llamada al sistema por medio |
Cuando tienes en memoria un Libro con título "Java Efectivo", autor "Bloch" e ISBN 978-0000000001, el objeto ocupa una zona de memoria con tres referencias a tres objetos String. En disco no puedes guardar referencias: una dirección de memoria no significa nada mañana ni en otra máquina. Tienes que serializar: convertir esa estructura en una secuencia de bytes que puedas volver a interpretar después. La forma más simple de hacerlo es escribir texto:
Esa línea es una decisión de diseño completa. Has elegido: un material por línea, campos separados por ;, orden fijo de campos, true/false para la disponibilidad. Cualquier programa que conozca esa convención puede leerla. Es exactamente lo que hace un fichero CSV, y lo formalizarás en 07-07.
La alternativa es dejar que Java haga la conversión por ti, con la serialización nativa de 07-05, que produce bytes ilegibles pero conserva la estructura completa de objetos. Cada opción tiene su precio, y lo verás.
Un matiz importante sobre el vocabulario: se habla de ficheros de texto y ficheros binarios, pero esa distinción no existe en el disco. Todos los ficheros son binarios. Un "fichero de texto" es simplemente un fichero cuyos bytes, interpretados con una determinada codificación, producen caracteres legibles. Si abres un .jpg con un editor de texto, verás basura: no porque el fichero sea distinto, sino porque estás aplicando la interpretación equivocada. Volveremos a esto en el apartado 10 y, a fondo, en 07-03.
- El recorrido de un dato desde la aplicación hasta el disco
Entre tu String y el plato del disco hay más capas de las que parece. Conocerlas explica por qué la E/S es lenta, por qué existe el búfer y por qué un close() puede fallar.
flowchart TD
A["Tu codigo Java<br/>String linea = lector.readLine()"] --> B["Clases java.io<br/>FileReader, Scanner"]
B --> C["Decodificacion de bytes a caracteres<br/>charset UTF-8"]
C --> D["Llamada al sistema operativo<br/>read syscall"]
D --> E["Cache de disco del sistema operativo<br/>page cache en RAM"]
E --> F["Controlador del dispositivo"]
F --> G["Disco fisico<br/>SSD o disco duro"]
style A fill:#e3f2fd
style D fill:#fff3e0
style G fill:#f3e5f5
Los puntos que importan de ese recorrido:
- La frontera cara es la llamada al sistema (recuadro naranja). Cada
readobliga a pasar del código de tu aplicación al núcleo del sistema operativo y volver. Ese cambio de contexto cuesta órdenes de magnitud más que ejecutar unas instrucciones en memoria. Si haces una llamada al sistema por cada carácter, el coste es demoledor: es exactamente lo que ocurre conFileReadersin búfer, y lo medirás en el apartado 7. - La decodificación es un paso real (recuadro azul claro del medio). Los bytes que llegan del disco no son caracteres. Alguien tiene que decidir qué byte o grupo de bytes forma qué carácter, y ese alguien es el charset. Si nadie lo especifica, se usa uno por defecto, y ahí empiezan los problemas del apartado 10.
- El sistema operativo ya tiene su propia caché (la page cache). Por eso leer dos veces el mismo fichero pequeño es mucho más rápido la segunda vez: la segunda no llega al disco. Esto también explica por qué medir rendimiento de E/S es traicionero.
- Escribir no significa "está en el disco". Cuando escribes, el dato pasa a búferes intermedios. Hasta que no se vacían, un corte de corriente lo pierde. Es el tema central de 07-02.
Quédate con la regla que gobierna todo el módulo: cuantas menos llamadas al sistema, mejor. Todo el diseño de la E/S de Java —los búferes, los bloques, transferTo— existe para reducir ese número.
- Rutas absolutas y relativas, y el directorio de trabajo
Para leer un fichero hay que localizarlo, y aquí aparece el primer tropiezo clásico: "a mí me funcionaba y en el servidor dice que no encuentra el fichero".
Una ruta absoluta parte de la raíz del sistema de ficheros y es autosuficiente:
/home/marta/bibliotech/datos/catalogo.txt (Linux, macOS)
C:\Users\marta\bibliotech\datos\catalogo.txt (Windows)Una ruta relativa no parte de la raíz, sino del directorio de trabajo actual del proceso:
La pregunta clave es: ¿relativa a qué? Y la respuesta es la que sorprende a todo el mundo: relativa al directorio desde el que se lanzó la JVM, no al directorio donde está el .class ni al del proyecto. Ese directorio está en la propiedad de sistema user.dir:
public class DondeEstoy {
public static void main(String[] args) {
System.out.println("Directorio de trabajo: " + System.getProperty("user.dir"));
System.out.println("Directorio del usuario: " + System.getProperty("user.home"));
System.out.println("Directorio temporal: " + System.getProperty("java.io.tmpdir"));
java.io.File f = new java.io.File("datos/catalogo.txt");
System.out.println("Ruta relativa dada: " + f.getPath());
System.out.println("Se resuelve como: " + f.getAbsolutePath());
System.out.println("¿Existe? " + f.exists());
}
}Ejecuta ese programa desde dos sitios distintos y verás el problema en directo:
# Desde la raiz del proyecto
$ cd /home/marta/bibliotech
$ java -cp target/classes DondeEstoy
Directorio de trabajo: /home/marta/bibliotech
Se resuelve como: /home/marta/bibliotech/datos/catalogo.txt
¿Existe? true
# Desde el directorio de clases
$ cd /home/marta/bibliotech/target/classes
$ java DondeEstoy
Directorio de trabajo: /home/marta/bibliotech/target/classes
Se resuelve como: /home/marta/bibliotech/target/classes/datos/catalogo.txt
¿Existe? falseEl mismo programa, el mismo fichero en disco, dos resultados distintos. Esto es lo que ocurre cuando funciona en el IDE (que lanza desde la raíz del proyecto) y falla al ejecutarlo con un script (que lanza desde otro sitio).
Cómo se trata esto profesionalmente:
| Estrategia | Cuándo usarla |
|---|---|
Ruta por parámetro (args[0] o propiedad -Druta=...) |
Casi siempre. Quien ejecuta decide dónde están los datos |
Ruta relativa a user.home |
Configuración de usuario: System.getProperty("user.home") + "/.bibliotech/" |
| Ruta absoluta en fichero de configuración | Despliegues en servidor. Lo verás en 07-07 |
Recurso del classpath (getResourceAsStream) |
Datos que viajan dentro del .jar y no cambian |
| Ruta relativa a secas | Solo en pruebas y herramientas de línea de comandos, y documentándolo |
Y un consejo que ahorra mucho tiempo: cuando un fichero "no existe", imprime siempre la ruta absoluta, nunca la que te pasaron. El mensaje No se encuentra 'datos/catalogo.txt' no te dice nada; No se encuentra '/home/marta/bibliotech/target/classes/datos/catalogo.txt' te resuelve el problema en tres segundos. Esta es una aplicación directa de la lección 06-04: una excepción debe transportar los datos que quien la lee necesita para decidir.
- Separadores de ruta y portabilidad
Windows usa \ como separador de directorios; Linux, macOS y prácticamente todo lo demás usan /. Java ofrece dos constantes para no fijarlo en el código:
import java.io.File;
public class Separadores {
public static void main(String[] args) {
System.out.println("Separador de rutas: '" + File.separator + "'");
System.out.println("Separador de listas: '" + File.pathSeparator + "'");
// Construccion portable de una ruta
String ruta = "datos" + File.separator + "catalogo.txt";
System.out.println("Ruta portable: " + ruta);
// Alternativa mucho mejor: el constructor de File con padre e hijo
File f = new File("datos", "catalogo.txt");
System.out.println("Con File(padre, hijo): " + f.getPath());
}
}Distingue las dos constantes, que se confunden con frecuencia:
| Constante | Linux/macOS | Windows | Para qué sirve |
|---|---|---|---|
File.separator |
/ |
\ |
Separar directorios dentro de una ruta |
File.pathSeparator |
: |
; |
Separar rutas dentro de una lista, como el CLASSPATH |
Ahora bien, la buena noticia práctica: Windows acepta / en casi todas las APIs de Java. new File("datos/catalogo.txt") funciona en Windows. Por eso, en la práctica:
- Para literales cortos en el código, escribe
/y no te compliques: es legible y funciona en todas partes. - Para componer rutas a partir de trozos, no concatenes cadenas: usa
new File(padre, hijo)o, mejor todavía,Path.resolve()de NIO.2, que verás en 07-06 y es la forma moderna y correcta. - Nunca escribas
"C:\\datos\\catalogo.txt"en el código. Además de no ser portable, ese doble backslash es una fuente inagotable de erratas.
- La clase
File: la API heredada que todavía te encontrarás
File: la API heredada que todavía te encontrarásjava.io.File es de Java 1.0. Representa una ruta, no un fichero abierto: puedes crear un File que apunte a algo inexistente sin que pase nada.
import java.io.File;
public class InspeccionarFichero {
public static void inspeccionar(String ruta) {
File f = new File(ruta);
System.out.println("=== " + ruta + " ===");
System.out.println(" Ruta absoluta : " + f.getAbsolutePath());
System.out.println(" Nombre : " + f.getName());
System.out.println(" Directorio : " + f.getParent());
System.out.println(" ¿Existe? : " + f.exists());
if (!f.exists()) {
System.out.println(" (no hay mas que inspeccionar)");
return;
}
System.out.println(" ¿Es fichero? : " + f.isFile());
System.out.println(" ¿Es carpeta? : " + f.isDirectory());
System.out.println(" ¿Legible? : " + f.canRead());
System.out.println(" ¿Escribible? : " + f.canWrite());
System.out.println(" ¿Oculto? : " + f.isHidden());
System.out.println(" Tamano : " + f.length() + " bytes");
System.out.println(" Modificado : " + f.lastModified() + " (milisegundos desde 1970)");
}
public static void main(String[] args) {
inspeccionar("datos/catalogo.txt");
inspeccionar("datos");
inspeccionar("no-existe-esto.txt");
}
}Los métodos que más se usan:
| Método | Devuelve | Nota importante |
|---|---|---|
exists() |
boolean |
false también si no tienes permiso para saberlo |
isFile() / isDirectory() |
boolean |
Ambos false si no existe |
canRead() / canWrite() |
boolean |
Depende del usuario que ejecuta la JVM |
length() |
long |
Bytes. 0 si no existe, indistinguible de un fichero vacío |
getName() |
String |
Solo el nombre, sin directorios |
getPath() |
String |
La ruta tal cual se dio |
getAbsolutePath() |
String |
Resuelta contra user.dir, sin normalizar los .. |
getCanonicalPath() |
String |
Absoluta y normalizada. Lanza IOException |
lastModified() |
long |
Milisegundos desde 1970. 0 si no existe |
listFiles() |
File[] |
null si no es un directorio o falla. Trampa clásica |
delete() |
boolean |
false sin decir por qué |
mkdirs() |
boolean |
Crea también los directorios intermedios |
Y aquí está el gran defecto de esta API, el que motivó su sustitución:
File f = new File("/datos/importante.txt");
boolean borrado = f.delete();
if (!borrado) {
// ¿Por que? ¿No existia? ¿No tengo permiso? ¿Estaba abierto?
// ¿Es un directorio no vacio? La API no lo dice. Un false mudo.
System.out.println("No se pudo borrar");
}Ese false mudo es exactamente el antipatrón que el módulo 6 dedicó siete lecciones a erradicar: comunicar un fallo sin decir cuál. La API moderna, NIO.2 (java.nio.file.Path y Files, lección 07-06), lanza excepciones específicas —NoSuchFileException, AccessDeniedException, DirectoryNotEmptyException— en lugar de devolver boolean.
Entonces, ¿por qué aprendemos
File? Por tres motivos muy prácticos. Primero, porque hay millones de líneas de código escritas con ella y las vas a leer. Segundo, porque muchas APIs todavía piden unFilecomo parámetro. Y tercero, porque la conversión entre ambos mundos es trivial:file.toPath()ypath.toFile(). Usa NIO.2 en el código nuevo; entiendeFilepara leer el antiguo.
- Lectura con
FileReader: carácter a carácter
FileReader: carácter a carácterFileReader es la clase más elemental para leer texto de un fichero. Su método fundamental es read(), y su contrato tiene un detalle que hay que entender bien:
Devuelve un int, no un char. ¿Por qué? Porque necesita un valor extra que no sea un carácter válido para señalar el fin de fichero, y ese valor es -1. Los caracteres válidos van de 0 a 65535; -1 no colisiona con ninguno. Si el método devolviera char, no habría forma de distinguir "he leído el carácter 0" de "se acabó".
import java.io.FileReader;
import java.io.IOException;
public class LecturaCaracterACaracter {
public static void mostrar(String ruta) throws IOException {
try (FileReader lector = new FileReader(ruta)) {
int codigo; // int, NO char
while ((codigo = lector.read()) != -1) {
char c = (char) codigo; // conversion explicita
System.out.print(c);
}
}
// El try-with-resources ya lo ha cerrado (06-06)
}
public static void main(String[] args) throws IOException {
mostrar("datos/catalogo.txt");
}
}Desmenucemos el bucle, porque su forma es idiomática y la verás por todas partes:
lector.read()lee el siguiente carácter y avanza la posición.codigo = ...guarda el resultado en la variable. En Java, una asignación es una expresión cuyo valor es el asignado; por eso puede usarse dentro de la condición.- Los paréntesis externos son obligatorios: sin ellos,
codigo = lector.read() != -1intentaría asignar unbooleana uninty no compilaría. - La comparación decide si seguir.
Y ahora, el problema. Este código es correcto pero lento, y conviene entender exactamente cuánto:
import java.io.FileReader;
import java.io.IOException;
public class MedirLecturaLenta {
public static long contarCaracteres(String ruta) throws IOException {
long total = 0;
try (FileReader lector = new FileReader(ruta)) {
while (lector.read() != -1) {
total++;
}
}
return total;
}
public static void main(String[] args) throws IOException {
long inicio = System.nanoTime();
long n = contarCaracteres("datos/catalogo-grande.txt");
long ms = (System.nanoTime() - inicio) / 1_000_000;
System.out.printf("%d caracteres leidos en %d ms%n", n, ms);
}
}Con un fichero de 5 MB, en una máquina de escritorio corriente, los órdenes de magnitud son estos (cifras ilustrativas, no una medición formal):
| Técnica | Llamadas al sistema aproximadas | Tiempo orientativo |
|---|---|---|
FileReader.read() carácter a carácter |
~5 000 000 | ~4 500 ms |
FileReader.read(char[8192]) por bloques |
~640 | ~90 ms |
BufferedReader.readLine() (lección 07-04) |
~640 | ~60 ms |
Dos órdenes de magnitud de diferencia, y la causa es exactamente la del apartado 3: cada read() sin búfer se traduce en una petición al sistema operativo. Cinco millones de cruces de frontera para leer cinco megabytes.
La solución intermedia, sin salir todavía de FileReader, es leer por bloques con la sobrecarga que acepta un array:
import java.io.FileReader;
import java.io.IOException;
public class LecturaPorBloques {
public static String leerTodo(String ruta) throws IOException {
StringBuilder contenido = new StringBuilder();
try (FileReader lector = new FileReader(ruta)) {
char[] bloque = new char[8192]; // 8 KB, tamano habitual
int leidos;
// read(char[]) devuelve CUANTOS caracteres ha puesto en el array,
// o -1 si no habia nada que leer. Puede devolver MENOS de 8192
// aunque queden datos: nunca supongas que llena el array.
while ((leidos = lector.read(bloque)) != -1) {
contenido.append(bloque, 0, leidos); // solo la parte util
}
}
return contenido.toString();
}
}Dos detalles críticos de ese código, y los dos son fuente de errores reales:
read(char[])devuelve el número de elementos leídos, que puede ser menor que el tamaño del array aunque el fichero no se haya terminado. Nunca supongas que llena el búfer.- Hay que usar
append(bloque, 0, leidos), noappend(bloque). Si usas la versión sin límites, en la última vuelta añadirás la basura que quedó en el array de la vuelta anterior. Es un fallo que solo se manifiesta al final del fichero y con contenido aparentemente aleatorio.
La forma definitiva de resolver esto —envolver el FileReader en un BufferedReader que gestiona el bloque por ti— es el tema de 07-04. Aquí queda claro el porqué; allí verás el cómo con todo el detalle.
- Lectura con
Scanner sobre un fichero
Scanner sobre un ficheroYa conoces Scanner del módulo 1, leyendo de System.in. La misma clase lee de un fichero: solo cambia lo que le pasas al constructor.
import java.io.File;
import java.io.FileNotFoundException;
import java.util.Scanner;
public class LecturaConScanner {
public static void mostrarLineas(String ruta) throws FileNotFoundException {
// Scanner es Closeable: try-with-resources obligatorio (06-06).
// Cerrarlo cierra tambien el fichero subyacente.
try (Scanner sc = new Scanner(new File(ruta))) {
int numero = 1;
while (sc.hasNextLine()) {
String linea = sc.nextLine();
System.out.printf("%3d | %s%n", numero++, linea);
}
}
}
}El par hasNextLine() / nextLine() es el patrón canónico y merece un aviso:
Comprueba siempre con
hasNextLine()antes de llamar anextLine(). Si llamas anextLine()sin quedar líneas, lanzaNoSuchElementException, que es no comprobada: el compilador no te avisa y explota en ejecución. Es el mismo contrato deIteratorque viste en 05-02.
Scanner no se queda en líneas: sabe parsear tipos, y ahí está su comodidad. Supón este fichero de préstamos de BiblioTech:
PR-0001 978-0000000001 E-001 12 0.25
PR-0002 978-0000000002 E-002 30 0.25
PR-0003 978-0000000003 E-003 5 0.10Se lee así, campo a campo, sin partir cadenas a mano:
import java.io.File;
import java.io.FileNotFoundException;
import java.util.Scanner;
public class LeerPrestamos {
public static void leer(String ruta) throws FileNotFoundException {
try (Scanner sc = new Scanner(new File(ruta))) {
while (sc.hasNext()) {
String referencia = sc.next(); // PR-0001
String isbn = sc.next(); // 978-0000000001
String empleado = sc.next(); // E-001
int dias = sc.nextInt(); // 12
double tarifa = sc.nextDouble();// 0.25
System.out.printf("%s: %s a %s, %d dias, %.2f EUR/dia%n",
referencia, isbn, empleado, dias, tarifa);
}
}
}
}Cuidado con dos comportamientos de Scanner que causan sorpresas:
nextDouble()depende de la configuración regional. Con el idioma español, el separador decimal es la coma, así que0.25puede fallar conInputMismatchExceptionmientras que0,25funciona. Es un fallo que aparece solo en algunas máquinas y desconcierta. La solución es fijar la configuración explícitamente:
import java.util.Locale;
Scanner sc = new Scanner(new File(ruta));
sc.useLocale(Locale.ROOT); // punto decimal, siempre, en cualquier maquinaEste mismo problema, aplicado a los ficheros CSV que se abren en una hoja de cálculo española, reaparece en 07-07.
nextInt()no consume el salto de línea. Si mezclasnextInt()connextLine(), elnextLine()devolverá la cadena vacía que queda hasta el final de la línea actual. Es la trampa que ya viste con la consola en el módulo 1, y aquí es idéntica.
Comparación honesta de las dos APIs vistas hasta ahora:
FileReader a pelo |
Scanner sobre fichero |
|
|---|---|---|
| Unidad de lectura | Carácter o bloque de caracteres | Línea, palabra, int, double, expresión regular |
| Parseo | A mano | Incluido |
| Velocidad | Lenta sin búfer | Lenta: usa expresiones regulares por dentro |
| Errores de formato | No los detecta | InputMismatchException |
| Excepción del constructor | FileNotFoundException |
FileNotFoundException |
| Codificación | Segundo parámetro Charset |
useLocale para números, Charset en el constructor |
| Bueno para | Copiar, procesar sin estructura | Ficheros pequeños con formato tabulado |
| Malo para | Cualquier cosa con estructura | Ficheros grandes: es el más lento de los tres |
Para un fichero de configuración de veinte líneas, Scanner es cómodo y su lentitud es irrelevante. Para el fichero de catálogo de diez mil materiales, la respuesta correcta es BufferedReader, y la verás en 07-04.
useDelimiter y el parseo cómodo
useDelimiter y el parseo cómodoPor defecto, Scanner separa por espacios en blanco. useDelimiter cambia ese criterio a cualquier expresión regular, lo que permite leer ficheros con separadores propios sin partir cadenas a mano.
Con este fichero de catálogo:
978-0000000001;Java Efectivo;Bloch;2018
978-0000000002;Patrones de Diseno;Gamma;1994
978-0000000003;Refactorizacion;Fowler;1999Se lee así:
import java.io.File;
import java.io.FileNotFoundException;
import java.util.Scanner;
public class LeerConDelimitador {
public static void leer(String ruta) throws FileNotFoundException {
try (Scanner sc = new Scanner(new File(ruta))) {
// Delimitador: un punto y coma O un salto de linea (en cualquiera
// de sus tres formas). \\R representa cualquier terminador de linea.
sc.useDelimiter(";|\\R");
while (sc.hasNext()) {
String isbn = sc.next();
String titulo = sc.next();
String autor = sc.next();
int anio = sc.nextInt();
System.out.printf("[%s] %-25s %-10s %d%n", isbn, titulo, autor, anio);
}
}
}
}Delimitadores útiles:
| Patrón | Significado |
|---|---|
";" |
Solo punto y coma |
";|\\R" |
Punto y coma o cualquier salto de línea |
"\\s*,\\s*" |
Coma con espacios opcionales alrededor |
"\\R" |
Solo saltos de línea: equivale a leer línea a línea |
"\\Z" |
Fin de entrada: lee el fichero entero como un solo token |
Ese último truco tiene su gracia para ficheros muy pequeños:
try (Scanner sc = new Scanner(new File("nota.txt"))) {
sc.useDelimiter("\\Z");
String todo = sc.hasNext() ? sc.next() : "";
System.out.println(todo);
}Funciona, pero es un apaño. Desde Java 11 existe Files.readString(Path), que hace lo mismo de forma explícita y correcta, y lo verás en 07-06. Reconoce el truco cuando lo leas en código ajeno; no lo escribas tú.
Y una advertencia que hay que dar ya, aunque se desarrolle en 07-07: partir un CSV por ; o por , es correcto solo mientras ningún campo contenga el separador. En cuanto aparezca un título como "Java: el lenguaje, la máquina y el ecosistema" dentro de comillas, este código produce campos mal alineados en silencio. La lección 07-07 implementa un lector CSV que sí lo hace bien.
- Codificación de caracteres: UTF-8 y el desastre de las tildes
Este apartado es el más importante de la lección. La mayoría de los fallos de E/S que verás en tu carrera profesional no son fallos de lógica: son fallos de codificación.
El problema de fondo
Un fichero contiene bytes. Un String contiene caracteres. Convertir de uno a otro requiere una tabla de correspondencias, y esa tabla es el charset o codificación.
- ASCII (1963): 128 caracteres, un byte cada uno. Sin
ñ, siná, sin€. - ISO-8859-1 (Latin-1): 256 caracteres, un byte cada uno. Tiene
ñy vocales acentuadas. No tiene€ni nada no europeo occidental. - Windows-1252: variante de la anterior, la de Windows en Europa occidental. Casi igual pero no exactamente, lo que genera fallos sutiles.
- UTF-8 (1993): cubre todo Unicode con longitud variable de 1 a 4 bytes. ASCII es un subconjunto exacto suyo. Es el estándar de facto de la web y del intercambio de datos.
Cuánto ocupa cada carácter en UTF-8:
| Carácter | Bytes en UTF-8 | Bytes en ISO-8859-1 |
|---|---|---|
a |
1 (0x61) |
1 (0x61) |
ñ |
2 (0xC3 0xB1) |
1 (0xF1) |
€ |
3 (0xE2 0x82 0xAC) |
No representable |
| Un emoji | 4 | No representable |
Consecuencia directa que sorprende a mucha gente: en UTF-8, el número de caracteres de un texto no es el número de bytes del fichero. "Diseño" son 6 caracteres y 7 bytes. Por eso File.length() (bytes) no te dice cuántos caracteres hay.
Qué pasa exactamente si te equivocas
Escribes "Patrones de Diseño" en UTF-8 y lo lees como ISO-8859-1. Los dos bytes de la ñ (0xC3 0xB1) se interpretan como dos caracteres independientes:
Al revés, escribes en ISO-8859-1 y lees en UTF-8. El byte 0xF1 de la ñ no forma una secuencia UTF-8 válida, y el decodificador lo sustituye por el carácter de reemplazo:
Ese ñ y ese � son los dos síntomas que hay que aprender a reconocer al instante. Cuando los veas, no busques el fallo en tu lógica: es codificación.
Y hay algo peor que la fealdad. Fíjate en este caso:
// Fichero escrito en UTF-8, leido como ISO-8859-1
String titulo = "Patrones de Diseño"; // lo que se ha cargado en memoria
// La busqueda del usuario, escrita correctamente, no encuentra nada:
if (titulo.equals("Patrones de Diseño")) { // false
// nunca entra
}
System.out.println(titulo.length()); // 19, no 18Los datos siguen mal después de leerlos. Se guardarán mal, se compararán mal y se mostrarán mal. La corrupción se propaga.
La codificación por defecto y por qué no debes confiar en ella
Hasta Java 17 incluido, cuando no especificas charset, FileReader y FileWriter usan la codificación por defecto de la plataforma, que depende del sistema operativo y de la configuración regional:
| Entorno | Codificación por defecto histórica |
|---|---|
| Linux moderno | UTF-8 |
| macOS | UTF-8 |
| Windows en España | Windows-1252 |
| Windows en Japón | Shift_JIS |
| Contenedor Docker minimalista | Con frecuencia US-ASCII |
Esto significa que el mismo programa leyendo el mismo fichero produce resultados distintos en máquinas distintas. Es la causa del clásico "en mi portátil se ve bien y en el servidor sale con interrogantes".
Java 18 (JEP 400) cambió el valor por defecto de file.encoding a UTF-8 en todas las plataformas, lo cual es una gran mejora. Pero:
- Si trabajas con Java 17 —la versión de referencia de este curso y la más usada en producción— sigues teniendo el comportamiento antiguo.
- Aunque tu proyecto sea Java 21, el código que especifica el charset es autoexplicativo y no depende de una propiedad que alguien pueda cambiar con
-Dfile.encoding.
Regla profesional, sin excepciones: especifica siempre el charset explícitamente en toda operación de texto. No cuesta nada y elimina una categoría entera de fallos.
Cómo hacerlo bien
Desde Java 11, FileReader acepta un Charset en el constructor:
import java.io.FileReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
public class LecturaConCharset {
public static void leer(String ruta) throws IOException {
// Java 11+: charset explicito en el constructor
try (FileReader lector = new FileReader(ruta, StandardCharsets.UTF_8)) {
int c;
while ((c = lector.read()) != -1) {
System.out.print((char) c);
}
}
}
}En versiones anteriores —y sigue siendo la forma más general, la que verás en 07-03— se usa el puente InputStreamReader:
import java.io.FileInputStream;
import java.io.InputStreamReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
try (InputStreamReader lector =
new InputStreamReader(new FileInputStream(ruta), StandardCharsets.UTF_8)) {
// ...
}Y Scanner también lo acepta:
Las constantes disponibles en java.nio.charset.StandardCharsets, garantizadas en toda JVM:
| Constante | Nombre |
|---|---|
StandardCharsets.UTF_8 |
UTF-8. Tu opción por defecto siempre |
StandardCharsets.ISO_8859_1 |
Latin-1. Solo para leer ficheros antiguos |
StandardCharsets.US_ASCII |
ASCII de 7 bits |
StandardCharsets.UTF_16 |
UTF-16 con marca de orden de bytes |
Usa las constantes, no las cadenas.
new FileReader(ruta, StandardCharsets.UTF_8)no puede fallar; la versión que acepta el nombre como cadena lanzaUnsupportedEncodingExceptionsi escribes"UTF8","utf-8 "con un espacio o cualquier otra errata. Es un error de compilación convertido en error de ejecución sin ninguna ventaja.
FileNotFoundException frente a IOException
FileNotFoundException frente a IOExceptionLa E/S es el territorio natural de las excepciones comprobadas del módulo 6, y por un motivo que ahora resulta evidente: el fichero es del mundo exterior. Puede no existir, puede haber cambiado de permisos, el disco puede llenarse, alguien puede desconectar la unidad de red. Nada de eso es un fallo de tu programa, y todo es previsible.
La jerarquía relevante:
flowchart TD
T["Throwable"] --> E["Exception"]
E --> IOE["IOException<br/>(comprobada)"]
IOE --> FNF["FileNotFoundException"]
IOE --> EOF["EOFException"]
IOE --> UEE["UnsupportedEncodingException"]
IOE --> NSF["NoSuchFileException<br/>(NIO.2, 07-06)"]
IOE --> ADE["AccessDeniedException<br/>(NIO.2, 07-06)"]
IOE --> MFE["MalformedInputException<br/>(charset invalido)"]
style IOE fill:#ffe0b2
style FNF fill:#e3f2fd
Qué significa cada una en la práctica:
| Excepción | Cuándo se lanza | Qué suele hacerse |
|---|---|---|
FileNotFoundException |
El fichero no existe, es un directorio, o no hay permiso de lectura | Degradar: usar un valor por defecto, o pedir la ruta correcta |
IOException |
Fallo genérico al leer: disco, red, dispositivo | Registrar y propagar. Rara vez recuperable |
EOFException |
Fin inesperado leyendo datos binarios estructurados (07-03) | Fichero truncado o corrupto: abortar la carga |
MalformedInputException |
Bytes que no forman caracteres válidos en el charset dado | Charset equivocado. Revisar el apartado 10 |
Nota engañosa sobre el nombre: FileNotFoundException no significa solo "no existe". También se lanza si la ruta apunta a un directorio o si el fichero existe pero no tienes permiso de lectura. Por eso el mensaje del sistema operativo es tan valioso y nunca debes descartarlo.
El orden de los catch sigue la regla de 06-02, de lo específico a lo general:
import java.io.FileNotFoundException;
import java.io.IOException;
public void cargar(String ruta) {
try (Scanner sc = new Scanner(new File(ruta), StandardCharsets.UTF_8)) {
// ... leer ...
} catch (FileNotFoundException e) {
// Lo especifico primero. Este caso se puede tratar de forma distinta.
LOG.log(Level.WARNING, "No se encontro el fichero: " + ruta, e);
} catch (IOException e) {
// Lo general despues. Al reves NO COMPILA: codigo inalcanzable.
LOG.log(Level.SEVERE, "Fallo de E/S leyendo " + ruta, e);
}
}
- Degradación elegante: BiblioTech arranca sin catálogo
Vamos a juntarlo todo con la primera pieza real del módulo. CargadorCatalogo lee un fichero de texto y construye materiales, y —esto es lo importante— si el fichero no existe, la aplicación arranca igualmente con el catálogo vacío.
Este es exactamente el criterio de 06-07 para distinguir lo recuperable de lo irrecuperable: degrada cuando el servicio reducido siga siendo correcto. Una biblioteca sin materiales es una biblioteca vacía, que es un estado perfectamente válido y en el que se pueden dar de alta materiales. No hay nada incorrecto en ello. Muy distinto sería arrancar sin conocer la tarifa de las multas: ahí no se puede degradar, porque cobraríamos importes falsos.
El formato del fichero:
# Catalogo de BiblioTech - Nexus Software
# tipo;referencia;titulo;autor;anio
LIBRO;978-0000000001;Java Efectivo;Joshua Bloch;2018
LIBRO;978-0000000002;Patrones de Diseno;Erich Gamma;1994
LIBRO;978-0000000003;Refactorizacion;Martin Fowler;1999Y el cargador:
package com.nexussoftware.bibliotech.servicio;
import java.io.File;
import java.io.FileNotFoundException;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;
import java.util.logging.Level;
import java.util.logging.Logger;
import com.nexussoftware.bibliotech.dominio.Libro;
import com.nexussoftware.bibliotech.dominio.ReferenciaDuplicadaException;
/**
* Carga el catalogo de BiblioTech desde un fichero de texto.
*
* Politica de errores (06-07):
* - Fichero ausente -> DEGRADAR: catalogo vacio y aviso en el log.
* - Linea con formato malo -> DESCARTAR esa linea y seguir con las demas.
* - Fallo de E/S grave -> propagar como CatalogoNoAccesibleException.
*
* Esta version usa Scanner por claridad. En 07-04 se sustituye por
* BufferedReader, que es lo correcto para ficheros de miles de lineas.
*/
public class CargadorCatalogo {
private static final Logger LOG = Logger.getLogger(CargadorCatalogo.class.getName());
private static final String COMENTARIO = "#";
private static final String SEPARADOR = ";";
private static final int CAMPOS_LIBRO = 5;
private final String ruta;
private int lineasLeidas = 0;
private int materialesAltas = 0;
private int lineasDescartadas = 0;
public CargadorCatalogo(String ruta) {
this.ruta = java.util.Objects.requireNonNull(ruta, "La ruta no puede ser nula");
}
/**
* Carga el catalogo. NUNCA lanza por fichero ausente: degrada.
*
* @return un Catalogo, posiblemente vacio, nunca null
*/
public Catalogo cargar() {
Catalogo catalogo = new Catalogo();
File fichero = new File(ruta);
// Aviso util: registrar SIEMPRE la ruta absoluta, no la relativa (apartado 4)
LOG.config(() -> "Cargando catalogo desde " + fichero.getAbsolutePath());
try (Scanner sc = new Scanner(fichero, StandardCharsets.UTF_8)) {
while (sc.hasNextLine()) {
String linea = sc.nextLine();
lineasLeidas++;
procesarLinea(catalogo, linea, lineasLeidas);
}
LOG.info(String.format(
"Catalogo cargado: %d materiales de %d lineas (%d descartadas)",
materialesAltas, lineasLeidas, lineasDescartadas));
} catch (FileNotFoundException e) {
// DEGRADACION ELEGANTE. No es un fallo de la aplicacion: la primera
// vez que se ejecuta BiblioTech, este fichero no existe todavia.
// Nivel WARNING, no SEVERE: cuando todo es SEVERE nadie mira los
// errores de verdad (06-07).
LOG.log(Level.WARNING,
"No hay fichero de catalogo en " + fichero.getAbsolutePath()
+ "; se arranca con el catalogo vacio", e);
}
return catalogo;
}
/** Procesa una linea. Los fallos de formato descartan la linea, no abortan la carga. */
private void procesarLinea(Catalogo catalogo, String linea, int numero) {
String limpia = linea.trim();
// Lineas vacias y comentarios: se ignoran en silencio, son normales
if (limpia.isEmpty() || limpia.startsWith(COMENTARIO)) {
return;
}
String[] campos = limpia.split(SEPARADOR);
if (campos.length != CAMPOS_LIBRO) {
descartar(numero, "se esperaban " + CAMPOS_LIBRO
+ " campos y hay " + campos.length);
return;
}
if (!"LIBRO".equalsIgnoreCase(campos[0].trim())) {
descartar(numero, "tipo desconocido '" + campos[0].trim() + "'");
return;
}
try {
String isbn = campos[1].trim();
String titulo = campos[2].trim();
String autor = campos[3].trim();
int anio = Integer.parseInt(campos[4].trim());
catalogo.registrar(new Libro(titulo, autor, isbn, anio));
materialesAltas++;
} catch (NumberFormatException e) {
// El anio no era un numero. Se traduce a un descarte con contexto (06-03).
descartar(numero, "el anio '" + campos[4].trim() + "' no es un numero");
} catch (ReferenciaDuplicadaException e) {
// Excepcion propia del modulo 6: el ISBN ya estaba. Se descarta la linea.
descartar(numero, e.getMessage());
}
}
private void descartar(int numero, String motivo) {
lineasDescartadas++;
LOG.warning(() -> "Linea " + numero + " descartada: " + motivo);
}
public int getLineasLeidas() { return lineasLeidas; }
public int getMaterialesAltas() { return materialesAltas; }
public int getLineasDescartadas() { return lineasDescartadas; }
}Y el arranque de la aplicación queda así:
package com.nexussoftware.bibliotech.presentacion;
import com.nexussoftware.bibliotech.servicio.CargadorCatalogo;
import com.nexussoftware.bibliotech.servicio.Catalogo;
import com.nexussoftware.bibliotech.infraestructura.ConfiguracionLog;
public class BiblioTechApp {
/** Ruta por defecto; se puede cambiar por argumento de linea de comandos. */
private static final String RUTA_CATALOGO_DEFECTO = "datos/catalogo.txt";
public static void main(String[] args) {
ConfiguracionLog.inicializar(); // 06-07
String ruta = (args.length > 0) ? args[0] : RUTA_CATALOGO_DEFECTO;
Catalogo catalogo = new CargadorCatalogo(ruta).cargar();
System.out.println("BiblioTech - Nexus Software");
System.out.println("Materiales en catalogo: " + catalogo.tamano());
new MenuBiblioTech(catalogo).ejecutar();
}
}Las tres decisiones de diseño que conviene señalar:
- La ruta llega por argumento, con un valor por defecto. Es la estrategia recomendada del apartado 4: quien ejecuta decide dónde están los datos.
cargar()nunca devuelvenull. Devuelve unCatalogovacío si no hay fichero. Es la regla que 06-07 estableció: elnullno es una forma de comunicar nada.- Una línea mala no aborta el fichero. Se descarta, se cuenta y se registra. Que un catálogo de diez mil líneas no se cargue porque la línea 4 200 tiene un año mal escrito sería una política pésima. Esto es el "objeto resultado" de 06-07 aplicado a la importación, y en 07-04 se convertirá en un informe completo.
- Ficheros grandes y por qué no leerlo todo en memoria
Cerramos con una advertencia de dimensionamiento. Es tentador escribir esto:
// PELIGROSO con ficheros grandes
String todo = leerFicheroEnteroComoCadena("datos/historico-prestamos.txt");Y para un fichero de configuración de 2 KB es perfectamente razonable. Pero para el histórico de préstamos de tres años, no:
| Tamaño del fichero | Memoria aproximada del String |
Viable con heap de 512 MB |
|---|---|---|
| 10 KB | ~20 KB | Sí, sin pensarlo |
| 5 MB | ~10 MB | Sí |
| 200 MB | ~400 MB | Al límite, con riesgo |
| 2 GB | ~4 GB | OutOfMemoryError |
Por qué el factor de dos: internamente un String de Java 9 en adelante guarda un byte por carácter cuando todo el texto es Latin-1 y dos bytes por carácter en cuanto aparece un carácter fuera de ese rango. Un solo emoji o un carácter cirílico en un fichero de 2 GB duplica la memoria del String completo. Y además necesitas el StringBuilder intermedio mientras lo construyes, así que en el pico llegas a tener el contenido dos veces.
Un OutOfMemoryError es un Error, no una Exception: no se captura, no se recupera y tumba el proceso, con la salvedad de la frontera de main que viste en 06-07.
La alternativa correcta es procesar en flujo (streaming): leer una línea, procesarla, olvidarla, leer la siguiente. La memoria usada es la de una línea, no la del fichero, y el consumo es constante aunque el fichero tenga 50 GB:
// Esquema de procesamiento en flujo. La version completa, en 07-04.
try (BufferedReader lector = new BufferedReader(
new FileReader(ruta, StandardCharsets.UTF_8))) {
String linea;
while ((linea = lector.readLine()) != null) {
procesar(linea); // se usa y se descarta
}
}La regla práctica:
Carga el fichero entero en memoria solo si conoces su tamaño máximo y es pequeño. Configuración, plantillas, ficheros de pocos KB: adelante. Datos, registros, exportaciones, cualquier cosa que crezca con el uso: procesa en flujo.
Esa es exactamente la razón de ser de BufferedReader, que es la lección 07-04.
Errores Comunes y Consejos
- Usar rutas relativas y no saber respecto a qué. El clásico "funciona en el IDE y falla en producción". Imprime
System.getProperty("user.dir")en cuanto tengas dudas y pasa las rutas por parámetro. - Informar del error con la ruta relativa.
No se encuentra 'datos/catalogo.txt'no sirve para nada. Registra siempregetAbsolutePath(). - No especificar el charset. El error más caro del módulo a largo plazo, porque no falla: corrompe en silencio.
StandardCharsets.UTF_8, siempre, en toda operación de texto. - Confundir
ñcon?.ñsignifica que escribiste UTF-8 y leíste Latin-1.?o�significa lo contrario. Reconocer el síntoma te ahorra media hora cada vez. - Usar cadenas en lugar de constantes de charset.
"UTF8"compila y explota en ejecución.StandardCharsets.UTF_8no puede escribirse mal. - Declarar
char c = lector.read(). No compila, y por un buen motivo: hace falta el-1para el fin de fichero, que no cabe en unchar. - Olvidar los paréntesis en
while ((c = lector.read()) != -1). Sin ellos no compila. Con ellos, es el bucle idiomático que verás siempre. - Usar
append(bloque)en lugar deappend(bloque, 0, leidos). Añade basura de la vuelta anterior en la última lectura. Falla solo al final del fichero, que es lo que lo hace difícil de diagnosticar. - Llamar a
nextLine()sin comprobarhasNextLine().NoSuchElementException, no comprobada, en ejecución. - Mezclar
nextInt()ynextLine()sin consumir el salto de línea. La misma trampa del módulo 1, idéntica sobre ficheros. - Confiar en
nextDouble()sin fijar elLocale. En una máquina con configuración española,0.25puede fallar.sc.useLocale(Locale.ROOT). - Leer carácter a carácter un fichero grande. Correcto y cien veces más lento. Bloques o
BufferedReader(07-04). - Cargar en un
Stringun fichero de tamaño desconocido.OutOfMemoryError, que no se captura. Procesa en flujo. - Interpretar
length() == 0como "no existe". Un fichero vacío da lo mismo. Usaexists(). - Ignorar que
listFiles()puede devolvernull.NullPointerExceptionen elfor. NIO.2 lo resuelve (07-06). - Consejo: escribe una única clase que sepa leer cada tipo de fichero. Si tienes
new FileReader(...)repartido por veinte clases, cambiar el charset o el formato será un rastreo. Concentra la E/S en la capa que le corresponde, como haceCargadorCatalogo. - Consejo: prueba siempre con un fichero que contenga
ñ,áy€. Un fichero de prueba solo con ASCII te ocultará todos los fallos de codificación hasta que llegue a producción. - Consejo: prueba también con el fichero ausente y con el fichero vacío. Son los dos casos que más se olvidan y los dos que más ocurren, sobre todo el primer día de vida del sistema.
Ejercicios
Ejercicio 1: diagnóstico de rutas
Escribe una clase DiagnosticoRuta con un método public static void diagnosticar(String ruta) que imprima un informe completo y útil sobre una ruta, pensado para que alguien pueda resolver un problema con él:
- La ruta tal como se dio y su forma absoluta.
- El directorio de trabajo actual.
- Si existe; y si no existe, si existe su directorio padre (para distinguir "el fichero falta" de "el directorio no es ese").
- Si existe: si es fichero o directorio, si es legible, su tamaño y el número de líneas.
- Un diagnóstico final en una frase con la causa más probable.
Pruébalo con una ruta correcta, una inexistente y un directorio.
Ejercicio 2: detector de codificación
Escribe DetectorCodificacion con un método public static void comparar(String ruta) que lea el mismo fichero tres veces —como UTF-8, como ISO-8859-1 y como US-ASCII— y muestre las tres primeras líneas de cada lectura, para ver el efecto sobre las tildes y las eñes.
Después, añade public static boolean pareceUtf8(String ruta), que devuelva true si el fichero se decodifica sin ningún carácter de reemplazo (\uFFFD) al leerlo como UTF-8. Explica en un comentario por qué esa comprobación detecta "esto no es UTF-8" pero no puede detectar "esto es Latin-1 leído como UTF-8 al revés".
Prepara un fichero de prueba con Patrones de Diseño, Refactorización y Año 2018.
Ejercicio 3: cargador de empleados con informe
Amplía la idea de CargadorCatalogo a los empleados. Dado un fichero como este:
# identificador;nombre;prestamos_activos
E-001;Marta Ruiz;2
E-002;Diego Alonso;0
E-003;Nuria Vidal;1Escribe CargadorEmpleados con:
public ResultadoCarga cargar(), que devuelva un objeto resultado (idea de 06-07) con la lista de empleados cargados y la lista de errores, en lugar de lanzar en el primer fallo.- Validación de cada línea: exactamente 3 campos, identificador con formato
E-NNN, nombre no vacío y número de préstamos entre 0 yEmpleado.MAX_PRESTAMOS_SIMULTANEOS. - Degradación si el fichero no existe: resultado vacío con un aviso, sin excepción.
- Charset explícito y
try-with-resources. - Un
mainde demostración que imprima el informe conprintf.
Añade al fichero de prueba una línea con dos campos, una con un identificador X-999 y una con 9 préstamos, para comprobar que las tres se descartan con su motivo y las buenas se cargan.
Soluciones
Solución 1
package com.nexussoftware.bibliotech.util;
import java.io.File;
import java.io.FileNotFoundException;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;
/**
* Informe de diagnostico de una ruta.
*
* El objetivo NO es saber si el fichero existe, sino poder explicar POR QUE
* no existe, que es lo que de verdad hace falta para arreglarlo (06-04).
*/
public class DiagnosticoRuta {
public static void diagnosticar(String ruta) {
File f = new File(ruta);
System.out.println("========================================");
System.out.println("DIAGNOSTICO DE: " + ruta);
System.out.println("========================================");
// 1. Ruta dada y ruta resuelta
System.out.println(" Ruta dada : " + f.getPath());
System.out.println(" Ruta absoluta : " + f.getAbsolutePath());
System.out.println(" ¿Es absoluta? : " + f.isAbsolute());
// 2. Contexto de ejecucion: la pieza que casi siempre falta
System.out.println(" Directorio actual: " + System.getProperty("user.dir"));
// 3. Existencia, distinguiendo fichero de directorio padre
boolean existe = f.exists();
System.out.println(" ¿Existe? : " + existe);
File padre = f.getAbsoluteFile().getParentFile();
boolean existePadre = (padre != null && padre.exists());
System.out.println(" Directorio padre : "
+ (padre == null ? "(ninguno)" : padre.getAbsolutePath())
+ (existePadre ? " [existe]" : " [NO existe]"));
// 4. Detalles solo si existe
if (existe) {
System.out.println(" ¿Es fichero? : " + f.isFile());
System.out.println(" ¿Es directorio? : " + f.isDirectory());
System.out.println(" ¿Legible? : " + f.canRead());
System.out.println(" ¿Escribible? : " + f.canWrite());
System.out.println(" Tamano : " + f.length() + " bytes");
if (f.isFile() && f.canRead()) {
System.out.println(" Lineas : " + contarLineas(f));
}
}
// 5. Conclusion en una frase
System.out.println(" ----");
System.out.println(" CONCLUSION: " + concluir(f, existe, existePadre));
System.out.println();
}
/** Cuenta lineas de forma segura: si falla, lo dice en lugar de propagarlo. */
private static String contarLineas(File f) {
try (Scanner sc = new Scanner(f, StandardCharsets.UTF_8)) {
int n = 0;
while (sc.hasNextLine()) {
sc.nextLine();
n++;
}
return String.valueOf(n);
} catch (FileNotFoundException e) {
// Puede pasar entre el exists() y el open(): es una condicion de
// carrera TOCTOU, exactamente la advertida en 06-07.
return "(no se pudo leer: " + e.getMessage() + ")";
}
}
private static String concluir(File f, boolean existe, boolean existePadre) {
if (existe && f.isFile() && f.canRead()) {
return "El fichero existe y se puede leer. Todo correcto.";
}
if (existe && f.isDirectory()) {
return "La ruta apunta a un DIRECTORIO, no a un fichero. "
+ "Al abrirlo se lanzaria FileNotFoundException, "
+ "pese a lo que sugiere su nombre.";
}
if (existe && !f.canRead()) {
return "El fichero existe pero el usuario que ejecuta la JVM "
+ "no tiene permiso de lectura. Revisa los permisos.";
}
if (!existe && !existePadre) {
return "No existe ni el fichero ni su directorio. Lo mas probable "
+ "es que la ruta relativa se este resolviendo desde otro "
+ "directorio de trabajo del esperado.";
}
return "El directorio existe pero el fichero no. Comprueba el nombre "
+ "exacto, incluidas mayusculas: en Linux distinguen.";
}
public static void main(String[] args) {
diagnosticar("datos/catalogo.txt"); // correcta
diagnosticar("datos"); // un directorio
diagnosticar("datos/no-existe.txt"); // falta el fichero
diagnosticar("otra/ruta/mala.txt"); // falta el directorio
}
}Lo que enseña este ejercicio: la diferencia entre detectar un fallo y explicarlo. Los cinco casos de concluir corresponden a cinco causas distintas que la API resume todas en un exists() a false. Distinguirlas es lo que convierte un mensaje de error en una solución. Fíjate también en el comentario sobre TOCTOU: entre comprobar exists() y abrir el fichero puede pasar cualquier cosa, así que la comprobación previa no sustituye al catch.
Solución 2
package com.nexussoftware.bibliotech.util;
import java.io.FileReader;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
/**
* Muestra el efecto de leer el mismo fichero con distintas codificaciones.
*/
public class DetectorCodificacion {
/** Carácter de reemplazo que inserta el decodificador ante bytes invalidos. */
private static final char REEMPLAZO = '\uFFFD';
public static void comparar(String ruta) {
Charset[] candidatos = {
StandardCharsets.UTF_8,
StandardCharsets.ISO_8859_1,
StandardCharsets.US_ASCII
};
for (Charset cs : candidatos) {
System.out.println("--- Leido como " + cs.name() + " ---");
try {
String[] lineas = primerasLineas(ruta, cs, 3);
for (String l : lineas) {
System.out.println(" " + l);
}
} catch (IOException e) {
System.out.println(" ERROR: " + e.getMessage());
}
System.out.println();
}
}
/** Lee las n primeras lineas con el charset dado. */
private static String[] primerasLineas(String ruta, Charset cs, int n) throws IOException {
String[] resultado = new String[n];
int indice = 0;
StringBuilder actual = new StringBuilder();
try (FileReader lector = new FileReader(ruta, cs)) {
int c;
while (indice < n && (c = lector.read()) != -1) {
if (c == '\n') {
resultado[indice++] = actual.toString();
actual.setLength(0);
} else if (c != '\r') {
actual.append((char) c);
}
}
}
// Ultima linea sin salto final
if (indice < n && actual.length() > 0) {
resultado[indice++] = actual.toString();
}
for (int i = indice; i < n; i++) {
resultado[i] = "(no hay mas lineas)";
}
return resultado;
}
/**
* ¿Se decodifica el fichero como UTF-8 sin caracteres de reemplazo?
*
* POR QUE FUNCIONA EN UN SENTIDO: UTF-8 tiene una gramatica estricta.
* El byte 0xF1 (la 'ñ' de Latin-1) anuncia una secuencia de 4 bytes que
* en un fichero Latin-1 no vendra bien formada, asi que el decodificador
* inserta \uFFFD. Detectamos "esto NO es UTF-8".
*
* POR QUE NO FUNCIONA EN EL OTRO: un fichero UTF-8 leido como Latin-1
* NUNCA produce reemplazos, porque en Latin-1 los 256 bytes posibles son
* caracteres validos. Simplemente salen los caracteres equivocados
* ("Diseño"). Un decodificador no puede saber que eso esta mal: para el
* es texto perfectamente legal. Por eso NO EXISTE deteccion fiable de
* codificacion, y por eso hay que ESPECIFICARLA siempre.
*/
public static boolean pareceUtf8(String ruta) throws IOException {
try (FileReader lector = new FileReader(ruta, StandardCharsets.UTF_8)) {
int c;
while ((c = lector.read()) != -1) {
if (c == REEMPLAZO) {
return false;
}
}
}
return true;
}
public static void main(String[] args) throws IOException {
String ruta = "datos/prueba-acentos.txt";
comparar(ruta);
System.out.println("¿Parece UTF-8? " + pareceUtf8(ruta));
}
}Con un fichero de prueba guardado en UTF-8 que contenga Patrones de Diseño, Refactorización y Año 2018, la salida es:
--- Leido como UTF-8 ---
Patrones de Diseño
Refactorización
Año 2018
--- Leido como ISO-8859-1 ---
Patrones de Diseño
Refactorización
Año 2018
--- Leido como US-ASCII ---
Patrones de Dise??o
Refactorizaci??n
A??o 2018
¿Parece UTF-8? trueLa conclusión práctica es la del comentario: la detección de codificación es fundamentalmente imposible en el caso general. Los editores de texto la adivinan con heurísticas y a veces se equivocan. Por eso ninguna cantidad de listeza en el código sustituye a la regla simple: especifica siempre el charset, al leer y al escribir.
Solución 3
package com.nexussoftware.bibliotech.servicio;
import java.io.File;
import java.io.FileNotFoundException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Scanner;
import java.util.logging.Level;
import java.util.logging.Logger;
import com.nexussoftware.bibliotech.dominio.Empleado;
/**
* Carga empleados desde un fichero de texto acumulando errores en lugar
* de abortar en el primero (objeto resultado, 06-07).
*/
public class CargadorEmpleados {
private static final Logger LOG = Logger.getLogger(CargadorEmpleados.class.getName());
private static final String SEPARADOR = ";";
private static final String COMENTARIO = "#";
private static final int NUM_CAMPOS = 3;
private static final String PATRON_ID = "E-\\d{3}";
private final String ruta;
public CargadorEmpleados(String ruta) {
this.ruta = Objects.requireNonNull(ruta, "La ruta no puede ser nula");
}
/** Resultado de la carga: lo que salio bien y lo que salio mal, junto. */
public static class ResultadoCarga {
private final List<Empleado> empleados = new ArrayList<>();
private final List<String> errores = new ArrayList<>();
private boolean ficheroEncontrado = true;
void anadir(Empleado e) { empleados.add(e); }
void error(int linea, String m) { errores.add("Linea " + linea + ": " + m); }
void marcarFicheroAusente() { ficheroEncontrado = false; }
public List<Empleado> getEmpleados() { return List.copyOf(empleados); }
public List<String> getErrores() { return List.copyOf(errores); }
public boolean hayErrores() { return !errores.isEmpty(); }
public boolean ficheroEncontrado() { return ficheroEncontrado; }
public int total() { return empleados.size() + errores.size(); }
}
public ResultadoCarga cargar() {
ResultadoCarga resultado = new ResultadoCarga();
File fichero = new File(ruta);
try (Scanner sc = new Scanner(fichero, StandardCharsets.UTF_8)) {
int numero = 0;
while (sc.hasNextLine()) {
numero++;
procesar(sc.nextLine(), numero, resultado);
}
} catch (FileNotFoundException e) {
// DEGRADACION: resultado vacio, aviso, y sin excepcion hacia arriba
resultado.marcarFicheroAusente();
LOG.log(Level.WARNING, "No hay fichero de empleados en "
+ fichero.getAbsolutePath() + "; se arranca sin empleados", e);
}
return resultado;
}
private void procesar(String linea, int numero, ResultadoCarga r) {
String limpia = linea.trim();
if (limpia.isEmpty() || limpia.startsWith(COMENTARIO)) {
return; // normal, no es un error
}
// -1 en split conserva los campos vacios del final: "E-001;;" da 3 campos.
// Sin el -1 daria 1, y el mensaje de error seria enganoso.
String[] campos = limpia.split(SEPARADOR, -1);
if (campos.length != NUM_CAMPOS) {
r.error(numero, "se esperaban " + NUM_CAMPOS + " campos y hay " + campos.length);
return;
}
String id = campos[0].trim();
String nombre = campos[1].trim();
String activos = campos[2].trim();
if (!id.matches(PATRON_ID)) {
r.error(numero, "identificador '" + id + "' no tiene el formato E-NNN");
return;
}
if (nombre.isEmpty()) {
r.error(numero, "el nombre esta vacio");
return;
}
int prestamos;
try {
prestamos = Integer.parseInt(activos);
} catch (NumberFormatException e) {
r.error(numero, "'" + activos + "' no es un numero de prestamos valido");
return;
}
if (prestamos < 0 || prestamos > Empleado.MAX_PRESTAMOS_SIMULTANEOS) {
r.error(numero, "prestamos activos fuera de rango (0.."
+ Empleado.MAX_PRESTAMOS_SIMULTANEOS + "): " + prestamos);
return;
}
Empleado empleado = new Empleado(nombre, id);
for (int i = 0; i < prestamos; i++) {
empleado.registrarPrestamo(); // restaura el estado guardado
}
r.anadir(empleado);
}
public static void main(String[] args) {
ResultadoCarga r = new CargadorEmpleados("datos/empleados.txt").cargar();
System.out.println("=== CARGA DE EMPLEADOS ===");
if (!r.ficheroEncontrado()) {
System.out.println(" (no habia fichero: se arranca sin empleados)");
return;
}
System.out.printf(" Lineas procesadas : %d%n", r.total());
System.out.printf(" Empleados cargados: %d%n", r.getEmpleados().size());
System.out.printf(" Lineas con error : %d%n", r.getErrores().size());
if (!r.getEmpleados().isEmpty()) {
System.out.println(" --- Cargados ---");
for (Empleado e : r.getEmpleados()) {
System.out.printf(" %-8s %s%n", e.getIdentificador(), e.getNombre());
}
}
if (r.hayErrores()) {
System.out.println(" --- Descartados ---");
for (String error : r.getErrores()) {
System.out.println(" " + error);
}
}
}
}Con este fichero de prueba:
# identificador;nombre;prestamos_activos
E-001;Marta Ruiz;2
E-002;Diego Alonso;0
E-003;Nuria Vidal;1
E-004;Solo dos campos
X-999;Formato malo;1
E-005;Demasiados;9La salida es:
=== CARGA DE EMPLEADOS ===
Lineas procesadas : 6
Empleados cargados: 3
Lineas con error : 3
--- Cargados ---
E-001 Marta Ruiz
E-002 Diego Alonso
E-003 Nuria Vidal
--- Descartados ---
Linea 5: se esperaban 3 campos y hay 2
Linea 6: identificador 'X-999' no tiene el formato E-NNN
Linea 7: prestamos activos fuera de rango (0..3): 9Las tres decisiones que merece la pena señalar:
- El objeto resultado en lugar de una excepción por línea. Quien importa un fichero quiere ver todos los problemas de una vez para corregirlos y volver a intentarlo, no descubrirlos de uno en uno. Es exactamente el caso de uso que 06-07 planteó para el objeto resultado.
split(SEPARADOR, -1). Sin ese-1,splitdescarta los campos vacíos finales, así queE-001;;daría 1 campo en vez de 3 y el mensaje de error sería incorrecto. Es una trampa clásica que reaparecerá en 07-07.- El fichero ausente no es un error de línea. Se marca aparte, porque son dos situaciones distintas: "no hay datos" y "hay datos mal escritos". Confundirlas produce informes inútiles.
Conclusión
Has puesto los cimientos de la persistencia de BiblioTech.
Entiendes qué es un fichero de verdad: una secuencia de bytes con un nombre, sin tipos ni estructura, frente a un objeto en memoria con campos y referencias. Sabes que pasar de uno a otro exige una decisión de formato explícita —un material por línea, campos separados por ;— y que esa decisión es el germen del CSV de 07-07. Y conoces el recorrido completo del dato hasta el disco, con la conclusión que gobierna todo el módulo: la llamada al sistema es la operación cara, y todo el diseño de la E/S existe para reducir su número.
Sabes localizar un fichero: rutas absolutas y relativas, el papel de user.dir como origen de las relativas, y por qué el mismo programa "no encuentra" el mismo fichero según desde dónde se lance. Tienes las cinco estrategias profesionales para no depender de eso, con la recomendación de pasar la ruta por parámetro, y la regla de registrar siempre la ruta absoluta en los mensajes de error. Conoces File.separator frente a File.pathSeparator y por qué es mejor componer rutas que concatenar cadenas.
Conoces java.io.File —exists, isFile, canRead, length, getAbsolutePath— y, sobre todo, el defecto que la condenó: los boolean mudos que dicen que algo falló sin decir por qué, exactamente el antipatrón que el módulo 6 erradicó. Sabes que 07-06 la sustituye por Path y Files, y que la conversión entre ambas es un toPath().
Sabes leer con FileReader carácter a carácter, con el int de retorno y el -1 de fin de fichero, y por qué ese bucle es dos órdenes de magnitud más lento de lo necesario. Conoces la lectura por bloques con read(char[]) y sus dos trampas: el valor de retorno que puede ser menor que el array, y el append(bloque, 0, leidos) que evita arrastrar basura. Y sabes leer con Scanner sobre fichero, con hasNextLine/nextLine, el parseo de tipos, useDelimiter con expresiones regulares, y sus dos sorpresas: el Locale que cambia el separador decimal y el nextInt() que no consume el salto de línea.
Y tienes el apartado que más incidencias reales evita: la codificación. Sabes qué es UTF-8, por qué ñ ocupa dos bytes, qué significa exactamente ver ñ y qué significa ver �, por qué la codificación por defecto de la plataforma es una bomba de relojería entre máquinas, qué cambió Java 18 y por qué eso no te exime de especificarla. Y sabes que detectar la codificación es imposible en el caso general, lo que convierte la regla en absoluta: StandardCharsets.UTF_8, explícito, siempre.
BiblioTech ya lee. CargadorCatalogo construye el catálogo desde un fichero de texto, descarta las líneas mal formadas contándolas y registrándolas en lugar de abortar la carga entera, traduce NumberFormatException y ReferenciaDuplicadaException a descartes con contexto, y —lo más importante— degrada elegantemente: si no hay fichero de catálogo, arranca con el catálogo vacío y un WARNING, porque una biblioteca vacía es un estado correcto. Es la política de errores de 06-07 aplicada a la E/S desde la primera línea de código.
Queda la mitad que falta, y es la que da miedo. Leer es seguro; escribir destruye. En la lección 07-02, Escritura de Archivos, verás FileWriter y el parámetro append que decide entre añadir al final y borrar el fichero entero, que es el error más caro de todo el módulo; PrintWriter con el printf que ya usas desde el módulo 1; el búfer y el vaciado, y por qué lo que has escrito puede no estar en el disco hasta que cierres; el patrón de escritura atómica —escribir a un temporal y renombrar— que impide dejar un fichero a medias cuando algo falla a mitad, hermano directo de la compensación en finally que ya sabes escribir; y el separador de línea del sistema, con el motivo por el que \n no siempre basta. Al terminarla, ExportadorCatalogo dejará de ser una clase que sabe cerrarse pero no escribir, y BiblioTech guardará por primera vez lo que ha hecho.
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
