Qui a dit que serialVersionUID était obligatoire ?

Sérialisation et serialVersionUID

Ceux d’entre nous qui se sont déjà heurtés aux problématiques de sérialisation d’objets java, savent qu’il est important de spécifier l’attribut statique serialVersionUID sur les classes sérialisables pour permettre à deux applications de continuer à pouvoir s’échanger des données même si elles ont des versions légèrement différentes des classes en question.

En effet, lorsqu’un objet est sérialisé, Java commence à envoyer dans le flux le nom de sa classe puis son serialVersionUID. Lorsque l’objet est désérialisé depuis ce flux, Java charge la classe puis vérifie que son serialVersionUID correspond à celui qui est dans le flux. Lorsqu’il n’est pas spécifié explicitement, le serialVersionUID est calculé à partir des caractéristiques de la classe. La plupart des modifications d’une classe modifie le serialVersionUID ainsi calculé. (J’ai longtemps cru que les modifications dans l’implémentation d’une méthode n’influençait pas ce calcul. J’avais tord, certaines modifications, comme utiliser la notation .class ont des effets de bord avec certains compilateurs qui peuvent changer ce calcul)

Ainsi, je pensais jusqu’à récemment que deux versions d’une classe donnée ne pouvaient être compatibles pour la sérialisation java que si leur serialVersionUID (spécifié ou calculé) était le même. (Ce qui est facilement vérifiable avec l’utilitaire serialver du jdk)

Cette condition n’étant pas suffisante (le changement de type d’un champ fait normalement échouer la sérialisation) : j’ai écrit un petit programme pour identifier le comportement de la sérialisation sur deux versions d’une classe : (rq : ce programme charge la 1ère version de la classe dans le répertoire "../ver1/bin/" et la 2ème dans le répertoire "../ver2/bin/" ; pour moi cela correspond à deux projets Eclipse ver1 et ver2 à côté du projet contenant ce programme)

package serial; import java.io.; import java.net.; public class TestSerialization { public static void main(String[] args) throws Exception { new TestSerialization().doIt(); } private void doIt() throws Exception { ClassLoader cl1 = createClassLoader("file:../ver1/bin/"); final ClassLoader cl2 = createClassLoader("file:../ver2/bin/"); Object myBean1 = Class.forName("serial.MyBean", true, cl1).newInstance(); describe("L'objet initial en version 1 :", myBean1); System.out.println(); // Sérialisation : ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(baos); oos.writeObject(myBean1); oos.close(); baos.close(); // désérialisation en utilisant le deuxième classloader : ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray()); ObjectInputStream ois = new ObjectInputStream(bais) { @Override protected Class resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { String className = desc.getName(); return Class.forName(className, true, cl2); } }; Object myBean2 = ois.readObject(); describe("L'objet désérialisé :", myBean2); } private ClassLoader createClassLoader(String path) throws Exception { return URLClassLoader.newInstance(new URL[] { new URL(path) }); } private void describe(String ctx, Object myBean) throws Exception { Class clazz = myBean.getClass(); ClassLoader cl = clazz.getClassLoader(); ObjectStreamClass desc = ObjectStreamClass.lookup(clazz); System.out.println(ctx); subDescribe("Objet : ", myBean); subDescribe("Classe de l'objet : ", clazz); subDescribe("ClassLoader : ", cl); System.out.println("SerialVersionUID : " + desc.getSerialVersionUID()); } private void subDescribe(String desc, Object obj) { System.out.println(desc + obj + " - h : " + Integer.toHexString(System.identityHashCode(obj))); } }

Jusqu’à maintenant une condition nécessaire pour que ce programme fonctionne était que le serialVersionUID des deux versions de la classe testée soit le même. Je viens de découvrir une exception ….

Sérialisation des objets XMLBeans

En étudiant l’impact d’une modification de xsd sur les classes Java générés par XMLBeans, j’ai découvert que les instances sérialisées d’une de ces classes peuvent être désérialisées par une version différente de la classe (si on ajoute un champ dans le type xsd par exemple) alors que leur serialVersionUID était différent…

L’explication se trouve dans la classe mère des classes générés : XMLObjectBase. Cette classe implémente la méthode writeReplace(). Lorsque cette méthode est présente, Java l’appelle au début du mécanisme de sérialisation pour récupérer un objet alternatif qui sera sérialisé dans le flux de sortie à la place de l’objet original. La méthode readResolve permet d’effectuer le même type de remplacement lors de la désérialisation. Associées aux méthodes de customisation du flux : writeObject et readObject ; ces méthodes permettent à XMLBeans de sérialiser ses objets directement sous la forme de chaînes de caractères des documents XML. Ces quatre méthodes sont décrites dans la javadoc de l’interface Serializable.

Un exemple de remplacement

Pour illustrer ce mécanisme, voici deux versions d’une classe nommée serial.MyBean. Malgré le fait qu’elles n’ont pas le même serialVersionUID, les instances sérialisées de la première peuvent être désérialisées dans un environnement ne connaissant que la deuxième version :

Version 1 :

package serial; import java.io.*; public class MyBean implements Serializable { int aInt = 5; // valeur par défaut uniquement dans la version 1 public Object writeReplace() { return new MyBeanReplacement(this); } public static class MyBeanReplacement implements Serializable { private static final long serialVersionUID = 1; transient MyBean myBean; public MyBeanReplacement(MyBean myBean) { this.myBean = myBean; } private void writeObject(ObjectOutputStream out) throws IOException { out.writeInt(myBean.aInt); } } @Override public String toString() { return "MyBean Version 1 [aInt:" + aInt + "]"; } }

Version 2 :

package serial; import java.io.*; public class MyBean implements Serializable { int aInt; // pas de valeur par défaut int anotherInt; // le nouveau champ modifie le serialVersionUID calculé public static class MyBeanReplacement implements Serializable { private static final long serialVersionUID = 1; transient MyBean myBean; private void readObject(ObjectInputStream in) throws IOException { int i = in.readInt(); this.myBean = new MyBean(); this.myBean.aInt = i; } public Object readResolve() throws ObjectStreamException { return myBean; } } @Override public String toString() { return "MyBean Version 2 [aInt:" + aInt + "]"; } }

L’exécution du programme de vérification ci-dessus fonctionne correctement avec ces deux versions de la classe en montrant bien que le serialVersionUID est différent :

L'objet initial en version 1 : Objet : MyBean Version 1 [aInt:5] - h : 1d5550d Classe de l'objet : class serial.MyBean - h : 1729854 ClassLoader : java.net.FactoryURLClassLoader@1172e08 - h : 1172e08 SerialVersionUID : -8350262324928340381 L'objet désérialisé : Objet : MyBean Version 2 [aInt:5] - h : 95fd19 Classe de l'objet : class serial.MyBean - h : 1975b59 ClassLoader : java.net.FactoryURLClassLoader@540408 - h : 540408 SerialVersionUID : -3858086206760552720

On notera que l’objet de remplacement lui a bien besoin d’avoir un serialVersionUID identique dans les deux versions.

Conclusion

Ce billet a juste pour but d’illustrer un cas de customisation peu connu des mécanismes de sérialisation java. Les cas d’utilisation sont toutefois très rares. La présence du champ serialVersionUID sur les classes java amenées à être sérialisées reste la meilleure pratique à adopter dans la plupart des cas. Il est toutefois intéressant de savoir que certains frameworks utilisent le pouvoir de Java pour faire mentir la phrase "Deux versions d’une classe ne peuvent pas être compatibles si leur serialVersionUID est différent" …

Author image
Fort de plus de 20 ans d'expérience autour de l'écosystème JavaEE, Fabien accompagne ses clients dans la conception et le cadrage de leurs projets.