Serialización en Java

Buenas tardes, en esta entrada vamos a ver lo referente a la Serialización en Java, que es, para que se usa y como podemos aplicarla.


Imaginemos que queremos guardar el estado de uno o mas objetos. Si java no tuviera la serialización (como las primeras versiones no tenían), tendríamos que haber usado algunas de las clases de I/O para escribir el estado de las variables de instancia de todos los objetos que quisieramos guardar. La peor parte de esto sería reconstruir todos estos objetos nuevos que serían virtualmente identicos a los objetos que estabamos intentando guardar. Necesitaríamos nuestro propio protocolo para la manera en la cual lo escribimos y en la que restauramos el estado de cada objeto, o podríamos establecer variables con valores incorrectos. Por ejemplo, imaginemos ue hemos guardado un objeto que tiene unas variables de instancia para el ancho y la altura de algo. Cuando los guardamos el estado del objeto, podríamos escribir la altura y el ancho como 2 int en un fichero, pero el orden en el que lo hemos escrito es crucial. Sería muy facil recrear el objeto pero a la vez sería muy facil equivocarse con las variables, y asignar el ancho a la altura o viceversa.

La serialización nos permite decir “Guarda este objeto y todas sus variables de instancia.” Actualmente es algo mas interesante que esto, porque podemos agregar, “… a no ser que una variable sea marcada como transient, lo que significa, que no se incluye el valor de las variables marcadas como transient como parte del estado del objeto serializado”.

  • Trabajando con ObjectOutputStream y ObjectInputStream

    La magia de la serializacion ocurre con solo 2 métodos: uno para serializar objetos y escribirlos en un stream, y el segundo para leer el stream y deserializar los objetos.

    		ObjectOutputStream.writeObject();  	// Serializa y escribe
    		ObjectInputStream.readObject();		// Lee y deserializa
    		

    Las clases java.io.ObjectOutputStream y java.io.ObjectInputStream son consideradas clases de alto nivel en el package java.io, y como vimos con anterioridad en la anterior sección, esto significa que las envolveremos en clases de bajo nivel, como java.io.FileOutputStream o java.io.FileInputStream. Aquí tenemos un pequeño programa que crea un objeto, lo serializa, y luego lo deserializa:

    		import java.io.*;
    		
    		class Cat implements Serializable { }	// 1
    		
    		public class SerializeCat {
    			public static void main (String[] args){
    				Cat c = new Cat();		// 2
    				try {
    					FileOutputStream fs = new FIleOutputStream("testSer.ser");
    					ObjectOutputStream os = new Obje
    					c.OutputStream(fs);
    					os.writeObject(c);		// 3
    					os.close()
    				}catch(IOException e){ e.printStackTrace(); }
    				
    				try{
    					FileInputStream fim = new FileInputStream("testSer.ser");
    					ObjectInputStream ois = new ObjectInputStream(fim);
    					c = (Cat)ois.readObject(); 	// 4
    					ois.close();
    				} catch (IOException ex){ e.printStackTrace(); }
    			}
    		}
    		

    Vamos a ver los puntos clave de este ejemplo:

    1. Hemos declarado una clase Cat la cual implementa la interface Serializable. Es una interface que marca, no tiene métodos que implementar.
    2. Hemos creado un objeto Cat, el cual como sabemos es serializable.
    3. Hemos serializado el objeto Cat cuya variable de referencia es c invocando el método writeObject(). Se requiere una preparación anterior antes de que podamos serializar nuestro Cat. Primero tenemos que poner todo el código relacionado con la operación I/O en un bloque try/catch. A continuación tenemos que crear un FileOutputStream para escribir el objeto. Despues hemos envuelto el objeto FileOutputStream en un ObjectOutputStream, el cual es la clase que tiene el método mágico de serialización que necesitamos. Recordemos que la invocación de writeObject() realiza 2 tareaas: serializa el objeto, y entonces escribe el objeto serializado en un fichero.
    4. De-serializamos el objeto Cat invocando el método readObject(). Este método retorna un objeto, por lo que tenemos que hacer un cast al objeto que hemos deserializado a Cat. De nuevo, tenemos que hacer todo lo relacionado con las operaciones I/O, bloque try/catch, etc.

    Este es un ejemplo muy básico para comprobar la serialización en acción, y apenas tiene dificultad. En las siguientes secciones vamos a ver ejemplos mas complejos y que nos pueden dar problemas relacionados con la serialización.

  • Gráficas de Objetos / Object Graphs

    ¿Qué significa realmente guardar un objeto?. Si las vriables de instancia son todas de tipos primitivos, es muy fácil. Pero ¿Qué pasa si las variables de instancia son referencias a otros objetos? ¿Qué se salva?. Claramente en Java no tendría sentido salvar el valor actual de las variables de referencia, porque son valores de una referencia en Java que tiene sentido solo en el contexto de una singular JVM, incluso ejecutandose en el mismo ordenador en el cual el objeto fuera originalmente serializado, la referencía no tendría utilidad. Pero ¿Qué pasa con el objeto al cual la referencia se refiere? Veamos esta clase:

    		class Dog {
    			private Collar theCollar;
    			private int dogSize;
    			public Dog(Collar, int size) {
    				theCollar = collar;
    				dogSize = size;
    			}
    			public Collar getCollar() { return theCollar; }
    		}
    		class Collar {
    			private int collarSeize;
    			public Collar (int size) { collarSize = size; }
    			public int getCollarSize() { return collarSize; }
    		}
    		[/sourcecode[]
    		<p>Ahora hacemos un objeto Dog, pero primero hacemos un collar para el perro:</p>
    		
    		Collar c = new Collar(3);
    		

    Entonces hacemos un Dog, pasandole el Collar:

    		Dog d = new Dog(c, 8);
    		

    Entonces ¿Qué pasa cuando guardamos el objeto Dog? Si el propósito es guardar y luego restaurar el objeto Dog, y el Dog retaurado es un duplicado exacto del objeto Dog que fué salvado, entonces el Dog necesita un Collar que sea exactamente duplicado al Collar del Dog que fué guardado. Esto significa que ambos, Dog y Collar tendrían que ser salvados.

    Y ¿Qué pasa si el Collar por sí mismo tiene referencias a otros objetos, como por ejemplo un objeto Color? Esto se ha complicado muy rápido. Si el programador supiera toda la estructura interna de todos los objetos a los que refiere Dog, el programador podría estar seguro de guardar todos los estados de todos los objetos. Esto sería una pesadilla incluso para los objetos mas simples.

    Afortunadamente, el mecanismo de serialización en Java tiene cuidado de todo esto. Cuando serialiamos un objeto, la serialización de Java toma el cuidado de guardar todo el objeto entero o “Gráfica del objeto / Object Graph”. Esto significa que se copiaría todo aquello que el objeto salvado necesita para ser restaurado. Por ejemplo, si serializamos el objeto Dog, el Collar se serializaría automáticamente. Y si la clase Collar contenía una referencia a otro objeto, ESE objeto sería también serializado, y así con todo. Y el único objeto del cual nos tendríamos que preocupar para salvar y restaurar sería el Dog. Los otros objetos requeridos para reconstruir completamente el Dog son salvados (y restaurados) automáticamente a través de la serialización.

    Recordemos, tenemos que hacer una elección consciente para crear objetos que son serializable, implementando la interface Serializable. Si queremos salvar objetos Dog, por ejemplo, tendremos que modificar la clase Dog de la siguiente manera:

    		class Dog implements Serializable{
    			// El resto de código de antes
    			
    			// Serializable no requiere implementar métodos
    		}
    		

    Y ahora podemos salvar Dog como en el siguiente código:

    		import java.io.*;
    		public class SerializeDog {
    			public static void main(String[] args){
    				Collar c = new Collar(3);
    				Dog d = new Dog(c, 8);
    				try{
    					FileOutputStream fs = new FileOutputStream("testSer.ser");
    					ObjectOutputStream os = new ObjectOutputStream(fs);
    					os.writeObject(d);
    					os.close();
    				}catch (Exception ex){ ex.printStackTrace(); }
    			}
    		}
    		

    Pero cuando ejecutamos este código tendremos una excepción en tiempo de ejecución que se parecería a lo siguiente:

    		java.io.NotSerializableException: Collar
    		

    ¿Qué hemos olvidado? La clase Collar debe tambien ser Serializable. Si modificamos la clase Collar y la hacemos serializable, entonces no habría problemas:

    		class Collar implementes Serializable{
    			// Lo mismo
    		}
    		

    Aquí tendríamos el listado completo:

    		import java.io.*;
    		public class SerializeDog{
    			public static void main (String[] args){
    				Collar c = new Collar(3);
    				Dog d = new Dog(c, 5);
    				System.out.println("before: collar size is " + d.getCollar().getCollarSize());
    				try{
    					FileOutputStream fs = new FileOutputStrem("testSer.ser");
    					ObjectOutputStream os = new ObjectOutputStream(fs);
    					os.writeObject(d);
    					os.close();
    				} catch (Exception e) {e.printStackTrace(); }
    				
    				try {
    					FileInputStream fis = new FileInputStream("testSer.ser");
    					ObjectInputStream ois = new ObjectInputStream(ois);
    					d = (Dog)ois.readObject();
    					ois.close();
    				} catch(Exception e) { e.printStackTrace(); }
    				
    				System.out.println("after: collar size is " + d.getCollar().getCollarSize());
    			}
    		}
    		class Dog implements Serializable {
    			private Collar theCollar;
    			private int dogSize;
    			public Dog (Collar collar, int size){
    				theCollar = collar;
    				dogSize = size;
    			}
    			public Collar getCollar(){
    				return theCollar;
    			}
    		}
    		class Collar implements Serializable {
    			private int collarSize;
    			public Collar(int size){
    				collarSize = size;
    			}
    			public int getCollarSize() {
    				return collarSize;
    			}
    		}
    		

    Esto produce la salida:

    		before: collar size is 3
    		after: collar size is 3
    		

    Pero ¿Qué pasaría si no tuvieramos acceso al código fuente de la clase Collar? En otras palabras, si hacer la clase Collar serializable no fuera una opción. ¿Estamos parados en un Dog que no puede ser Serializable?.

    Obviamente podríamos hacer una subclase de la clase Collar, marcar la subclase como Serializable, y entonces usar la subclase COllar en vez de la clase Collar. Pero no es siempre una opción por varias razones potenciales:

    1. La clase Collar puede ser final, para prevenir hacer subclases.
    2. La clase Collar puede por sí misma referir a otros objetos no serializabls, y sin saber la estructura de Collar, no podríamos hacer estos ajustes.
    3. Hacer subclases no es una opción por otras razones en relación a nuestro diseño.

    Por lo que…¿Qué hacemos si queremos guardar la clase Dog?.

    Aquí es donde viene el modificador transient. Si marcamos la variable de instancia Collar como transient, entonces la serialización simplemente omite Collar durante la serialización:

    		class Dog implements Serializable{
    			private transient Collar theCollar;		// Añade el modificador
    			// El resto de la clase es igual
    		}
    		
    		class Collar {
    			// el mismo código
    		}
    		

    Ahora tenemos un Dog serializable, con un objeto Collar no serializable, pero el Dog tiene Collar como transient; la salida es:

    		before: collar size is 3
    		Exception in thread "main" java.lang.NullPointerException
    		

    ¿Y ahora qué podemos hacer?

  • Usando writeObject y readObject

    Consideramos el problema: tenemos un objeto Dog que queremos salvar. El Dog tiene Collar, y el estado de Collar que debería tambien ser guardado es parte del estado de Dog. Pero…el Collar no es serializable, por lo que lo hemos marcado como transient. Esto significa que cuando Dog es deserializado, viene con el un Collar cuyo valor es null. ¿ue podemos hacer para estar eguros de que Dog es deserializado y obtiene un Collar que coincida con el que Dog tenía cuando fué salvado?. La serialización en Java tiene unos mecanimos especiales para esto, un set de métodos privados que podemos implementar en nuestra clase, y si están presentes, serán invocados automáticamente durante la serialización y la deserialización. Es como si estos métodos estuvieran definidos en la interface Serializable, excepto que no lo están. Son parte de una llamada especial junto con el sistema de serialización que básicamente dice, “Si tienes un par de métodos que se llaman igual, estos métodos serán llamados durante el proceso de la serialización/deserialización”.

    Estos métodos nos permiten un paso en la mitad de la serialización y deserialización. Por esto es perfecto para permitir solventar el problema de Dog/Collar: cuando un Dog está siendo salvado, podemos entrar en el medio de la serialización y decir, “Me gustaría guardar el estado de la variable Collar (un int) en el stream cuando el Dog sea serializado”. Hemos añadido manualmente el estado de Collar a la representación de Dog serializada, incluso aunque el Collar por sí mismo no sea salvado.

    Por suuesto, necesitarás restaurar el Collar durante la deserialización entrando en la mitad del proceso y diciendo, “Leeré un int extra que salvé en el stream de Dog, y lo uso para crear un nuevo Collar, y entonces se lo asigno al nuevo Collar del Dog que se está deserializando”. Los 2 métodos especiales que definimos tienen que tener el mismo nombre, EXACTAMENTE el siguiente:

    		private void writeObject(ObjectOutputStream os){
    			// Cödigo para guardar las variables de collar
    		}
    		
    		private void readObject(ObjectInputStream is){
    			// El código para leer el estado de Collar, crear un nuevo Collar
    			// y asignarlo a Dog
    		}
    		

    Si, estamos escribiendo métodos con el mismo nombre que los que hemos estado llamando. ¿Dónde van estos métodos? Vamos a cambiar la clase Dog:

    		class Dog implements Serializable{
    			transient private collar theCollar;	// No podemos seralizar esto
    			private int dogSize;
    			public Dog(Collar collar, int size){
    				theCollar = collar;
    				dogSize = size;
    			}
    			public Collar getCollar(){
    				return theCollar;
    			}
    			private void writeObject(ObjectOutputStream os){
    				// throws IOException{
    				try{
    					os.defaultWriteObject();
    					os.writeInt(theCollar.getCollarSize());
    				}catch (Exception e) { e.printStackTrace(); }
    			}
    			private void readObject(ObjectInputStream is){
    				// throws IOException, ClassNotFoundException
    				try{
    					is.defaultReadObject();
    					theCollar = new Collar(is.readInt());
    				} catch (Exception e) { e.printStackTrace(); }
    			}
    		}
    		

    Vamos a echar un vistazo al ejemplo anterior.

    En nuestro escenario hemos agregado que, por alguna razón del mundo, no podemos serializar el objeto Collar, pero queremos serializar Dog. Para hacer esto vamos a implementar los métodos writeObject() y readObject(). Implementando estos métodos estamos diciendole al compilador: “Si algo invoca a writeObject() o readObject() que concierne con el objeto Dog, usa esta parte del código de para leer y escribir”-

    1. Como todos los métodos relacionados con procesos de I/O, writeObject puede lanzar exceptiones. Podemos declararlas o manejarlas pero es recomendable manejarlas.
    2. Cuando invocamos defaultWriteObject() dentro de writeObject() le estamos diciendo a la JVM que haga el proceso normal de serialización para esto objeto. Cuando implementamos writeObject(), estamos haciendo el proceso normal de serialización, y algo mas personalizado a la hora de leer y escribir.
    3. En este caso hemos decidido escribir un int extra (el tamaño del collar) en el stream que está creando el Dog serializado. Podemos escribir mas cosas antes o despues de invocar el método defaultWriteObject(). PERO…cuando lo leamos, debemos leer todo aquello que hayamos agregado extra en el mismo orden que el que hemos escrito.
    4. De nuevo, elegimos manejar las excepciones en vez de declararlas.
    5. Cuando es tiempo de deserializar, defaultReadObject() maneja la deserialización normalmente como si no hubieramos implementado el método readObject().
    6. Finalmente hemos construido un nuevo objeto Collar para el Dog usando la variable int que manualmente serializamos. (Tenemos que invocar readInt() después de invocar defaultReadObject() o la información podría desincronizarse).

    Recordemos, que la razón mas común para implementar writeObject() y readObject() es cuando tenemos que guardar alguna parte del estado de un objeto manualmente. Podemos elegir y escribir y leer TODO el estado por nosotros mismos, pero es muy raro. Por lo que cuando queremos hacer que una parte del proceso de serialización/deserialización sea hecha por nosotros, DEBEMOS invocar los métodos defaultWriteObject() y defaultReadObject() para hacer el resto.

    Esto nos trae otra cuestión, ¿Por qué no todas las clases de Java son serializables? ¿Por qué la clase Object no es serializable? Hay cosas en Java que simplemente no pueden ser serializadas porque son específicas en tiempo de ejecución. Cosas como streams, threads, runtime, etc, e incluso algunas clases para las GUI no pueden ser serializables. Lo que es o no es serializable en la API de Java son cosas que tenemos que tener en mente si queremos serializar objetos complejos.

  • Como la Herencia afecta a la Serialización

    La serialización es interesante, pero a la hora de aplicarla de manera efetiva tenemos que entender como las superclases de las clases afectan a la serialización.

    Esto trae consigo otro problema clave sobre la serialización, ¿Qué pasa si una superclase no está marcada como Serializable, pero la subclase si? ¿Puede ser la subclase seguir siendo Serializable incluso si su superclase no implementa Serializable? Imaginemos esto:

    		class Animal{ }
    		class Dog extends Animal implements Serializable{
    			// El resto del código Dog
    		}
    		

    Ahora tenemos una clase Dog Serializable, con una superclase que no es Serializable. Esto funciona. Pero tenemos implicaciones potencialmente serias. Para entender completamente estas implicaciones, vamos a dar un paso atrás y mirar la diferencia entre un objeto que viene de la deserialización contra un objeto creado usando new. ecordemos, cuando un objeto es construido usando new (en oposición de ser deserializado), los siguientes pasos ocurren:

    1. A todas las variables de instancias le son asignados valores por defecto.
    2. El constructor es invocado, el cual inmediatamente invoca el constructor de la superclase (o cualquier otro constructor sobrecargado, hasta que uno de los constructores sobrecargados invoque al constructor de la superclase).
    3. Todos los constructores de la superclase se completan.
    4. Las variables de instancia que son inicializadas como parte de su declaración son asignados con un valor inicial (en oposición al valor por defecto que se les ha dado cuando se ha completado el constructor de la superclase).
    5. El constructor se completa.

    Estas cosas no pasan cuando un objeto es deserializado. Cuando una instancia de una clase serializable es deserializada, el constructor no se ejecuta, y las variables de instancia no obtienen un valor inicial asignado. Pensemos en ello, si el constructor fuer ainvocado, y /o las variables de instancia fueran asignadas por valores dados en sus declaraciones, el objeto al que estaríamos intentando restaurar volvería a su estado original, en vez de venir con cambios en su estado que ocurriero alguna vez después de ser creado. Por ejemplo, imaginamos que tenemos una clase que declara una variable de instancia y asignamos el valor para el int de 3, e incluimos el método que cambia la variable de instancia con un valor de 10:

    		class Foo implements Serializable{
    			int num = 3;
    			void changeNum(){
    				num = 10;
    			}
    		}
    		

    Obviamente si serializamos una instancia de Foo despues de que el método changeNum() se ejecute, el valor de la variable num debería ser 10. Cuando la instancia de Foo es deseralizada, queremos que el valor de num siga siendo 10. Obviamente no queremos que la inicialización ocurra de nuevo. Pensemos en los constructores y en las asignaciones de variables de instancia entre ellas como parte de un proceso de inicialización del objeto. El punto s, cuando un objeto es deserializado no queremos que cualquierinicialización normal ocurra. No queremos que un constructor se ejecute, y no queremos que se asignen valores declarados explícitamente. Solo queremos que los valores guardados como parte del estado de la serialización del objeto sean reasignados.

    Por supuesto si tenemos variables marcadas como transient, no serán restauradas a su estado original (a no ser qu eimplementemos el método readObject()), pero le será dado el valor por defecto para el tipo de dato. En otras palabras, incluso aunque digamos:

    		class Bar implements Serializable{
    			transient int x = 42;
    		}
    		

    Cuando la instancia de Bar sea deserializada, la variable x tendrá el valor de 0. Las referencias a objeto marcadas como transient siempre tendrán el valor null, sin importar donde son inicializadas en el tiempo de declaración de la clase.

    Por esto, lo quepasa cuando un objeto es deserializado, y la clase del objeto serializado hereda directamente de Object, o tiene SOLO clases en su arbol de herencia. Esto se complica un poco cuando la clase serializable tiene una o mas superclases que no son serializables. Volviendo a nuestro Animal no serializable con una subclase serializable:

    		class Animal{
    			public String name;
    		}
    		class Dog extends Animal implements Serializable{
    			// El resto del código Dog
    		}
    		

    Ya que Animal no es serializable, cualquier estado mantenido por la clase Animal, incluso aunque la variable de estado sea heredado por DOg, no va a ser restaurada cuando Dog sea deserializado. La razón es, que la parte de Animal que contiene Dog va a ser reinicializada como si estuvieramos haciendo un new Dog. Esto significa que todas las cosas que le pasan a un objeto durante la construcción, ocurrirá. En otras palabras, las variables de instancia de la clase Dog serán serializadas y deserializadas correctamente, pero las variables heredadas de la clase Animal que no es serializable volverán con los valores por defecto/inicializados que se asignan en vez de los valores que tenían antes de la serialización.

    Si somos una clase serializable, pero nuestra superclase no lo es, entonces cualquier variable que heredamos de la superclase serán reiniciadas a los valores que les fue dado durante el proceso original de construcción del objeto. Esto es así porque el constructor de la clase no serializable será ejecutado.

    Todo constructor debajo del primer constructor de la primera clase no serializable se ejecutará tambien, no importa lo que pase, porque una vez que el primer super constructor sea invocado, invocará su super constructor y todos los que estén por encima en su árbol de herencia.

    Tenemos que reconocer que variables serán o no restauradas con sus valores apropiados cuando un objeto es deserializado, por lo que tenemos que estudiar este ejemplo y su salida, que nos ayudará a entender esto:

    		import java.io.*;
    		class SuperNotSerial {
    			public static void main (String[] args){
    				
    				Dog d = new Dog (35, "Fido");
    				System.out.println("before: " + d.name + " " + d.weight);
    				try{
    					FileOutputStream fs = new FileOutputStream("testSer.ser");
    					ObjectOutputStream os = new ObjectOutputStream(fs);
    					os.writeObject(d);
    					os.close();
    				} catch(Exception ex){ ex.printStackTrace(); }
    				try{
    					FileInputStream fis = new FileInputStream("testSer.ser");
    					ObjectInputStream ois = new ObjectInputStream(fis);
    					d = (Dog) ois.readObject();
    					ois.close();
    				} catch (Exception e) { e.printStackTrace(); }
    				
    				System.out.println("after: " + d.name + " " + d.weight);
    			}
    		}
    		class Dog extends Animal implements Serializable{
    			String name;
    			Dog(int w, String n){
    				weight = w;		// Heredado
    				name = n;		// No heredado
    			}
    		}
    		class Animal {		// No serializable
    			int weight = 42;
    		}
    		

    Esto producirá la salida:

    		before: Fido 35
    		after: Fido 42
    		

    La clave aquí es que Animal no es serializable, cuando el Dog fué deserializado, el constructor de Animal se ejecutó y reinició la variable heredada weight de Dog.

  • La Serialización no es para Static

    Finalmente, hemos visto que hemos hablado solo de variables de instancia, no de variables static. ¿Deberían ser las variables static guardadas como parte del estado del objeto? ¿No es el estado de una variable static importante cuando un objeto es serializado? SI y no. Puede ser importante, pero no es parte del estado de una instancia al fin. Recordemos, tenemos que saber que las variables static son puramente variables de CLASE. No tienen que ver nada con las instancias individuales. Pero la serialización solo se aplica a OBJETOS. ¿Y qué pasa si deserializamos 3 instancias de Dog diferentes, de las cuales toda fueron serializadas en diferente momento, y todas ellas fueron guardadas cuando el valor de la variable static era diferente?. ¿Qué instancia ganaría?¿Qué valor sttatic de la instancia sería usado para reemplazar ese que actualmente se ha cargado? ¿Vemos el problema?.

    Las variables static nunca son guardadas como parte del estado del objeto, porque no pertenecen al objeto!.


En esta entrada hemos visto que es la Serialización en Java, su uso, y en que casos podemos usarla y en otros casos en los que no podemos. Esto nos servirá para irnos introduciendo en la serialización de objetos antes de empezar con cosas mucho mas complejas.

Sin más, cualquier aporte o corrección son bienvenidos.

Saludos!!!