È nel contratto! Versioni degli oggetti per JavaBeans

Negli ultimi due mesi, abbiamo approfondito come serializzare gli oggetti in Java. (Vedere "Serializzazione e specifica JavaBeans" e "Fallo alla maniera di` Nescafé '- con JavaBeans liofilizzati "). L'articolo di questo mese presume che abbiate già letto questi articoli o che comprendiate gli argomenti trattati. Dovresti capire cos'è la serializzazione, come usare l' Serializableinterfaccia e come usare le classi java.io.ObjectOutputStreame java.io.ObjectInputStream.

Perché hai bisogno del controllo delle versioni

Ciò che fa un computer è determinato dal suo software e il software è estremamente facile da modificare. Questa flessibilità, generalmente considerata un bene, ha i suoi passivi. A volte sembra che il software sia troppo facile da cambiare. Senza dubbio ti sei imbattuto in almeno una delle seguenti situazioni:

  • Un file di documento che hai ricevuto via e-mail non verrà letto correttamente nel tuo elaboratore di testi, perché la tua è una versione precedente con un formato di file incompatibile

  • Una pagina Web funziona in modo diverso su browser diversi perché versioni di browser diverse supportano set di funzionalità differenti

  • Un'applicazione non verrà eseguita perché hai la versione sbagliata di una particolare libreria

  • Il tuo C ++ non verrà compilato perché i file di intestazione e di origine sono di versioni incompatibili

Tutte queste situazioni sono causate da versioni incompatibili del software e / o dai dati manipolati dal software. Come gli edifici, le filosofie personali e i letti dei fiumi, i programmi cambiano costantemente in risposta alle mutevoli condizioni che li circondano. (Se non pensi che gli edifici cambino, leggi l'eccezionale libro di Stewart Brand How Buildings Learn , una discussione su come le strutture si trasformano nel tempo. Vedi Risorse per maggiori informazioni.) Senza una struttura per controllare e gestire questo cambiamento, qualsiasi sistema software di qualsiasi la dimensione utile alla fine degenera nel caos. L'obiettivo nel controllo delle versioni del software è garantire che la versione del software attualmente in uso produca risultati corretti quando incontra dati prodotti da altre versioni di se stesso.

Questo mese parleremo di come funziona il controllo delle versioni delle classi Java, in modo da poter fornire il controllo della versione dei nostri JavaBeans. La struttura di controllo delle versioni per le classi Java consente di indicare al meccanismo di serializzazione se un particolare flusso di dati (cioè un oggetto serializzato) è leggibile da una particolare versione di una classe Java. Parleremo di modifiche "compatibili" e "incompatibili" alle classi e del motivo per cui queste modifiche influenzano il controllo delle versioni. Esamineremo gli obiettivi della struttura di controllo delle versioni e come il pacchetto java.io soddisfi tali obiettivi. E impareremo a mettere misure di sicurezza nel nostro codice per garantire che quando leggiamo flussi di oggetti di varie versioni, i dati siano sempre coerenti dopo la lettura dell'oggetto.

Avversione alla versione

Esistono vari tipi di problemi di controllo delle versioni nel software, tutti relativi alla compatibilità tra blocchi di dati e / o codice eseguibile:

  • Versioni diverse dello stesso software possono o non possono essere in grado di gestire i formati di archiviazione dati reciproci

  • I programmi che caricano codice eseguibile in fase di runtime devono essere in grado di identificare la versione corretta dell'oggetto software, della libreria caricabile o del file oggetto per eseguire il lavoro

  • I metodi e i campi di una classe devono mantenere lo stesso significato di come la classe si evolve, altrimenti i programmi esistenti potrebbero interrompersi nei punti in cui vengono utilizzati tali metodi e campi

  • Il codice sorgente, i file di intestazione, la documentazione e gli script di compilazione devono essere tutti coordinati in un ambiente di compilazione del software per garantire che i file binari vengano creati dalle versioni corrette dei file di origine

Questo articolo sul controllo delle versioni degli oggetti Java affronta solo i primi tre, ovvero il controllo della versione degli oggetti binari e la loro semantica in un ambiente runtime. (C'è una vasta gamma di software disponibile per il controllo delle versioni del codice sorgente, ma non ne parleremo qui.)

È importante ricordare che i flussi di oggetti Java serializzati non contengono bytecode. Contengono solo le informazioni necessarie per ricostruire un oggetto supponendo che tu abbia i file di classe disponibili per costruire l'oggetto. Ma cosa succede se i file di classe delle due Java virtual machine (JVM) (il writer e il lettore) sono di versioni diverse? Come sappiamo se sono compatibili?

Una definizione di classe può essere pensata come un "contratto" tra la classe e il codice che la chiama. Questo contratto include l' API della classe (interfaccia di programmazione dell'applicazione). Modificare l'API equivale a modificare il contratto. (Altre modifiche a una classe possono anche implicare modifiche al contratto, come vedremo.) Man mano che una classe si evolve, è importante mantenere il comportamento delle versioni precedenti della classe in modo da non danneggiare il software in luoghi che dipendevano da dato comportamento.

Un esempio di cambio di versione

Immagina di avere un metodo chiamato getItemCount()in una classe, il che significa ottenere il numero totale di elementi contenuti in questo oggetto e che questo metodo è stato utilizzato in una dozzina di punti del sistema. Quindi, immagina in un secondo momento di cambiare getItemCount()per significare ottenere il numero massimo di elementi che questo oggetto abbia mai contenuto. Molto probabilmente il tuo software si interromperà nella maggior parte dei luoghi in cui è stato utilizzato questo metodo, perché improvvisamente il metodo riporterà informazioni diverse. In sostanza, hai rotto il contratto; quindi ti serve bene che il tuo programma ora contenga dei bug.

Non c'è modo, a meno di non consentire del tutto le modifiche, di automatizzare completamente il rilevamento di questo tipo di cambiamento, perché avviene a livello di ciò che un programma significa , non semplicemente a livello di come quel significato è espresso. (Se si pensa ad un modo per farlo facilmente e, in generale, si sta andando ad essere più ricco di Bill.) Così, in assenza di una soluzione completa, in generale, e automatizzato a questo problema, che cosa possiamo fare per evitare di entrare in acqua calda quando cambiamo classe (che, ovviamente, dobbiamo)?

La risposta più semplice a questa domanda è dire che se una classe cambia affatto , non dovrebbe essere "attendibile" mantenere il contratto. Dopo tutto, un programmatore potrebbe aver fatto qualcosa alla classe e chissà se la classe funziona ancora come pubblicizzato? Questo risolve il problema del controllo delle versioni, ma è una soluzione poco pratica perché è troppo restrittiva. Se la classe viene modificata per migliorare le prestazioni, ad esempio, non c'è motivo di non consentire l'utilizzo della nuova versione della classe semplicemente perché non corrisponde a quella vecchia. Qualsiasi numero di modifiche può essere apportato a una classe senza rompere il contratto.

D'altra parte, alcune modifiche alle classi garantiscono praticamente la rottura del contratto: l'eliminazione di un campo, ad esempio. Se elimini un campo da una classe, sarai comunque in grado di leggere i flussi scritti da versioni precedenti, perché il lettore può sempre ignorare il valore per quel campo. Ma pensa a cosa succede quando scrivi uno stream destinato a essere letto dalle versioni precedenti della classe. Il valore per quel campo sarà assente dallo stream e la versione precedente assegnerà un valore predefinito (possibilmente logicamente incoerente) a quel campo quando legge lo stream. Ecco! : Hai un corso rotto.

Modifiche compatibili e incompatibili

Il trucco per gestire la compatibilità delle versioni degli oggetti è identificare quali tipi di modifiche possono causare incompatibilità tra le versioni e quali no, e trattare questi casi in modo diverso. Nel gergo Java, le modifiche che non causano problemi di compatibilità sono chiamate modifiche compatibili ; quelle che possono essere chiamate modifiche incompatibili .

I progettisti del meccanismo di serializzazione per Java avevano in mente i seguenti obiettivi quando hanno creato il sistema:

  1. Per definire un modo in cui una versione più recente di una classe può leggere e scrivere flussi che una versione precedente della classe può anche "comprendere" e utilizzare correttamente

  2. Fornire un meccanismo predefinito che serializza oggetti con buone prestazioni e dimensioni ragionevoli. Questo è il meccanismo di serializzazione che abbiamo già discusso nelle due precedenti colonne JavaBeans menzionate all'inizio di questo articolo

  3. Per ridurre al minimo il lavoro relativo al controllo delle versioni sulle classi che non richiedono il controllo delle versioni. Idealmente, le informazioni sul controllo delle versioni devono essere aggiunte a una classe solo quando vengono aggiunte nuove versioni

  4. Per formattare il flusso di oggetti in modo che gli oggetti possano essere ignorati senza caricare il file di classe dell'oggetto. Questa capacità consente a un oggetto client di attraversare un flusso di oggetti contenente oggetti che non comprende

Vediamo come il meccanismo di serializzazione affronta questi obiettivi alla luce della situazione delineata sopra.

Differenze riconciliabili

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.ObjectInputStreamnon scrive oggetti di classe quando crea un flusso di byte che rappresenta un oggetto. Invece, scrive a java.io.ObjectStreamClass, che è una descrizione della classe. Il programma di caricamento classi della JVM di destinazione utilizza questa descrizione per trovare e caricare i bytecode per la classe. Inoltre crea e include un numero intero a 64 bit chiamato SerialVersionUID , che è una sorta di chiave che identifica in modo univoco una versione del file di classe.

Il SerialVersionUIDè creato calcolando un hash a 64 bit di sicurezza delle seguenti informazioni sulla classe. Il meccanismo di serializzazione vuole essere in grado di rilevare il cambiamento in una delle seguenti cose: