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()
etreadObject()
. En particulier, on ne peut pas accéder aux méthodeswriteDefault()
,putFields()
oureadFields()
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 deObjectInputStream
, 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éthoderesolveObject()
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 dansreadResolve()
, ce qui permet de substituer les objets en désérialization, même lorsque l’on ne peut pas ajouter de méthodereadResolve()
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 champserialPersistentFields
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 étendantObjectInputStream
etObjectOutputStream
.
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 Breakfast : Sérialiser des objets non sérialisables.
Excellent article !
Si je puis ajouter une référence :
http://thecodersbreakfast.net/index.php?post/2011/05/07/Serializing-non-serializable-objects
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 ?
Certainement pas ! C’est juste pour la beauté du geste – ou pour scandaliser les puristes 🙂
Même un an après, c’est toujours un super article 😛
Merci 😉