11 de febrero de 2019

Persistencia de Datos - Serialización Simple

Hasta ahora hemos visto objetos almacenados en memoria. Los instanciábamos directamente desde nuestro código fuente para usar objetos en nuestra aplicación. Está claro que cuando pasemos a aplicaciones reales no tiene sentido que los datos deban iniciarse "hardcodeados" y cada vez que haya que cambiarlos haya que editar el código fuente y recompilar.

Tampoco esperamos que siempre que arranque la aplicación tenga los mismos datos. Empezamos el curso hablando del concepto CRUD (crear, leer, actualizar y borrar). Si ejecuto mi aplicación en una sesión espero que los datos permanezcan en el estado en que la termine, no que vuelva al estado de cuando se compiló, perdiendo todo el trabajo.

En definitiva, necesitamos que los objetos se creen leyéndose desde un soporte en el que estén guardados, dándoles persistencia cuando se cierra nuestra aplicación y se libera la memoria

Necesitamos guardar nuestros datos en un soporte que nos permita conservarlos entre distintas ejecuciones de nuestra aplicación


Hay varias formas de hacerlo:
  • Almacenar en ficheros en disco (es lo que vamos a ver)
  • Guardar en una base de datos
  • Enviar por red

Serializar y Deserializar

Obviando por ahora las dos últimas opciones, para guardar en un fichero debemos transformar un objeto de cualquier complejidad a ceros y unos que será lo que se va a guardar en disco. Para ello necesitamos reducir todo a una "fila" de ceros y unos, ponerlos en serie: Serializar.

Cuando queramos leerlos debemos de hacer lo inverso, leer del fichero e ir leyendo las partes que nos permitan instanciar los objetos en memoria: Deserializar.

Serializar pasa objetos a un formato sencillo de guardar o transportar y Deserializar los reconstruye en sentido inverso


Aparte de serializar y deserializar, otra palabra que debemos incorporar a nuestro diccionario es parsear. Parsear es transformar un tipo en otro como cuando por ejemplo transformamos la String "5" en el int 5 mediante Integer.parseInt("5"). Esta transformación más o menos "sencilla" puede ampliarse a estructuras más complejas que una vez analizas producen el resultado que esperamos.

Si los datos leídos no tienen la estructura bien formada, tanto el parse como el deserializado no podrán producir el objeto y desencadenará habitualmente una excepción.

¿Cómo serializar?

Te invito a que inventes por tu cuenta una forma en que podrías guardar los datos por ejemplo de nuestros objetos Coche. En general si es tu primera vez seguramente pienses en algo como una línea con los valores separados por comas:

Peugeot 3008;Negro;4

Esto podría ser modelo, color y numeroDeRuedas de uno de nuestros objetos Coche. Añadiendo más líneas tendríamos en un fichero todos los que quisiéramos. También podríamos poner una primera línea de encabezado para saber qué es cada valor.

La otra forma rápida que se te puede haber ocurrido es haciendo pares clave-valor:

Modelo=Peugeot 3008
Color=Negro
numeroRuedas=4

Si es así, enhorabuena, prácticamente has dicho la base de las formas más populares de hacerlo y que usan muchos servicios como estándares y las dos son texto plano: XML y JSON.

Hay más formas de serializar, como en binario, pero vamos a centrarnos en hacerlo en texto plano.

Formatos en texto plano

Vamos a ver una manera rápida de cómo serializar en CSV (Comma-Separated Values). Son ficheros normalmente con esa extensión de archivo y que guardan valores separados por un carácter especial (habitualmente ';')

Seguirían el patrón del primer ejemplo que pusimos, es muy sencillo y todavía se usan.

También nos los podemos encontrar con los valores ocupando un ancho fijo rellenando el espacio sobrante con espacios, en vez de separados por un carácter. Digamos que al modelo le podríamos dar 20 espacios, al color 10 y al número de ruedas 1.

Como veréis son formatos muy fáciles de serializar (en nuestro caso pasar a texto CSV) y deserializar (reconstruir un objeto desde un texto CSV). Usamos el siguiente código para ver un ejemplo:
public static String serializar (Coche coche) {
    return coche.modelo + ";" + coche.getColor() + ";"
            + coche.getNumeroDeRuedas() + ";" + coche.matricula;
}

public static Coche deserializar (String vehiculoCSV) {
    String[] campos = vehiculoCSV.split(";");
    Coche c = new Coche(campos[0], campos[1]);
    c.numeroDeRuedas = Integer.parseInt(campos[2]);
    c.setMatricula(campos[3]);
    
    return c;
}
Y lo ejecutamos desde nuestro main:
String coche1EnCsv = serializar(coche1);
System.out.println(coche1EnCsv);
Coche coche4 = deserializar(coche1EnCsv);
System.out.println(coche4);
Esto nos dará la siguiente salida (serializar:cada campo separado por ";" y deserializar: dando un objeto igual al que se serializó):

Seat Ibiza;Rojo;4;1234 BBB
Placa 1234 BBB - Seat Ibiza (Rojo), 4 ruedas

Esta sencillez acarréa una serie de problemas:
  • Si usamos ancho fijo:
    • ¿Qué pasa cuando el tamaño marcado para el campo sea más grande? se truncará y no tendremos el valor completo.
  • Si usamos CSV
    • ¿Qué pasa cuando no tengamos unos valores fijos?
    • ¿Y si un campo contiene el caracter separador?
    • ¿Y si un valor es una colección? No podremos hacerla crecer hasta donde queramos
    • Si no tengo valor para un campo debo seguir poniendo ';' para que siga corriendo la posición al siguiente valor.
Independientemente de soluciones "mas o menos caseras", tenemos un problema en el horizonte y eso será un problema de mantenimiento y/o interoperabilidad de sistemas.

Por esas limitaciones existen formatos que nos permiten organizar esas estructuras más complejas. Los más habituales son XML y JSON que mejoran la interoperabilidad con otros sistemas

¿Qué tengo que serializar?

Lo primero que tengo que pensar es qué debo guardar y qué no. No se trata de dar persistencia a cada campo. Si vamos a usar serialización de Java debemos echar un vistazo a la interfaz Serializable.

Serializable es una interfaz muy fácil de implementar, simplemente se define que la clase lo implementa y sólo debemos añadir un número de versión (serialVersionUID) aunque si no lo ponemos nos lo marcará como un warning, no un error. Este número sirve para saber si es compatible el dato serializado con el objeto que se quiere obtener. No prestar atención a este número puede ser un quebradero de cabeza con nuevas versiones.

Aquí no vamos a ver esta interfaz, no vamos a usar la serialización nativa de Java, nos centraremos en guardar datos en JSON y lo veremos en futuras sesiones pues es el formato de notación nativa de JavaScript y nos va a servir también para trabajar fácilmente con datos en aplicaciones web.

Lo que vamos a ver para terminar esta sesión es cómo preparo mi clase para indicar qué se debe serializar y qué debemos tener en cuenta para que nos funcionen otras librerías de serializado como la que vamos a usar para JSON (jsonbeans de EsotericSoftware).

Normalmente se va a serializar:
  1. El nombre de la clase cuando haga falta.
  2. Los campos NO marcados como transient (esta palabra clave indica que un campo no forma parte de su estado persistente, normalmente son campos calculados: la edad si tengo la fecha de nacimiento o un mapa para mejorar el acceso a valores por clave, esos campos los puedo calcular al inicializar el objeto).
    Este modificador es usado por librerías que serializan en función de este modificador (como Serializable o jsonbeans). Sin embargo no hay que confundirlo con nuestro ejemplo sobre CSV en el que se ha implementado sin tener en cuenta el modificador transient y evidentemente no afecta a la serialización
  3. Los campos cuyo valor sea distinto al que reciben al inicializar la variable.
  4. Se serializarán los objetos internos conforme a su tipo: si modelo fuera un tipo que contuviese una un objeto de tipo Marca, se serializaría modelo como parte de Coche y marca como parte de Modelo, etc... es decir que se sigue profundizando hasta que todo lo que se debe serializar está reducido a texto plano que se pueda deserializar.
No obstante, todo esto depende de la implementación concreta de la librería que usemos y de cómo guardemos los datos, pero sería una lista de "sospechosos habituales" (por ejemplo algunas formas de serializar podrían sólo guardar los campos public).

Como verás no se guardan las variables de clase. Es normal teniendo en cuenta que normalmente son constantes y están definidos sus valores en la clase.

No debemos olvidar que lo normal es que para reconstruir el objeto se utilice la información de su clase (Class) para crear una nueva instancia e ir asignándole los valores a cada campo guardado. A menos que se haga un serializador personalizado, normalmente se usará el constructor vacío para instanciarlo, así que debemos asegurarnos que nuestros tipos serializables lo tengan.

¿Cómo preparo mi clase para que se serialice correctamente?

Básicamente debes hacer lo siguiente:
  1. Usar transient en los campos que no quiero serializar
  2. Asegurarme de tener un constructor vacío
  3. Que los otros tipos que se deben serializar como parte de mi clase también cumplen estas normas
Vamos a ver un ejemplo usando JSON en la siguiente sesión.

8 de febrero de 2019

Leer y guardar texto en un fichero

En esta sesión voy a poner sendos ejemplos de lectura y escritura de un texto en disco.

Si buscáis por Internet encontraréis muchas formas distintas de hacerlo usando distintos tipos. Esto es debido a que hay distintas implementaciones de las clases Reader y Writer:
  • Unas permiten especificar el soporte (no tiene por qué usarse un fichero, podríamos leer y escribir de un socket web o un buffer de datos cualquiera)
  • Unas mejoran el rendimiento del ancho del buffer
  • Unas están especializadas en guardar texto y otras para datos en crudo (raw)
  • Algunos tienen métodos particulares que nos pueden resultar útiles y no pertenecen a las interfaces (como newLine())
  • Además hay que sumar las distintas librerías que hay para facilitar el tratamiento de estas operaciones como Apache common o guava.

Por todo lo anterior y desde mi punto de vista, voy a usar la forma más eficiente y flexible para hacer operaciones con texto sobre ficheros:
  1. Usaré sólo Java Nativo.
  2. Usaré BufferedReader/BufferedWriter porque me va a encapsular un Reader/Writer para mejorar el rendimiento en los accesos al disco (en cada acceso va a usar el buffer completo y por tanto necesitará menos operaciones de acceso)
  3. Usaré un InputStreamReader/OutputStreamWriter porque me van a permitir especificar la codificación de mi texto (usaré UTF-8)
  4. Usaré un FileInputStream/FileOutputStream para realizar operaciones sobre archivos.
Este tipo de operaciones van a reservar recursos de mi sistema (si tengo un fichero abierto porque lo estoy usando, otro proceso no podrá usarlo, como cuando queremos sacar nuestro USB y nos dice que está en uso). Por tanto, cuando dejemos de usar un archivo hay que liberarlo para que no se quede reservado impidiendo su uso. Para eso existe el método close().

También estás operaciones suelen declarar varias excepciones (cuando no existe un fichero, no se tiene acceso, hay una codificación desconocida... cualquier error de entrada y salida). Esto hace que deban estar rodeadas de la cláusula try-catch para controlarlas.

No obstante, como BufferedReader/Writer implementan la interfaz AutoCloseable, voy a usar un try-with-resources para que se encargue automáticamente de cerrarme el fichero cuando acabe de usar mi Reader/Writer.

El código para la lectura queda:
public static String leer (String ruta) {
    String leido = "";
    
    try (BufferedReader buffer = new BufferedReader(
                new InputStreamReader(
                        new FileInputStream(ruta), "UTF-8"))) {
        String linea;
        while((linea = buffer.readLine()) != null) {
            leido += linea + System.lineSeparator(); //Esto variará
        }
    } catch (Exception e) {
      e.printStackTrace();
    }
    
    return leido;
}
Para la escritura:
public static void escribir (String texto, String ruta) {
    
    try (BufferedWriter buffer = new BufferedWriter(
                new OutputStreamWriter(
                        new FileOutputStream(ruta), "UTF-8"))) {
        buffer.write(texto); //Esto variará
    } catch (Exception e) {
        e.printStackTrace();
    }
    
}
Nota: Si queremos guardar en varias lineas es mejor usar el método buffer.newLine() antes que usar la secuencia de escape "\n", pues hará el salto de línea correcto independientemente del SO donde se esté ejecutando (no es igual para Linux (\r\n) que para Windows (\n))

He encapsulado el código en métodos estáticos para que quede más claro el código para leer y el código para escribir.

Con esta sintaxis tienes optimizada la escritura de texto en ficheros y puedes elegir la codificación


Para practicar con una ruta relativa con un directorio, nos tenemos que crear una nueva carpeta en nuestro proyecto (New > Folder) que llamaremos datos. Cuando ejecutemos el código de abajo y refresquemos el proyecto (Refresh F5), nuestro fichero aparecerá dentro de ella con el nombre miTexto.txt.

Vamos a ejecutarlo con un ejemplo sencillo en nuestro main:
String ruta = "datos\\miTexto.txt";
String texto = "Primera linea.\nSegunda Linea.";

System.out.println("Guardo:\n" + texto);
escribir(texto, ruta);

String textoLeido = leer(ruta);
System.out.println("\nLeido:\n" + textoLeido);
De esta forma vemos que nuestro texto se guardo en disco y se ha leído devolviendo el mismo resultado.

Evidentemente no todo será guardar una cadena de texto formada y devolver un texto leído, pero con este ejemplo se ve la estructura básica en un ejemplo sencillo. Luego tocará cambiar la linea marcada con el comentario "Esto variara" con lo que haya que hacer al leer cada línea o al tener que guardar varias lineas distintas por ejemplo al guardar elementos de una colección.

La primera vez puede parecer complejo ver cómo se instancia nuestro buffer (es normal, le pasa a todo el mundo). Usándolo más veces al final todo tiene sentido, pero se soluciona fácilmente guardando el enlace donde se ve la sintaxis o simplemente haciéndonos nuestros propios métodos que encapsulen este código.

Compárteme

Entradas populares