Comment fonctionne la sérialisation en Java?

Tout au long de ce livre, nous avons géré notre modèle de données en utilisant des classes, il est donc logique que nous souhaitions sauvegarder ces objets entre les exécutions du programme. Les données concernant la santé des animaux de notre zoo ne seraient pas particulièrement utiles si elles devaient être saisies chaque fois que le programme s’exécute!

Vous pouvez certainement utiliser les classes I/O stream que vous avez apprises jusqu’à présent pour stocker des données textuelles et binaires, mais vous devez toujours déterminer comment placer les données dans l’I/O stream, puis les décoder par la suite. Il existe différents formats de fichiers comme XML et CSV que vous pouvez standardiser, mais vous devez souvent construire la traduction vous-même.

Alternativement, nous pouvons utiliser la sérialisation pour résoudre le problème de conversion des objets vers/depuis un I/O stream. La sérialisation est le processus de conversion d’un objet en mémoire en flux d’octets. De même, la désérialisation est le processus de conversion d’un flux d’octets en objet. La sérialisation implique souvent l’écriture d’un objet dans un format stockable ou transmissible, tandis que la désérialisation est le processus réciproque.

La Figure 14.6 montre une représentation visuelle de la sérialisation et de la désérialisation d’un objet Girafe vers et depuis un fichier girafe.txt.

Dans cette section, nous vous montrons comment Java fournit des mécanismes intégrés pour sérialiser et désérialiser des I/O streams d’objets directement vers et depuis le disque, respectivement.

Application de l’Interface Serializable

Pour sérialiser un objet à l’aide de l’API I/O, l’objet doit implémenter l’interface java.io.Serializable. L’interface Serializable est une interface marqueur, ce qui signifie qu’elle n’a pas de méthodes. N’importe quelle classe peut implémenter l’interface Serializable puisqu’il n’y a pas de méthodes requises à implémenter.

Puisque Serializable est une interface marqueur sans membres abstraits, pourquoi ne pas l’appliquer à chaque classe? En général, vous ne devriez marquer comme sérialisables que les classes orientées données. Les classes orientées processus, comme les I/O streams discutés dans ce chapitre ou les instances Thread que vous avez apprises dans le Chapitre 13, sont souvent de mauvais candidats pour la sérialisation, car l’état interne de ces classes tend à être éphémère ou de courte durée.

Le but d’utiliser l’interface Serializable est d’informer tout processus tentant de sérialiser l’objet que vous avez pris les mesures appropriées pour rendre l’objet sérialisable. Toutes les primitives Java et beaucoup de classes Java intégrées avec lesquelles vous avez travaillé tout au long de ce livre sont Serializable. Par exemple, cette classe peut être sérialisée:

import java.io.Serializable;
public class Gorille implements Serializable {
    private static final long serialVersionUID = 1L;
    private String nom;
    private int age;
    private Boolean amical;
    private transient String nourritureFavorite;
    // Constructeurs/Getters/Setters/toString() omis
}

Dans cet exemple, la classe Gorille contient trois membres d’instance (nom, age, amical) qui seront sauvegardés dans un I/O stream si la classe est sérialisée. Notez que puisque Serializable ne fait pas partie du package java.lang, il doit être importé ou référencé avec le nom du package.

Qu’en est-il du champ nourritureFavorite qui est marqué transient? Tout champ marqué transient ne sera pas sauvegardé dans un I/O stream lorsque la classe est sérialisée. Nous en discutons plus en détail ci-après.

Maintenir un serialVersionUID

C’est une bonne pratique de déclarer une variable static serialVersionUID dans chaque classe qui implémente Serializable. La version est stockée avec chaque objet dans le cadre de la sérialisation. Ensuite, chaque fois que la structure de la classe change, cette valeur est mise à jour ou incrémentée.

Peut-être que notre classe Gorille reçoit un nouveau membre d’instance Double banane, ou peut-être que le champ age est renommé. L’idée est qu’une classe pourrait avoir été sérialisée avec une version plus ancienne de la classe et désérialisée avec une version plus récente de la classe.

Le serialVersionUID aide à informer la JVM que les données stockées peuvent ne pas correspondre à la nouvelle définition de classe. Si une version plus ancienne de la classe est rencontrée lors de la désérialisation, une java.io.InvalidClassException peut être lancée. Alternativement, certaines API prennent en charge la conversion de données entre les versions.

Marquer des Données transient

Le modificateur transient peut être utilisé pour les données sensibles de la classe, comme un mot de passe. Il existe d’autres objets qu’il n’est pas logique de sérialiser, comme l’état d’un Thread en mémoire. Si l’objet fait partie d’un objet sérialisable, nous le marquons simplement transient pour ignorer ces membres d’instance sélectionnés.

Que se passe-t-il avec les données marquées transient lors de la désérialisation? Elles reviennent à leurs valeurs Java par défaut, comme 0.0 pour double, ou null pour un objet. Vous verrez des exemples de cela prochainement lorsque nous présenterons les classes d’object stream.

Marquer des champs static comme transient a peu d’effet sur la sérialisation. Mis à part le serialVersionUID, seuls les membres d’instance d’une classe sont sérialisés.

Assurer qu’une Classe Est Serializable

Puisque Serializable est une interface marqueur, vous pourriez penser qu’il n’y a pas de règles à suivre. Pas tout à fait! Tout processus tentant de sérialiser un objet lancera une NotSerializableException si la classe n’implémente pas correctement l’interface Serializable.

Comment Rendre une Classe Serializable

  • La classe doit être marquée Serializable.
  • Chaque membre d’instance de la classe doit être sérialisable, marqué transient, ou avoir une valeur null au moment de la sérialisation.

Soyez prudent avec la deuxième règle. Pour qu’une classe soit sérialisable, nous devons appliquer la deuxième règle de manière récursive. Voyez-vous pourquoi la classe Chat suivante n’est pas sérialisable?

public class Chat implements Serializable {
    private Queue queue = new Queue();
}

public class Queue implements Serializable {
    private Fourrure fourrure = new Fourrure();
}

public class Fourrure {}

Chat contient une instance de Queue, et les deux classes sont marquées Serializable, donc pas de problèmes là. Malheureusement, Queue contient une instance de Fourrure qui n’est pas marquée Serializable.

L’une des modifications suivantes corrige le problème et permet à Chat d’être sérialisé:

public class Queue implements Serializable {
    private transient Fourrure fourrure = new Fourrure();
}

public class Fourrure implements Serializable {}

Nous pourrions également faire en sorte que nos membres d’instance queue ou fourrure soient null, bien que cela rendrait Chat sérialisable uniquement pour des instances particulières, plutôt que pour toutes les instances.

Sérialisation des Records

Pensez-vous que ce record est sérialisable?

record Enregistrement(String nom) {}

Il n’est pas sérialisable car il n’implémente pas Serializable. Un record suit les mêmes règles que les autres types de classes en ce qui concerne sa capacité à être sérialisé. Par conséquent, celui-ci peut l’être:

record Enregistrement(String nom) implements Serializable {}

Stockage de Données avec ObjectOutputStream et ObjectInputStream

La classe ObjectInputStream est utilisée pour désérialiser un objet, tandis que ObjectOutputStream est utilisée pour sérialiser un objet. Ce sont des streams de haut niveau qui opèrent sur des I/O streams existants. Bien que ces deux classes contiennent un certain nombre de méthodes pour les types de données intégrés comme les primitives, les deux méthodes que vous devez connaître sont celles liées au travail avec des objets.

// ObjectInputStream
public Object readObject() throws IOException, ClassNotFoundException

// ObjectOutputStream
public void writeObject(Object obj) throws IOException

Notez les paramètres, les types de retour et les exceptions lancées. Nous fournissons maintenant une méthode exemple qui sérialise une Liste d’objets Gorille dans un fichier:

void sauvegarderDansFichier(List gorilles, File fichierDonnees)
    throws IOException {
    try (var out = new ObjectOutputStream(
        new BufferedOutputStream(
            new FileOutputStream(fichierDonnees)))) {
        for (Gorille gorille : gorilles)
            out.writeObject(gorille);
    }
}

Assez facile, n’est-ce pas? Notez que nous commençons par un file stream, l’enveloppons dans un I/O stream bufferisé pour améliorer les performances, puis l’enveloppons avec un object stream. Sérialiser les données est aussi simple que de les passer à writeObject().

Une fois les données stockées dans un fichier, nous pouvons les désérialiser en utilisant la méthode suivante:

List lireDuFichier(File fichierDonnees) throws IOException,
    ClassNotFoundException {
    var gorilles = new ArrayList();
    try (var in = new ObjectInputStream(
        new BufferedInputStream(
            new FileInputStream(fichierDonnees)))) {
        while (true) {
            var objet = in.readObject();
            if (objet instanceof Gorille g)
                gorilles.add(g);
        }
    } catch (EOFException e) {
        // Fin du fichier atteinte
    }
    return gorilles;
}

Ah, pas aussi simple que notre méthode de sauvegarde, n’est-ce pas? Lorsque l’on appelle readObject(), null et -1 n’ont pas de signification particulière, car quelqu’un pourrait avoir sérialisé des objets avec ces valeurs. Contrairement à nos techniques précédentes pour lire des méthodes à partir d’un input stream, nous devons utiliser une boucle infinie pour traiter les données, qui lance une EOFException lorsque la fin de l’I/O stream est atteinte.

Si votre programme connaît le nombre d’objets dans l’I/O stream, vous pouvez appeler readObject() un nombre fixe de fois, plutôt que d’utiliser une boucle infinie.

Puisque le type de retour de readObject() est Object, nous devons vérifier le type avant d’obtenir l’accès à nos propriétés Gorille. Notez que readObject() déclare une exception vérifiée ClassNotFoundException puisque la classe pourrait ne pas être disponible lors de la désérialisation.

L’extrait de code suivant montre comment appeler les méthodes de sérialisation:

var gorilles = new ArrayList();
gorilles.add(new Gorille("Michel", 5, false));
gorilles.add(new Gorille("Tanguy", 8, true));
File fichierDonnees = new File("gorille.data");

sauvegarderDansFichier(gorilles, fichierDonnees);
var gorillesDuDisque = lireDuFichier(fichierDonnees);
System.out.print(gorillesDuDisque);

En supposant que la méthode toString() a été correctement surchargée dans la classe Gorille, cela imprime ce qui suit au moment de l’exécution:

[[nom=Michel, age=5, amical=false],
[nom=Tanguy, age=8, amical=true]]

ObjectInputStream hérite d’une méthode available() de InputStream que vous pourriez penser pouvoir utiliser pour vérifier la fin de l’I/O stream plutôt que de lancer une EOFException. Malheureusement, cela vous indique seulement le nombre de blocs qui peuvent être lus sans bloquer un autre thread. En d’autres termes, il peut renvoyer 0 même s’il y a plus d’octets à lire.

Comprendre le Processus de Création lors de la Désérialisation

Il est important de comprendre comment un objet désérialisé est créé. Lorsque vous désérialisez un objet, le constructeur de la classe sérialisée, ainsi que tous les initialisateurs d’instance, ne sont pas appelés lorsque l’objet est créé. Java appellera le constructeur sans argument de la première classe parente non sérialisable qu’il peut trouver dans la hiérarchie des classes. Dans notre exemple Gorille, ce serait simplement le constructeur sans argument de Object.

Comme nous l’avons indiqué précédemment, tous les champs static ou transient sont ignorés. Les valeurs qui ne sont pas fournies recevront leur valeur Java par défaut, comme null pour String, ou 0 pour les valeurs int.

Examinons une nouvelle classe Chimpanze. Cette fois, nous listons les constructeurs pour illustrer qu’aucun d’entre eux n’est utilisé lors de la désérialisation.

import java.io.Serializable;
public class Chimpanze implements Serializable {
    private static final long serialVersionUID = 2L;
    private transient String nom;
    private transient int age = 10;
    private static char type = 'C';
    { this.age = 14; }
    
    public Chimpanze() {
        this.nom = "Inconnu";
        this.age = 12;
        this.type = 'Q';
    }
    
    public Chimpanze(String nom, int age, char type) {
        this.nom = nom;
        this.age = age;
        this.type = type;
    }
    // Getters/Setters/toString() omis
}

Supposons que nous réécrivions nos méthodes précédentes de sérialisation et de désérialisation pour traiter un objet Chimpanze au lieu d’un objet Gorille. Que pensez-vous que ce qui suit imprime?

var chimpanzes = new ArrayList();
chimpanzes.add(new Chimpanze());
chimpanzes.add(new Chimpanze("Pierre", 2, 'A'));
chimpanzes.add(new Chimpanze("Jules", 4, 'B'));
File fichierDonnees = new File("chimpanze.data");

sauvegarderDansFichier(chimpanzes, fichierDonnees);
var chimpanzesDuDisque = lireDuFichier(fichierDonnees);
System.out.println(chimpanzesDuDisque);

Réfléchissez-y. Allez-y, nous allons attendre.

Prêt pour la réponse? Pour commencer, aucun des membres d’instance n’est sérialisé dans un fichier. Les variables nom et age sont toutes deux marquées transient, tandis que la variable type est static. Nous avons délibérément accédé à la variable type en utilisant this pour voir si vous étiez attentif.

Lors de la désérialisation, aucun des constructeurs de Chimpanze n’est appelé. Même le constructeur sans argument qui définit les valeurs [nom=Inconnu,age=12,type=Q] est ignoré. L’initialisateur d’instance qui définit age à 14 n’est également pas exécuté.

Dans ce cas, la variable nom est initialisée à null puisque c’est la valeur par défaut pour String en Java. De même, la variable age est initialisée à 0. Le programme imprime ce qui suit, en supposant que la méthode toString() est implémentée:

[[nom=null,age=0,type=B],
[nom=null,age=0,type=B]]

Qu’en est-il de la variable type? Puisqu’elle est static, elle affichera la dernière valeur définie. Si les données sont sérialisées et désérialisées dans la même exécution, elle affichera B, puisque c’était le dernier Chimpanze que nous avons créé. D’autre part, si le programme effectue la désérialisation et l’impression au démarrage, il imprimera C, puisque c’est la valeur avec laquelle la classe est initialisée.

Assurez-vous de comprendre que le constructeur et toutes les initialisations d’instance définies dans la classe sérialisée sont ignorés pendant le processus de désérialisation. Java appelle uniquement le constructeur de la première classe parente non sérialisable dans la hiérarchie des classes.

Enfin, ajoutons une sous-classe:

public class BebeChimpanze extends Chimpanze {
    private static final long serialVersionUID = 3L;
    private String mere = "Maman";
    
    public BebeChimpanze() { super(); }
    
    public BebeChimpanze(String nom, char type) {
        super(nom, 0, type);
    }
    // Getters/Setters/toString() omis
}

Notez que cette sous-classe est sérialisable parce que la superclasse a implémenté Serializable. Nous avons maintenant une variable d’instance supplémentaire. Le code pour sérialiser et désérialiser reste le même. Nous pouvons même toujours convertir en Chimpanze car c’est une sous-classe.