Les dessous de la sérialization – 1

La sérialization Java fait partie de ces mécanismes présents dès les premières versions du langage. Elle n’a subi qu’une seule modification majeure, depuis longtemps assumée par tout le monde. Elle fait également partie de ces mécanismes que l’on utilise sans même y penser, qui ont tendance à « marcher tous seuls », sans que l’on ait à s’en préoccuper. Il y a bien ce warning qu’Eclipse ne manque pas de nous coller lorsque l’on crée une classe sérializable sans déclarer de champ serialVersionUID. Mais finalement, il suffit de le créer et de lui coller une valeur au hasard pour que notre IDE préféré (quoique…) soit satisfait. Même plus besoin de connaître exactement le rôle de ce champ… Et quand bien même on ne le déclare pas, Eclipse continue de couiner, mais sans conséquence apparente sur le fonctionnement de notre application.

La sérialization Java permet de résoudre la plupart des problème qu’elle traite, de façon transparente, et sans avoir à connaître son fonctionnement interne. Finalement, n’est-ce pas ça la définition d’une API efficace ?

Le petit voyage auquel je t’invite à présent, précieux lecteur, va précisément nous mener dans les bas-fonds de cette API, de son fonctionnement interne, et des possibilités qu’elle offre, quasiment jamais utilisées. Un voyage inutile ? Peut-être, mais pour le savoir, il faut l’avoir fait !

Pourquoi sérializer des objets ?

On peut commencer par se poser cette question. Si la réponse est « je n’ai pas besoin de sérializer des objets », alors plus besoin de se casser la tête à lire des articles, et encore moins à les écrire !

La sérialization est au cœur de tous les mécanismes d’enregistrement et de relecture d’objets, ainsi que de transmission de ces objets au travers de réseaux. Dès qu’une application a besoin de transmettre ses objets à une autre application, qui peut d’ailleurs être un module d’elle-même, elle fait appel à un mécanisme de sérialization. La sérialization Java n’est pas la seule que l’on puisse utiliser, il est aussi classique de sérializer des objets en XML. L’API Java fournit d’ailleurs deux classes, XMLEncoder et XMLDecoder, qui permettent de sérializer directement des objets au format XML. Je ne me rappelle pas avoir jamais vu cette approche utilisée en pratique.

On rencontre parfois le terme linéariser pour traduire sérializer, que le lecteur précieux et néanmoins attentif n’aura pas manqué de qualifier d’ignoble anglicisme™. Je n’aime pas ce terme (ce qui a le mérite d’être clair !). Même s’il présente l’intérêt d’être français, je trouve que son utilisation est impropre. Il m’est arrivé de linéariser pas mal de choses dans ma vie, mais des objets Java, jamais.

Deux termes se rencontrent fréquemment dans l’orbite de la sérialization : le marshalling et le démarshalling (traduction partielle de unmarshalling). Marshaller un objet consiste à le sérializer.

Aperçu de la problématique

La problématique de la sérialization porte sur trois points :

  • garantir l’enregistrement de l’état de l’objet ;
  • garantir la bonne reconstruction de cet état ;
  • garantir l’intégrité de cet état.

Les objets que l’on a envie de transmettre, sont en général les objets qui possèdent un état. S’ils n’en ont pas, l’intérêt de les transmettre doit être remis en cause (à mon avis). L’état d’un objet est simplement l’ensemble des valeurs d’une partie de ses champs, non statiques. Enregistrer une partie de l’état d’un objet dans un champ statique est une mauvaise idée™. Je me demande même (en fait pas tant que ça…) si elle a un sens !

L’API de sérialization fonctionne avec un minimum de réglages. Mais elle permet aussi de redéfinir complètement la façon dont Java transforme nos objet en tableaux d’octets, voyons ces mécanismes.

Mise en œuvre de la sérialization

L’utilisation de cette API est bien connue de tous. L’écriture d’un objet passe par l’appel à la méthode writeObject() de la classe ObjectOutputStream.

ObjectOutputStream oos = new ObjectOutputStream(...) ;
MySmartBusinessObject o = ... ; // mon bel objet métier
oos.writeObject(o) ;

Comme je sais que tu aimes bien gérer les exceptions toi-même, je te laisse, précieux lecteur, le soin de rendre ce code illisible en le saturant de try ... catch() dans tous les sens. Ce qui lui permettra au passage, de se faire compiler comme il se doit !

La relecture d’un objet n’est pas beaucoup plus complexe.

ObjectInputStream ois = new ObjectInputStream(...) ;
MySmartBusinessObject o = (MySmartBusinessObject)ois.readObject() ;

La seule contrainte imposée par le langage est d’une simplicité désespérante : il suffit que la classe de l’objet que l’on veut sérializer implémente l’interface Serializable. Il se trouve que cette interface ne définit aucune méthode, cette implémentation est donc une simple déclaration, qui n’engage à rien. Enfin presque.

Quels champs sérializer ?

Là les choses commencent à se compliquer un peu. Par défaut, la sérialization prend en compte la totalité des champs d’un objet, les champs de types primitifs, et les références vers d’autres objets. Si l’un de ces objets n’est pas serializable (d’une façon où d’une autre), le mécanisme génère une ignoble NotSerializableException, et s’interrompt. De nombreuses classes du JDK ne sont pas sérializables, tout simplement parce que cela n’aurait pas de sens, ou poserait des problèmes de sécurité. C’est par exemple le cas des classes Connection (de l’API JDBC) ou Thread.

Il nous faut donc un moyen de désigner, parmi les champs d’un objet, lesquels seront sérializés, et lesquels ne le seront pas. L’API nous donne deux solutions :

  • désigner les champs qui ne seront pas sérializés, en les marquant transient ;
  • désigner les champs qui seront sérializés, en les déclarant dans une variable spéciale : serialPersistentFields.

Voici un exemple de champ transient dans un code jouet.

public class A implements Serializable {

	// ce champ est sérializé
	private String s ;

	// ce champ ne l'est pas
	private transient B b ;

	// reste de la classe
}

Voyons maintenant un exemple de déclaration de ce champ spécial, privé et statique, nommé serialPersistentFields. Il s’agit d’un tableau d’instances de classe ObjectStreamField. Lorsque ce champ est déclaré, alors seuls les champs spécifiés dans ce tableau sont pris en compte. Nous verrons dans la suite qu’utiliser ce champ est un des moyens simples de garantir la pérennité des fichiers sérializés. Voici un exemple de déclaration de la variable serialPersistentFields.

public class A implements Serializable {

	private static final ObjectStreamField[] serialPersistentFields =
		{new ObjectStreamField("s", String.class)} ;

	// ce champ est sérializé
	private String s ;

	// ce champ ne l'est pas
	private B b ;

	// reste de la classe
}

On peut tout de suite noter que ce champ doit être privé, statique, final, et qu’il doit porter ce nom.

Structure d’un flux sérializé

Un flux sérializé est, en première approche, un tableau d’octets. Sa structure est parfaitement spécifiée par l’API. Il serait long et fastidieux d’en faire ici le tableau complet, je vais donc nous épargner cette épreuve, précieux lecteur. Simplement, il est à mon avis important de comprendre que toutes les informations des champs et instances enregistrés dans ce flux le sont en clair, sans aucune forme de cryptage. À partir de ce simple tableau, il est possible de construire une structure de classes compatibles, qui permettrait à un tiers de reconstituer les informations enregistrées. Croire qu’un flux sérializé est une boite noire dans laquelle les données sont écrites dans un format impossible à inverser est une erreur.

Version d’un flux sérializé

Dans sa version proposée par l’API Java, tout flux sérializé comporte deux informations capitales :

  • le nom complet de la classe dont l’objet sérializé dans ce flux est une instance ;
  • la valeur du champ serialVersionUID de cette classe, lue au calculées au moment de la sérialization.

Lors de la désérialization d’un flux, l’une des premières étapes consiste à chercher la classe de l’objet à désérializer. Si cette classe n’est pas trouvée, une exception de type ClassNotFoundException est générée. Si le serialVersionUID lu dans le flux n’est pas le même que celui de la classe que la JVM possède (qu’il soit lu dans la classe ou calculé), alors une exception de type InvalidClassException est générée. Ce nombre serialVersionUID est défini dans les spécifications de la sérialization de façon très précise. Il s’agit d’un code de hachage de la classe considérée, passé à la moulinette SHA-1.

Java nous offre la possibilité de donner la valeur de ce nombre explicitement dans une variable statique et de type long nommée serialVersionUID. Si cette variable existe, alors elle est utilisée, même si sa valeur n’est pas celle que la JVM aurait calculée (en fait ce point n’est pas vérifié). La plupart des IDE permettent de générer ce champ automatiquement avec la bonne valeur.

Si cette valeur n’est pas fournie, alors elle est calculée à la première demande de la JVM, et cachée. Cette façon de faire est fortement déconseillée, pour deux raisons.

  • Cela pose éventuellement un problème de performance, puisque le calcul est coûteux.
  • Cela peut surtout poser un problème de compatibilité. Si la classe sérializée comporte des classes internes éventuellement anonymes, les serialVersionUID générées peuvent varier suivant les compilateurs, ce qui rendrait incompatibles les flux sérializés et leurs désérializeurs. Ce second problème est autrement plus important que le problème de performance observé à la première sérialization d’un objet d’une classe donnée.

Modifier une classe entraîne le plus souvent une modification de son serialVersionUID. Dans ce cas, cette classe ne pourra pas être utilisée pour relire des flux sérializés avec son ancienne version. Cela dit, ces modifications de serialVersionUID ne signifient par nécessairement que les flux sérializés ne sont plus compatibles. La discrimination que la JVM s’impose en utilisant le serialVersionUID est en fait trop discriminante.

Compatibilité des flux sérializés

Le premier cas pathologique que l’on rencontre dans un cycle de sérialization / désérialization, est celui dans lequel la classe de l’objet sérializé, bien qu’elle porte le même nom, n’est pas exactement la même que celle utilisée pour la désérialization. Ce cas est simple à imaginer : cette classe a subi une évolution, la JVM qui sérialize porte une certaine version de cette classe, et celle qui désérialize une autre version.

L’API définit deux types de modifications.

  • Les modifications compatibles : la désérialization pourra se faire sans problème.
  • Les modifications incompatibles, qui vont empêcher la désérialization.

Bien sûr, ces deux versions de classe doivent avoir même valeur de serialVersionUID, qui, dans ce cas, doit donc être écrite dans la classe.

Modifications compatibles

Les principales modifications compatibles, c’est-à-dire qui autoriseront la relecture d’un flux sérializé par une classe modifiée, sont :

  • L’ajout de champs, le passage d’un champ statique à un champ non-statique, le passage d’un champ transient à un champ non-transient. Ces champs n’étant pas définis dans le flux sérializé, ils seront initialisés avec leurs valeurs par défaut.
  • L’ajout de classes dans la hiérarchie de la classe dont l’objet sérializé est une instance. Là encore, comme aucune information n’est disponible dans le flux sérializé, les champs de cette nouvelle classe auront leurs valeurs par défaut.
  • L’effacement de classes dans la hiérarchie de la classe dont l’objet sérializé est une instance. Ici le traitement des champs de la classe éliminée varie en fonction du champ. Les champs de type primitif ne sont pas relus. En revanche, les éventuels objets en relation par ces champs sont recréés, dans la mesure où ils peuvent être mis en relation d’autres objets plus loin dans le flux sérializé.
  • Le changement de visibilité d’un champ : public, protected, private.

Modifications incompatibles

Parmi les modifications incompatibles, notons :

  • L’effacement de champs, le changement d’un champ non-statique en champ statique, le changement d’un champ non-transient en transient.
  • Le changement du type d’un champ primitif.

Sérializer soi-même

Rien de tel que le travail fait à la main ! Java offre trois possibilités pour redéfinir partiellement ou complètement le mécanisme de sérialization :

  • créer une méthode writeObject() et readObject() dans la classe qui implémente Serializable ;
  • implémenter Externalizable plutôt que Serializable, et créer les méthodes writeExternal() et readExternal() définies par cette interface ;
  • créer une méthode writeReplace() pour écrire un objet proxy dans le flux sérializé.

Voyons ces trois méthodes en détails.

Utilisation de writeObject() / readObject()

En écriture

Une instance d’une classe qui implémente Serializable est sérializée par appel à la méthode defaultWriteObject() de ObjectOutputStream. Si une méthode est présente, dont la signature est très précisément la suivante :

private void writeObject(ObjectOutputStream oos)
throws IOException { ... }

alors cette méthode est appelée. Elle a pour responsabilité d’écrire l’état de l’objet courant, à l’exclusion des éléments définis dans ses super-classes.

En lecture

De même, une instance d’une classe qui implémente Serializable est désérializée par appel à la méthode defaultReadObject() de ObjectInputStream. Si une méthode est présente, dont la signature est très précisément la suivante :

private void readObject(ObjectInputStream ois)
throws IOException, ClassNotFoundException { ... }

alors cette méthode est appelée, et elle a pour responsabilité de reconstruire l’état de l’objet courant, à l’exclusion des éléments définis dans ses super-classes.

Fonctionnement

Ces deux méthodes peuvent elles-mêmes appeler le comportement par défaut (méthode defaultWriteObject() ou defaultReadObject()), ce qui est intéressant lorsque l’on veut juste ajouter des éléments optionnels dans le flux standard. Le plus souvent, elles sont utilisées pour écrire les champs de l’objet dans un format personnalisé, en utilisant les méthodes standard de l’instance d’ObjectOutputStream passée en paramètre. C’est le pattern que l’on rencontre le plus souvent. On peut toutefois mieux faire.

On peut ajouter (ou lire) des champs manuellement dans le flux, en utilisant tout de même le mécanisme standard de sérialization. Le mécanisme de sérialization connaît les champs à sérializer, soit parce qu’on lui en a donné la liste explicitement via le champ serialPersistentFields, soit parce qu’il les a découverts par introspection. On peut donc lui demander d’écrire (ou de relire) ces champs de façon standard, en les nommant.

public class StringBox implements Serializable {

	private String value ;

	// litanie des getters & setters

	private void writeObject(ObjectOutputStream oos)
	throws IOException {
		oos.putFields().put("value", value) ;
		oos.writeFields() ;
	}

	private void readObject(ObjectInputStream ois)
	throws IOException, ClassNotFoundException {
		this.value = (String)ois.readFields().get("value", null) ;
	}
}

L’utilisation de ces deux méthodes indique qu’il faut écrire dans le flux sérializé un champ qui sera nommé value dans le flux, et dont la valeur est donnée par le champ value. Ici le nom du champ dans le flux, et le nom du champ dans la classe doivent se correspondre, car les champs de la classe sont déterminés par introspection.

Si l’on déclare les champs présents dans le flux sérializé en utilisant le champ serialPersistentFields, alors on lève cette restriction.

public class StringBox implements Serializable {

	private static final ObjectStreamField[] serialPersistentFields =
		{new ObjectStreamField("boxed", String.class)} ;

	private String value ;

	// litanie des getters & setters

	private void writeObject(ObjectOutputStream oos)
	throws IOException {
		oos.putFields().put("boxed", value) ;
		oos.writeFields() ;
	}

	private void readObject(ObjectInputStream ois)
	throws IOException, ClassNotFoundException {
		this.value = (String)ois.readFields().get("boxed", null) ;
	}
}

Ici les champs à sérializer sont décrits dans le tableau serialPersistentFields. Les noms des champs dans le flux sont donc ceux présents dans ce tableau, et leurs valeurs celles qu’on leur donne dans la méthode writeObject().

Voyons comment ce mécanisme peut être utilisé pour relire des flux d’une classe qui a évolué.

Notre première classe StringBox est construite sur une instance de String, et est sérializable. On a pris soin de fixer la valeur de son serialVersionUID, ce que l’on devrait toujours faire (le compilateur ne nous donne pas que des mauvais conseils !).

public class StringBox implements Serializable { // version 1

	// doit être static final long
	// peut être private, protected ou public
	private static final long serialVersionUID = 5101293455258542475L;

	private String value ;

	// litanie des getters et setters
}

Puis cette classe évolue : plutôt que de la construire sur une instance de String, on préfère utiliser un StringBuffer (ou un StringBuilder). Pour pouvoir lire et écrire des flux compatibles avec la première version il suffit de :

  • conserver la valeur du serialVersionUID ;
  • définir explicitement les champs présents dans le flux, dans le tableau serialPersistentFields ;
  • comme ces champs ne sont plus présents dans la classe, il faut les créer et les relire dans le flux explicitement dans les méthodes writeObject() et readObject() respectivement.
public class StringBox implements Serializable { // version 2

	private static final long serialVersionUID = 5101293455258542475L;

	// fixé par la version 1 des flux
	private static final ObjectStreamField[] serialPersistentFields =
		{new ObjectStreamField("value", String.class)} ;

	private StringBuffer boxedValue ;

	// litanie des getters et setters

	private void writeObject(ObjectOutputStream oos)
	throws IOException {
		oos.putFields().put("value", boxedValue.toString()) ;
		oos.writeFields() ;
	}

	private void readObject(ObjectInputStream ois)
	throws IOException, ClassNotFoundException {
		this.boxedValue =
			new StringBuffer((String)ois.readFields().get("value", null)) ;
	}
}

On notera que le nom du champ value doit correspondre au nom du champ de la classe en version 1.

Ce pattern constitue une première façon simple de rendre un flux sérializé raisonnablement indépendant des instances que l’on sérialize. Ce n’est pas la seule, mais c’est à mon avis la plus élégante.

Conclusion provisoire

Je présenterai le reste de l’API dans le deuxième partie de cet article, et notamment :

  • l’interface Externalizable ;
  • les méthodes writeReplace() et readResolve() ;
  • la surcharge des classes ObjectOutputStream et ObjectInputStream.

Bonne lecture, précieux lecteur !