6. Serialization d'objets

6.1. Enjeu de la sérialization d'objets

6.1.1. Fonctionnement de la sérialization

Dans le jargon des développeurs Java, sérializer un objet consiste à le convertir en un tableau d'octets, que l'on peut ensuite écrire dans un fichier, envoyer sur un réseau au travers d'une socket etc... Ce mécanisme existe depuis les débuts de l'API Java I/O, et il est très pratique. Il suffit de passer tout objet qui implémente l'interface Serializable à une instance de ObjectOutputStream pour sérialiser un objet. Si cet objet ne comporte pas de champ trop exotique, comme des connexions à des bases de données, des fichiers ou des threads, cette sérialization se déroulera sans problème. L'interface Serializable n'expose aucune méthode, implémenter cette interface consiste donc juste à déclarer cette implémentation. Des problèmes peuvent se poser pour les objets qui possèdent des champs eux-mêmes non sérializables. C'est le cas pour les trois champs que nous venons de donner en exemple. Dans ce cas, ces champs doivent être marqués avec le mot-clé transient. Cela a pour effet de les retirer du flux sérializé. Après désérialization, ces champs seront à null.

Exemple 125. Champ transient

// création d'une classe Serializable
 public  class Marin  implements Serializable {

    // la classe String est Serializable, donc ces champs sont légaux
    private String nom, prenom ;
   
   
    // en revanche la classe Connection ne l'est pas, 
    // il faut donc retirer ce champ de la serialization
    private  transient Connection con ;
}

Pour des raisons d'optimisation, ne se trouve dans le flux sérialisé, que le minimum d'information requis pour reconstruire l'objet. Typiquement, on y trouve donc :
  • le nom complet de la classe de l'objet ;
  • les noms de ses champs, et pour chacun de ces champs, son type et sa valeur.

6.1.2. Notion de serialVersionUID

Ces informations sont a priori suffisantes pour reconstruire l'objet. Cela dit, il faut tout de même comprendre que la reconstruction d'un objet à partir de ces octets peut se faire dans un contexte très différent de celui de la création de ces octets. Cela peut se faire longtemps après, pour reconstituer des objets sauvegardés dans des fichiers ou dans une base de données, ou encore dans une JVM différente, dans le cas d'objets transmis au travers d'un réseau. Dans ces deux cas, il est nécessaire de vérifier que la classe que l'on possède est bien la même que celle qui a servi à la création de ces octets. Vérifier son nom complet n'est pas suffisant, elle doit définir les mêmes champs, de même nom et de même type. Pour cela, Java introduit un code de hachage associé aux classes qui implémentent Serializable, stocké dans un champ standard. Ce champ standard s'appelle serialVersionUID, doit être de type long et doit être private static final. Ce champ est systématiquement enregistré dans tout paquet d'octets qui représente un objet sérializé. S'il a été défini explicitement dans la classe de cet objet, c'est cette valeur fournie qui est utilisée. S'il ne l'a pas été, alors la machine Java en détermine un, en fonction des éléments publics et non statiques de la classe. Toute modification de la classe entraîne une modification de cette valeur par défaut. Lorsque l'on a une classe qui implémente Serializable, il peut donc être important d'imposer une valeur pour ce champ, de façon à ne pas perturber la désérialization des objets. On pourra remarquer qu'Eclipse propose de générer ce champ automatiquement, en utilisant le même algorithme que celui utilisé en interne par la JVM.

6.2. Serialization d'un objet

La sérialization d'un objet consiste à passer un objet à la méthode writeObject(Object) de la classe ObjectOutputStream. Prenons par exemple la classe Marin suivante.

Exemple 126. Classe Marin sérializable

public  class Marin  implements Serializable {

    private  static  final  long serialVersionUID =  1350092881346723535L;

    private String nom, prenom ;

    private  int salaire ;

    public Marin(String nom, String prenom) {
       this.nom = nom ;
       this.prenom = prenom ;
   }

    public String toString() {
      StringBuffer sb =  new StringBuffer() ;
       return sb.append(nom).append(" ").append(prenom).toString() ;
   }
}

La valeur du serialVersionUID de cet exemple a été générée avec Eclipse. En fait, dès qu'une classe implémente Serializable, Eclipse émet une alerte si l'on ne donne pas de valeur explicite à ce nombre. Le code simplifié de la sérialization est le suivant.

Exemple 127. Sérialization d'un objet

// dans une méthode main
 // on simplifie le code en retirant la gestion des exceptions
File fichier =  new File("tmp/marin.ser") ;

 // ouverture d'un flux sur un fichier
ObjectOutputStream oos =  new ObjectOutputStream(new FileOutputStream(fichier)) ;
		
 // création d'un objet à sérializer
Marin m =  new Marin("Surcouf",  "Robert") ;

 // sérialization de l'objet
oos.writeObject(m) ;

 // fermeture du flux dans le bloc finally

Si l'on ouvre le fichier tmp/marin.ser, on peut lire dedans les informations suivantes en clair :
  • le nom complet de la classe ;
  • la liste des champs sérializés, avec leur type et leur valeur.
On notera qu'il est donc facile de recréer un objet Java à partir d'un flux sérializé, même quand on ne possède pas la classe qui a servi à créer cet objet. Il suffit de lire le flux, de créer une classe de même nom, avec les bonnes caractéristiques. En aucun cas le flux sérializé d'un objet Java ne doit être considéré comme une donnée sécurisée ! La désérialization suit le processus inverse.

Exemple 128. Désérialization d'un objet

// dans une méthode main
 // on simplifie le code en retirant la gestion des exceptions
File fichier =  new File("tmp/marin.ser") ;

 // ouverture d'un flux sur un fichier
ObjectInputStream ois =  new ObjectInputStream(new FileInputStream(fichier)) ;
		
 // désérialization de l'objet
Marin m = (Marin)ois.readObject() ;
System.out.println(m) ;

 // fermeture du flux dans le bloc finally

Si jamais notre classe a changé, ou que du moins son serialVersionUID a changé, alors l'exception java.io.InvalidClassException sera jetée. Dans certains cas applicatifs, le mécanisme standard de sérialization proposé par Java peut se révéler inadapté, ou tout simplement ne pas convenir. Il est possible de le surcharger de trois manières.

6.3. Sérialization d'une grappe d'objets

Si l'objet que l'on sérialize a d'autres objets en relation, alors ces objets sont eux aussi sérializés, et placés dans le flux, sauf s'ils sont déclarés transient. Cela suppose qu'ils implémentent eux-mêmes Serializable, sans quoi des exceptions seront jetées. La machine Java utilise un mécanisme particulier pour éviter de sérializer plusieurs fois un même objet en relation de plusieurs autres objets. Chaque objet est associé à un numéro de série (d'où l'appellation de "sérialization"), qui est placé dans la relation, et n'est sérializé qu'une seule fois. Lors de la déserialization, chaque objet est désérializé une unique fois, et vient remplacer ce numéro de série.

6.4. Première surcharge : méthode writeObject() readObject()

La première manière est de créer deux méthodes dans la classe que l'on souhaite sérializer.
  • private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {} : cette méthode est appelée pour reconstituer l'objet à partir d'un flux sérializé. Elle doit avoir exactement cette signature, et être privée.
  • private void writeObject(ObjectOutptStream oos) throws IOException {} : cette méthode est appelée pour écrire l'objet sur un flux sérializé. Elle doit avoir exactement cette signature, et être privée.
Lorsque la machine Java constate qu'une classe Serializable comporte ces deux méthodes, alors elle les appelle plutôt que d'utiliser ses mécanismes internes de sérialization. La méthode writeObject() a la responsabilité d'écrire les champs de l'objet sur le flux sérializé passé en paramètre. On peut choisir de n'écrire qu'une partie des champs, cela ne pose pas de problème. La méthode readObject() a la responsabilité de restaurer les valeurs des champs de l'objet. Son processus de lecture doit correspondre au processus d'écriture utilisé par la méthode writeObject(). Si une partie des champs n'a pas été écrite par la méthode d'écriture, alors la méthode readObject() peut restaurer ces valeurs à partir d'informations externes. Il est important de noter que ces deux méthodes fonctionnent en tandem, et doivent donc être compatibles l'une avec l'autre.

Exemple 129. Utilisation de readObject() et writeObject()

public  class Marin  implements Serializable {

    private String nom, prenom ;
    private  int salaire ;
   
    // suivent les getters / setters
   
    // méthode readObject, utilisée pour reconstituer un objet sérializé
    private  void readObject(ObjectInputStream ois)
    throws IOException, ClassNotFoundException {

       // l'ordre de lecture doit être le même que l'ordre d'écriture d'un objet
       this.nom = ois.readUTF() ;
       this.prenom = ois.readUTF() ;
       // le salaire n'est pas relu, vu qu'il n'a pas été écrit
   }

    // méthode writeObject, utilisée lors de la sérialization
    private  void writeObject(ObjectOutputStream oos)
    throws IOException {

       // écriture de toute ou partie des champs d'un objet
      oos.writeUTF(nom) ;
      oos.writeUTF(prenom) ;
       // on choisit de ne pas écrire le salaire, qui ne fait
       // pas partie de l'état d'une instance de marin
   }
}

6.5. Deuxième surcharge : utilisation d'un externalizer

L'utilisation d'un externalizer fonctionne différemment. La classe que l'on veut sérializer, dans ce cas, doit implémenter Externalizable plutôt que Serializable, et doit obligatoirement posséder un constructeur vide, explicite ou par défaut. L'interface Externalizable impose deux méthodes.
  • public void writeExternal(ObjectOutput out) throws IOException {} est appelée pour écrire l'objet sur le flux sérializé passé en paramètre.
  • public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {} est appelée pour relire cet objet.
Formellement, cette technique ressemble donc à l'écriture d'un objet en utilisant la première surcharge, celle qui consiste créer deux méthodes readObject() et writeObject(). Dans la pratique, le flux sérializé généré est assez différent, et souvent beaucoup plus court (moins d'octets) qu'un flux sérializé standard. Il faut noter qu'il est en plus beaucoup plus rapide à écrire, car la machine Java n'a pas besoin d'explorer la classe à sérializer par introspection, comme c'est le cas dans le mécanisme standard. On prendra garde toutefois au fait que cette technique est moins sécurisée que la sérialization standard. Effectivement, la méthode readExternal() est ici publique, alors que readObject() est privée. N'importe quel code peut donc l'appeler, sans aucun contrôle. Or, cette méthode permet de modifier l'état interne de l'objet, ce qui peut être indésirable, notamment si l'on souhaite que la classe soit immutable .

6.6. Troisième surcharge : utilisation d'un objet proxy

Cette dernière façon de faire consiste à écrire un objet à la place de l'objet à sérializer, si possible plus simple. La classe à sérializer doit alors comporter une méthode privée writeReplace() qui retourne cet objet. C'est cet objet que l'on appelle objet proxy . Cet objet doit exposer une méthode readResolve(), qui a la charge de reconstruire l'objet originel. Construisons une classe MarinProxy, qui enregistre les champs nom et prenom dans une chaîne de caractère unique.

Exemple 130. Sérialization par objet proxy : classe proxy

// classe sérializée à la place de Marin
 // elle doit être sérializable
 public  class MarinProxy  implements Serializable {

    // un seul champ à sérializer
    private String data ;

    // constructeur utilitaire pour construire un proxy
    // sur un marin
    public MarinProxy(Marin marin) {
   
       // l'unique champ concatène le nom et le prénom d'un marin
       this.data = marin.getNom() +  ":" + marin.getPrenom() ;
   }

    // méthode appelée pour la désérialization
    // lorsque cette méthode est appelée, le champ data a été désérializé
    private Object readResolve()  throws ObjectStreamException {

      StringTokenizer st =  new StringTokenizer(this.data,  ":") ;
      String nom = st.nextToken() ;
      String prenom = st.nextToken() ;

      Marin m =  new Marin(nom, prenom) ;
       return m ;
   }
}

Cette classe proxy reconstruit une instance de Marin en reconstituant son nom et son prenom en interne. Elle aurait pu aussi passer la chaîne de caractères data directement à un constructeur particulier de Marin. Voyons maintenant la classe Marin.

Exemple 131. Sérialization par objet proxy : classe à sérializer

public  class Marin  implements Serializable { 

    private String nom, prenom ;
    private  int salaire ;
   
    public Marin(String nom, String prenom) {
       this.nom = nom ;
       this.prenom = prenom ;
   }

    // suivent les getters et setters
   
    // méthode standard appelée par la sérialization
    private Object writeReplace()  throws ObjectStreamException {

       // cette méthode retourne l'objet proxy, qui va être
       // sérializé à la place de ce marin
       return  new MarinProxy(this) ;
   }
}

Cette méthode fait partie de la boite à outil utilisable pour la sérialization et la désérialization d'objets. Elle peut se révéler très utile lorsque l'on a des objets sérializés dans des fichiers ou des bases de données, qu'il faut désérializer vers des modèles objet qui ne correspondent plus au modèle qui avait servi à la sérialization. En d'autres termes, elle peut servir de pont entre un modèle qui n'est plus utilisé, mais dont on possède encore des objets sauvegardés, et un nouveau modèle, évolution du premier.
Java langage & API
Retour au blog Java le soir
Cours & Tutoriaux
Table des matières
Introduction : un peu d'histoire
1. Java : genèse d'un nouveau langage
Programmer en Java
1. Un premier exemple
1.1. Aperçu général, cycle de vie
1.2. Un premier programme
1.3. Programmes, applets, servlets, etc...
2. Une première classe
2.1. Écriture d'une classe
2.2. Instanciation de cette classe
3. Types de base, classes et objets
3.1. Types de base
3.2. Classes et objets
Classes Object et String
1. Introduction
2. La classe Object
2.1. La méthode toString()
2.2. La méthode clone()
2.3. La méthode equals()
2.4. La méthode hashCode()
2.5. La méthode finalize()
2.6. La méthode getClass()
3. La classe String
3.1. Introduction
3.2. Construction d'un objet de type String
3.3. Concaténation, StringBuffer et StringBuilder
3.4. Concaténations de chaînes de caractères depuis Java 8
3.5. Concaténations de chaînes de caractères depuis Java 11
3.6. Extraction d'une sous-chaîne de caractères
3.7. Comparaison de deux chaînes de caractères
3.8. Méthodes de comparaisons lexicographiques
3.9. Méthode de recherche de caractères
3.10. Méthode de modification de chaîne
3.11. Méthode de duplication
3.12. Support de l'unicode, internationalisation
Structure d'une classe
1. Introduction
2. Classes
2.1. Classes publiques
2.2. Classes internes
2.3. Classe membre
2.4. Classes locales
2.5. Classes anonymes
2.6. Le mot-clé this
3. Éléments statiques
3.1. Champ statique
3.2. Cas des constantes
3.3. Bloc statique
3.4. Classe membre statique
4. Membres d'une classe, visibilité
4.1. Bloc non statique
4.2. Accès à un membre, visibilité
4.3. Les champs
4.4. Signature d'une méthode
4.5. Les méthodes
4.6. Getters et Setters
5. Constructeur, instanciation
5.1. Chargement d'une classe
5.2. Constructeurs d'une classe
5.3. Instanciation d'un objet
5.4. Destruction d'objets
5.5. Le mot-clé final
6. Énumérations
6.1. Déclaration d'une énumération
6.2. Classe énumération
6.3. Méthode toString()
6.4. Méthode valueOf()
6.5. Méthode values()
6.6. Méthode ordinal()
6.7. Méthode compareTo()
6.8. Constructeurs privés
6.9. Classe utilitaire : EnumSet
Noms, opérateurs, tableaux
1. Introduction
2. Identificateurs, noms et expressions
2.1. Identificateurs et noms
2.2. Expressions
3. Opérateurs, ordre d'exécution
3.1. Ordre d'exécution
3.2. Les opérateurs
3.3. Les opérateurs ++ et --
3.4. Les opérateurs % et /
3.5. Les opérateurs <<, >> et >>>
3.6. L'opérateur instanceof
3.7. Les opérateurs &, | et ^
3.8. Les opérateurs && et ||
3.9. L'opérateur ? ... :
3.10. Les opérateurs d'affectation
4. Tableaux
4.1. Création d'un tableau
4.2. Initialisation d'un tableau
4.3. Utilisation d'un tableau comme un Object
4.4. Tableaux de tableaux
4.5. Copie de tableaux
5. Blocs, boucles et contrôles
5.1. Blocs
5.2. Mots-clés réservés
5.3. Tests : if et switch
5.4. Boucles : for, while, do ... while
5.5. Commandes continue et break
5.6. Commandes return et goto
Nombres, précision, calculs
1. Introduction
2. Calculs
2.1. Précision
2.2. Codage des nombres flottants
2.3. Le mot-clé strictfp
2.4. Conversion de types
3. Dépassements de capacité
3.1. Cas des entiers
3.2. Cas des flottants
3.3. Bibliothèques BigInteger et BigDecimal
4. Fonctions mathématiques
4.1. Fonctions usuelles
4.2. Générateurs aléatoires
5. Classes enveloppe
5.1. Associer les types de base à des objets
5.2. Auto-boxing
Héritage, abstraction, interfaces
1. Introduction
2. Abstraction et encapsulation
2.1. Abstraction
2.2. Encapsulation
3. Héritage
3.1. Définition de l'héritage
3.2. Conséquences pour les membres
3.3. Polymorphisme
3.4. Empêcher l'héritage
4. Classes abstraites
5. Interfaces
5.1. Introduction
5.2. Définition
5.3. Java 8 et les interfaces
5.4. Utilisation des interfaces
5.5. Définition de constantes dans les interfaces
5.6. Utilité des interfaces
Packages
1. Introduction
2. Notion de paquet
2.1. Déclaration d’appartenance à un paquet
2.2. Chargement d’une classe
2.3. Choix de nom
3. Archives, chemin de recherche, classpath
3.1. Archives
3.2. Variable CLASSPATH
3.3. Notion de classloader
3.4. Bilan sur les classes chargées
3.5. Visibilité
3.6. Conseils d'écriture, bibliothèque standard
Exceptions
1. Introduction
2. Erreurs et Exceptions
2.1. Classe Throwable, notion de stack trace
2.2. Classe Error
2.3. Classe RuntimException
2.4. Classe Exception
3. Déclenchement d'une exception
3.1. Exceptions déclenchées par la JVM
3.2. Exceptions déclenchées par l'application
4. Capter une exception
4.1. Traiter une exception localement
4.2. Code de captage
5. Créer ses propres exceptions
Entrées / sorties
1. Introduction
2. Notion de fichier
2.1. Introduction
2.2. La classe File
2.3. Construction d'une instance de File
2.4. Méthodes exposées
3. Flux de sortie
3.1. Introduction, notion de flux
3.2. Écriture de caractères, classe Writer
3.3. Bufferisation, construction d'un flux sur un autre
3.4. Utilisation de PrintWriter
3.5. Écriture d'octets, OuputStream
3.6. Écriture de types primitifs : DataOutputStream
3.7. Écriture d'objets : ObjectOutputStream
4. Flux d'entrée
4.1. Introduction
4.2. Lecture de caractères, classe Reader
4.3. Bufferisation, lecture ligne par ligne
4.4. Lecture d'octets : InputStream
4.5. Lecture de types primitifs : DataInputStream
4.6. Lecture d'objets : ObjectInputStream
5. Lecture et écriture de flux croisés
5.1. Introduction
5.2. Lire des caractères sur un flux binaire : classe InputStreamReader
5.3. Écrire des caractères sur un flux binaire : classe OutputStreamWriter
5.4. Remarque sur les jeux de caractères
6. Serialization d'objets
6.1. Enjeu de la sérialization d'objets
6.2. Serialization d'un objet
6.3. Sérialization d'une grappe d'objets
6.4. Première surcharge : méthode writeObject() readObject()
6.5. Deuxième surcharge : utilisation d'un externalizer
6.6. Troisième surcharge : utilisation d'un objet proxy
7. Flux compressés
7.1. Introduction
7.2. Flux de type gzip
7.3. Flux de type zip