Les dessous de la sérialization – 2

Nous avions laissé notre héros au moment où il se rendait compte que cette merveilleuse API, la sérialization Java, pouvait tout faire elle-même, sans qu’il ait à se préoccuper d’autre chose que d’ouvrir un fichier ou une socket. En grattant un peu la surface, il s’est rendu compte qu’il pouvait personnaliser quelques aspects.

Mais il n’est pas au bout de ses découvertes, et en continuant son exploration, il va se rendre compte qu’il peut aller bien plus loin encore !

Interface Externalizable

Implémenter cette interface constitue la deuxième façon de surcharger le mécanisme standard de sérialization. L’interface Externalizable est une extension de Serializable, et définit deux méthodes :

public interface Externalizable {

	void writeExternal(ObjectOutput out) throws IOException ;

	void readExternal(ObjectInput in) throws IOException, ClassNotFoundException ;
}

Une classe qui implémente cette interface est donc sérializable, et est donc gérée par la sérialization standard. Lorsqu’une classe implémente cette interface, le mécanisme standard de sérialization appelle ces deux méthodes en écriture et en lecture.

À première vue cette surcharge ressemble fort à la précédente. En réalité, les différences sont importantes.

  • La méthode writeExternal() est censée écrire l’intégralité de l’état de l’objet sérializé sur le flux passé en paramètre, là où writeObject() n’écrit que les champs de la classe dans laquelle elle est déclarée.
  • Ces deux méthodes sont publiques, ce qui donne la possibilité à toute classe externe (éventuellement malicieuse !) d’accéder en lecture et en écriture à l’état des instances de cette classe. Dans le premier cas on ne pouvait le faire que par introspection (comme toujours), dont on peut se protéger en activant le security manager. Une classe (malicieuse aussi !) peut également surcharger ces méthodes, ce qui n’est pas toujours désirable.
  • Le flux passé en paramètre est moins riche en fonctionnalités que celui que reçoivent les méthodes writeObject() et readObject(). En particulier, on ne peut pas accéder aux méthodes writeDefault(), putFields() ou readFields() pour utiliser, éventuellement partiellement, le mécanisme standard.

La javadoc précise que « seulement l’identité de l’objet Externalizable doit être écrite dans le flux sérializé ». Il appartient ensuite à la méthode readExternal() de reconstituer l’état de l’objet à partir de cette identité. Il s’agit du pattern « officiel », mais on ne peut pas dire qu’il soit respecté systématiquement.

Notons que la construction d’un objet qui implémente cette interface commence par l’invocation du constructeur vide de la classe, qui doit donc exister. Cette contrainte n’est pas présente dans le cas des classes qui implémentent juste Serializable.

Méthode writeReplace()

L’utilisation de cette méthode permet d’écrire un objet dans le flux sérializé, en lieu et place de l’objet que l’on est en train de sérializer. Cet autre objet est a priori différent de celui que l’on sérialize, il est en général l’instance d’une autre classe. Reprenons une version simplifiée de notre classe StringBox.

public class StringBox implements Serializable {

	private StringBuffer boxedValue ;

	// litanie des getters & setters
}

Cette classe est sérializable, nous avons déjà vu comment l’on pouvait modifier la façon dont ses instances s’écrivent sur un flux sérializé.

Prenons une classe Person, représentant une personne libre. On rencontre souvent la classe Employee, mais je la trouve tristement révélatrice de pas mal de choses…

public class Person implements Serializable {

	private String nom, prenom ;

	// litanie des getters & setters

	// peut être private, protected ou public
	private Object writeReplace() throws ObjectStreamException {

		StringBox box = new StringBox(nom + "@" + prenom) ;
		return box ;
	}
}

La méthode writeReplace() est appelée par le mécanisme standard de sérialization quand elle est présente. Son contrat est simple : lorsqu’une instance de Person doit être sérializée, cette méthode est appelée, et, plutôt que d’écrire l’instance de Person directement dans le flux sérializé, c’est l’objet retourné par cette méthode qui est écrit.

Dans notre exemple, il s’agit donc d’une instance de StringBox, construite sur une concaténation (enfin presque) des champs nom et prenom de notre instance de Person. Bien sûr, l’objet retourné doit lui-même être sérializable, et va être traité de façon standard par le mécanisme de sérialization. Il peut lui-même surcharger ce mécanisme, comme nous sommes en train de le voir.

Méthode readResolve()

Le problème est que, lors de la désérialization, ce n’est pas un objet Person qui sera reconstruit, mais un objet StringBox, ce qui risque de poser des problèmes, si c’est une instance de Person que l’on veut obtenir. Si rien ne se passe, on a toutes les chances de prendre une ignoble ClassCastException. Heureusement les choses ne sont pas si mal faites que cela, et ce cas est prévu.

Pour que les choses se passent bien, la classe StringBox doit posséder une méthode readResolve(), dont la responsabilité est de reconstruire l’objet Person.

Voici une version de StringBox avec la méthode readResolve().

public class StringBox implements Serializable {

	private StringBuffer boxedValue ;

	// litanie des getters & setters

	// peut être private, protected ou public
	private Object readResolve() throws ObjectStreamException {

		String nom = boxedValue.substring(0, boxedValue.indexOf("@")) ;
		String prenom = boxedValue.substring(boxedValue.indexOf("@") + 1) ;

		Person p = new Person(nom, prenom) ;
		return p ;
	}
}

Lors d’une désérialization, le mécanisme standard teste la présence de cette méthode dans l’objet désérializé, et si elle est là, il l’appelle. Plutôt que l’objet désérializé, c’est l’objet retourné par cette méthode qui est retourné par le mécanisme de désérialization.

On pourrait penser que l’utilisation en tandem de ces deux méthodes est obligatoire, mais il n’en est rien :

  • lorsque le mécanisme de sérialization rencontre une méthode writeReplace() dans une classe, alors cette méthode est appelée, et l’objet qu’elle retourne est placé dans le flux à la place de l’instance de cette classe ;
  • lorsque le mécanisme de désérialization rencontre une méthode readResolve() dans un objet désérializé, alors cette méthode est appelée, et l’objet qu’elle retourne remplace l’objet désérializé.

En jouant sur l’une ou l’autre méthode, on peut donc reconstituer des instances de classes qui ont évolué à partir de flux sérializés sur d’anciennes versions de cette classe.

Surcharger ObjectOutputStream

J’entends d’ici poindre les critiques : tout cela est bien joli, mais mon modèle, je n’ai pas le droit d’y toucher, car :

  • c’est un modèle legacy, et on ne met pas ses doigts comme ça dans un modèle legacy ;
  • moi je m’occupe des traitements, le modèle c’est l’équipe au bout du couloir, et on ne se parle pas ;
  • on utilise un générateur de code à partir de diagrammes UML, déjà on a du mal, alors s’il faut ajouter des méthodes comme ça, on ne s’en sortira jamais ;
  • etc… etc…

Je sais bien que toutes ces raisons sont plus ridicules les unes que les autres, et d’ailleurs on ne les rencontre jamais dans la vraie vie. Mais bon, au cas où on pourrait les rencontrer quand même, Java, qui est un langage bien fichu, a prévu la chose : tout n’est pas perdu.

On peut également surcharger ObjectOutputStream et ObjectInputStream, et commander ces deux substitutions (en lecture et en écriture) directement au niveau du mécanisme de sérialization.

Jusqu’à ce point, précieux lecteur, nous sommes restés dans les limites du raisonnable de l’API. Mais la classe ObjectOutputStream nous permet des extensions méconnues. En particulier, elle peut être étendue. On ne peut pas dire qu’il est rare d’étendre des classes en Java (même si des messieurs très bien sous tout rapport, dont un qui a écrit Effective Java, écrivent que c’est une mauvais idée…), mais en général, les classes délicates en Java sont final, et ce n’est pas le cas de ObjectOutputStream.

Surcharger ObjectOutputStream peut nous apporter deux choses.

  • Redéfinir entièrement le mécanisme de sérialization, de façon transparente pour l’utilisateur de l’API. Cela passe par la surcharge de la méthode writeObjectOverride(). Bon courage.
  • Remplacer des instances d’une classe par d’autres dans le flux sérializé, à la manière de ce que nous avons vu lors de la présentation de la méthode writeReplace(), mais sans toucher à la classe dont on veut remplacer les instances. C’est ce point qui nous intéresse.

La surcharge du mécanisme complet est contrôlée par le security manager. S’il est actif, il faut posséder la permission SUBCLASS_IMPLEMENTATION_PERMISSION pour que la surcharge soit autorisée.

Surcharge de writeObjectOverride()

En fouinant un peu plus, on se rend compte que la méthode writeObject(), qui est final, ne prend pas un objet Serializable en paramètre, mais un Object. Elle commence par tester si le booléen enableOverride est true ou pas. Par défaut il ne l’est pas, mais s’il l’est, alors plutôt que d’appeler la mécanique standard de sérialization (qui, elle, teste si l’objet passé en paramètre implémente Serializable), la méthode writeObjectOverride() est appelée. Cette méthode ne fait rien, on doit donc la surcharger.

Voyons une version épurée de cette classe pour comprendre ce mécanisme.

public class ObjectOutputStream
extends OutputStream implements ObjectOutput, ObjectStreamConstants
{

	private final boolean enableOverride ;

	protected ObjectOutputStream() throws IOException, SecurityException {
		// vérifications de sécurité
		enableOverride = true;
	}

	public final void writeObject(Object obj) throws IOException {
		if (enableOverride) {
			writeObjectOverride(obj) ;
			return ;
		}
		// appel de la mécanique standard
	}

	protected void writeObjectOverride(Object obj) throws IOException {
	}
}

La classe complète fait environ 2500 lignes, je n’en montre ici que les parties pertinentes.

Cela signifie que l’on peut étendre cette classe, et simplement surcharger la méthode writeObjectOverride(). La seule façon de passer ce booléen à true, est d’appeler le constructeur vide de la classe, qui n’initialise pas le flux dans lequel la sérialization enregistre les octets. Il appartient donc à notre surcharge de le faire, et de redéfinir la totalité du mécanisme. Un peu fastidieux…

Surcharge de replaceObject()

Cette surcharge est à mon avis plus raisonnable à mettre en œuvre.

La méthode replaceObject() est appelée si le booléen enableReplace est à true. À la différence de enableOverride, on peut passer ce booléen à true par appel à la méthode enableReplaceObject().

Reprenons l’exemple déjà donné pour illustrer l’utilisation de la méthode writeReplace(). Nous avions ajouté une méthode dans la classe Person, de façon à écrire des objets de type StringBox dans le flux sérializé.

Supposons que l’on ne puisse pas changer la classe Person. La surcharge de ObjectOutputStream apporte une solution à notre problème.

public class MyObjectOutputStream extends ObjectOutputStream {

	public MyObjectOutputStream(OutputStream out)
	throws IOException {
		super(out) ;
		// autorise la substitution d'objets
		enableReplaceObject(true) ;
	}

	protected Object replaceObject(Object o)
	throws IOException {
		if (o instanceof Person) {
			Person p = (Person) o ;
			// on reprend le code de writeReplace()
			return new StringBox(
				p.getNom() + "@" + p.getPrenom()) ;
		}

		return o ;
	}
}

À chaque fois que la sérialization rencontre un objet qu’elle n’a pas encore sérializé, elle appellera notre méthode replaceObject(), qui pourra faire le même travail que writeReplace().

Surcharger ObjectInputStream

La surcharge de ObjectInputStream est rigoureusement symétrique à celle de ObjectOutputStream.

  • Elle permet de surcharger la totalité du mécanisme de désérialization, en passant enableOverride à true. Cela ne peut se faire qu’en appelant le constructeur vide de ObjectInputStream, ce qui impose à la classe qui l’étend de définir elle-même le flux à désérializer (ai-je déjà dit que ce point était fastidieux ?).
  • Elle permet de passer enableResolve à true. La méthode resolveObject() est alors appelée à chaque désérialization d’objet, et on peut la surcharger. On peut placer dans cette surcharge le même code que celui que l’on avait mis dans readResolve(), ce qui permet de substituer les objets en désérialization, même lorsque l’on ne peut pas ajouter de méthode readResolve() dans la classe que l’on désérialize.

Voici ce que pourrait donner la surcharge de ObjectOutputStream dans notre exemple.

public class MyObjectInputStream extends ObjectInputStream {

	public MyObjectInputStream(InputStream in) throws IOException {
		super(in) ;
		enableResolveObject(true) ;
	}

	protected Object resolveObject(Object o) throws IOException {
		if (o instanceof StringBox) {

			StringBox box = (StringBox)o ;
			String boxedValue = box.getBoxedValue() ;

			String nom = boxedValue.substring(0, boxedValue.indexOf("@")) ;
			String prenom = boxedValue.substring(boxedValue.indexOf("@") + 1) ;

			Person p = new Person(nom, prenom) ;
			return p ;
		}

		return o ;
	}
}

On se rend compte en fait que le mécanisme writeReplace() / readResolve() est toujours utilisable, que l’on puisse modifier les classes sérializées ou non.

Valider un objet reconstruit

Enfin, le mécanisme standard de sérialization donne la main à des callbacks une fois qu’un objet a été reconstruit entièrement. Un callback de validation est une implémentation de l’interface ObjectInputValidation, qui n’expose qu’une unique méhode : validateObject(). Cette méthode ne retourne rien, mais peut jeter une exception de type InvalidObjectException. Dans ce cas l’objet est réputé invalide, et une exception est générée au niveau du processus complet.

On enregistre ces callbacks auprès de l’instance d’ObjectInputStream qui réalise la désérialization, en appelant la méthode registerValidation(). Cette méthode prend également en paramètre une priorité, codée sur un entier, qui sert à choisir l’ordre dans lequel ces callbacks sont appelés. Les callbacks sont appelés dans l’ordre décroissant de cet entier.

Conclusion

Voici pour ce petit panorama sur la sérialization Java. Si l’on veut faire un bilan, on peut se dire qu’il y a trois moyens de personnaliser la façon dont nos objets sont sérializés.

  • Créer des méthodes readObject() / writeObject(). Ces méthode doivent être utilisées lorsque l’on veut ajouter des informations optionnelles dans un flux standard, ou lorsque les champs que l’on veut lire ou écrire, ont changé de nom ou de type. Ces méthodes devraient être réservées à cela : utiliser le mécanisme standard, sur des classes qui ont évolué, tout en conservant les flux sérialiés compatibles au travers de ces évolutions. L’utilisation du champ serialPersistentFields permet de faire ces choses simplement, de façon élégante, et en respectant le standard.
  • Implémenter Externalizable, à utiliser lorsque l’on ne veut transporter que des clés primaires (par exemple). C’est ce que préconise la javadoc.
  • Utiliser la substitution d’objet par readResolve() / writeReplace(), symétrique ou non. Ce mécanisme peut être introduit de deux façons. Ces méthodes sont écrites directement dans les classes à sérializer / désérializer. Cette façon de faire est la plus simple, et la moins intrusive. Effectivement, le code modificatif est strictement limité à ces classes, et ne touche pas les méthodes qui lancent la sérialization ou désérialization. Si l’on ne peut pas modifier ces objets (on imagine sans peine que ce doit être souvent le cas !), il reste possible d’incorporer ce mécanisme en étendant ObjectInputStream et ObjectOutputStream.

Références

Beaucoup d’informations se trouvent dans la Javadoc de Serializable et d’Externalizable (on devrait toujours lire la Javadoc attentivement !).

La spécification de la sérialization en Java (date de la v1.3).

5 things you didnt know about Java Object Serialization (sur le IBM Developer Network).

Citons également l’article d’Olivier Croisier, auteur de l’excellent Coder’s BreakfastSérialiser des objets non sérialisables.

5 réflexions au sujet de « Les dessous de la sérialization – 2 »

  1. Merci pour la référence, que j’ai ajoutée au dernier paragraphe (qui s’appelle justement Références, ça tombe bien !). Sérializer les objets non sérializables ? Est-ce vraiment une chose que l’on peut recommander à tous les publics ?

Les commentaires sont fermés.