W3docs

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:

  1. L'interfaccia marcatore Serializable. Una classe dichiara di poter essere serializzata implementando java.io.Serializable. L'interfaccia non ha metodi; è un flag che il JDK controlla a runtime.
  2. ObjectOutputStream. Un decoratore che avvolge qualsiasi OutputStream e aggiunge writeObject(Object). È il motore che percorre il grafo e scrive i byte.
  3. 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 transient e non static, 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 (privatepublic).
  • Non sicuro: rimuovere campi non-transient, cambiare il tipo di un campo, cambiare il serialVersionUID di 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.

java— editable, runs on the server

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 di ObjectOutputStream.
  • Il dump dei byte conteneva "alice" (un campo non-transient) e non conteneva "hash-A" (un campo transient). Marcare un campo transient è il modo supportato per escluderlo. I campi sensibili (password, token, chiavi di sessione) appartengono a transient.
  • La scrittura di BadEmployee ha lanciato NotSerializableException e il messaggio nominava Settings — 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 campo transient). Il controllo avviene a livello di campo, non di classe — un singolo riferimento non serializzabile è sufficiente.
  • serialVersionUID = 1L era 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.

Esercitazione

Pratica
Una classe `Employee` ha un campo `transient String sessionToken`. Il token è `'abc123'` al momento della serializzazione. Dopo la deserializzazione in una nuova JVM, qual è il valore di `sessionToken` sull'oggetto ricostruito?
Una classe `Employee` ha un campo `transient String sessionToken`. Il token è `'abc123'` al momento della serializzazione. Dopo la deserializzazione in una nuova JVM, qual è il valore di `sessionToken` sull'oggetto ricostruito?
Was this page helpful?