Vous aimez ce que vous lisez sur ce blog ?
Envie d'aller plus loin avec véritable formation d'expertise en Java ?
Venez suivre ma formation Masterclasse Expertise Java !

"Même un développeur experimenté a besoin de continuer à apprendre. Et dans cette formation... j'ai appris beaucoup !" - A.G., Java Champion

Prochaine sessions inter-entreprises : 13-16 février 2018
Sessions intra-entreprises sur demande : contact[at]mokatech.net.
Inscrivez-vous vite !

Sérialiser des objets non sérialisables

Le mécanisme de sérialisation de Java permet de compresser un graphe d'objets sous une forme compacte, transportable hors de la JVM, puis de reconstituer ces objets en mémoire - à condition qu'ils implementent Serializable.

Dans cet article, je vous propose de découvrir et de tirer parti de certaines options méconnues pour sérialiser des objets n'implémentant pas Serializable - et sans même les modifier !

Dans la vie y'a deux types d'objets...

Y'a ceux qui sont Serializable

Supposons que l'on ait besoin de sérialiser le Pojo ci-dessous :

public class Pojo implements Serializable {
    private final String msg;
    public Pojo(String msg) {
        this.msg = msg;
    }
    public String getMsg() {
        return msg;
    }
    public String toString() {
        return "Pojo says : " + msg;
    }
}

Comme il implémente Serializable, il suffit de l'insérer dans un ObjectOutputStream pour qu'il soit automatiquement pris en charge par le mécanisme de sérialisation standard.
Inversement, un ObjectInputStream permet tout aussi facilement de le reconstituer en mémoire à partir de sa forme compressée.

La classe ci-dessous montre l'utilisation de l'API java.io pour sérialiser ou dé-sérialiser un objet sous la forme d'un tableau de bytes.

public class Serializer {
 
    public static byte[] serialize(Object o) throws IOException {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ObjectOutputStream os = new ObjectOutputStream(baos);
        os.writeObject(o);
        os.flush();
        os.close();
        return baos.toByteArray();
    }
 
    public static Object deserialize(byte[] bytes) throws IOException, ClassNotFoundException {
        ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
        ObjectInputStream ois = new ObjectInputStream(bais);
        Object o = ois.readObject();
        bais.close();
        return o;
    }
 
}
 
public class Test {
 
    public static void main(String[] args) throws Exception {
        Pojo pojo = new Pojo("Hello world"); // The original object
        byte[] pojoBytes = Serializer.serialize(pojo); // Serialize
        pojo = (Pojo) Serializer.deserialize(pojoBytes); // Deserialize
        System.out.println(pojo); // Good as new !
    }
 
}

En plus des objets unitaires, il est fréquent de sérialiser des graphes complexes. Par chance, ceux-ci sont le plus souvent composés d'objets communs du JDK implémentant déjà Serializable (String, Integer, ArrayList, etc.), ou de classes dont on possède le code source, facilement adaptables.

Et y'a les autres.

Mais ce n'est pas toujours aussi simple.
Parfois des objets plus problématiques se mettent en travers de notre chemin, provenant par exemple de librairies tierces au code fermé.

Pour l'exemple, modifions notre Pojo de façon qu'il n'implémente plus Serializable, et qu'il ne soit plus dérivable, afin de ne pas pouvoir déclarer de sous-classes sérialisables. Cette nouvelle version est visiblement plus réfractaire à la sérialisation.

public final class Pojo {
 
    private final String msg;
 
    public Pojo(String msg) {
        this.msg = msg;
    }
 
    public String getMsg() {
        return msg;
    }
 
    public String toString() {
        return "Pojo says : " + msg;
    }
}

C'est là que les problèmes commencent pour le développeur : Pojo n'étant plus sérializable, ObjectOutputStream ne peut plus prendre sa conversion en charge. Il va donc falloir trouver une solution alternative.

Par exemple, si le flux de sérialisation cible ne contient que des Pojos, il est toujours possible de développer une solution sur-mesure, par exemple en ne sérialisant que le champ msg, de type String. Mais cette technique est fastidieuse, supporte mal le refactoring, et surtout ne fonctionne plus dès lors que les Pojos font partie de graphes d'objets arbitraires.

Que faire alors ?

ObjectOutputStream à la rescousse

La solution vient de la classe ObjectOutputStream elle-même, et notamment de sa méthode replaceObject(), déclarée comme suit :

/*
 * This method will allow trusted subclasses of ObjectOutputStream to
 * substitute one object for another during serialization. 
 * Replacing objects is disabled until enableReplaceObject is called.
 * (...) 
 */
protected Object replaceObject(Object obj) throws IOException {
    return obj;
}

Détail intéressant, sa Javadoc explique également une particularité de la signature de la méthode writeObject(), qui m'avait toujours intrigué :

The ObjectOutputStream.writeObject method takes a parameter of type Object (as opposed to type Serializable) to allow for cases where non-serializable objects are replaced by serializable ones.

Développer un ObjectOutputStream personnalisé semble donc être la solution à notre problème.
Côté sérialisation, la méthode replaceObject() substituera à chaque Pojo un équivalent sérialisable - appelons-le PojoSurrogate. Côté désérialisation, il suffira de s'appuyer sur la méthode "magique" readResolve() pour reconstruire de véritables Pojos à partir des PojoSurrogates désérialisés.

public class PojoSurrogate implements Serializable {
 
    private String foo;
 
    public PojoSurrogate(Pojo pojo) {
        this.foo = pojo.getMsg();
    }   
 
    private Object readResolve() throws ObjectStreamException {
        return new Pojo(foo);
    }
 
}
 
public class SurrogateObjectOutputStream extends ObjectOutputStream {
 
    public SurrogateObjectOutputStream(OutputStream out) throws IOException {
        super(out);
        enableReplaceObject(true);
    }
 
    protected SurrogateObjectOutputStream() throws IOException, SecurityException {
        super();
        enableReplaceObject(true);
    }
 
    @Override
    protected Object replaceObject(Object obj) throws IOException {
        if (obj instanceof Pojo) {
            return new PojoSurrogate((Pojo) obj);
        } else return super.replaceObject(obj);
    }
 
}

Il suffit ensuite d'utiliser des SurrogateObjectOutputStreams à la place des simples ObjectOutputStream :

private static byte[] serialize(Object o) throws Exception {
    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    ObjectOutputStream oos = new SurrogateObjectOutputStream(baos); // Magically handle Pojos !
    oos.writeObject(o);
    oos.flush();
    oos.close();
    return baos.toByteArray();
}

Pour vérifier le bon fonctionnement du système, relançons le test de sérialisation du début avec notre nouvelle version de Pojo.

public class Test {
 
    public static void main(String[] args) throws Exception {
        Pojo pojo = new Pojo("Hello world"); // The original object, NOT SERIALIZABLE !
        byte[] pojoBytes = Serializer.serialize(pojo); // Serialize
        pojo = (Pojo) Serializer.deserialize(pojoBytes); // Deserialize
        System.out.println(pojo); // Good as new !
    }
 
}

Le message "Hello world" s'affiche comme attendu.
Nous venons de sérialiser un objet non sérialisable, sans le modifier et de manière transparente pour le mécanisme de sérialisation. Surprenant, n'est-ce pas ?

Et pour prouver que cette solution fonctionne également avec des graphes d'objets, répétons le test sur un PojoContainer possédant une référence sur un Pojo :

public class PojoContainer implements Serializable {
 
    private final Pojo pojo = new Pojo("Java");
 
    public String toString() {
        return "Container contains : " + pojo;
    }
 
}
 
public class Test {
 
    public static void main(String[] args) throws Exception {
        PojoContainer pojoContainer = new PojoContainer();
        byte[] pojoContainerBytes = Serializer.serialize(pojoContainer);
        pojoContainer = (PojoContainer) Serializer.deserialize(pojoContainerBytes);
        System.out.println(pojoContainer); // Works as expected !
    }
 
}

Industrialisation

La sous-classe d'ObjectOutputStream développée plus haut ne gère que les Pojos, ce qui limite son utilité et sa réutilisabilité. Il nous faut un système plus souple et plus générique.

Tout d'abord, l'interface marqueur Surrogate<T> permettra de lier un Surrogate à sa classe cible. Elle documentera également l'obligation d'implémenter la méthode readResolve().

/**
 * Declares a serialization surrogate for class T.
 * A Surrogate is required to implement the standard readResolve method, so
 * it can be replaced by an instance of the target class during deserialization.
 */
public interface Surrogate<T> {
}

Ensuite, des classes implémentant l'interface SurrogateFactory<T> auront pour charge de fournir, pour une instance donnée d'un objet non sérialisable, une instance correspondante du Surrogate associé.

public interface SurrogateFactory<T> {
    Class<T> getTargetClass();
    Object newSurrogate(T target);
}

Le plus simple est généralement de déclarer la SurrogateFactory comme classe interne statique de son Surrogate :

public class PojoSurrogate implements Serializable, Surrogate<Pojo> {
 
    private final String foo;
 
    public PojoSurrogate(Pojo pojo) {
        this.foo = pojo.getMsg();
    }
 
    private Object readResolve() throws ObjectStreamException {
        return new Pojo(foo);
    }
 
    public static class Factory implements SurrogateFactory<Pojo> {
        @Override
        public Class<Pojo> getTargetClass() {
            return Pojo.class;
        }
 
        @Override
        public PojoSurrogate newSurrogate(Pojo target) {
            return new PojoSurrogate(target);
        }
    }
}

Les SurrogateFactories sont ensuite rassemblées au sein d'un registre réutilisable :

public class SurrogateFactoryRegistry {
 
    private final Map<Class<?>, SurrogateFactory<?>> surrogates = new HashMap<Class<?>, SurrogateFactory<?>>();
 
    public void registerSurrogateFactory(SurrogateFactory<?> surrogate) {
        surrogates.put(surrogate.getTargetClass(), surrogate);
    }
 
    @SuppressWarnings("unchecked")
    public <T> SurrogateFactory<T> getSurrogateFactory(Class<T> targetClass) {
        return (SurrogateFactory<T>) surrogates.get(targetClass);
    }
 
}

Enfin, SurrogateObjectOutputStream s'appuiera sur ce registre afin de remplacer à la volée certains objets par leurs Surrogates :

public class SurrogateObjectOutputStream extends ObjectOutputStream {
 
    private final SurrogateFactoryRegistry registry;
 
    public SurrogateObjectOutputStream(OutputStream out, SurrogateFactoryRegistry registry) throws IOException {
        super(out);
        this.registry = registry;
        enableReplaceObject(true);
    }
 
    protected SurrogateObjectOutputStream(SurrogateFactoryRegistry registry) throws IOException, SecurityException {
        super();
        this.registry = registry;
        enableReplaceObject(true);
    }
 
    @Override
    @SuppressWarnings("unchecked")
    protected Object replaceObject(Object obj) throws IOException {
        SurrogateFactory surrogateFactory = registry.getSurrogateFactory(obj.getClass());
        if (surrogateFactory != null) {
            return surrogateFactory.newSurrogate(obj);
        } else return super.replaceObject(obj);
    }
 
}

Tout cela peut paraître bien complexe, mais tout s'éclaire lorsqu'on voit ces classes en action (la classe de test reste inchangée) :

public static byte[] serialize(Object o) throws IOException {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
 
        SurrogateFactoryRegistry registry = new SurrogateFactoryRegistry();
        registry.registerSurrogateFactory(new PojoSurrogate.Factory());
        SurrogateObjectOutputStream os = new SurrogateObjectOutputStream(baos, registry);
 
        os.writeObject(o);
        os.flush();
        os.close();
        return baos.toByteArray();
    }

Nous disposons maintenant d'un système souple et extensible !

Pour aller plus loin...

L'association entre un objet non sérialisable et son Surrogate est actuellement portée par le Surrogate ; il serait sans doute plus propre qu'elle soit portée par l'objet lui-même, par exemple sous la forme d'une annotation de classe. Ce pourrait être un excellent use-case pour ma librairie d'injection d'annotations !

Conclusion

Le cas présenté ici est certes un peu extrême, mais il démontre toute la puissance et la flexibilité du mécanisme de sérialisation de Java. Celui-ci regorge d'options permettant de contrôler le contenu et le format du flux sérialisé, même si elles restent largement méconnues.

Je ne sais pas si le mini-framework développé ici pourrait être utilisé à des fins autres qu'académiques, mais il était intéressant à développer. Son code est disponible en annexe du billet.
Quoi qu'il en soit, je vous remercie d'avoir pris le temps de lire ce (long) article, en espérant qu'il vous aura intéressé ou surpris !

Note : Si le sujet de la sérialisation vous intéresse, et notamment ses aspects de performance, sachez qu'il est traité dans la formation Java Spécialiste que j'anime. Je vous attends nombreux !


Commentaires

1. Le dimanche 8 mai 2011, 14:16 par Etienne Neveu

Merci pour ce post.

En lisant cette solution, j'ai pensé à JBoss Marshalling ( http://www.jboss.org/jbossmarshalli... ), librairie que j'ai découverte il y a peu. C'est une alternative à l'API de sérialisation classique du JDK. Compatible avec cette dernière, elle permet notamment de tuner la sérialisation, et ajoute pas mal de features, dont l'"object replacement" et l'"externalisation".

Un Externalizer permet d'externaliser une classe lors de la sérialisation:
http://docs.jboss.org/jbossmarshall...

On peut déclarer un Externalizer via l'annotation @Externalize(MonExternalizer.class), ou via une ClassExternalizerFactory:
http://docs.jboss.org/jbossmarshall...
http://docs.jboss.org/jbossmarshall...

La ClassExternalizerFactory est utile lorsque l'on n'a pas accès au code source d'une classe, et ne peut donc pas la modifier pour rajouter un @Externalize.

JBoss a créé cette librairie pour avoir de meilleures performances et plus de flexibilité, et ils l'utilisent notamment dans Infinispan (leur cache distribué) et dans JBoss AS 7 (la prochaine version de leur serveur d'application).

Il reste à voir si les avantages apportés par cette librairie (performance, flexibilité, tuning, features...) valent la peine d'utiliser une solution "non standard" ;)

2. Le mardi 10 mai 2011, 11:37 par Piwaï

Passionnant ! Bravo, c'est très bien documenté.

Je vois une autre possibilité : utiliser l'annotation processing pour générer la classe de Surrogate et la factory associée.

Hum.. hum.. ce sujet mérite d'être creusé.

3. Le mercredi 11 mai 2011, 11:17 par Praveen

Very good post! Clear and crisp explain..:)

4. Le mercredi 11 mai 2011, 23:25 par Olivier Croisier

Thank you !

5. Le lundi 29 août 2011, 21:26 par Ashok

Olivier, Its a very nice article. Thanks for that...



Can the SurrogateFactory<T> interfacte be changed to the following??

public interface SurrogateFactory<T> {

   Class<T> getTargetClass();
   Surrogate<T> newSurrogate();

}

6. Le mardi 30 août 2011, 13:15 par Olivier Croisier

You are right, it should look like this. My bad !

7. Le vendredi 20 janvier 2012, 19:52 par PhiLho

Encore un bon article.
Puisqu'Étienne mentionne une librairie, je voudrais aussi mentionner XStream qui est pas mal pour sérialiser en format XML (ou Json, ou binaire), ce qui est utile par exemple pour sauver des fichiers documents. Et contrairement à la sérialisation classique, il peut gérer des évolutions des classes (avec un effort du développeur, quand même !).

Ajouter un commentaire

Le code HTML est affiché comme du texte et les adresses web sont automatiquement transformées.