Serialización en Java – Serialización en Java

La serialización en Java se introdujo en JDK 1.1 y es una característica importante de Core Java.

La serialización en Java

La serialización en Java nos permite convertir un objeto a un flujo que podemos enviar por la red o guardarlo como archivo o almacenarlo en la base de datos para un uso posterior. La deserialización es el proceso de convertir el flujo de objetos en un objeto Java real que se utilizará en nuestro programa. La serialización en Java parece muy fácil de usar al principio, pero viene con algunos problemas de seguridad e integridad triviales que veremos más adelante en este artículo. Exploraremos los siguientes temas en este tutorial.

  1. Serializable en Java
  2. Refactorización de clases con Serialización y serialVersionUID
  3. Interfaz Externalizable de Java
  4. Métodos de Serialización de Java
  5. Serialización con Herencia
  6. Patrón de Proxy de Serialización

Serializable en Java

Si deseas que un objeto de clase sea serializable en Java, todo lo que necesitas hacer es implementar la interfaz java.io.Serializable. Serializable en Java es una interfaz de marcador y no tiene campos ni métodos que implementar. Es como un proceso de aceptación a través del cual hacemos que nuestras clases sean serializables. La serialización en Java se implementa mediante ObjectInputStream y ObjectOutputStream, así que todo lo que necesitamos es un envoltorio sobre ellos para guardarlos en un archivo o enviarlos por la red. Veamos un ejemplo simple de un programa de serialización en Java.

package com.journaldev.serialization;

import java.io.Serializable;

public class Employee implements Serializable {

//	private static final long serialVersionUID = -6470090944414208496L;
	
	private String name;
	private int id;
	transient private int salary;
//	private String password;
	
	@Override
	public String toString(){
		return "Employee{name="+name+",id="+id+",salary="+salary+"}";
	}
	
	//métodos getter y setter
	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public int getId() {
		return id;
	}

	public void setId(int id) {
		this.id = id;
	}

	public int getSalary() {
		return salary;
	}

	public void setSalary(int salary) {
		this.salary = salary;
	}

//	public String getPassword() {
//		return password;
//	}
//
//	public void setPassword(String password) {
//		this.password = password;
//	}
	
}

Observa que es un simple Java Bean con algunas propiedades y métodos getter-setter. Si deseas que una propiedad del objeto no se serialice al flujo, puedes usar la palabra clave transient, como he hecho con la variable de salario. Ahora supongamos que queremos escribir nuestros objetos en un archivo y luego deserializarlos desde el mismo archivo. Por lo tanto, necesitamos métodos de utilidad que utilizarán ObjectInputStream y ObjectOutputStream con fines de serialización.

package com.journaldev.serialization;

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

/**
 * A simple class with generic serialize and deserialize method implementations
 * 
 * @author pankaj
 * 
 */
public class SerializationUtil {

	// deserializar a Objeto desde el archivo dado
	public static Object deserialize(String fileName) throws IOException,
			ClassNotFoundException {
		FileInputStream fis = new FileInputStream(fileName);
		ObjectInputStream ois = new ObjectInputStream(fis);
		Object obj = ois.readObject();
		ois.close();
		return obj;
	}

	// serializar el objeto dado y guardarlo en un archivo
	public static void serialize(Object obj, String fileName)
			throws IOException {
		FileOutputStream fos = new FileOutputStream(fileName);
		ObjectOutputStream oos = new ObjectOutputStream(fos);
		oos.writeObject(obj);

		fos.close();
	}

}

Tenga en cuenta que los argumentos del método funcionan con Object que es la clase base de cualquier objeto Java. Está escrito de esta manera para ser genérico en su naturaleza. Ahora, escribamos un programa de prueba para ver la serialización en acción en Java.

package com.journaldev.serialization;

import java.io.IOException;

public class SerializationTest {
	
	public static void main(String[] args) {
		String fileName="employee.ser";
		Employee emp = new Employee();
		emp.setId(100);
		emp.setName("Pankaj");
		emp.setSalary(5000);
		
		// serializar en archivo
		try {
			SerializationUtil.serialize(emp, fileName);
		} catch (IOException e) {
			e.printStackTrace();
			return;
		}
		
		Employee empNew = null;
		try {
			empNew = (Employee) SerializationUtil.deserialize(fileName);
		} catch (ClassNotFoundException | IOException e) {
			e.printStackTrace();
		}
		
		System.out.println("emp Object::"+emp);
		System.out.println("empNew Object::"+empNew);
	}
}

Cuando ejecutamos el programa de prueba anterior para la serialización en Java, obtenemos la siguiente salida.

emp Object::Employee{name=Pankaj,id=100,salary=5000}
empNew Object::Employee{name=Pankaj,id=100,salary=0}

Dado que el salario es una variable transitoria, su valor no se guardó en el archivo y, por lo tanto, no se recuperó en el nuevo objeto. De manera similar, los valores de variables estáticas tampoco se serializan, ya que pertenecen a la clase y no al objeto.

Refactorización de Clases con Serialización y serialVersionUID

La serialización en Java permite algunos cambios en la clase Java si pueden ser ignorados. Algunos de los cambios en la clase que no afectarán el proceso de deserialización son:

  • Agregar nuevas variables a la clase
  • Cambiar las variables de transitorias a no transitorias, para la serialización es como tener un nuevo campo.
  • Cambiar la variable de estática a no estática, para la serialización es como tener un nuevo campo.

Pero para que todos estos cambios funcionen, la clase java debería tener definido serialVersionUID para la clase. Escribamos una clase de prueba solo para la deserialización del archivo serializado previamente desde la clase de prueba anterior.

package com.journaldev.serialization;

import java.io.IOException;

public class DeserializationTest {

	public static void main(String[] args) {

		String fileName="employee.ser";
		Employee empNew = null;
		
		try {
			empNew = (Employee) SerializationUtil.deserialize(fileName);
		} catch (ClassNotFoundException | IOException e) {
			e.printStackTrace();
		}
		
		System.out.println("empNew Object::"+empNew);
		
	}
}

Ahora descomenta la variable “password” y sus métodos getter-setter de la clase Employee y ejecútalo. Obtendrás la siguiente excepción;

java.io.InvalidClassException: com.journaldev.serialization.Employee; local class incompatible: stream classdesc serialVersionUID = -6470090944414208496, local class serialVersionUID = -6234198221249432383
	at java.io.ObjectStreamClass.initNonProxy(ObjectStreamClass.java:604)
	at java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:1601)
	at java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1514)
	at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:1750)
	at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1347)
	at java.io.ObjectInputStream.readObject(ObjectInputStream.java:369)
	at com.journaldev.serialization.SerializationUtil.deserialize(SerializationUtil.java:22)
	at com.journaldev.serialization.DeserializationTest.main(DeserializationTest.java:13)
empNew Object::null

La razón es clara: serialVersionUID de la clase anterior y la nueva clase son diferentes. En realidad, si la clase no define serialVersionUID, se calcula automáticamente y se asigna a la clase. Java utiliza variables de clase, métodos, nombre de clase, paquete, etc., para generar este número largo único. Si estás trabajando con algún IDE, automáticamente recibirás una advertencia de que “La clase serializable Employee no declara un campo serialVersionUID estático final de tipo long”. Podemos usar la utilidad java “serialver” para generar el serialVersionUID de la clase, para la clase Employee podemos ejecutarlo con el siguiente comando.

SerializationExample/bin$serialver -classpath . com.journaldev.serialization.Employee

Ten en cuenta que no es necesario que la versión serial se genere desde este programa en sí, podemos asignar este valor como queramos. Solo necesita estar allí para que el proceso de deserialización sepa que la nueva clase es la nueva versión de la misma clase y debería deserializarse si es posible. Por ejemplo, descomenta solo el campo serialVersionUID de la clase Employee y ejecuta el programa SerializationTest. Ahora descomenta el campo de contraseña de la clase Employee y ejecuta el programa DeserializationTest y verás que el flujo de objetos se deserializa correctamente porque el cambio en la clase Employee es compatible con el proceso de serialización.

Interfaz Externalizable de Java

Si observas el proceso de serialización en Java, se realiza automáticamente. A veces queremos oscurecer los datos del objeto para mantener su integridad. Podemos hacer esto implementando la interfaz java.io.Externalizable y proporcionar la implementación de los métodos writeExternal() y readExternal() para ser utilizados en el proceso de serialización.

package com.journaldev.externalization;

import java.io.Externalizable;
import java.io.IOException;
import java.io.ObjectInput;
import java.io.ObjectOutput;

public class Person implements Externalizable{

	private int id;
	private String name;
	private String gender;
	
	@Override
	public void writeExternal(ObjectOutput out) throws IOException {
		out.writeInt(id);
		out.writeObject(name+"xyz");
		out.writeObject("abc"+gender);
	}

	@Override
	public void readExternal(ObjectInput in) throws IOException,
			ClassNotFoundException {
		id=in.readInt();
		// leer en el mismo orden que se escribió
		name=(String) in.readObject();
		if(!name.endsWith("xyz")) throw new IOException("corrupted data");
		name=name.substring(0, name.length()-3);
		gender=(String) in.readObject();
		if(!gender.startsWith("abc")) throw new IOException("corrupted data");
		gender=gender.substring(3);
	}

	@Override
	public String toString(){
		return "Person{id="+id+",name="+name+",gender="+gender+"}";
	}
	public int getId() {
		return id;
	}

	public void setId(int id) {
		this.id = id;
	}

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public String getGender() {
		return gender;
	}

	public void setGender(String gender) {
		this.gender = gender;
	}

}

Observa que he cambiado los valores de los campos antes de convertirlos en flujo y luego, al leer, he revertido los cambios. De esta manera, podemos mantener la integridad de los datos de alguna manera. Podemos lanzar una excepción si, después de leer los datos del flujo, las comprobaciones de integridad fallan. Escribamos un programa de prueba para verlo en acción.

package com.journaldev.externalization;

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

public class ExternalizationTest {

	public static void main(String[] args) {
		
		String fileName = "person.ser";
		Person person = new Person();
		person.setId(1);
		person.setName("Pankaj");
		person.setGender("Male");
		
		try {
			FileOutputStream fos = new FileOutputStream(fileName);
			ObjectOutputStream oos = new ObjectOutputStream(fos);
		    oos.writeObject(person);
		    oos.close();
		} catch (IOException e) {
			// TODO bloque de captura generado automáticamente
			e.printStackTrace();
		}
		
		FileInputStream fis;
		try {
			fis = new FileInputStream(fileName);
			ObjectInputStream ois = new ObjectInputStream(fis);
		    Person p = (Person)ois.readObject();
		    ois.close();
		    System.out.println("Person Object Read="+p);
		} catch (IOException | ClassNotFoundException e) {
			e.printStackTrace();
		}
	    
	}
}

Cuando ejecutamos el programa anterior, obtenemos la siguiente salida.

Person Object Read=Person{id=1,name=Pankaj,gender=Male}

Entonces, ¿cuál es mejor usar para la serialización en Java? En realidad, es mejor usar la interfaz Serializable y para cuando lleguemos al final del artículo, sabrás por qué.

Métodos de Serialización en Java

Hemos visto que la serialización en Java es automática y todo lo que necesitamos es implementar la interfaz Serializable. La implementación está presente en las clases ObjectInputStream y ObjectOutputStream. Pero ¿qué pasa si queremos cambiar la forma en que guardamos los datos, por ejemplo, si tenemos información sensible en el objeto y antes de guardar/recuperar queremos encriptar/desencriptar? Es por eso que hay cuatro métodos que podemos proporcionar en la clase para cambiar el comportamiento de serialización. Si estos métodos están presentes en la clase, se utilizan para fines de serialización.

  1. readObject(ObjectInputStream ois): Si este método está presente en la clase, el método readObject() de ObjectInputStream utilizará este método para leer el objeto del flujo.
  2. writeObject(ObjectOutputStream oos): Si este método está presente en la clase, el método writeObject() de ObjectOutputStream utilizará este método para escribir el objeto en el flujo. Uno de los usos comunes es obscuring las variables del objeto para mantener la integridad de los datos.
  3. Object writeReplace(): Si este método está presente, entonces después del proceso de serialización se llama a este método y el objeto devuelto se serializa en el flujo.
  4. Object readResolve(): Si este método está presente, entonces después del proceso de deserialización, se llama a este método para devolver el objeto final al programa llamador. Uno de los usos de este método es implementar el patrón Singleton con clases serializadas. Lee más en Serialización y Singleton.

Generalmente, al implementar los métodos mencionados, se mantienen como privados para que las subclases no puedan anularlos. Están destinados únicamente a fines de serialización, y mantenerlos privados evita cualquier problema de seguridad.

Serialización con Herencia

A veces necesitamos extender una clase que no implementa la interfaz Serializable. Si confiamos en el comportamiento automático de serialización y la superclase tiene algún estado, entonces no se convertirán a flujo y, por lo tanto, no se recuperarán más tarde. Aquí es donde realmente ayudan los métodos readObject() y writeObject(). Al proporcionar su implementación, podemos guardar el estado de la superclase en el flujo y luego recuperarlo más tarde. Veamos esto en acción.

package com.journaldev.serialization.inheritance;

public class SuperClass {

	private int id;
	private String value;
	
	public int getId() {
		return id;
	}
	public void setId(int id) {
		this.id = id;
	}
	public String getValue() {
		return value;
	}
	public void setValue(String value) {
		this.value = value;
	}	
}

SuperClase es un simple bean de Java pero no implementa la interfaz Serializable.

package com.journaldev.serialization.inheritance;

import java.io.IOException;
import java.io.InvalidObjectException;
import java.io.ObjectInputStream;
import java.io.ObjectInputValidation;
import java.io.ObjectOutputStream;
import java.io.Serializable;

public class SubClass extends SuperClass implements Serializable, ObjectInputValidation{

	private static final long serialVersionUID = -1322322139926390329L;

	private String name;

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}
	
	@Override
	public String toString(){
		return "SubClass{id="+getId()+",value="+getValue()+",name="+getName()+"}";
	}
	
	// agregando un método auxiliar para la serialización para guardar/inicializar el estado de la superclase
	private void readObject(ObjectInputStream ois) throws ClassNotFoundException, IOException{
		ois.defaultReadObject();
		
		// nota el orden de lectura y escritura debe ser el mismo
		setId(ois.readInt());
		setValue((String) ois.readObject());	
	}
	
	private void writeObject(ObjectOutputStream oos) throws IOException{
		oos.defaultWriteObject();
		
		oos.writeInt(getId());
		oos.writeObject(getValue());
	}

	@Override
	public void validateObject() throws InvalidObjectException {
		// validar el objeto aquí
		if(name == null || "".equals(name)) throw new InvalidObjectException("name can't be null or empty");
		if(getId() <=0) throw new InvalidObjectException("ID can't be negative or zero");
	}	
}

Tenga en cuenta que el orden de escritura y lectura de los datos adicionales en el flujo debe ser el mismo. Podemos aplicar alguna lógica al leer y escribir datos para hacerlo seguro. También observe que la clase implementa la interfaz ObjectInputValidation. Al implementar el método validateObject(), podemos aplicar algunas validaciones comerciales para asegurarnos de que la integridad de los datos no se vea afectada. Escribamos una clase de prueba y veamos si podemos recuperar el estado de la superclase a partir de los datos serializados o no.

package com.journaldev.serialization.inheritance;

import java.io.IOException;

import com.journaldev.serialization.SerializationUtil;

public class InheritanceSerializationTest {

	public static void main(String[] args) {
		String fileName = "subclass.ser";
		
		SubClass subClass = new SubClass();
		subClass.setId(10);
		subClass.setValue("Data");
		subClass.setName("Pankaj");
		
		try {
			SerializationUtil.serialize(subClass, fileName);
		} catch (IOException e) {
			e.printStackTrace();
			return;
		}
		
		try {
			SubClass subNew = (SubClass) SerializationUtil.deserialize(fileName);
			System.out.println("SubClass read = "+subNew);
		} catch (ClassNotFoundException | IOException e) {
			e.printStackTrace();
		}
	}
}

Cuando ejecutamos la clase anterior, obtenemos la siguiente salida:

SubClass read = SubClass{id=10,value=Data,name=Pankaj}

De esta manera, podemos serializar el estado de la superclase aunque no implemente la interfaz Serializable. Esta estrategia resulta útil cuando la superclase es una clase de terceros que no podemos modificar.

Patrón de Proxy de Serialización

La serialización en Java presenta algunas desventajas graves, como:

  • La estructura de la clase no se puede cambiar mucho sin romper el proceso de serialización de Java. Por lo tanto, incluso si no necesitamos algunas variables más adelante, debemos mantenerlas solo por compatibilidad hacia atrás.
  • La serialización causa grandes riesgos de seguridad, un atacante puede cambiar la secuencia del flujo y causar daños en el sistema. Por ejemplo, se serializa el rol del usuario y un atacante cambia el valor del flujo para convertirlo en administrador y ejecutar código malicioso.

El patrón de proxy de serialización de Java es una forma de lograr una mayor seguridad con la serialización. En este patrón, se utiliza una clase interna privada estática como clase proxy con el propósito de serialización. Esta clase está diseñada de tal manera que mantiene el estado de la clase principal. Este patrón se implementa mediante la implementación adecuada de los métodos readResolve() y writeReplace(). Primero escribamos una clase que implemente el patrón de proxy de serialización y luego lo analizaremos para comprenderlo mejor.

package com.journaldev.serialization.proxy;

import java.io.InvalidObjectException;
import java.io.ObjectInputStream;
import java.io.Serializable;

public class Data implements Serializable{

	private static final long serialVersionUID = 2087368867376448459L;

	private String data;
	
	public Data(String d){
		this.data=d;
	}

	public String getData() {
		return data;
	}

	public void setData(String data) {
		this.data = data;
	}
	
	@Override
	public String toString(){
		return "Data{data="+data+"}";
	}
	
	// Clase de proxy de serialización
	private static class DataProxy implements Serializable{
	
		private static final long serialVersionUID = 8333905273185436744L;
		
		private String dataProxy;
		private static final String PREFIX = "ABC";
		private static final String SUFFIX = "DEFG";
		
		public DataProxy(Data d){
			// Ocultando datos por seguridad
			this.dataProxy = PREFIX + d.data + SUFFIX;
		}
		
		private Object readResolve() throws InvalidObjectException {
			if(dataProxy.startsWith(PREFIX) && dataProxy.endsWith(SUFFIX)){
			return new Data(dataProxy.substring(3, dataProxy.length() -4));
			}else throw new InvalidObjectException("data corrupted");
		}
		
	}
	
	// Reemplazando objeto serializado con objeto DataProxy
	private Object writeReplace(){
		return new DataProxy(this);
	}
	
	private void readObject(ObjectInputStream ois) throws InvalidObjectException{
		throw new InvalidObjectException("Proxy is not used, something fishy");
	}
}
  • Ambas clases Data y DataProxy deben implementar la interfaz Serializable.
  • DataProxy debe ser capaz de mantener el estado del objeto Data.
  • DataProxy es una clase interna privada estática, por lo que otras clases no pueden acceder a ella.
  • DataProxy debe tener un único constructor que tome Data como argumento.
  • La clase Data debe proporcionar el método writeReplace() que devuelva una instancia de DataProxy. Por lo tanto, cuando se serializa el objeto Data, el flujo devuelto es de la clase DataProxy. Sin embargo, la clase DataProxy no es visible externamente, por lo que no se puede utilizar directamente.
  • La clase DataProxy debe implementar el método readResolve() que devuelva un objeto Data. Por lo tanto, cuando se deserializa la clase Data, internamente se deserializa DataProxy y cuando se llama a su método readResolve(), obtenemos el objeto Data.
  • Finalmente implementa el método readObject() en la clase Data y lanza InvalidObjectException para evitar ataques de hackers intentando fabricar un flujo de objetos Data y analizarlo.

Escribamos una pequeña prueba para verificar si la implementación funciona o no.

package com.journaldev.serialization.proxy;

import java.io.IOException;

import com.journaldev.serialization.SerializationUtil;

public class SerializationProxyTest {

	public static void main(String[] args) {
		String fileName = "data.ser";
		
		Data data = new Data("Pankaj");
		
		try {
			SerializationUtil.serialize(data, fileName);
		} catch (IOException e) {
			e.printStackTrace();
		}
		
		try {
			Data newData = (Data) SerializationUtil.deserialize(fileName);
			System.out.println(newData);
		} catch (ClassNotFoundException | IOException e) {
			e.printStackTrace();
		}
	}

}

Cuando ejecutamos la clase anterior, obtenemos la siguiente salida en la consola.

Data{data=Pankaj}

Si abres el archivo data.ser, verás que el objeto DataProxy se guarda como flujo en el archivo.

Descarga el Proyecto de Serialización en Java

Eso es todo para la Serialización en Java, parece simple pero debemos usarla con prudencia y siempre es mejor no depender de la implementación predeterminada. Descarga el proyecto desde el enlace anterior y juega con él para aprender más.

Source:
https://www.digitalocean.com/community/tutorials/serialization-in-java