Ir al contenido principal

Entrada/Salida avanzada: archivos y streams

Introducción

El manejo de entrada/salida (E/S o I/O) es una parte fundamental de cualquier lenguaje de programación, ya que permite a nuestras aplicaciones interactuar con el mundo exterior. En Java, el sistema de E/S se basa en el concepto de streams (flujos), que son secuencias ordenadas de datos que fluyen desde un origen hacia un destino.

En artículos anteriores, hemos visto cómo trabajar con entrada y salida básica por consola. Ahora, daremos un paso adelante y exploraremos el sistema de E/S avanzado de Java, centrándonos especialmente en la manipulación de archivos y diferentes tipos de streams. Este conocimiento es esencial para desarrollar aplicaciones que necesiten persistir datos, procesar archivos o comunicarse con recursos externos.

En este artículo, aprenderemos las diferencias entre streams basados en bytes y caracteres, cómo trabajar con archivos, y exploraremos las clases más importantes del paquete java.io y java.nio.

Fundamentos de la E/S en Java

Jerarquía de clases para E/S

Java organiza sus capacidades de E/S en dos paquetes principales:

  1. java.io: El paquete clásico, presente desde las primeras versiones de Java.
  2. java.nio: Introducido en Java 1.4, proporciona capacidades de E/S no bloqueante y otras mejoras de rendimiento.

Tipos de streams

En Java, existen dos tipos fundamentales de streams:

  1. Streams de bytes (InputStream y OutputStream): Trabajan con datos binarios, byte a byte.
  2. Streams de caracteres (Reader y Writer): Trabajan con datos de texto, utilizando el sistema de codificación de caracteres de Java.

Esta distinción es importante porque:

  • Los streams de bytes son adecuados para datos binarios (imágenes, archivos comprimidos, etc.).
  • Los streams de caracteres son más apropiados para texto (documentos, configuraciones, etc.).

Trabajando con archivos

La clase File

La clase File representa una ruta a un archivo o directorio en el sistema de archivos. No contiene métodos para leer o escribir datos, pero es útil para obtener información sobre archivos y directorios:

import java.io.File;
import java.io.IOException;
import java.util.Date;

public class ManipulacionArchivos {
    public static void main(String[] args) {
        // Crear un objeto File
        File archivo = new File("datos.txt");
        
        // Comprobar si el archivo existe
        if (archivo.exists()) {
            System.out.println("El archivo existe");
            System.out.println("Nombre: " + archivo.getName());
            System.out.println("Ruta absoluta: " + archivo.getAbsolutePath());
            System.out.println("Tamaño: " + archivo.length() + " bytes");
            System.out.println("Última modificación: " + new Date(archivo.lastModified()));
            System.out.println("¿Es directorio? " + archivo.isDirectory());
            System.out.println("¿Es archivo? " + archivo.isFile());
            System.out.println("¿Se puede leer? " + archivo.canRead());
            System.out.println("¿Se puede escribir? " + archivo.canWrite());
        } else {
            System.out.println("El archivo no existe, intentando crearlo...");
            try {
                if (archivo.createNewFile()) {
                    System.out.println("Archivo creado correctamente");
                } else {
                    System.out.println("No se pudo crear el archivo");
                }
            } catch (IOException e) {
                System.out.println("Error al crear el archivo: " + e.getMessage());
            }
        }
    }
}

Trabajando con directorios

Para trabajar con directorios, también usamos la clase File:

import java.io.File;

public class ManipulacionDirectorios {
    public static void main(String[] args) {
        // Crear un objeto File para un directorio
        File directorio = new File("documentos");
        
        // Comprobar si existe y crear si no
        if (!directorio.exists()) {
            if (directorio.mkdir()) {
                System.out.println("Directorio creado");
            } else {
                System.out.println("No se pudo crear el directorio");
                return;
            }
        }
        
        // Listar contenido del directorio
        File[] archivos = directorio.listFiles();
        if (archivos != null) {
            System.out.println("Contenido del directorio:");
            for (File archivo : archivos) {
                String tipo = archivo.isDirectory() ? "Directorio" : "Archivo";
                System.out.println(tipo + ": " + archivo.getName());
            }
        }
    }
}

Streams de bytes

InputStream y OutputStream

Las clases abstractas InputStream y OutputStream son la base para todas las operaciones de E/S basadas en bytes:

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;

public class CopiaArchivo {
    public static void main(String[] args) {
        // Rutas de los archivos
        String archivoOrigen = "origen.dat";
        String archivoDestino = "destino.dat";
        
        try (
            // Streams de bytes para lectura y escritura
            FileInputStream entrada = new FileInputStream(archivoOrigen);
            FileOutputStream salida = new FileOutputStream(archivoDestino)
        ) {
            int bytesLeidos;
            byte[] buffer = new byte[1024]; // Buffer de 1KB
            
            // Leer del origen y escribir en el destino
            while ((bytesLeidos = entrada.read(buffer)) != -1) {
                salida.write(buffer, 0, bytesLeidos);
            }
            
            System.out.println("Archivo copiado correctamente");
            
        } catch (IOException e) {
            System.out.println("Error de E/S: " + e.getMessage());
        }
    }
}

Este ejemplo utiliza try-with-resources (introducido en Java 7), que asegura que los streams se cierren automáticamente al finalizar el bloque try, incluso si ocurre una excepción.

Streams de bytes filtrados

Java proporciona streams filtrados que añaden funcionalidades adicionales:

  1. BufferedInputStream/BufferedOutputStream: Añaden un buffer para mejorar el rendimiento.
  2. DataInputStream/DataOutputStream: Permiten leer/escribir tipos primitivos de Java.
  3. ObjectInputStream/ObjectOutputStream: Permiten leer/escribir objetos serializados.

Ejemplo con DataInputStream y DataOutputStream:

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;

public class DatosEstudiante {
    public static void main(String[] args) {
        // Escribir datos
        try (
            DataOutputStream salida = new DataOutputStream(
                new FileOutputStream("estudiante.dat"))
        ) {
            // Escribir datos de un estudiante
            salida.writeUTF("Ana García");     // Nombre (String)
            salida.writeInt(25);               // Edad (int)
            salida.writeDouble(8.75);          // Nota media (double)
            salida.writeBoolean(true);         // ¿Beca? (boolean)
            
            System.out.println("Datos escritos correctamente");
            
        } catch (IOException e) {
            System.out.println("Error al escribir: " + e.getMessage());
        }
        
        // Leer datos
        try (
            DataInputStream entrada = new DataInputStream(
                new FileInputStream("estudiante.dat"))
        ) {
            // Leer datos en el mismo orden
            String nombre = entrada.readUTF();
            int edad = entrada.readInt();
            double notaMedia = entrada.readDouble();
            boolean tieneBeca = entrada.readBoolean();
            
            System.out.println("Datos del estudiante:");
            System.out.println("Nombre: " + nombre);
            System.out.println("Edad: " + edad);
            System.out.println("Nota media: " + notaMedia);
            System.out.println("Tiene beca: " + tieneBeca);
            
        } catch (IOException e) {
            System.out.println("Error al leer: " + e.getMessage());
        }
    }
}

Streams de caracteres

Reader y Writer

Para trabajar con texto, Java proporciona las clases abstractas Reader y Writer:

import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;

public class CopiaTexto {
    public static void main(String[] args) {
        String archivoOrigen = "origen.txt";
        String archivoDestino = "destino.txt";
        
        try (
            FileReader entrada = new FileReader(archivoOrigen);
            FileWriter salida = new FileWriter(archivoDestino)
        ) {
            int caracterLeido;
            
            // Leer carácter a carácter
            while ((caracterLeido = entrada.read()) != -1) {
                salida.write(caracterLeido);
            }
            
            System.out.println("Archivo de texto copiado correctamente");
            
        } catch (IOException e) {
            System.out.println("Error de E/S: " + e.getMessage());
        }
    }
}

Streams de caracteres filtrados

Similar a los streams de bytes, existen streams filtrados para caracteres:

  1. BufferedReader/BufferedWriter: Añaden un buffer para mejorar el rendimiento.
  2. PrintWriter: Proporciona métodos para imprimir representaciones formateadas de objetos.

Ejemplo de lectura de líneas con BufferedReader:

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

public class LeerLineas {
    public static void main(String[] args) {
        String archivo = "poema.txt";
        
        try (
            BufferedReader br = new BufferedReader(new FileReader(archivo))
        ) {
            String linea;
            int numLinea = 1;
            
            // Leer línea a línea
            while ((linea = br.readLine()) != null) {
                System.out.println(numLinea + ": " + linea);
                numLinea++;
            }
            
        } catch (IOException e) {
            System.out.println("Error de lectura: " + e.getMessage());
        }
    }
}

Ejemplo de escritura con PrintWriter:

import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;

public class EscribirFormateado {
    public static void main(String[] args) {
        String archivo = "informe.txt";
        
        try (
            PrintWriter pw = new PrintWriter(new FileWriter(archivo))
        ) {
            // Escribir texto formateado
            pw.println("Informe de ventas");
            pw.println("================");
            pw.println();
            
            String[] productos = {"Portátil", "Móvil", "Tablet"};
            double[] precios = {899.99, 349.50, 199.75};
            int[] cantidades = {5, 12, 8};
            
            pw.printf("%-10s %10s %10s %10s%n", "Producto", "Precio", "Cantidad", "Total");
            pw.println("-------------------------------------------");
            
            double total = 0;
            for (int i = 0; i < productos.length; i++) {
                double subtotal = precios[i] * cantidades[i];
                total += subtotal;
                pw.printf("%-10s %10.2f€ %10d %10.2f€%n", 
                          productos[i], precios[i], cantidades[i], subtotal);
            }
            
            pw.println("-------------------------------------------");
            pw.printf("TOTAL: %33.2f€%n", total);
            
            System.out.println("Informe generado correctamente");
            
        } catch (IOException e) {
            System.out.println("Error de escritura: " + e.getMessage());
        }
    }
}

Serialización de objetos

La serialización permite convertir objetos Java en secuencias de bytes para almacenarlos o transmitirlos, y después reconstruirlos:

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;

// Clase serializable
class Persona implements Serializable {
    private static final long serialVersionUID = 1L;
    
    private String nombre;
    private int edad;
    private transient String contrasena; // transient: no se serializa
    
    public Persona(String nombre, int edad, String contrasena) {
        this.nombre = nombre;
        this.edad = edad;
        this.contrasena = contrasena;
    }
    
    @Override
    public String toString() {
        return "Persona [nombre=" + nombre + ", edad=" + edad + 
               ", contraseña=" + (contrasena != null ? "****" : "null") + "]";
    }
}

public class SerializacionObjetos {
    public static void main(String[] args) {
        String archivo = "personas.dat";
        
        // Crear y serializar objetos
        try (
            ObjectOutputStream salida = new ObjectOutputStream(
                new FileOutputStream(archivo))
        ) {
            // Crear lista de personas
            List<Persona> personas = new ArrayList<>();
            personas.add(new Persona("Carlos", 30, "clave123"));
            personas.add(new Persona("Laura", 28, "segura456"));
            personas.add(new Persona("Miguel", 35, "contraseña789"));
            
            // Serializar la lista completa
            salida.writeObject(personas);
            System.out.println("Objetos serializados correctamente");
            
        } catch (IOException e) {
            System.out.println("Error al serializar: " + e.getMessage());
        }
        
        // Deserializar objetos
        try (
            ObjectInputStream entrada = new ObjectInputStream(
                new FileInputStream(archivo))
        ) {
            // Leer la lista completa
            @SuppressWarnings("unchecked")
            List<Persona> personasLeidas = (List<Persona>) entrada.readObject();
            
            System.out.println("\nPersonas deserializadas:");
            for (Persona p : personasLeidas) {
                System.out.println(p);
            }
            
        } catch (IOException | ClassNotFoundException e) {
            System.out.println("Error al deserializar: " + e.getMessage());
        }
    }
}

Puntos clave sobre serialización:

  1. Las clases deben implementar la interfaz Serializable.
  2. Se recomienda definir un serialVersionUID para control de versiones.
  3. Los campos marcados como transient no se serializan (útil para datos sensibles o temporales).
  4. Las relaciones entre objetos se mantienen en la serialización.

El paquete java.nio

Java NIO (New I/O) proporciona una API alternativa para operaciones de E/S con mejor rendimiento y características adicionales:

Canales y buffers

En NIO, los datos se leen desde canales a buffers y se escriben desde buffers a canales:

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;

public class EjemploNIOCanales {
    public static void main(String[] args) {
        // Definir rutas
        Path rutaOrigen = Paths.get("origen.dat");
        Path rutaDestino = Paths.get("destino.dat");
        
        try (
            // Abrir canales para lectura y escritura
            FileChannel canalOrigen = FileChannel.open(rutaOrigen, StandardOpenOption.READ);
            FileChannel canalDestino = FileChannel.open(rutaDestino, 
                StandardOpenOption.CREATE, StandardOpenOption.WRITE)
        ) {
            // Crear buffer
            ByteBuffer buffer = ByteBuffer.allocate(1024);
            
            // Leer datos del origen y escribirlos en el destino
            while (canalOrigen.read(buffer) > 0) {
                // Preparar buffer para lectura
                buffer.flip();
                
                // Escribir contenido del buffer al canal de destino
                canalDestino.write(buffer);
                
                // Limpiar buffer para siguiente lectura
                buffer.clear();
            }
            
            System.out.println("Archivo copiado correctamente con NIO");
            
        } catch (IOException e) {
            System.out.println("Error de E/S: " + e.getMessage());
        }
    }
}

En este ejemplo:

  1. Abrimos canales para el archivo de origen (solo lectura) y destino (creación y escritura).
  2. Creamos un buffer de bytes con capacidad de 1KB.
  3. Leemos datos del canal de origen al buffer.
  4. Usamos flip() para cambiar el buffer del modo escritura al modo lectura.
  5. Escribimos datos del buffer al canal de destino.
  6. Usamos clear() para preparar el buffer para la siguiente operación de lectura.

Path y Files

El paquete java.nio.file proporciona las clases Path y Files que simplifican muchas operaciones:

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.List;

public class EjemploNIOFiles {
    public static void main(String[] args) {
        // Crear objeto Path
        Path archivo = Paths.get("ejemplo.txt");
        
        try {
            // Escribir líneas de texto a un archivo
            List<String> lineas = List.of(
                "Primera línea",
                "Segunda línea",
                "Tercera línea"
            );
            Files.write(archivo, lineas);
            System.out.println("Archivo escrito correctamente");
            
            // Leer todas las líneas de un archivo
            List<String> lineasLeidas = Files.readAllLines(archivo);
            System.out.println("\nContenido del archivo:");
            for (String linea : lineasLeidas) {
                System.out.println(linea);
            }
            
            // Copiar un archivo
            Path copia = Paths.get("copia_ejemplo.txt");
            Files.copy(archivo, copia, StandardCopyOption.REPLACE_EXISTING);
            System.out.println("\nArchivo copiado correctamente");
            
            // Obtener información del archivo
            System.out.println("\nInformación del archivo:");
            System.out.println("Tamaño: " + Files.size(archivo) + " bytes");
            System.out.println("Última modificación: " + Files.getLastModifiedTime(archivo));
            System.out.println("Es directorio: " + Files.isDirectory(archivo));
            System.out.println("Es archivo regular: " + Files.isRegularFile(archivo));
            System.out.println("Es ejecutable: " + Files.isExecutable(archivo));
            
        } catch (IOException e) {
            System.out.println("Error de E/S: " + e.getMessage());
        }
    }
}

La API de Files proporciona métodos estáticos para operaciones comunes como:

  • Lectura y escritura completa de archivos
  • Copia, movimiento y eliminación de archivos
  • Obtención de atributos
  • Creación de directorios y archivos temporales
  • Listado de contenidos de directorios

Walking the File Tree

Para recorrer una estructura de directorios recursivamente:

import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;

public class RecorridoDirectorios {
    public static void main(String[] args) {
        Path inicio = Paths.get(".");  // Directorio actual
        
        try {
            Files.walkFileTree(inicio, new SimpleFileVisitor<Path>() {
                @Override
                public FileVisitResult visitFile(Path archivo, BasicFileAttributes attrs) {
                    System.out.println("Archivo: " + archivo);
                    return FileVisitResult.CONTINUE;
                }
                
                @Override
                public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
                    System.out.println("Directorio: " + dir);
                    return FileVisitResult.CONTINUE;
                }
                
                @Override
                public FileVisitResult visitFileFailed(Path archivo, IOException exc) {
                    System.err.println("Error al acceder a: " + archivo);
                    return FileVisitResult.CONTINUE;
                }
            });
        } catch (IOException e) {
            System.out.println("Error al recorrer directorios: " + e.getMessage());
        }
    }
}

Streams en Java moderno

Con Java 8 se introdujeron nuevas características en el manejo de archivos utilizando Streams:

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class StreamsArchivos {
    public static void main(String[] args) {
        Path directorio = Paths.get(".");
        
        try {
            // Listar archivos en el directorio actual
            System.out.println("Archivos en el directorio actual:");
            try (Stream<Path> stream = Files.list(directorio)) {
                stream.forEach(System.out::println);
            }
            
            // Contar líneas en un archivo de texto
            Path archivo = Paths.get("ejemplo.txt");
            if (Files.exists(archivo)) {
                long numLineas = Files.lines(archivo).count();
                System.out.println("\nNúmero de líneas en ejemplo.txt: " + numLineas);
                
                // Encontrar las 3 palabras más frecuentes
                System.out.println("\nPalabras más frecuentes:");
                Map<String, Long> frecuenciaPalabras = Files.lines(archivo)
                    .flatMap(linea -> Stream.of(linea.split("\\s+")))
                    .filter(palabra -> !palabra.isEmpty())
                    .map(String::toLowerCase)
                    .collect(Collectors.groupingBy(
                        palabra -> palabra,
                        Collectors.counting()
                    ));
                
                frecuenciaPalabras.entrySet().stream()
                    .sorted(Map.Entry.<String, Long>comparingByValue().reversed())
                    .limit(3)
                    .forEach(e -> System.out.println(e.getKey() + ": " + e.getValue()));
            }
            
            // Buscar archivos recursivamente
            System.out.println("\nArchivos .java en directorios y subdirectorios:");
            try (Stream<Path> stream = Files.find(directorio, 
                                               Integer.MAX_VALUE,
                                               (path, attr) -> path.toString().endsWith(".java"))) {
                stream.forEach(System.out::println);
            }
            
        } catch (IOException e) {
            System.out.println("Error: " + e.getMessage());
        }
    }
}

Este ejemplo muestra cómo usar:

  • Files.list() para listar archivos en un directorio
  • Files.lines() para leer un archivo línea por línea
  • Files.find() para buscar archivos recursivamente
  • Operaciones de Stream para procesar el contenido de los archivos

Trabajando con archivos de propiedades

Los archivos de propiedades son comunes para configuraciones:

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Properties;

public class ArchivoPropiedades {
    public static void main(String[] args) {
        String archivo = "config.properties";
        
        // Crear y guardar propiedades
        Properties propiedades = new Properties();
        propiedades.setProperty("db.url", "jdbc:mysql://localhost:3306/midb");
        propiedades.setProperty("db.usuario", "admin");
        propiedades.setProperty("db.password", "secreto");
        propiedades.setProperty("app.timeout", "30");
        propiedades.setProperty("app.debug", "true");
        
        try (FileOutputStream salida = new FileOutputStream(archivo)) {
            propiedades.store(salida, "Configuración de la aplicación");
            System.out.println("Archivo de propiedades guardado");
        } catch (IOException e) {
            System.out.println("Error al guardar propiedades: " + e.getMessage());
        }
        
        // Cargar propiedades
        Properties propiedadesCargadas = new Properties();
        try (FileInputStream entrada = new FileInputStream(archivo)) {
            propiedadesCargadas.load(entrada);
            
            System.out.println("\nPropiedades cargadas:");
            System.out.println("URL de la base de datos: " + 
                              propiedadesCargadas.getProperty("db.url"));
            System.out.println("Timeout de la aplicación: " + 
                              propiedadesCargadas.getProperty("app.timeout") + " segundos");
            
            // Valor por defecto si la propiedad no existe
            String puerto = propiedadesCargadas.getProperty("app.puerto", "8080");
            System.out.println("Puerto: " + puerto);
            
        } catch (IOException e) {
            System.out.println("Error al cargar propiedades: " + e.getMessage());
        }
    }
}

Buenas prácticas

  1. Siempre cerrar recursos: Utiliza try-with-resources para garantizar que los recursos se cierren correctamente.

  2. Buffers para mejor rendimiento: Utiliza streams con buffer (BufferedInputStream, BufferedReader, etc.) para mejorar el rendimiento con archivos grandes.

  3. Manejo adecuado de excepciones: No ignores las excepciones de E/S; manéjalas o propágalas según corresponda.

  4. Especificar codificación de caracteres: Al trabajar con archivos de texto, especifica siempre la codificación.

    Files.write(path, lines, StandardCharsets.UTF_8);
    BufferedReader reader = new BufferedReader(
        new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8)
    );
    
  5. Usa NIO para operaciones avanzadas: Considera usar java.nio para operaciones no bloqueantes, manipulación de grandes volúmenes de datos o cuando necesites características avanzadas.

  6. Liberar recursos en el orden correcto: Al cerrar recursos manualmente, ciérralos en orden inverso a como fueron abiertos.

Ejemplo práctico completo: Gestor de notas

Vamos a crear un ejemplo completo que utilice diversas técnicas de E/S para gestionar un sistema simple de notas:

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;

class Nota implements Serializable {
    private static final long serialVersionUID = 1L;
    
    private String titulo;
    private String contenido;
    private LocalDateTime fechaCreacion;
    
    public Nota(String titulo, String contenido) {
        this.titulo = titulo;
        this.contenido = contenido;
        this.fechaCreacion = LocalDateTime.now();
    }
    
    public String getTitulo() {
        return titulo;
    }
    
    public String getContenido() {
        return contenido;
    }
    
    public LocalDateTime getFechaCreacion() {
        return fechaCreacion;
    }
    
    @Override
    public String toString() {
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm");
        return "Título: " + titulo + 
               "\nFecha: " + fechaCreacion.format(formatter) + 
               "\n--------------------\n" + contenido + 
               "\n====================\n";
    }
}

public class GestorNotas {
    private static final String DIRECTORIO_NOTAS = "notas";
    private static final String ARCHIVO_INDICE = DIRECTORIO_NOTAS + "/indice.dat";
    private static final DateTimeFormatter FORMATO_ARCHIVO = 
            DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
    
    public static void main(String[] args) {
        // Crear directorio de notas si no existe
        try {
            Files.createDirectories(Paths.get(DIRECTORIO_NOTAS));
        } catch (IOException e) {
            System.err.println("Error al crear directorio: " + e.getMessage());
            return;
        }
        
        // Cargar índice existente o crear uno nuevo
        Map<String, String> indice = cargarIndice();
        
        // Menú principal
        Scanner scanner = new Scanner(System.in);
        int opcion;
        
        do {
            System.out.println("\nGESTOR DE NOTAS");
            System.out.println("1. Crear nota");
            System.out.println("2. Listar notas");
            System.out.println("3. Ver nota");
            System.out.println("4. Eliminar nota");
            System.out.println("0. Salir");
            System.out.print("Seleccione opción: ");
            
            try {
                opcion = Integer.parseInt(scanner.nextLine());
            } catch (NumberFormatException e) {
                opcion = -1;
            }
            
            switch (opcion) {
                case 1:
                    crearNota(scanner, indice);
                    break;
                case 2:
                    listarNotas(indice);
                    break;
                case 3:
                    verNota(scanner, indice);
                    break;
                case 4:
                    eliminarNota(scanner, indice);
                    break;
                case 0:
                    guardarIndice(indice);
                    System.out.println("¡Hasta pronto!");
                    break;
                default:
                    System.out.println("Opción no válida");
            }
            
        } while (opcion != 0);
        
        scanner.close();
    }
    
    private static Map<String, String> cargarIndice() {
        Map<String, String> indice = new HashMap<>();
        Path archivoIndice = Paths.get(ARCHIVO_INDICE);
        
        if (Files.exists(archivoIndice)) {
            try (ObjectInputStream entrada = new ObjectInputStream(
                    new FileInputStream(ARCHIVO_INDICE))) {
                @SuppressWarnings("unchecked")
                Map<String, String> indiceLeido = (Map<String, String>) entrada.readObject();
                indice = indiceLeido;
                System.out.println("Índice cargado correctamente");
            } catch (IOException | ClassNotFoundException e) {
                System.err.println("Error al cargar índice: " + e.getMessage());
            }
        }
        
        return indice;
    }
    
    private static void guardarIndice(Map<String, String> indice) {
        try (ObjectOutputStream salida = new ObjectOutputStream(
                new FileOutputStream(ARCHIVO_INDICE))) {
            salida.writeObject(indice);
            System.out.println("Índice guardado correctamente");
        } catch (IOException e) {
            System.err.println("Error al guardar índice: " + e.getMessage());
        }
    }
    
    private static void crearNota(Scanner scanner, Map<String, String> indice) {
        System.out.println("\nCREAR NOTA");
        System.out.print("Título: ");
        String titulo = scanner.nextLine().trim();
        
        if (titulo.isEmpty()) {
            System.out.println("El título no puede estar vacío");
            return;
        }
        
        System.out.println("Contenido (termina con una línea que contenga solo '---'):");
        StringBuilder contenido = new StringBuilder();
        String linea;
        
        while (!(linea = scanner.nextLine()).equals("---")) {
            contenido.append(linea).append("\n");
        }
        
        Nota nota = new Nota(titulo, contenido.toString());
        String nombreArchivo = FORMATO_ARCHIVO.format(nota.getFechaCreacion()) + ".txt";
        String rutaArchivo = DIRECTORIO_NOTAS + "/" + nombreArchivo;
        
        try {
            // Guardar contenido de la nota
            Files.write(Paths.get(rutaArchivo), 
                        nota.toString().getBytes(StandardCharsets.UTF_8));
            
            // Actualizar índice
            indice.put(titulo, nombreArchivo);
            guardarIndice(indice);
            
            System.out.println("Nota creada correctamente");
        } catch (IOException e) {
            System.err.println("Error al guardar la nota: " + e.getMessage());
        }
    }
    
    private static void listarNotas(Map<String, String> indice) {
        System.out.println("\nLISTA DE NOTAS");
        
        if (indice.isEmpty()) {
            System.out.println("No hay notas guardadas");
            return;
        }
        
        List<String> titulos = new ArrayList<>(indice.keySet());
        Collections.sort(titulos);
        
        for (int i = 0; i < titulos.size(); i++) {
            System.out.println((i + 1) + ". " + titulos.get(i));
        }
    }
    
    private static void verNota(Scanner scanner, Map<String, String> indice) {
        if (indice.isEmpty()) {
            System.out.println("\nNo hay notas guardadas");
            return;
        }
        
        System.out.println("\nVER NOTA");
        System.out.print("Título de la nota: ");
        String titulo = scanner.nextLine().trim();
        
        if (!indice.containsKey(titulo)) {
            System.out.println("No existe ninguna nota con ese título");
            return;
        }
        
        String rutaArchivo = DIRECTORIO_NOTAS + "/" + indice.get(titulo);
        
        try {
            List<String> lineas = Files.readAllLines(Paths.get(rutaArchivo), 
                                                   StandardCharsets.UTF_8);
            System.out.println("\n======== NOTA ========");
            for (String linea : lineas) {
                System.out.println(linea);
            }
        } catch (IOException e) {
            System.err.println("Error al leer la nota: " + e.getMessage());
        }
    }
    
    private static void eliminarNota(Scanner scanner, Map<String, String> indice) {
        if (indice.isEmpty()) {
            System.out.println("\nNo hay notas guardadas");
            return;
        }
        
        System.out.println("\nELIMINAR NOTA");
        System.out.print("Título de la nota a eliminar: ");
        String titulo = scanner.nextLine().trim();
        
        if (!indice.containsKey(titulo)) {
            System.out.println("No existe ninguna nota con ese título");
            return;
        }
        
        String rutaArchivo = DIRECTORIO_NOTAS + "/" + indice.get(titulo);
        
        try {
            Files.deleteIfExists(Paths.get(rutaArchivo));
            indice.remove(titulo);
            guardarIndice(indice);
            System.out.println("Nota eliminada correctamente");
        } catch (IOException e) {
            System.err.println("Error al eliminar la nota: " + e.getMessage());
        }
    }
}

Este ejemplo completo integra:

  • Manejo de archivos y directorios
  • Serialización para guardar el índice de notas
  • Lectura y escritura de archivos de texto
  • Manejo de excepciones
  • Uso de clases modernas como Path y Files

Resumen

Java proporciona un sistema de entrada/salida completo y flexible a través de los paquetes java.io y java.nio. Los streams de bytes (InputStream y OutputStream) nos permiten trabajar con datos binarios, mientras que los streams de caracteres (Reader y Writer) son adecuados para texto. Para operaciones avanzadas, el paquete java.nio ofrece canales, buffers y métodos simplificados a través de las clases Path y Files.

Hemos explorado las principales clases y métodos para trabajar con archivos y directorios, visto cómo serializar objetos para persistencia, y aprendido sobre las operaciones más comunes como lectura, escritura, copia y eliminación de archivos. También hemos revisado las buenas prácticas y acabamos de ver un ejemplo práctico completo que demuestra cómo integrar estas técnicas en una aplicación real.

Con estas herramientas, estarás bien equipado para desarrollar aplicaciones Java que necesiten interactuar con el sistema de archivos, procesar datos externos o comunicarse con otros sistemas a través de operaciones de entrada/salida.