C'est dans le contrat! Versions d'objets pour JavaBeans

Au cours des deux derniers mois, nous avons approfondi la question de la sérialisation des objets en Java. (Voir "Sérialisation et spécification JavaBeans" et "Faites-le à la manière` Nescafé '- avec des JavaBeans lyophilisés. ") L'article de ce mois suppose que vous avez déjà lu ces articles ou que vous comprenez les sujets qu'ils couvrent. Vous devez comprendre ce qu'est la sérialisation, comment utiliser l' Serializableinterface et comment utiliser les classes java.io.ObjectOutputStreamet java.io.ObjectInputStream.

Pourquoi vous avez besoin du contrôle de version

Ce que fait un ordinateur est déterminé par son logiciel, et le logiciel est extrêmement facile à modifier. Cette flexibilité, généralement considérée comme un atout, a son passif. Parfois, il semble que le logiciel est trop facile à changer. Vous avez sans aucun doute rencontré au moins l'une des situations suivantes:

  • Un fichier de document que vous avez reçu par e-mail ne sera pas lu correctement dans votre traitement de texte, car le vôtre est une version plus ancienne avec un format de fichier incompatible

  • Une page Web fonctionne différemment sur différents navigateurs car différentes versions de navigateur prennent en charge différents ensembles de fonctionnalités

  • Une application ne s'exécute pas car vous avez la mauvaise version d'une bibliothèque particulière

  • Votre C ++ ne se compilera pas car l'en-tête et les fichiers source sont de versions incompatibles

Toutes ces situations sont causées par des versions incompatibles du logiciel et / ou des données que le logiciel manipule. Tout comme les bâtiments, les philosophies personnelles et les lits de rivières, les programmes changent constamment en réponse aux conditions changeantes qui les entourent. (Si vous ne pensez pas que les bâtiments changent, lisez le livre exceptionnel How Buildings Learn de Stewart Brand , une discussion sur la façon dont les structures se transforment au fil du temps. Voir Ressources pour plus d'informations.) Sans structure pour contrôler et gérer ce changement, tout système logiciel quel qu'il soit la taille utile dégénère finalement en chaos. L'objectif de la gestion des versions de logiciel est de garantir que la version du logiciel que vous utilisez actuellement produit des résultats corrects lorsqu'elle rencontre des données produites par d'autres versions d'elle-même.

Ce mois-ci, nous allons discuter du fonctionnement de la gestion des versions de classe Java, afin de pouvoir fournir le contrôle de version de nos JavaBeans. La structure de gestion des versions des classes Java vous permet d'indiquer au mécanisme de sérialisation si un flux de données particulier (c'est-à-dire un objet sérialisé) est lisible par une version particulière d'une classe Java. Nous parlerons des modifications «compatibles» et «incompatibles» des classes, et pourquoi ces modifications affectent le contrôle de version. Nous passerons en revue les objectifs de la structure de gestion des versions et la manière dont le package java.io atteint ces objectifs. Et nous apprendrons à mettre des sauvegardes dans notre code pour nous assurer que lorsque nous lisons des flux d'objets de différentes versions, les données sont toujours cohérentes après la lecture de l'objet.

Aversion de version

Il existe différents types de problèmes de version dans les logiciels, qui concernent tous la compatibilité entre des morceaux de données et / ou du code exécutable:

  • Des versions différentes du même logiciel peuvent ou non être en mesure de gérer les formats de stockage de données les uns des autres

  • Les programmes qui chargent du code exécutable au moment de l'exécution doivent être en mesure d'identifier la version correcte de l'objet logiciel, de la bibliothèque chargeable ou du fichier objet pour effectuer le travail

  • Les méthodes et les champs d'une classe doivent conserver la même signification au fur et à mesure que la classe évolue, ou les programmes existants peuvent se briser aux endroits où ces méthodes et ces champs sont utilisés

  • Le code source, les fichiers d'en-tête, la documentation et les scripts de construction doivent tous être coordonnés dans un environnement de construction de logiciel pour garantir que les fichiers binaires sont créés à partir des versions correctes des fichiers source

Cet article sur la gestion des versions d'objets Java ne traite que des trois premiers, c'est-à-dire le contrôle de version des objets binaires et leur sémantique dans un environnement d'exécution. (Il existe une vaste gamme de logiciels disponibles pour la gestion des versions du code source, mais nous ne couvrons pas cela ici.)

Il est important de se rappeler que les flux d'objets Java sérialisés ne contiennent pas de bytecodes. Ils ne contiennent que les informations nécessaires pour reconstruire un objet en supposant que vous disposez des fichiers de classe disponibles pour construire l'objet. Mais que se passe-t-il si les fichiers de classe des deux machines virtuelles Java (JVM) (l'écrivain et le lecteur) sont de versions différentes? Comment savons-nous s'ils sont compatibles?

Une définition de classe peut être considérée comme un «contrat» entre la classe et le code qui appelle la classe. Ce contrat comprend l' API de la classe (interface de programmation d'application). Changer l'API équivaut à changer le contrat. (D'autres modifications apportées à une classe peuvent également impliquer des modifications du contrat, comme nous le verrons.) Au fur et à mesure qu'une classe évolue, il est important de maintenir le comportement des versions précédentes de la classe afin de ne pas casser le logiciel dans des endroits qui dépendaient de comportement donné.

Un exemple de changement de version

Imaginez que vous ayez une méthode appelée getItemCount()dans une classe, ce qui signifie obtenir le nombre total d'éléments que cet objet contient , et que cette méthode a été utilisée dans une douzaine d'endroits dans votre système. Imaginez ensuite que vous changez getItemCount()pour signifier obtenir le nombre maximum d'éléments que cet objet a jamais contenus. Votre logiciel se cassera très probablement dans la plupart des endroits où cette méthode a été utilisée, car soudainement la méthode rapportera des informations différentes. Essentiellement, vous avez rompu le contrat; il est donc utile que votre programme contienne maintenant des bogues.

Il n'y a aucun moyen, à part interdire complètement les changements, d'automatiser complètement la détection de ce type de changement, car cela se produit au niveau de ce que signifie un programme , pas simplement au niveau de la façon dont ce sens est exprimé. (Si vous pensez à un moyen de le faire facilement et généralement, vous allez être plus riche que Bill.) Donc, en l'absence de solution complète, générale et automatisée à ce problème, que pouvons- nous faire pour éviter entrer dans l'eau chaude lorsque nous changeons de cours (ce que nous devons bien sûr)?

La réponse la plus simple à cette question est de dire que si une classe change du tout , elle ne devrait pas être «fiable» pour maintenir le contrat. Après tout, un programmeur peut avoir fait quelque chose à la classe, et qui sait si la classe fonctionne toujours comme annoncé? Cela résout le problème de la gestion des versions, mais c'est une solution peu pratique car elle est beaucoup trop restrictive. Si la classe est modifiée pour améliorer les performances, par exemple, il n'y a aucune raison d'interdire l'utilisation de la nouvelle version de la classe simplement parce qu'elle ne correspond pas à l'ancienne. Un certain nombre de modifications peuvent être apportées à une classe sans rompre le contrat.

En revanche, certaines modifications des classes garantissent pratiquement que le contrat est rompu: suppression d'un champ, par exemple. Si vous supprimez un champ d'une classe, vous pourrez toujours lire les flux écrits par les versions précédentes, car le lecteur peut toujours ignorer la valeur de ce champ. Mais pensez à ce qui se passe lorsque vous écrivez un flux destiné à être lu par les versions précédentes de la classe. La valeur de ce champ sera absente du flux et la version plus ancienne attribuera une valeur par défaut (peut-être logiquement incohérente) à ce champ lorsqu'elle lit le flux. Voilà! : Vous avez une classe cassée.

Modifications compatibles et incompatibles

L'astuce pour gérer la compatibilité des versions d'objet est d'identifier quels types de changements peuvent provoquer des incompatibilités entre les versions et ceux qui ne le feront pas, et de traiter ces cas différemment. Dans le langage Java, les changements qui ne causent pas de problèmes de compatibilité sont appelés changements compatibles ; ceux que l'on peut appeler des changements incompatibles .

Les concepteurs du mécanisme de sérialisation pour Java avaient les objectifs suivants à l'esprit lorsqu'ils ont créé le système:

  1. Pour définir un moyen par lequel une version plus récente d'une classe peut lire et écrire des flux qu'une version précédente de la classe peut également «comprendre» et utiliser correctement

  2. To provide a default mechanism that serializes objects with good performance and reasonable size. This is the serialization mechanism we've already discussed in the two previous JavaBeans columns mentioned at the beginning of this article

  3. To minimize versioning-related work on classes that don't need versioning. Ideally, versioning information need only be added to a class when new versions are added

  4. To format the object stream so that objects can be skipped without loading the object's class file. This capability allows a client object to traverse an object stream containing objects it doesn't understand

Let's see how the serialization mechanism addresses these goals in light of the situation outlined above.

Reconcilable differences

Some changes made to a class file can be depended on not to change the contract between the class and whatever other classes may call it. As noted above, these are called compatible changes in the Java documentation. Any number of compatible changes may be made to a class file without changing the contract. In other words, two versions of a class that differ only by compatible changes are compatible classes: The newer version will continue to read and write object streams that are compatible with previous versions.

The classes java.io.ObjectInputStream and java.io.ObjectOutputStream don't trust you. They are designed to be, by default, extremely suspicious of any changes to a class file's interface to the world -- meaning, anything visible to any other class that may use the class: the signatures of public methods and interfaces and the types and modifiers of public fields. They're so paranoid, in fact, that you can scarcely change anything about a class without causing java.io.ObjectInputStream to refuse to load a stream written by a previous version of your class.

Let's look at an example. of a class incompatibility, and then solve the resulting problem. Say you've got an object called InventoryItem, which maintains part numbers and the quantity of that particular part available in a warehouse. A simple form of that object as a JavaBean might look something like this:

001 002 import java.beans.*; 003 import java.io.*; 004 import Printable; 005 006 // 007 // Version 1: simply store quantity on hand and part number 008 // 009 010 public class InventoryItem implements Serializable, Printable { 011 012 013 014 015 016 // fields 017 protected int iQuantityOnHand_; 018 protected String sPartNo_; 019 020 public InventoryItem() 021 { 022 iQuantityOnHand_ = -1; 023 sPartNo_ = ""; 024 } 025 026 public InventoryItem(String _sPartNo, int _iQuantityOnHand) 027 { 028 setQuantityOnHand(_iQuantityOnHand); 029 setPartNo(_sPartNo); 030 } 031 032 public int getQuantityOnHand() 033 { 034 return iQuantityOnHand_; 035 } 036 037 public void setQuantityOnHand(int _iQuantityOnHand) 038 { 039 iQuantityOnHand_ = _iQuantityOnHand; 040 } 041 042 public String getPartNo() 043 { 044 return sPartNo_; 045 } 046 047 public void setPartNo(String _sPartNo) 048 { 049 sPartNo_ = _sPartNo; 050 } 051 052 // ... implements printable 053 public void print() 054 { 055 System.out.println("Part: " + getPartNo() + "\nQuantity on hand: " + 056 getQuantityOnHand() + "\n\n"); 057 } 058 }; 059 

(We also have a simple main program, called Demo8a, which reads and writes InventoryItems to and from a file using object streams, and interface Printable, which InventoryItem implements and Demo8a uses to print the objects. You can find the source for these here.) Running the demo program produces reasonable, if unexciting, results:

C:\beans>java Demo8a w file SA0091-001 33 Wrote object: Part: SA0091-001 Quantity on hand: 33 C:\beans>java Demo8a r file Read object: Part: SA0091-001 Quantity on hand: 33 

The program serializes and deserializes the object correctly. Now, let's make a tiny change to the class file. The system users have done an inventory and have found discrepancies between the database and the actual item counts. They've requested the ability to track the number of items lost from the warehouse. Let's add a single public field to InventoryItem that indicates the number of items missing from the storeroom. We insert the following line into the InventoryItem class and recompile:

016 // fields 017 protected int iQuantityOnHand_; 018 protected String sPartNo_; 019 public int iQuantityLost_; 

The file compiles fine, but look at what happens when we try to read the stream from the previous version:

C:\mj-java\Column8>java Demo8a r file IO Exception: InventoryItem; Local class not compatible java.io.InvalidClassException: InventoryItem; Local class not compatible at java.io.ObjectStreamClass.setClass(ObjectStreamClass.java:219) at java.io.ObjectInputStream.inputClassDescriptor(ObjectInputStream.java:639) at java.io.ObjectInputStream.readObject(ObjectInputStream.java:276) at java.io.ObjectInputStream.inputObject(ObjectInputStream.java:820) at java.io.ObjectInputStream.readObject(ObjectInputStream.java:284) at Demo8a.main(Demo8a.java:56) 

Whoa, dude! What happened?

java.io.ObjectInputStreamn'écrit pas d'objets de classe lorsqu'il crée un flux d'octets représentant un objet. Au lieu de cela, il écrit un java.io.ObjectStreamClass, qui est une description de la classe. Le chargeur de classe de la JVM de destination utilise cette description pour rechercher et charger les bytecodes de la classe. Il crée et inclut également un entier 64 bits appelé SerialVersionUID , qui est une sorte de clé qui identifie de manière unique une version de fichier de classe.

Le SerialVersionUIDest créé en calculant un hachage sécurisé 64 bits des informations suivantes sur la classe. Le mécanisme de sérialisation veut être capable de détecter les changements dans l'une des choses suivantes: