Serializzazione in Java
Serializza oggetti Java in byte con l'interfaccia Serializable, ObjectOutputStream e serialVersionUID.
I capitoli precedenti hanno trattato stream di contenuto — byte, caratteri, primitive, righe. La serializzazione sale di un gradino: uno stream di oggetti. Si chiama writeObject(someObject) e il JDK percorre l'intero grafo di riferimenti a partire da quell'oggetto, codificando ogni campo di ogni oggetto raggiungibile come byte e scrivendo il risultato nello stream. Sul lato lettura, readObject() ricostruisce il grafo.
È una promessa ambiziosa, ma con un'importante riserva. La serializzazione funziona, funziona da Java 1.1, e la si trova in codebase datate (RMI, EJB, replica delle sessioni, alcuni layer di caching). Ma il design presenta problemi noti — versioning fragile, falle di sicurezza, forte accoppiamento tra persistenza e struttura della classe — e Oracle ha dichiaratamente cercato di abbandonarla da anni. Per il nuovo codice, la risposta è quasi sempre JSON o Protocol Buffers. Questo capitolo esiste per permetterti di leggere e mantenere il codice già esistente.
Il meccanismo
Tre componenti:
- L'interfaccia marcatore
Serializable. Una classe dichiara di poter essere serializzata implementandojava.io.Serializable. L'interfaccia non ha metodi; è un flag che il JDK controlla a runtime. ObjectOutputStream. Un decoratore che avvolge qualsiasiOutputStreame aggiungewriteObject(Object). È il motore che percorre il grafo e scrive i byte.ObjectInputStream(capitolo successivo). Lo specchio che legge i byte e ricostruisce il grafo.
class User implements Serializable { // the marker
private static final long serialVersionUID = 1L;
String name;
int age;
User(String name, int age) { this.name = name; this.age = age; }
}
try (ObjectOutputStream out = new ObjectOutputStream(Files.newOutputStream(path))) {
out.writeObject(new User("alice", 30)); // the user is now on disk
}Questa è la ricetta minima. La classe implementa Serializable; il writer è ObjectOutputStream; la chiamata è writeObject. Alla successiva lettura del file (trattata nel capitolo successivo) si ottiene un'istanza di User.
Cosa viene scritto
Tutto ciò che è raggiungibile dall'oggetto, per impostazione predefinita:
- Ogni campo non
transiente nonstatic, tramite reflection, nell'ordine di dichiarazione. - Ricorsivamente, ogni oggetto a cui quei campi fanno riferimento.
- Per ogni classe coinvolta, un descrittore (il nome della classe, i tipi dei campi e il
serialVersionUID) in modo che il lettore possa validare il formato.
Il formato è binario, auto-descrittivo (trasporta metadati della classe) e non leggibile dall'uomo. È anche specifico del sistema di tipi Java — i byte codificano offset dei campi, nomi dei tipi e gerarchie di ereditarietà che non hanno significato al di fuori di Java. Questo è il limite fondamentale: un file User.bin non può essere letto da Python, Go o JavaScript senza un parser personalizzato.
transient: campi che non vuoi serializzare
Un campo marcato transient viene ignorato durante la serializzazione. Il lettore lo vede con il valore predefinito per il suo tipo — null, 0, false. Usalo per:
- Cache ricostruibili:
transient Map<String, Result> cache; - Campi che non hanno senso tra JVM diverse:
transient Thread worker;,transient Connection db; - Dati sensibili che non vuoi su disco:
transient String password;
class Session implements Serializable {
private static final long serialVersionUID = 1L;
String userId;
long createdAt;
transient byte[] sessionToken; // never gets written
}Il Session deserializzato avrà sessionToken == null. Il codice deve gestire l'assenza del campo dopo la ricostruzione.
Anche i campi statici vengono ignorati — static appartiene alla classe, non all'istanza, quindi non fa parte dello stato per-oggetto.
serialVersionUID: dichiararlo esplicitamente
Ogni classe serializzabile ha un serialVersionUID — un numero di versione a 64 bit scritto nello stream e confrontato con la classe sul lato lettura. Se non corrispondono, la deserializzazione lancia InvalidClassException.
Dovresti sempre dichiararlo:
private static final long serialVersionUID = 1L;Se non lo fai, la JVM ne calcola uno dalla struttura della classe — ogni campo, ogni firma di metodo, ogni interfaccia. Aggiungere un campo, modificare il tipo di ritorno di un metodo, rinominare un parametro, e l'UID calcolato cambia. Il codice che ha scritto User.bin con la classe della settimana scorsa non può leggerlo con la classe di questa settimana. Non lo scoprirai nei test unitari perché entrambi i lati vedono la stessa classe. Lo scoprirai in produzione quando un utente aggiorna.
Dichiarare l'UID esplicitamente ti mette in controllo. Incrementalo manualmente solo quando hai apportato una modifica incompatibile. (Vedi il Javadoc di Serializable per le regole complete di evoluzione — sono articolate.)
Cosa puoi cambiare tra versioni
Le regole per i cambiamenti "compatibili" sono sorprendentemente rigide. In sintesi:
- Sicuro: aggiungere nuovi campi, rimuovere campi transient/static, espandere l'accesso (
private→public). - Non sicuro: rimuovere campi non-transient, cambiare il tipo di un campo, cambiare il
serialVersionUIDdi una classe, cambiare la catena di ereditarietà.
Il punto: i byte su disco sono accoppiati alla struttura della gerarchia delle classi, non solo ai dati. I formati di archiviazione a lungo termine hanno bisogno di un proprio schema. La serializzazione va bene per cache di breve durata e trasporto intra-JVM, è fragile per tutto ciò che deve sopravvivere a un deploy.
L'intero grafo, cicli inclusi
writeObject segue ogni riferimento. Se User contiene un Team e il Team contiene una List<User> che include il primo User, il ciclo viene gestito: il JDK traccia l'identità di ogni oggetto che scrive e, quando ne incontra uno una seconda volta, scrive un back-reference invece di ricorrere di nuovo. Il grafo ricostruito dall'altro lato ha le stesse relazioni di identità.
È potente e anche una fonte di problemi. Un oggetto serializzabile trascina tutto ciò che può raggiungere — e se uno qualsiasi di quegli oggetti raggiungibili non è Serializable, la scrittura fallisce con NotSerializableException che nomina il tipo problematico. La soluzione è una tra: implementare Serializable sul tipo problematico, marcare il campo transient, oppure ristrutturare la classe per non mantenere il riferimento.
Sicurezza: non deserializzare mai byte non attendibili
Questo è principalmente un argomento del capitolo successivo, ma la conseguenza influenza anche il lato scrittura. Il formato di serializzazione Java esegue codice sul lettore — costruttori di classi e hook readObject — durante la deserializzazione. Flussi di byte artigianali sono stati usati per l'esecuzione remota di codice contro ogni importante app server Java. La regola emersa da anni di CVE:
Non deserializzare byte da nessuna sorgente che non controlli pienamente.
Sul lato scrittura questo significa: non progettare protocolli in cui una parte serializza i dati con ObjectOutputStream e un'altra li deserializza con ObjectInputStream. Usa JSON o Protocol Buffers oltre i confini di fiducia; riserva la serializzazione per casi d'uso "stessa JVM, stesso class loader, stesso dominio di fiducia".
Quando usare la serializzazione (e quando no)
Usala quando:
- Hai bisogno di fare un checkpoint di un grafo di oggetti nella stessa JVM per il recovery al riavvio.
- Stai lavorando con un framework esistente (RMI, JMX, EJB, alcune repliche di sessione) che la richiede.
- Vuoi un'implementazione in 10 righe per un file "salva partita" di cui puoi rompere la compatibilità in qualsiasi momento.
Non usarla quando:
- Il formato deve sopravvivere a un deploy. Usa invece un formato con schema versionato (JSON + un campo versione, Protobuf, Avro).
- I dati attraversano un confine di fiducia. Usa JSON o Protobuf.
- Un altro linguaggio deve leggere o scrivere i dati. Il formato di serializzazione Java è solo per Java.
Per la maggior parte del nuovo codice, Jackson.writeValueAsString(obj) su un file JSON è la scelta migliore. È senza schema ma flessibile, leggibile dall'uomo e analizzabile da qualsiasi linguaggio.
Un esempio pratico: scrittura di un grafo di record
Il programma seguente definisce due semplici tipi serializzabili, Department e Employee, con un back-reference (ogni Employee conosce il suo Department, e ogni Department mantiene una lista dei suoi Employee — un ciclo). Scrive il grafo con ObjectOutputStream, mostra il conteggio dei byte e illustra la NotSerializableException che si ottiene quando si introduce un campo non serializzabile. La lettura dei byte viene trattata nel capitolo successivo; qui ci concentriamo sul lato scrittura.
Cosa ricavare dall'esecuzione:
- Una singola chiamata
writeObject(eng)ha serializzato il Department, tutti e tre gli Employee, i back-reference da Employee a Department e la lista all'interno di Department. Questa è la funzionalità principale della serializzazione: grafi, non record. Cicli gestiti, identità preservata, nessuna navigazione manuale. - I primi quattro byte erano
AC ED 00 05— il "magic number" della serializzazione Java e la versione dello stream. Ogni file serializzato inizia con questi. Se vedi questo header su un file trovato in produzione, stai guardando l'output diObjectOutputStream. - Il dump dei byte conteneva
"alice"(un campo non-transient) e non conteneva"hash-A"(un campotransient). Marcare un campotransientè il modo supportato per escluderlo. I campi sensibili (password, token, chiavi di sessione) appartengono atransient. - La scrittura di
BadEmployeeha lanciatoNotSerializableExceptione il messaggio nominavaSettings— il tipo esatto non serializzabile. È così che si trovano i tipi problematici: si prova a scrivere, si legge l'eccezione, si corregge la classe nominata (o si marca il campotransient). Il controllo avviene a livello di campo, non di classe — un singolo riferimento non serializzabile è sufficiente. serialVersionUID = 1Lera dichiarato su ogni classe serializzabile. L'esecuzione attuale non noterebbe la sua assenza, ma un te futuro che refactora la classe e tenta di caricare un vecchio file con il nuovo codice lo noterebbe immediatamente. Dichiaralo; incrementalo deliberatamente quando apporti una modifica incompatibile.
Cosa viene dopo
Questo capitolo ha trattato la scrittura — Serializable, ObjectOutputStream, la traversata del grafo, il formato. La lettura e la ricostruzione del grafo è l'operazione speculare con il proprio insieme di insidie (quella della sicurezza essendo la più grande). Questo è il capitolo successivo, Java Deserialization.