Tot el que BiblioTech ha desat fins ara és text que tu vas formatar a mà: LLIBRE;isbn;titol;autor;any. Funciona per al catàleg, però prova d'estendre-ho a l'estat complet del sistema. Un Prestec referencia un Material i un Empleat; el mateix Empleat apareix en tres préstecs diferents; cada préstec té una llista d'Incidencia imbricades; CuaReserves desa reserves que apunten als mateixos materials. Escriure això a mà significa inventar identificadors, escriure cada objecte una vegada, resoldre les referències en carregar i reconstruir el graf. És feina, i és feina repetitiva.

Java ho sap fer sol. La serialització converteix un graf d'objectes complet en una seqüència de bytes i el reconstrueix després, amb totes les referències al seu lloc, incloses les compartides i les circulars. Dues crides: writeObject i readObject.

I aquí ve l'avís, que és tan important com el mecanisme: la serialització nativa de Java és una de les funcionalitats més problemàtiques del llenguatge. És còmoda, és potent, i ha estat durant vint anys una de les vies d'atac més explotades de l'ecosistema Java. Aquesta lliçó t'ensenya a fer-la servir i, amb el mateix èmfasi, quan no fer-la servir.

try-with-resources a tot, com sempre des de 06-06.

Contingut

  1. Què és serialitzar: el graf d'objectes
  2. La interfície marcadora Serializable
  3. ObjectOutputStream i ObjectInputStream
  4. Què es serialitza i què no: transient i static
  5. NotSerializableException: l'efecte contagi
  6. serialVersionUID: què és i per què l'has de declarar
  7. InvalidClassException en acció
  8. Evolució de classes: quins canvis són compatibles
  9. Personalització amb writeObject i readObject privats
  10. Externalizable i el control total
  11. Serialització i herència
  12. Riscos de seguretat: per què això està desaconsellat
  13. Filtres de deserialització amb ObjectInputFilter
  14. Alternatives i quan continua sent raonable
  15. BiblioTech: desar i restaurar una sessió
  16. Errors Comuns i Consells
  17. Exercicis

  1. Què és serialitzar: el graf d'objectes

Serialitzar és convertir un objecte —i tot el que en penja— en una seqüència de bytes. Deserialitzar és el camí invers.

La paraula clau és graf. Un objecte no està sol: té camps que apunten a altres objectes, que al seu torn apunten a altres. El conjunt forma un graf dirigit, i serialitzar significa recórrer-lo sencer.

Considera aquest estat de BiblioTech:

flowchart TD
    S["SessioBiblioteca"]
    P1["Prestec PR-0001"]
    P2["Prestec PR-0002"]
    P3["Prestec PR-0003"]
    E1["Empleat E-001<br/>Marta Ruiz"]
    E2["Empleat E-002<br/>Diego Alonso"]
    M1["Llibre<br/>Java Eficac"]
    M2["Llibre<br/>Patrons de Disseny"]
    M3["Llibre<br/>Refactoritzacio"]
    I1["Incidencia<br/>pagina trencada"]

    S --> P1
    S --> P2
    S --> P3
    P1 --> E1
    P2 --> E1
    P3 --> E2
    P1 --> M1
    P2 --> M2
    P3 --> M3
    P1 --> I1

    style E1 fill:#fff3e0
    style S fill:#e3f2fd

Fixa't en Empleat E-001: dos préstecs apunten al mateix objecte. No són dues còpies de Marta Ruiz; és la mateixa instància, referenciada dues vegades. I això importa molt: si Marta retorna un llibre, el seu comptador de préstecs baixa, i els dos préstecs veuen el mateix canvi perquè comparteixen l'objecte.

La serialització de Java preserva aquesta identitat compartida. Escriu l'objecte E-001 una sola vegada i, quan el torna a trobar, escriu una referència interna al que ja s'ha escrit. En deserialitzar, tots dos préstecs tornen a apuntar a la mateixa instància.

Això s'aconsegueix amb una taula d'objectes ja escrits que manté l'ObjectOutputStream:

Situació Què escriu
Objecte nou La seva classe, els seus camps, i l'apunta a la taula amb un identificador
Objecte ja escrit Només una referència a l'identificador de la taula
Referència circular (A → B → A) Es resol amb el mateix mecanisme, sense bucle infinit
null Un marcador de nul

Que les referències circulars funcionin no és un detall menor: un algorisme ingenu entraria en recursió infinita. La serialització de Java les gestiona correctament i sense que hagis de fer res.

Aquest és el seu gran avantatge, i no és petit. Reproduir-ho a mà amb un format de text exigeix inventar identificadors, escriure cada objecte una vegada, i fer dues passades en carregar per resoldre les referències.

  1. La interfície marcadora Serializable

Perquè un objecte es pugui serialitzar, la seva classe ha d'implementar java.io.Serializable:

package java.io;

public interface Serializable {
    // Sense metodes. Cap.
}

És buida. És una interfície marcadora, exactament el concepte que vas veure a 04-01: una interfície sense mètodes l'únic propòsit de la qual és etiquetar una classe perquè un altre codi pugui comprovar amb instanceof si té una propietat.

package com.nexussoftware.bibliotech.domini;

import java.io.Serializable;

public class Empleat implements Serializable {

    private static final long serialVersionUID = 1L;      // apartat 6

    private final String nom;
    private final String identificador;
    private int prestecsActius;

    // ... la resta igual que sempre
}

Sense implements Serializable, intentar serialitzar llança NotSerializableException.

Per què una interfície buida i no un mecanisme automàtic? Perquè serialitzar ha de ser una decisió conscient. En implementar Serializable estàs declarant un compromís seriós, i convé saber-ho:

La forma serialitzada d'una classe forma part de la seva API pública. Tots els seus camps privats queden exposats al fitxer, i qualsevol canvi en ells pot trencar la compatibilitat amb els fitxers ja escrits. Un camp privat que pots reanomenar lliurement en una classe normal es converteix, en una classe serialitzable, en un compromís permanent.

Per això Joshua Bloch, a Java Eficaç —un dels llibres del catàleg de BiblioTech—, dedica un capítol sencer a la serialització i la seva recomanació central és: implementa Serializable amb molta cautela.

Què és serialitzable de fàbrica al JDK:

Serialitzable No serialitzable
String, tots els embolcalls (Integer, Double...) Thread
ArrayList, LinkedList, HashMap, HashSet Socket, InputStream, OutputStream
Arrays de tipus serialitzables FileReader, FileWriter
LocalDate i tot java.time (10-05) Connection de bases de dades
Els record els components dels quals ho siguin La majoria de classes que embolcallen recursos del sistema

La regla que explica la columna dreta: un objecte que representa un recurs viu del sistema operatiu no es pot serialitzar, perquè els bytes d'un descriptor de fitxer o d'un socket no signifiquen res demà ni en una altra màquina.

  1. ObjectOutputStream i ObjectInputStream

Les dues classes que fan la feina són filtres de la família de bytes (07-03):

package com.nexussoftware.bibliotech.demo;

import java.io.*;

public class SerialitzacioBasica {

    public static void desar(Empleat empleat, String cami) throws IOException {
        try (ObjectOutputStream sortida = new ObjectOutputStream(
                new BufferedOutputStream(new FileOutputStream(cami)))) {

            sortida.writeObject(empleat);        // escriu l'objecte i tot el seu graf
        }
    }

    public static Empleat carregar(String cami) throws IOException, ClassNotFoundException {
        try (ObjectInputStream entrada = new ObjectInputStream(
                new BufferedInputStream(new FileInputStream(cami)))) {

            // readObject retorna Object: cal convertir
            return (Empleat) entrada.readObject();
        }
    }

    public static void main(String[] args) throws Exception {
        Empleat marta = new Empleat("Marta Ruiz", "E-001");
        marta.registrarPrestec();
        marta.registrarPrestec();

        desar(marta, "dades/marta.ser");

        Empleat recuperada = carregar("dades/marta.ser");
        System.out.println(recuperada.getNom());               // Marta Ruiz
        System.out.println(recuperada.getPrestecsActius());    // 2

        // Es UN ALTRE objecte, amb el MATEIX estat
        System.out.println(marta == recuperada);               // false
        System.out.println(marta.equals(recuperada));          // true, si equals esta be (03-09)
    }
}

Els mètodes principals:

Mètode Què fa
writeObject(Object) Serialitza l'objecte i tot el seu graf
readObject() Deserialitza. Retorna Object: cal convertir
writeInt, writeDouble, writeUTF... Heretats de DataOutputStream (07-03)
defaultWriteObject() Escriu els camps per defecte. Només dins d'un writeObject propi
defaultReadObject() Llegeix els camps per defecte. Només dins d'un readObject propi

I les excepcions que cal gestionar:

Excepció Quan Tipus
NotSerializableException Un objecte del graf no és Serializable IOException
InvalidClassException La classe ha canviat de forma incompatible IOException
ClassNotFoundException La classe no és al classpath en deserialitzar Exception, no IOException
StreamCorruptedException El fitxer no és un flux d'objectes vàlid IOException
EOFException Es llegeixen més objectes dels escrits IOException

Fixa't en ClassNotFoundException: no estén IOException, així que necessita el seu propi catch o un multi-catch (06-02). És la que apareix quan llegeixes un fitxer escrit per una altra versió de l'aplicació que tenia classes que la teva no té.

Com escriure i llegir diversos objectes:

/** Diversos objectes: s'escriuen i es llegeixen EN EL MATEIX ORDRE. */
public static void desarDiversos(List<Empleat> empleats, String cami) throws IOException {
    try (ObjectOutputStream sortida = new ObjectOutputStream(
            new BufferedOutputStream(new FileOutputStream(cami)))) {

        sortida.writeInt(empleats.size());          // primer, quants n'hi ha
        for (Empleat e : empleats) {
            sortida.writeObject(e);
        }
    }
}

public static List<Empleat> carregarDiversos(String cami)
        throws IOException, ClassNotFoundException {

    List<Empleat> empleats = new ArrayList<>();
    try (ObjectInputStream entrada = new ObjectInputStream(
            new BufferedInputStream(new FileInputStream(cami)))) {

        int quants = entrada.readInt();
        for (int i = 0; i < quants; i++) {
            empleats.add((Empleat) entrada.readObject());
        }
    }
    return empleats;
}

Encara més simple: serialitzar la col·lecció sencera, perquè ArrayList és serialitzable:

sortida.writeObject(new ArrayList<>(empleats));      // un sol writeObject
// ...
List<Empleat> empleats = (List<Empleat>) entrada.readObject();

Aquesta segona forma és millor: menys codi i sense possibilitat de descompensar el comptador. Però compte amb quina implementació de List deses: List.of(...) i Collections.unmodifiableList(...) produeixen classes internes del JDK que sí que són serialitzables però la identitat concreta de les quals pot canviar entre versions. Si serialitzaràs una col·lecció, desa un ArrayList o un HashMap explícit.

Com són els bytes que produeix:

AC ED 00 05 73 72 00 2B 63 6F 6D 2E 6E 65 78 75  |....sr.+com.nexu|
73 73 6F 66 74 77 61 72 65 2E 62 69 62 6C 69 6F  |ssoftware.biblio|
74 65 63 68 2E 64 6F 6D 69 6E 69 2E 45 6D 70 6C  |tech.domini.Empl|
65 61 74 00 00 00 00 00 00 00 01 02 00 03 49 00  |eat...........I.|

AC ED és el nombre màgic de la serialització de Java —el mateix concepte de l'exercici 3 de 07-03—, seguit de la versió del protocol i, en text llegible, el nom complet de la classe. Això revela dues coses: el format no és opac, i qui tingui el fitxer sap exactament quines classes fa servir la teva aplicació. Guarda aquesta observació per a l'apartat 12.

  1. Què es serialitza i què no: transient i static

Per defecte es serialitzen tots els camps d'instància. Amb dues excepcions:

Modificador Es serialitza? Per què
(cap) És l'estat de l'objecte
private La privacitat no protegeix de la serialització
final Es restaura sense passar pel constructor
static No Pertany a la classe, no a l'objecte
transient No Exclòs explícitament

Dos detalls que sorprenen:

Els camps private es serialitzen. L'encapsulament del mòdul 3 no protegeix res aquí: tots els camps privats queden escrits al fitxer, amb els seus noms. És la conseqüència directa que la forma serialitzada sigui part de l'API.

Els camps final es restauren sense executar el constructor. La deserialització no crida cap constructor de la classe serialitzable: crea l'objecte directament i li assigna els camps llegits. Això té una implicació seriosa que es desenvolupa a l'apartat 12: la deserialització és una via de creació d'objectes que se salta totes les teves validacions.

Els static no es serialitzen perquè són de la classe. Si Material.MULTA_MAXIMA val 20.0 en desar i algú el canvia a 25.0 abans de carregar, l'objecte restaurat farà servir 25.0. És correcte: la constant és de la classe, no de l'objecte.

Per a què serveix transient de veritat

transient marca un camp com a no serialitzable. Té tres usos legítims:

1. Dades derivades que es poden recalcular. No té sentit desar el que es dedueix d'una altra cosa:

public class Prestec implements Serializable {

    private static final long serialVersionUID = 1L;

    private final String referencia;
    private final Material material;
    private final int diaInici;
    private int diaDevolucio = -1;

    // DERIVAT: es calcula a partir dels dies i la tarifa del material.
    // Desar-lo seria duplicar informacio, i pitjor: si la tarifa canvia,
    // el valor desat quedaria obsolet i contradiria el calculat.
    private transient double multaCalculada;
    private transient boolean multaEnCache = false;

    public double calcularMulta(int diaActual) {
        if (!multaEnCache) {
            multaCalculada = material.calcularMulta(diaActual - diaInici);
            multaEnCache = true;
        }
        return multaCalculada;
    }
}

2. Secrets que no han de quedar escrits al disc. Contrasenyes, testimonis, claus:

public class SessioUsuari implements Serializable {

    private static final long serialVersionUID = 1L;

    private final String identificador;

    // MAI al disc. Tot el que 06-07 va dir sobre que no registrar s'aplica
    // igual, o mes, al que es persisteix: el fitxer es queda.
    private transient char[] credencial;
    private transient String tokenDeSessio;
}

3. Recursos no serialitzables. Connexions, fluxos, fils. És el cas de l'apartat següent, i transient és la solució al problema del contagi.

Quin valor tenen els camps transient en deserialitzar: el valor per defecte del seu tipus. 0 per als numèrics, false per a boolean, null per a referències. No s'executa cap inicialitzador de camp ni cap constructor.

Aquesta és una font de fallades molt concreta:

public class CacheFitxes implements Serializable {

    private static final long serialVersionUID = 1L;

    // MALAMENT: en deserialitzar sera null, no un HashMap buit.
    // L'inicialitzador NO s'executa.
    private transient Map<String, Fitxa> cache = new HashMap<>();

    public void desar(String clau, Fitxa f) {
        cache.put(clau, f);         // NullPointerException despres de deserialitzar
    }
}

La solució és un readObject privat que reinicialitzi, i és l'apartat 9.

  1. NotSerializableException: l'efecte contagi

Aquí hi ha el parany que més desconcerta:

Un objecte només és serialitzable si TOT el seu graf ho és. Amb un sol camp no serialitzable, a qualsevol profunditat, ja falla l'operació sencera.

import java.io.Serializable;
import java.util.logging.Logger;

public class GestorPrestecs implements Serializable {

    private static final long serialVersionUID = 1L;

    private final Cataleg cataleg;               // serialitzable
    private final RegistrePrestecs registre;     // serialitzable

    // PROBLEMA: Logger NO es Serializable.
    // Aquest unic camp fa que TOT el GestorPrestecs falli.
    private final Logger log = Logger.getLogger(GestorPrestecs.class.getName());
}

En intentar desar-lo:

Exception in thread "main" java.io.NotSerializableException: java.util.logging.Logger
    at java.base/java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1197)
    at java.base/java.io.ObjectOutputStream.defaultWriteFields(...)
    at ...

El missatge diu quina classe ha fallat, però no on era aquell camp, que és el que necessites saber. En un graf de vint classes imbricades, trobar-lo és un rastreig. El truc: llegir la traça de pila de baix a dalt seguint els defaultWriteFields, com vas aprendre a 06-01.

La solució és transient, gairebé sempre:

public class GestorPrestecs implements Serializable {

    private static final long serialVersionUID = 1L;

    private final Cataleg cataleg;
    private final RegistrePrestecs registre;

    // transient: no es desa. I no cal, perque un Logger es
    // recupera amb una crida estatica en qualsevol moment.
    private transient Logger log = Logger.getLogger(GestorPrestecs.class.getName());

    /**
     * Reinicialitza el que es transient despres de la deserialitzacio (apartat 9).
     * Sense aixo, 'log' seria null i el primer us llancaria NullPointerException.
     */
    private void readObject(java.io.ObjectInputStream entrada)
            throws java.io.IOException, ClassNotFoundException {

        entrada.defaultReadObject();
        log = Logger.getLogger(GestorPrestecs.class.getName());
    }
}

I una regla pràctica que estalvia molt de dolor:

Els camps Logger han de ser sempre private static final. En ser static no es serialitzen i el problema no existeix. És exactament la convenció que vas establir a 06-07 per motius de rendiment; resulta que també resol això.

Les tres solucions al contagi, segons el cas:

Situació Solució
El camp es pot recrear (logger, memòria cau, connexió) transient + readObject que el reinicialitza
El camp és un objecte de domini teu Fes que ell implementi Serializable
El camp és d'una llibreria que no controles transient + desar les dades necessàries per reconstruir-lo

  1. serialVersionUID: què és i per què l'has de declarar

Aquest és l'apartat que reprèn el que vas fer al mòdul 6. A 06-04 vas escriure, a cada excepció:

public class MaterialNoTrobatException extends CatalegException {
    private static final long serialVersionUID = 1L;
    // ...
}

I l'explicació va ser breu: el compilador ho demana perquè Throwable és Serializable. Ara toca l'explicació completa.

Què és

serialVersionUID és un nombre que identifica la versió de la forma serialitzada d'una classe. S'escriu al fitxer en serialitzar, i en deserialitzar es compara amb el de la classe carregada. Si no coincideixen, es llança InvalidClassException.

És un control de compatibilitat: "aquest fitxer el va escriure la versió 1 de la classe; ets tu aquesta versió?".

Com es calcula per defecte

Si no el declares, el compilador en genera un automàticament a partir d'un resum criptogràfic de:

  • El nom de la classe.
  • Els modificadors de la classe.
  • Les interfícies que implementa, en ordre.
  • Els noms, tipus i modificadors de tots els camps.
  • Els noms, tipus i modificadors de tots els mètodes, inclosos els privats.
  • Els constructors.

I aquí hi ha el problema:

Gairebé qualsevol canvi a la classe canvia el serialVersionUID generat. Afegir un mètode privat. Canviar un private per protected. Reordenar interfícies. Fins i tot compilar amb una altra versió del compilador pot produir un valor diferent.

Conseqüència pràctica: sense declarar-lo, afegir un mètode auxiliar privat —que no canvia l'estat en absolut— trenca tots els fitxers desats. I el missatge d'error no diu "has afegit un mètode"; diu que els identificadors no coincideixen, amb dos nombres llargs.

Per què l'has de declarar

En declarar-lo tu, prens el control:

public class Empleat implements Serializable {

    /**
     * Versio de la forma serialitzada.
     *
     * NOMES s'incrementa quan es fa un canvi INCOMPATIBLE (apartat 8).
     * Els canvis compatibles (afegir camps, metodes, canviar codi)
     * mantenen el mateix valor.
     */
    private static final long serialVersionUID = 1L;

    // ...
}

Ara els fitxers antics continuen sent llegibles mentre tu no decideixis el contrari, i decideixes tu quan trencar la compatibilitat.

Com escriure'l, exactament:

private static final long serialVersionUID = 1L;
  • private: no forma part de l'API pública.
  • static: és de la classe.
  • final: no canvia.
  • long: és el tipus exigit.
  • El sufix L és obligatori.

Els quatre modificadors importen: si te'n deixes cap, el mecanisme l'ignora en silenci i torna al càlcul automàtic. Un serialVersionUID sense static no serveix de res i no dona cap avís.

Com activar l'avís del compilador:

javac -Xlint:serial *.java
warning: [serial] serializable class Empleat has no definition of serialVersionUID

Activa'l al projecte. És l'avís que et diu "tindràs un problema d'aquí a sis mesos".

  1. InvalidClassException en acció

La demostració val més que l'explicació. Versió 1 de la classe:

// VERSIO 1 - es compila, s'executa i es desa un fitxer
public class Empleat implements Serializable {
    // SENSE serialVersionUID declarat: es calcula automaticament
    private final String nom;
    private final String identificador;
    private int prestecsActius;

    public Empleat(String nom, String identificador) {
        this.nom = nom;
        this.identificador = identificador;
    }
    public String getNom() { return nom; }
}
Empleat marta = new Empleat("Marta Ruiz", "E-001");
desar(marta, "dades/marta.ser");           // OK

Ara es fa un canvi innocent: afegir un mètode. Ni un camp nou, ni un canvi de tipus. Un mètode.

// VERSIO 2 - nomes s'afegeix un metode
public class Empleat implements Serializable {
    private final String nom;
    private final String identificador;
    private int prestecsActius;

    public Empleat(String nom, String identificador) {
        this.nom = nom;
        this.identificador = identificador;
    }
    public String getNom() { return nom; }

    /** Metode NOU. No toca l'estat. */
    public String getInicials() {
        String[] parts = nom.split(" ");
        return "" + parts[0].charAt(0) + parts[1].charAt(0);
    }
}

En llegir el fitxer desat amb la versió 1:

Exception in thread "main" java.io.InvalidClassException:
    com.nexussoftware.bibliotech.domini.Empleat;
    local class incompatible:
    stream classdesc serialVersionUID = -4738291056473829104,
    local class serialVersionUID = 8273645019283746152

El fitxer és illegible per sempre. I el canvi no afectava l'estat en absolut.

Amb serialVersionUID = 1L declarat a les dues versions, el fitxer es llegeix sense cap problema. El mètode nou simplement existeix als objectes restaurats.

Aquesta és la raó que sigui obligatori declarar-lo. Sense ell, la compatibilitat dels teus fitxers depèn de detalls del compilador que no controles ni coneixes. Amb ell, decideixes tu.

I això és exactament el que fan les teves excepcions del mòdul 6: BiblioTechException i totes les seves subclasses el declaren, perquè les excepcions es serialitzen quan viatgen entre màquines —una crida remota, un servidor d'aplicacions—, i una excepció que no es pot deserialitzar a l'altre costat converteix un error de negoci comprensible en una fallada incomprensible d'infraestructura.

  1. Evolució de classes: quins canvis són compatibles

Amb serialVersionUID declarat, què pots canviar sense trencar els fitxers?

Canvi Compatible? Què passa en deserialitzar un fitxer antic
Afegir un camp El camp nou pren el seu valor per defecte (0, null, false)
Eliminar un camp El valor del fitxer es descarta
Afegir, treure o canviar mètodes Res: els mètodes no es serialitzen
Canviar el cos d'un mètode Res
Afegir constructors Res: no s'executen en deserialitzar
Canviar transient a normal Com afegir un camp
Canviar normal a transient Com eliminar un camp
Canviar static a no static Sí, amb compte Com afegir un camp
Canviar el TIPUS d'un camp NO InvalidClassException
Reanomenar un camp NO Equival a eliminar-ne un i afegir-ne un altre: es perd la dada
Reanomenar o moure la classe NO ClassNotFoundException
Canviar la jerarquia d'herència NO InvalidClassException
Treure implements Serializable NO Illegible
Canviar de classe a enum o record NO Incompatible

Els dos casos perillosos mereixen atenció especial:

Afegir un camp és compatible, però el valor per defecte pot ser invàlid. Si afegeixes private int diesMaxims; i tot el teu codi suposa que val com a mínim 1, els objectes restaurats de fitxers antics tindran 0 i trencaran els teus invariants en silenci. La solució és un readObject que comprovi i corregeixi, i és l'apartat següent.

Reanomenar un camp perd la dada sense avisar. Canviar nom per nomComplet significa, per al mecanisme, que el camp nom ja no existeix (es descarta) i que n'hi ha un de nou anomenat nomComplet (es posa a null). No hi ha cap error: obtens objectes amb el nom a null. És la mena de fallada que apareix en producció tres setmanes després.

La regla pràctica:

Incrementa serialVersionUID només quan facis un canvi incompatible, i assumeix que a partir d'aquell moment els fitxers antics són illegibles. Si necessites poder-los llegir, no l'incrementis: escriu un readObject que sàpiga gestionar les dues formes.

  1. Personalització amb writeObject i readObject privats

El mecanisme per defecte es pot intervenir amb dos mètodes de signatura exacta:

private void writeObject(ObjectOutputStream sortida) throws IOException;
private void readObject(ObjectInputStream entrada) throws IOException, ClassNotFoundException;

Són privats i tot i així el mecanisme els troba i els crida —ho fa per reflexió, que veuràs a 10-03—. Si la signatura no és exactament aquesta, s'ignoren en silenci, que és un dels paranys més frustrants d'aquesta API.

Els quatre usos legítims:

package com.nexussoftware.bibliotech.servei;

import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;
import java.util.logging.Logger;

import com.nexussoftware.bibliotech.domini.Fitxa;

/**
 * Cache de fitxes amb serialitzacio personalitzada.
 *
 * La cache NO es desa (es derivada), pero cal reconstruir-la buida en
 * carregar: si no, seria null i el primer us llancaria NullPointerException.
 */
public class CacheFitxes implements Serializable {

    private static final long serialVersionUID = 1L;

    /** static: no es serialitza. La convencio correcta per a un Logger. */
    private static final Logger LOG = Logger.getLogger(CacheFitxes.class.getName());

    private final String nom;
    private int capacitatMaxima;

    /** transient: en deserialitzar sera null si no ho arreglem. */
    private transient Map<String, Fitxa> cache = new HashMap<>();
    private transient int encerts = 0;
    private transient int fallades = 0;

    public CacheFitxes(String nom, int capacitatMaxima) {
        this.nom = java.util.Objects.requireNonNull(nom);
        if (capacitatMaxima < 1) {
            throw new IllegalArgumentException(
                    "La capacitat ha de ser com a minim 1, i era: " + capacitatMaxima);
        }
        this.capacitatMaxima = capacitatMaxima;
    }

    /**
     * Serialitzacio personalitzada.
     *
     * US 1: registrar l'operacio.
     * US 2: escriure dades addicionals despres dels camps per defecte.
     */
    private void writeObject(ObjectOutputStream sortida) throws IOException {
        LOG.fine(() -> "Serialitzant cache '" + nom + "' amb "
                + cache.size() + " entrades (que NO es desen)");

        sortida.defaultWriteObject();         // primer, els camps normals

        // Es poden escriure dades extra. Es llegiran en el MATEIX ordre.
        sortida.writeInt(cache.size());       // nomes informatiu
    }

    /**
     * Deserialitzacio personalitzada.
     *
     * US 3: reinicialitzar els camps transient.
     * US 4: VALIDAR l'estat llegit.
     */
    private void readObject(ObjectInputStream entrada)
            throws IOException, ClassNotFoundException {

        entrada.defaultReadObject();          // primer, els camps normals

        int midaAnterior = entrada.readInt();     // l'extra, en el mateix ordre

        // US 3: reinicialitzar. Sense aixo, cache seria null.
        this.cache = new HashMap<>();
        this.encerts = 0;
        this.fallades = 0;

        // US 4: VALIDAR. La deserialitzacio NO crida el constructor, aixi que
        // les seves comprovacions no s'han executat. Un fitxer manipulat o d'una
        // versio antiga pot portar valors impossibles.
        if (capacitatMaxima < 1) {
            LOG.warning(() -> "Capacitat invalida en deserialitzar ("
                    + capacitatMaxima + "); es corregeix a 100");
            this.capacitatMaxima = 100;
        }
        if (nom == null) {
            throw new java.io.InvalidObjectException(
                    "Cache deserialitzada sense nom: fitxer corrupte o manipulat");
        }

        LOG.fine(() -> "Cache '" + nom + "' restaurada buida (tenia "
                + midaAnterior + " entrades)");
    }

    public java.util.Optional<Fitxa> cercar(String clau) {
        Fitxa f = cache.get(clau);
        if (f != null) { encerts++; } else { fallades++; }
        return java.util.Optional.ofNullable(f);
    }

    public void desar(String clau, Fitxa fitxa) {
        if (cache.size() >= capacitatMaxima) {
            cache.clear();                    // politica simple de desallotjament
        }
        cache.put(clau, fitxa);
    }

    public int getEncerts()  { return encerts; }
    public int getFallades() { return fallades; }
}

L'ús 4 és el més important dels quatre, i convé subratllar-ho:

La deserialització no crida cap constructor. Totes les validacions que vas escriure a 06-03 —Objects.requireNonNull, comprovacions de rang, formats— se salten del tot. Un fitxer manipulat pot produir objectes que el teu codi considera impossibles: un Prestec amb referència null, un Empleat amb −5 préstecs actius, un Material amb multa negativa.

Per això readObject ha de validar com si fos un constructor, i llançar InvalidObjectException si l'estat no és admissible. És la porta del darrere del teu domini, i cal tancar-la.

Un mètode relacionat, readResolve, permet substituir l'objecte deserialitzat per un altre:

/** Preserva el patro singleton en deserialitzar. */
private Object readResolve() {
    return INSTANCIA;      // es retorna la instancia unica, no la deserialitzada
}

Sense ell, deserialitzar un singleton crea una segona instància, trencant la garantia d'unicitat. Els enum no tenen aquest problema —el llenguatge ho garanteix—, que és una de les raons per les quals 04-07 recomanava enum per als singleton.

  1. Externalizable i el control total

Externalizable estén Serializable i substitueix el mecanisme automàtic per control complet:

public interface Externalizable extends Serializable {
    void writeExternal(ObjectOutput sortida) throws IOException;
    void readExternal(ObjectInput entrada) throws IOException, ClassNotFoundException;
}

Diferències:

Serializable Externalizable
Camps desats Tots, automàticament Només els que escriguis
Mètodes Opcionals i privats Obligatoris i públics
Constructor en deserialitzar No es crida Es crida el públic sense arguments
Herència de camps Automàtica La gestiones tu
Mida del fitxer Més gran: inclou metadades Menor
Risc d'error Baix Alt: un camp oblidat es perd en silenci
public class Ubicacio implements Externalizable {

    private String sala;
    private int prestatgeria;
    private int lleixa;

    /** OBLIGATORI i PUBLIC: Externalizable el crida en deserialitzar. */
    public Ubicacio() { }

    public Ubicacio(String sala, int prestatgeria, int lleixa) {
        this.sala = sala;
        this.prestatgeria = prestatgeria;
        this.lleixa = lleixa;
    }

    @Override
    public void writeExternal(ObjectOutput sortida) throws IOException {
        sortida.writeUTF(sala);
        sortida.writeInt(prestatgeria);
        sortida.writeInt(lleixa);
    }

    @Override
    public void readExternal(ObjectInput entrada) throws IOException {
        sala = entrada.readUTF();          // el MATEIX ordre
        prestatgeria = entrada.readInt();
        lleixa = entrada.readInt();
    }
}

Els tres motius pels quals gairebé mai no es fa servir:

  1. Els camps final són impossibles. El constructor sense arguments no els pot inicialitzar i readExternal no els pot assignar. Adeu a la immutabilitat de 03-07.
  2. Un camp oblidat es perd en silenci. Afegeixes un camp, oblides actualitzar els dos mètodes, i aquella dada desapareix sense cap error.
  3. L'estalvi poques vegades compensa. Les metadades de Serializable són un percentatge petit en la majoria dels casos.

Coneix-lo per llegir codi aliè. Si necessites aquest nivell de control, gairebé sempre és millor escriure el teu propi format, com vas fer amb DataOutputStream a 07-03 o com faràs amb CSV a 07-07.

  1. Serialització i herència

Les regles quan hi ha jerarquia:

Si la superclasse és Serializable, les subclasses ho són automàticament. Serializable s'hereta com qualsevol interfície. Si Material la implementa, Llibre, Revista i Dvd ho són sense declarar res.

Si la superclasse NO és Serializable, hi ha una condició estricta:

La superclasse no serialitzable ha de tenir un constructor accessible sense arguments. En deserialitzar, els seus camps no es llegeixen del fitxer: s'inicialitzen cridant aquest constructor.

/** Superclasse NO serialitzable. */
public abstract class ElementInventari {

    private final String codiIntern;
    private int revisions;

    /**
     * OBLIGATORI perque les subclasses serialitzables funcionin.
     * Sense ell: InvalidClassException "no valid constructor".
     */
    protected ElementInventari() {
        this.codiIntern = "SENSE-CODI";        // valor per defecte
        this.revisions = 0;
    }

    protected ElementInventari(String codiIntern) {
        this.codiIntern = codiIntern;
        this.revisions = 0;
    }

    public String getCodiIntern() { return codiIntern; }
}

/** Subclasse SI serialitzable. */
public class Material extends ElementInventari implements Serializable {

    private static final long serialVersionUID = 1L;

    private final String titol;
    private final String referencia;
    private boolean disponible;

    // ...
}

En deserialitzar un Material:

  1. Es crida el constructor sense arguments d'ElementInventari, així que codiIntern val "SENSE-CODI" i el valor original s'ha perdut.
  2. Es llegeixen del fitxer titol, referencia i disponible.

Els camps de la superclasse no serialitzable no es desen. Si això no és acceptable —i amb un codiIntern real no ho seria—, hi ha dues sortides: fer serialitzable la superclasse, o desar aquests camps a mà en un writeObject/readObject de la subclasse.

I l'error si falta el constructor:

java.io.InvalidClassException: com.nexussoftware.bibliotech.domini.Material;
    no valid constructor

Un missatge escarit que significa exactament: "la teva superclasse no serialitzable no té constructor sense arguments accessible".

  1. Riscos de seguretat: per què això està desaconsellat

Aquest és l'apartat més important de la lliçó. Fins aquí has vist una eina còmoda. Ara toca per què la comunitat Java porta anys recomanant evitar-la.

El problema de fons

Deserialitzar dades no fiables permet, en el cas general, executar codi arbitrari a la teva màquina.

No és una exageració. És la causa d'algunes de les vulnerabilitats més greus de la història de l'ecosistema Java.

El mecanisme, explicat sense donar receptes:

  1. readObject() instancia classes el nom de les quals ve escrit al fitxer. Recorda el bolcat de l'apartat 3: el nom de la classe hi és, en text.
  2. En deserialitzar, s'executen mètodes definits per aquestes classes: readObject, readResolve, validateObject, i de forma indirecta equals, hashCode o compareTo en inserir en col·leccions.
  3. Un atacant que controli els bytes pot compondre un graf d'objectes de classes que ja són al teu classpath —les teves, les del JDK, les de les teves llibreries— encadenant-ne els efectes fins a aconseguir alguna cosa nociva.
  4. Tot això passa ABANS que el teu codi vegi l'objecte. No hi ha cap punt on puguis comprovar res: quan readObject() retorna, el dany ja està fet.

El punt 4 és el que fa que aquest problema sigui diferent de tots els altres:

// Aquest codi JA ES VULNERABLE si 'entrada' ve de fora.
// La comprovacio arriba TARD: el dany s'ha produit dins de readObject().
Object o = entrada.readObject();
if (o instanceof Prestec p) {           // <-- massa tard
    processar(p);
}

No hi ha manera de validar abans, perquè la validació hauria de passar durant la mateixa deserialització. Per això l'única defensa real és no deserialitzar dades no fiables, i en segon lloc els filtres de l'apartat 13.

Què es considera "no fiable"

Pràcticament tot el que no hagis escrit tu en un fitxer que només tu pots tocar:

Origen Fiable?
Fitxer escrit per la teva pròpia aplicació, en un directori protegit Raonablement
Fitxer que l'usuari pot pujar o substituir No
Dades rebudes per xarxa No
Contingut d'una galeta o d'un camp de formulari No
Missatge d'una cua o d'un sistema extern No
Fitxer en un directori compartit o temporal No
Còpia de seguretat d'origen desconegut No

I l'observació incòmoda: fins i tot un fitxer "teu" deixa de ser-ho si algú pot escriure en aquell directori. Un fitxer de sessió a /tmp amb permisos amplis no és fiable.

La postura oficial

No és una opinió de blog. La documentació del mateix JDK, a java.io.ObjectInputStream, adverteix que deserialitzar dades no fiables és intrínsecament perillós. I el projecte Amber d'OpenJDK treballa des de fa anys a substituir la serialització nativa, precisament per això.

Les recomanacions professionals establertes:

  1. No deserialitzis res que no controlis del tot.
  2. Per a intercanvi de dades, fes servir formats de text: CSV (07-07), JSON (11-07), XML. Aquests formats no instancien classes arbitràries: produeixen cadenes i nombres que tu converteixes en objectes amb el teu codi i les teves validacions.
  3. Si no queda més remei, fes servir un filtre de deserialització (apartat 13).
  4. Redueix la superfície: no facis Serializable el que no ho necessiti.
  5. Mantén les dependències actualitzades. Molts atacs fan servir classes de llibreries conegudes.

AVÍS EXPLÍCIT I NECESSARI: tot el d'aquest apartat és una introducció, no una guia de seguretat. En un sistema real, qualsevol ús de la serialització que creui una frontera de confiança l'ha de revisar el responsable de seguretat de la teva organització, i les decisions s'han de documentar. Això no és una cosa que un desenvolupador decideixi pel seu compte un dimarts a la tarda. La seguretat d'aplicacions es tracta, pel que fa a aquest curs, a 12-07.

  1. Filtres de deserialització amb ObjectInputFilter

Java 9 va introduir un mecanisme de defensa: filtres que s'apliquen durant la deserialització, abans d'instanciar cada classe.

package com.nexussoftware.bibliotech.infraestructura;

import java.io.ObjectInputFilter;
import java.io.ObjectInputStream;

/**
 * Filtres de deserialitzacio per a BiblioTech.
 *
 * ADVERTIMENT: un filtre REDUEIX el risc, no l'elimina. L'unica defensa
 * completa es no deserialitzar dades no fiables (apartat 12).
 */
public final class FiltreDeserialitzacio {

    private FiltreDeserialitzacio() { }

    /**
     * Filtre per llista blanca: nomes es permeten les classes de BiblioTech
     * i un conjunt minim del JDK. Tota la resta es REBUTJA.
     *
     * La llista blanca es l'unica forma correcta: una llista negra sempre
     * es queda curta, perque no pots enumerar tot el que es perillos.
     */
    public static ObjectInputFilter deBiblioTech() {
        String patro = String.join(";",
                "com.nexussoftware.bibliotech.**",   // les nostres classes
                "java.util.ArrayList",
                "java.util.LinkedList",
                "java.util.HashMap",
                "java.util.HashSet",
                "java.lang.String",
                "java.lang.Number",
                "java.lang.Integer",
                "java.lang.Double",
                "java.lang.Boolean",
                "java.lang.Enum",
                "maxdepth=20",           // profunditat maxima del graf
                "maxrefs=10000",         // referencies maximes
                "maxbytes=10485760",     // 10 MB
                "maxarray=100000",       // elements maxims d'un array
                "!*");                   // REBUTJAR TOTA LA RESTA

        return ObjectInputFilter.Config.createFilter(patro);
    }

    /** Aplica el filtre a un flux concret. */
    public static void aplicarA(ObjectInputStream entrada) {
        entrada.setObjectInputFilter(deBiblioTech());
    }
}

Ús:

try (ObjectInputStream entrada = new ObjectInputStream(
        new BufferedInputStream(new FileInputStream(cami)))) {

    // ABANS de qualsevol readObject
    FiltreDeserialitzacio.aplicarA(entrada);

    SessioBiblioteca sessio = (SessioBiblioteca) entrada.readObject();
}

Si arriba una classe no permesa:

java.io.InvalidClassException: filter status: REJECTED

Sintaxi dels patrons:

Patró Significat
com.exemple.Classe Aquesta classe exacta
com.exemple.* Les classes d'aquell paquet, sense subpaquets
com.exemple.** Aquell paquet i tots els seus subpaquets
!com.dolent.** Rebutja aquell paquet
!* Rebutja tot el que no s'ha permès abans. Ha d'anar l'últim
maxdepth=N Profunditat màxima del graf
maxrefs=N Nombre màxim de referències
maxbytes=N Mida màxima del flux
maxarray=N Elements màxims d'un array

Els quatre límits numèrics no són decoratius: protegeixen contra les anomenades bombes de deserialització, fitxers petits que en deserialitzar-se produeixen estructures enormes o entren en recursió profunda i esgoten la memòria o la pila. Un fitxer d'uns quants KB pot tombar un servidor sense ells.

També es pot aplicar un filtre global a tota la JVM:

java -Djdk.serialFilter='com.nexussoftware.bibliotech.**;java.util.*;!*' BiblioTechApp

Repeteix amb mi: el filtre redueix el risc, no l'elimina. Continua havent-hi classes permeses els readObject de les quals fan coses, i continua sent cert que el codi s'executa abans que tu vegis res. Un filtre és una capa de defensa en profunditat, no una autorització per deserialitzar el que arribi.

  1. Alternatives i quan continua sent raonable

Comparació honesta de les opcions:

Format Llegible Portable entre llenguatges Segur amb dades alienes Mida Velocitat Es tracta a
Serialització Java No No No Mitjana Ràpida Aquesta lliçó
CSV Petita Ràpida 07-07
Properties Petita Ràpida 07-07
JSON (amb un analitzador correcte) Mitjana Mitjana 11-07
XML Sí, amb precaucions Gran Lenta Esmentat a 07-07
Binari propi (DataStream) No Amb esforç Petita Molt ràpida 07-03
Base de dades 11-03

La diferència crucial de la columna de seguretat: els formats de text no instancien classes. Un fitxer CSV produeix cadenes; tu decideixes quin objecte construir amb elles, passant pels teus constructors i les teves validacions de 06-03. La serialització, en canvi, construeix els objectes per tu a partir de noms de classe que vénen al fitxer.

Quan la serialització nativa continua sent raonable:

Cas Per què
Memòria cau local de dades que pots recalcular Si es corromp, s'esborra i es recalcula. No creua fronteres
Estat intern entre execucions d'una aplicació d'escriptori El fitxer és al directori de l'usuari i no viatja
Còpia profunda d'un objecte en memòria Serialitzar a ByteArrayOutputStream i deserialitzar. És un truc conegut
Comunicació entre processos de confiança de la mateixa aplicació Amb filtres i controlant els dos extrems
Codi heretat que ja la fa servir Canviar de format té el seu propi cost

Quan no fer-la servir, sense excepcions:

  • Dades que vénen de fora, de qualsevol forma.
  • Format d'intercanvi amb altres sistemes.
  • Emmagatzematge a llarg termini: els fitxers deixen de llegir-se tan bon punt canvien les classes.
  • Res que travessi una xarxa.
  • Res que un usuari pugui substituir.

  1. BiblioTech: desar i restaurar una sessió

Amb totes les cauteles posades, aquest és un cas on la serialització encaixa: un fitxer local, escrit i llegit per la mateixa aplicació, en un directori que ella controla, amb dades que es poden regenerar.

package com.nexussoftware.bibliotech.servei;

import java.io.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;

import com.nexussoftware.bibliotech.infraestructura.FiltreDeserialitzacio;

/**
 * Desa i restaura l'estat d'una sessio de treball de BiblioTech.
 *
 * PER QUE AQUI SI QUE ES ADMISSIBLE LA SERIALITZACIO NATIVA:
 *   - El fitxer l'escriu i el llegeix la MATEIXA aplicacio.
 *   - Viu al directori de dades local, no viatja per xarxa ni el puja ningu.
 *   - El seu contingut es pot REGENERAR: si es corromp, s'esborra i es comenca
 *     una sessio nova. No es la font de veritat; el cataleg en CSV ho es.
 *
 * PER QUE EL CATALEG NO FA SERVIR AIXO: el cataleg es la dada de valor, ha de
 * ser llegible, versionable i importable des d'un full de calcul. Aixo es 07-07.
 *
 * Tot i aixi s'aplica un FILTRE de deserialitzacio: defensa en profunditat.
 */
public class GestorSessio {

    private static final Logger LOG = Logger.getLogger(GestorSessio.class.getName());

    private static final String EXTENSIO = ".sessio";

    private final File directori;

    public GestorSessio(String directoriDades) {
        this.directori = new File(
                Objects.requireNonNull(directoriDades, "El directori no pot ser nul"));
        if (!directori.exists() && !directori.mkdirs()) {
            LOG.warning("No s'ha pogut crear " + directori.getAbsolutePath());
        }
    }

    // ------------------------- L'ESTAT -------------------------

    /**
     * Instantania serialitzable de l'estat d'una sessio.
     *
     * Es una classe A PART de SessioBiblioteca a proposit: la sessio
     * gestiona recursos (bloqueigs, logger) que no s'han de serialitzar.
     * Aquesta classe nomes conte DADES.
     */
    public static class EstatSessio implements Serializable {

        /**
         * DECLARAT des del primer dia. Sense ell, afegir un metode privat
         * d'aqui a sis mesos deixaria illegibles totes les sessions desades
         * (apartat 7).
         */
        private static final long serialVersionUID = 1L;

        private final String idEmpleat;
        private final int diaObertura;
        private final List<String> operacions;
        private final int prestecsRealitzats;
        private final int devolucionsRealitzades;
        private final double multesCobrades;

        /** Derivat: es recalcula. No te sentit desar-lo. */
        private transient String resumFormatat;

        public EstatSessio(String idEmpleat, int diaObertura, List<String> operacions,
                           int prestecsRealitzats, int devolucionsRealitzades,
                           double multesCobrades) {

            this.idEmpleat = Objects.requireNonNull(idEmpleat, "L'empleat es obligatori");
            if (diaObertura < 1) {
                throw new IllegalArgumentException("Dia invalid: " + diaObertura);
            }
            if (multesCobrades < 0) {
                throw new IllegalArgumentException("Multes negatives: " + multesCobrades);
            }
            this.diaObertura = diaObertura;
            // ArrayList explicit: es serialitzable i la seva forma es estable
            this.operacions = new ArrayList<>(operacions);
            this.prestecsRealitzats = prestecsRealitzats;
            this.devolucionsRealitzades = devolucionsRealitzades;
            this.multesCobrades = multesCobrades;
        }

        /**
         * VALIDACIO en deserialitzar.
         *
         * Imprescindible: la deserialitzacio NO crida el constructor, aixi que
         * les comprovacions de dalt NO s'han executat. Un fitxer
         * corrupte o manipulat podria portar un dia negatiu o multes
         * impossibles, i la resta del sistema les donaria per bones.
         */
        private void readObject(ObjectInputStream entrada)
                throws IOException, ClassNotFoundException {

            entrada.defaultReadObject();

            if (idEmpleat == null || idEmpleat.isBlank()) {
                throw new InvalidObjectException(
                        "Sessio sense empleat: fitxer corrupte o manipulat");
            }
            if (diaObertura < 1) {
                throw new InvalidObjectException(
                        "Dia d'obertura invalid: " + diaObertura);
            }
            if (multesCobrades < 0) {
                throw new InvalidObjectException(
                        "Multes negatives: " + multesCobrades);
            }
            if (operacions == null) {
                throw new InvalidObjectException("Llista d'operacions nulla");
            }
            // El transient es recalcula sota demanda: no cal tocar-lo aqui
        }

        public String getIdEmpleat()            { return idEmpleat; }
        public int getDiaObertura()             { return diaObertura; }
        public List<String> getOperacions()     { return List.copyOf(operacions); }
        public int getPrestecsRealitzats()      { return prestecsRealitzats; }
        public int getDevolucionsRealitzades()  { return devolucionsRealitzades; }
        public double getMultesCobrades()       { return multesCobrades; }

        /** Camp derivat: es calcula la primera vegada i es desa a la cache. */
        public String getResum() {
            if (resumFormatat == null) {
                resumFormatat = String.format(
                        "Sessio de %s (dia %d): %d prestecs, %d devolucions, %.2f EUR",
                        idEmpleat, diaObertura, prestecsRealitzats,
                        devolucionsRealitzades, multesCobrades);
            }
            return resumFormatat;
        }
    }

    // ------------------------- DESAR -------------------------

    /**
     * Desa l'estat de forma ATOMICA (07-02): temporal i reanomenat.
     *
     * Una sessio desada a mitges seria pitjor que cap: en restaurar-la
     * donaria StreamCorruptedException o, pitjor, un objecte incomplet.
     */
    public void desar(EstatSessio estat) throws IOException {
        Objects.requireNonNull(estat, "L'estat no pot ser nul");

        File desti = new File(directori, estat.getIdEmpleat() + EXTENSIO);
        File temporal = new File(desti.getAbsolutePath() + ".tmp");

        boolean completat = false;
        try {
            try (ObjectOutputStream sortida = new ObjectOutputStream(
                    new BufferedOutputStream(new FileOutputStream(temporal)))) {

                sortida.writeObject(estat);
            }   // close() -> flush garantit

            if (desti.exists() && !desti.delete()) {
                throw new IOException("No s'ha pogut substituir " + desti.getName());
            }
            if (!temporal.renameTo(desti)) {
                throw new IOException("No s'ha pogut reanomenar " + temporal.getName());
            }
            completat = true;

            LOG.info(() -> String.format("Sessio de %s desada a %s (%d bytes)",
                    estat.getIdEmpleat(), desti.getName(), desti.length()));

        } finally {
            if (!completat && temporal.exists() && !temporal.delete()) {
                LOG.warning(() -> "Temporal sense esborrar: " + temporal.getAbsolutePath());
            }
        }
    }

    // ------------------------- RESTAURAR -------------------------

    /**
     * Restaura la sessio d'un empleat.
     *
     * DEGRADACIO ELEGANT (06-07): si no hi ha sessio desada, o si esta
     * corrupta, o si la classe ha canviat, es retorna buit i es comenca de
     * zero. Perdre una sessio no es una fallada greu: les dades de valor
     * son al cataleg.
     *
     * @return Optional buit si no s'ha pogut restaurar
     */
    public java.util.Optional<EstatSessio> restaurar(String idEmpleat) {
        File fitxer = new File(directori, idEmpleat + EXTENSIO);

        if (!fitxer.exists()) {
            LOG.fine(() -> "No hi ha sessio desada per a " + idEmpleat);
            return java.util.Optional.empty();
        }

        try (ObjectInputStream entrada = new ObjectInputStream(
                new BufferedInputStream(new FileInputStream(fitxer)))) {

            // FILTRE abans de llegir res. Defensa en profunditat (apartat 13).
            FiltreDeserialitzacio.aplicarA(entrada);

            EstatSessio estat = (EstatSessio) entrada.readObject();

            LOG.info(() -> "Sessio restaurada: " + estat.getResum());
            return java.util.Optional.of(estat);

        } catch (InvalidClassException e) {
            // La classe ha canviat de forma incompatible (apartat 8).
            LOG.log(Level.WARNING, "La sessio de " + idEmpleat
                    + " es va desar amb una altra versio de l'aplicacio i no es pot llegir", e);
            descartar(fitxer);
            return java.util.Optional.empty();

        } catch (InvalidObjectException e) {
            // El nostre readObject ha rebutjat el contingut: corrupte o manipulat.
            LOG.log(Level.SEVERE, "Sessio de " + idEmpleat + " rebutjada per validacio", e);
            descartar(fitxer);
            return java.util.Optional.empty();

        } catch (StreamCorruptedException | EOFException e) {
            // Fitxer truncat: es va tallar una escriptura. L'escriptura atomica
            // de desar() ho evita, pero una fallada del disc no.
            LOG.log(Level.WARNING, "Sessio de " + idEmpleat + " corrupta", e);
            descartar(fitxer);
            return java.util.Optional.empty();

        } catch (ClassNotFoundException e) {
            // No esten IOException: necessita el seu propi catch (apartat 3)
            LOG.log(Level.SEVERE, "Classe no trobada en restaurar la sessio de "
                    + idEmpleat, e);
            return java.util.Optional.empty();

        } catch (IOException e) {
            LOG.log(Level.SEVERE, "Fallada d'E/S restaurant la sessio de " + idEmpleat, e);
            return java.util.Optional.empty();
        }
    }

    /** Aparta un fitxer illegible en lloc d'esborrar-lo: pot caldre per diagnosticar. */
    private void descartar(File fitxer) {
        File apartat = new File(fitxer.getAbsolutePath() + ".invalid");
        if (apartat.exists()) {
            apartat.delete();
        }
        if (fitxer.renameTo(apartat)) {
            LOG.info(() -> "Sessio illegible apartada com a " + apartat.getName());
        }
    }
}

Ús complet:

public class DemoSessio {

    public static void main(String[] args) throws IOException {
        GestorSessio gestor = new GestorSessio("dades/sessions");

        // --- En arrencar: intentar restaurar ---
        gestor.restaurar("E-001").ifPresentOrElse(
                estat -> System.out.println("Continuant: " + estat.getResum()),
                ()    -> System.out.println("Sessio nova per a E-001"));

        // --- Feina del torn ---
        List<String> operacions = new ArrayList<>();
        operacions.add("PRESTEC PR-0001");
        operacions.add("PRESTEC PR-0002");
        operacions.add("DEVOLUCIO PR-0001 (multa 1,75)");

        // --- En sortir: desar ---
        gestor.desar(new GestorSessio.EstatSessio(
                "E-001", 15, operacions, 2, 1, 1.75));

        System.out.println("Sessio desada. Torna a executar per veure la restauracio.");
    }
}

Primera execució:

Sessio nova per a E-001
Sessio desada. Torna a executar per veure la restauracio.

Segona execució:

Continuant: Sessio de E-001 (dia 15): 2 prestecs, 1 devolucions, 1,75 EUR
Sessio desada. Torna a executar per veure la restauracio.

Les set decisions de disseny que cal entendre:

  1. EstatSessio és una classe a part de SessioBiblioteca. La sessió gestiona recursos —bloqueigs, logger, catàleg— que no s'han de serialitzar. L'estat conté només dades. Separar l'objecte viu de l'objecte persistible és una decisió de disseny que evita el contagi de l'apartat 5.
  2. serialVersionUID declarat des del primer dia. Afegir un mètode privat d'aquí a sis mesos deixaria illegibles totes les sessions si no hi fos.
  3. readObject valida com si fos un constructor. És imprescindible, perquè el constructor no s'executa. Sense aquella validació, un fitxer corrupte produiria objectes que la resta del sistema considera impossibles.
  4. El filtre s'aplica abans de llegir res. Defensa en profunditat, encara que el fitxer sigui local.
  5. Escriptura atòmica. Una sessió desada a mitges seria pitjor que cap.
  6. Cinc catch diferents, cadascun amb el seu tractament. InvalidClassException és un canvi de versió; InvalidObjectException és contingut rebutjat; StreamCorruptedException és un fitxer truncat; ClassNotFoundException no és una IOException i necessita el seu propi bloc. És 06-02 aplicat amb precisió.
  7. El fitxer illegible s'aparta, no s'esborra. Reanomenar-lo a .invalid permet diagnosticar després què va passar, sense bloquejar l'usuari.

I la decisió que resumeix la lliçó: la sessió fa servir serialització; el catàleg, no. El catàleg és la dada de valor, ha de ser llegible, versionable, importable des d'un full de càlcul i estable davant de canvis de codi. Això és CSV, i és la lliçó 07-07.

Errors Comuns i Consells

  • No declarar serialVersionUID. L'error número u. Un mètode privat afegit sis mesos després trenca tots els fitxers desats, amb un missatge que no explica la causa.
  • Declarar-lo malament. Sense static, sense final o sense el sufix L, s'ignora en silenci i torna el càlcul automàtic.
  • Incrementar-lo per costum. Només s'incrementa davant d'un canvi incompatible. Incrementar-lo per rutina invalida els fitxers sense necessitat.
  • Reanomenar un camp. Equival a esborrar-ne un i crear-ne un altre: la dada es perd i no hi ha cap error. La fallada apareix setmanes després.
  • Creure que private protegeix. Tots els camps privats queden escrits al fitxer, amb els seus noms.
  • Oblidar que la deserialització no crida el constructor. Totes les teves validacions se salten. Valida a readObject o el teu domini té una porta del darrere.
  • No reinicialitzar els camps transient. Queden a null o 0, i l'inicialitzador de camp no s'executa. NullPointerException al primer ús.
  • Un Logger no static. Contagia la NotSerializableException a tota la classe. private static final, sempre.
  • Serialitzar objectes amb recursos vius. Un socket o un flux no tenen sentit fora del seu procés.
  • Serialitzar List.of(...) o col·leccions no modificables del JDK. Són serialitzables, però la seva classe concreta és un detall intern. Desa un ArrayList o un HashMap explícits.
  • Un writeObject/readObject amb la signatura incorrecta. Ha de ser exactament private void, amb aquell paràmetre i aquelles excepcions. Si no, s'ignora sense cap avís.
  • Oblidar readResolve en un singleton. Deserialitzar crea una segona instància i trenca la unicitat. Els enum no tenen aquest problema.
  • Deserialitzar dades que no controles. L'error més greu possible. Permet execució de codi i el dany passa abans que el teu codi vegi res.
  • Confiar en un instanceof posterior. Arriba tard: la classe ja s'ha instanciat durant readObject().
  • Fer servir una llista negra al filtre. Sempre es queda curta. Llista blanca i !* al final.
  • Creure que un filtre n'hi ha prou. Redueix el risc; no autoritza a deserialitzar el que arribi.
  • Fer servir serialització com a format d'intercanvi. Només Java ho llegeix, no és llegible, i es trenca amb cada canvi de classe.
  • Fer-la servir per a emmagatzematge a llarg termini. Els fitxers de fa dos anys deixen de llegir-se tan bon punt evoluciona el codi.
  • Consell: separa l'objecte viu de l'objecte persistible. SessioBiblioteca gestiona recursos; EstatSessio només té dades. Aquesta separació elimina d'arrel el problema del contagi.
  • Consell: activa -Xlint:serial. L'avís que evita el problema abans de tenir-lo.
  • Consell: escriu una prova de compatibilitat. Desa un fitxer de referència amb la versió actual, fica'l al repositori i tingues una prova que el llegeixi. El dia que algú trenqui la compatibilitat, falla la prova en lloc de fallar el client.
  • Consell: si la dada té valor, no la desis només així. La serialització és acceptable per a memòria cau i estat transitori. Per al que importa, un format llegible.

Exercicis

Exercici 1: laboratori de compatibilitat

Escriu LaboratoriCompatibilitat que demostri empíricament l'efecte del serialVersionUID:

  1. Una classe interna DadaV1 sense serialVersionUID, amb tres camps.
  2. Un mètode que la serialitzi a fitxer i un altre que la deserialitzi.
  3. Documenta amb comentaris l'experiment: compilar, desar, afegir un mètode privat, recompilar, i intentar llegir. Inclou el missatge d'error esperat.
  4. Una classe DadaV2 amb serialVersionUID = 1L i un camp afegit, i demostra que sí que pot llegir un fitxer escrit per DadaV1B (mateixa classe, mateix UID, sense aquell camp), mostrant quin valor pren el camp nou.
  5. Un informe final amb la taula de quins canvis són compatibles.

Exercici 2: còpia profunda per serialització

Escriu una classe d'utilitat CopiaProfunda amb un mètode que creï una còpia profunda de qualsevol objecte serialitzable fent servir ByteArrayOutputStream i ByteArrayInputStream (07-03), sense tocar el disc.

  1. El mètode ha de funcionar amb qualsevol objecte serialitzable (fes servir Object; no defineixis genèrics propis, que és 10-01).
  2. Demostra la diferència entre còpia superficial i profunda amb una classe CarretPrestecs que contingui una List<String>: modifica la llista de la còpia i comprova que l'original no canvia.
  3. Mesura el temps i compara'l amb una còpia manual escrita a mà.
  4. Explica en comentaris tres limitacions d'aquesta tècnica.

Exercici 3: gestor de sessions amb versionatge

Amplia GestorSessio perquè suporti dues versions de l'estat desat:

  1. EstatSessio versió 1 amb els camps actuals.
  2. Afegeix un camp int materialsConsultats mantenint serialVersionUID = 1L.
  3. A readObject, detecta que el fitxer és antic —el camp nou val 0— i aplica un valor per defecte raonable amb un avís al logger.
  4. Afegeix un mètode llistarSessions() que retorni un informe de totes les sessions desades al directori, indicant quines són llegibles i quines no.
  5. Afegeix netejarSessionsInvalides() que aparti els fitxers illegibles.
  6. Un main que demostri el cicle complet.

Solucions

Solució 1

package com.nexussoftware.bibliotech.demo;

import java.io.*;

/**
 * Laboratori del serialVersionUID.
 *
 * COM REPRODUIR L'EXPERIMENT PRINCIPAL:
 *
 *   1. Compilar i executar amb el metode getEtiqueta() COMENTAT a DadaV1.
 *      Es genera dades/v1.ser
 *
 *   2. DESCOMENTAR getEtiqueta() (un metode PRIVAT, que no toca l'estat).
 *
 *   3. Recompilar i tornar a executar.
 *
 *   RESULTAT: en llegir dades/v1.ser s'obte
 *
 *     java.io.InvalidClassException: ...DadaV1; local class incompatible:
 *       stream classdesc serialVersionUID = -4738291056473829104,
 *       local class serialVersionUID = 8273645019283746152
 *
 *   El fitxer queda ILLEGIBLE PER SEMPRE per haver afegit un metode
 *   privat que no afecta l'estat en absolut.
 */
public class LaboratoriCompatibilitat {

    // ---------------- SENSE serialVersionUID: fragil ----------------

    static class DadaV1 implements Serializable {
        // SENSE serialVersionUID: es calcula automaticament a partir de
        // nom, camps, METODES (inclosos els privats), constructors...
        private final String referencia;
        private final String titol;
        private final int any;

        DadaV1(String referencia, String titol, int any) {
            this.referencia = referencia;
            this.titol = titol;
            this.any = any;
        }

        // PAS 2 DE L'EXPERIMENT: descomentar aixo i recompilar.
        // private String getEtiqueta() { return referencia + " - " + titol; }

        @Override
        public String toString() {
            return String.format("DadaV1[%s, %s, %d]", referencia, titol, any);
        }
    }

    // ---------------- AMB serialVersionUID: estable ----------------

    /** Versio B: tres camps, UID declarat. */
    static class DadaV2 implements Serializable {
        private static final long serialVersionUID = 1L;

        private final String referencia;
        private final String titol;
        private final int any;

        // CAMP AFEGIT a la versio 2. Compatible: els fitxers escrits
        // sense ell es llegeixen igual, i aquest camp pren el valor per defecte.
        private final String autor;

        DadaV2(String referencia, String titol, int any, String autor) {
            this.referencia = referencia;
            this.titol = titol;
            this.any = any;
            this.autor = autor;
        }

        @Override
        public String toString() {
            return String.format("DadaV2[%s, %s, %d, autor=%s]",
                    referencia, titol, any,
                    autor == null ? "(null: fitxer antic)" : autor);
        }
    }

    // ---------------- Utilitats ----------------

    static void desar(Object o, String cami) throws IOException {
        try (ObjectOutputStream sortida = new ObjectOutputStream(
                new BufferedOutputStream(new FileOutputStream(cami)))) {
            sortida.writeObject(o);
        }
    }

    static Object carregar(String cami) throws IOException, ClassNotFoundException {
        try (ObjectInputStream entrada = new ObjectInputStream(
                new BufferedInputStream(new FileInputStream(cami)))) {
            return entrada.readObject();
        }
    }

    /** Mostra l'UID que el mecanisme assigna a una classe, declarat o calculat. */
    static long uidDe(Class<?> classe) {
        ObjectStreamClass osc = ObjectStreamClass.lookup(classe);
        return (osc == null) ? 0L : osc.getSerialVersionUID();
    }

    public static void main(String[] args) throws Exception {
        new File("dades").mkdirs();

        System.out.println("=== UID DE CADA CLASSE ===");
        System.out.printf("  DadaV1 (sense declarar): %d%n", uidDe(DadaV1.class));
        System.out.printf("  DadaV2 (declarat)      : %d%n", uidDe(DadaV2.class));
        System.out.println("  El primer canvia en tocar la classe; el segon, mai.");
        System.out.println();

        // --- Experiment 1: sense UID declarat ---
        System.out.println("=== SENSE serialVersionUID ===");
        File v1 = new File("dades/v1.ser");

        if (!v1.exists()) {
            desar(new DadaV1("978-0000000001", "Java Eficac", 2018), v1.getPath());
            System.out.println("  Fitxer creat. ARA: descomenta getEtiqueta(),");
            System.out.println("  recompila i torna a executar.");
        } else {
            try {
                System.out.println("  Llegit: " + carregar(v1.getPath()));
                System.out.println("  (la classe no ha canviat des que es va desar)");
            } catch (InvalidClassException e) {
                System.out.println("  INCOMPATIBLE, com s'esperava:");
                System.out.println("  " + e.getMessage());
                System.out.println("  Causa: s'ha afegit un metode. El fitxer es illegible.");
            }
        }

        // --- Experiment 2: amb UID declarat i camp afegit ---
        System.out.println();
        System.out.println("=== AMB serialVersionUID ===");

        File v2 = new File("dades/v2.ser");
        desar(new DadaV2("978-0000000002", "Patrons de Disseny", 1994, "Gamma"),
                v2.getPath());
        System.out.println("  Desat i llegit: " + carregar(v2.getPath()));

        System.out.println();
        System.out.println("=== CANVIS COMPATIBLES (amb l'UID declarat) ===");
        System.out.println("  COMPATIBLES:");
        System.out.println("    - Afegir un camp        -> val null / 0 / false");
        System.out.println("    - Eliminar un camp      -> el valor del fitxer es descarta");
        System.out.println("    - Afegir o treure metodes-> cap efecte");
        System.out.println("    - Canviar el cos        -> cap efecte");
        System.out.println("    - normal <-> transient  -> com afegir o treure camp");
        System.out.println("  INCOMPATIBLES:");
        System.out.println("    - Canviar el TIPUS d'un camp  -> InvalidClassException");
        System.out.println("    - REANOMENAR un camp          -> es PERD la dada, sense error");
        System.out.println("    - Reanomenar o moure la classe-> ClassNotFoundException");
        System.out.println("    - Canviar l'herencia          -> InvalidClassException");
    }
}

El punt crític de l'exercici és al comentari de capçalera: un mètode privat que no toca l'estat invalida tots els fitxers. És la demostració més contundent de per què serialVersionUID no és opcional. I fixa't en la línia de DadaV2 que imprimeix autor=(null: fitxer antic): així es veu exactament què passa en afegir un camp, i per què cal decidir un valor per defecte sensat en lloc d'acceptar el null.

Solució 2

package com.nexussoftware.bibliotech.util;

import java.io.*;
import java.util.ArrayList;
import java.util.List;

/**
 * Copia profunda mitjancant serialitzacio en memoria.
 *
 * Serialitza a un array de bytes i deserialitza des d'ell: el resultat es un
 * graf completament nou, sense cap referencia compartida amb l'original.
 * Sense tocar el disc: ByteArrayOutputStream / ByteArrayInputStream
 * (07-03).
 *
 * TRES LIMITACIONS REALS:
 *
 *   1. TOT el graf ha de ser Serializable. Un sol camp no serialitzable a
 *      qualsevol profunditat fa fallar la copia sencera (NotSerializableException).
 *
 *   2. ES LENTA. Serialitzar i deserialitzar implica reflexio, construccio
 *      de metadades i copia de bytes. Un metode de copia escrit a ma es
 *      entre deu i cent vegades mes rapid.
 *
 *   3. ELS CAMPS transient NO ES COPIEN. Queden a null o 0 a la copia,
 *      que es correcte per a una cache pero un error silencios si el camp
 *      es va marcar transient per un altre motiu.
 *
 *   I una quarta que conve tenir present: si l'objecte vingues d'una
 *   font no fiable, aquesta tecnica hereta TOTS els riscos de l'apartat
 *   12. Fes-la servir nomes amb objectes que ja tens en memoria i controles.
 */
public final class CopiaProfunda {

    private CopiaProfunda() { }

    /**
     * Copia profunda d'un objecte serialitzable.
     *
     * Retorna Object: convertir es cosa de qui crida. (Amb generics
     * quedaria millor, pero aixo es 10-01.)
     */
    public static Object copiar(Serializable original) throws IOException {
        if (original == null) {
            return null;
        }

        ByteArrayOutputStream memoria = new ByteArrayOutputStream();

        try (ObjectOutputStream sortida = new ObjectOutputStream(memoria)) {
            sortida.writeObject(original);
        }
        // ByteArrayOutputStream no cal tancar-lo: el seu close() no fa res,
        // i tancar-lo abans de toByteArray() seria un error conceptual (07-03).

        try (ObjectInputStream entrada = new ObjectInputStream(
                new ByteArrayInputStream(memoria.toByteArray()))) {

            return entrada.readObject();

        } catch (ClassNotFoundException e) {
            // Impossible a la practica: la classe esta carregada, acabem de
            // serialitzar-la. Es tradueix a IOException per no obligar
            // qui crida a capturar una cosa que no pot passar (06-03).
            throw new IOException("Fallada impossible en copiar: classe no trobada", e);
        }
    }

    // ------------------------- DEMOSTRACIO -------------------------

    /** Objecte de prova amb una llista mutable a dins. */
    static class CarretPrestecs implements Serializable {
        private static final long serialVersionUID = 1L;

        private final String idEmpleat;
        private final List<String> referencies;
        private double totalEstimat;

        /** transient: NO es copiara. Queda a null a la copia. */
        private transient String notaInterna;

        CarretPrestecs(String idEmpleat) {
            this.idEmpleat = idEmpleat;
            this.referencies = new ArrayList<>();
            this.notaInterna = "nota de treball";
        }

        void afegir(String referencia, double cost) {
            referencies.add(referencia);
            totalEstimat += cost;
        }

        List<String> getReferencies() { return referencies; }   // referencia mutable
        String getNotaInterna()       { return notaInterna; }

        @Override
        public String toString() {
            return String.format("Carret[%s, %s, %.2f, nota=%s]",
                    idEmpleat, referencies, totalEstimat,
                    notaInterna == null ? "(null)" : notaInterna);
        }
    }

    /** Copia manual, escrita a ma, per comparar rendiment. */
    static CarretPrestecs copiarAMa(CarretPrestecs original) {
        CarretPrestecs copia = new CarretPrestecs(original.idEmpleat);
        copia.referencies.addAll(original.referencies);      // llista nova
        copia.totalEstimat = original.totalEstimat;
        copia.notaInterna = original.notaInterna;            // aquesta SI que es copia
        return copia;
    }

    public static void main(String[] args) throws IOException {
        CarretPrestecs original = new CarretPrestecs("E-001");
        original.afegir("978-0000000001", 0.25);
        original.afegir("978-0000000002", 0.25);

        System.out.println("=== COPIA SUPERFICIAL (assignacio de referencia) ===");
        CarretPrestecs superficial = original;
        superficial.getReferencies().add("978-0000000003");
        System.out.println("  Original: " + original.getReferencies());
        System.out.println("  'Copia' : " + superficial.getReferencies());
        System.out.println("  Son el MATEIX objecte: " + (original == superficial));

        System.out.println();
        System.out.println("=== COPIA PROFUNDA (serialitzacio) ===");
        CarretPrestecs profunda = (CarretPrestecs) copiar(original);
        profunda.getReferencies().add("978-0000000004");

        System.out.println("  Original: " + original.getReferencies());
        System.out.println("  Copia   : " + profunda.getReferencies());
        System.out.println("  Objectes diferents     : " + (original != profunda));
        System.out.println("  Llistes diferents      : "
                + (original.getReferencies() != profunda.getReferencies()));
        System.out.println("  Nota (transient) copiada: " + profunda.getNotaInterna());

        System.out.println();
        System.out.println("=== RENDIMENT (10 000 copies) ===");

        long inici = System.nanoTime();
        for (int i = 0; i < 10_000; i++) {
            copiar(original);
        }
        long msSerialitzacio = (System.nanoTime() - inici) / 1_000_000;

        inici = System.nanoTime();
        for (int i = 0; i < 10_000; i++) {
            copiarAMa(original);
        }
        long msManual = (System.nanoTime() - inici) / 1_000_000;

        System.out.printf("  Per serialitzacio: %5d ms%n", msSerialitzacio);
        System.out.printf("  A ma             : %5d ms%n", msManual);
        System.out.printf("  Factor           : %.1fx mes lenta%n",
                (double) msSerialitzacio / Math.max(1, msManual));
    }
}

Sortida:

=== COPIA SUPERFICIAL (assignacio de referencia) ===
  Original: [978-0000000001, 978-0000000002, 978-0000000003]
  'Copia' : [978-0000000001, 978-0000000002, 978-0000000003]
  Son el MATEIX objecte: true

=== COPIA PROFUNDA (serialitzacio) ===
  Original: [978-0000000001, 978-0000000002, 978-0000000003]
  Copia   : [978-0000000001, 978-0000000002, 978-0000000003, 978-0000000004]
  Objectes diferents     : true
  Llistes diferents      : true
  Nota (transient) copiada: null

=== RENDIMENT (10 000 copies) ===
  Per serialitzacio:   412 ms
  A ma             :     4 ms
  Factor           : 103.0x mes lenta

Els tres punts que cal veure en aquesta sortida:

  1. La còpia superficial no és una còpia. superficial = original copia la referència: afegir a una afegeix a l'altra, perquè són el mateix objecte. És l'error clàssic que aquesta tècnica resol.
  2. transient no es copia. La nota interna surt a null. És correcte per a una memòria cau i és un error silenciós si el camp es va marcar transient per un altre motiu, com una contrasenya que la còpia sí que necessitava.
  3. Cent vegades més lenta. Per copiar un objecte en un bucle, escriu el mètode de còpia a mà. Aquesta tècnica serveix quan el graf és profund, canvia sovint i el rendiment no és crític: llavors estalvia escriure i mantenir desenes de línies de còpia.

Solució 3

package com.nexussoftware.bibliotech.servei;

import java.io.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;

import com.nexussoftware.bibliotech.infraestructura.FiltreDeserialitzacio;

/**
 * Gestor de sessions amb suport de DUES versions de l'estat desat.
 *
 * La versio 2 afegeix el camp 'materialsConsultats' MANTENINT el mateix
 * serialVersionUID, perque afegir un camp es un canvi COMPATIBLE: els
 * fitxers de la versio 1 es continuen llegint i el camp nou arriba a 0.
 *
 * El truc es a readObject: distingir "de veritat se'n van consultar 0" de
 * "aquest fitxer es antic i no ho sap" es impossible amb el valor per
 * defecte, aixi que es fa servir un SENTINELLA explicit.
 */
public class GestorSessioVersionat {

    private static final Logger LOG =
            Logger.getLogger(GestorSessioVersionat.class.getName());

    private static final String EXTENSIO = ".sessio";
    private static final String EXTENSIO_INVALIDA = ".sessio.invalid";

    private final File directori;

    public GestorSessioVersionat(String directoriDades) {
        this.directori = new File(Objects.requireNonNull(directoriDades));
        if (!directori.exists() && !directori.mkdirs()) {
            LOG.warning("No s'ha pogut crear " + directori.getAbsolutePath());
        }
    }

    // ------------------------- L'ESTAT, VERSIO 2 -------------------------

    public static class EstatSessio implements Serializable {

        /** MATEIX valor que a la versio 1: el canvi es compatible. */
        private static final long serialVersionUID = 1L;

        /** Marca "no informat", per distingir-lo d'un 0 real. */
        private static final int NO_INFORMAT = -1;

        private final String idEmpleat;
        private final int diaObertura;
        private final List<String> operacions;
        private final int prestecsRealitzats;
        private final int devolucionsRealitzades;
        private final double multesCobrades;

        /**
         * CAMP AFEGIT A LA VERSIO 2.
         *
         * No es final: readObject necessita poder corregir-lo quan el
         * fitxer ve de la versio 1.
         */
        private int materialsConsultats;

        /** Versio del format amb que es va crear. transient: es dedueix. */
        private transient int versioDetectada = 2;

        public EstatSessio(String idEmpleat, int diaObertura, List<String> operacions,
                           int prestecsRealitzats, int devolucionsRealitzades,
                           double multesCobrades, int materialsConsultats) {

            this.idEmpleat = Objects.requireNonNull(idEmpleat, "L'empleat es obligatori");
            if (diaObertura < 1) {
                throw new IllegalArgumentException("Dia invalid: " + diaObertura);
            }
            if (multesCobrades < 0) {
                throw new IllegalArgumentException("Multes negatives: " + multesCobrades);
            }
            this.diaObertura = diaObertura;
            this.operacions = new ArrayList<>(operacions);
            this.prestecsRealitzats = prestecsRealitzats;
            this.devolucionsRealitzades = devolucionsRealitzades;
            this.multesCobrades = multesCobrades;
            this.materialsConsultats = Math.max(0, materialsConsultats);
        }

        /**
         * Migracio i validacio.
         *
         * Un fitxer de la versio 1 no conte 'materialsConsultats', aixi
         * que arriba amb 0. Com que no podem distingir aquest 0 d'un 0 real,
         * apliquem una HEURISTICA raonable i DEIXEM CONSTANCIA al log:
         * qui va consultar materials com a minim va consultar els que va prestar.
         */
        private void readObject(ObjectInputStream entrada)
                throws IOException, ClassNotFoundException {

            entrada.defaultReadObject();

            // --- VALIDACIO (el constructor no s'ha executat) ---
            if (idEmpleat == null || idEmpleat.isBlank()) {
                throw new InvalidObjectException("Sessio sense empleat: fitxer corrupte");
            }
            if (diaObertura < 1) {
                throw new InvalidObjectException("Dia invalid: " + diaObertura);
            }
            if (multesCobrades < 0) {
                throw new InvalidObjectException("Multes negatives: " + multesCobrades);
            }
            if (operacions == null) {
                throw new InvalidObjectException("Llista d'operacions nulla");
            }

            // --- MIGRACIO DE VERSIO ---
            if (materialsConsultats == 0 && prestecsRealitzats > 0) {
                versioDetectada = 1;
                materialsConsultats = prestecsRealitzats;

                LOG.info(() -> String.format(
                        "Sessio de %s desada amb la versio 1 del format: s'estima "
                                + "materialsConsultats = %d a partir dels prestecs",
                        idEmpleat, prestecsRealitzats));
            } else {
                versioDetectada = 2;
            }

            if (materialsConsultats < 0) {
                materialsConsultats = 0;
            }
        }

        public String getIdEmpleat()            { return idEmpleat; }
        public int getDiaObertura()             { return diaObertura; }
        public List<String> getOperacions()     { return List.copyOf(operacions); }
        public int getPrestecsRealitzats()      { return prestecsRealitzats; }
        public int getDevolucionsRealitzades()  { return devolucionsRealitzades; }
        public double getMultesCobrades()       { return multesCobrades; }
        public int getMaterialsConsultats()     { return materialsConsultats; }
        public int getVersioDetectada()         { return versioDetectada; }

        public String getResum() {
            return String.format(
                    "Sessio de %s (dia %d, format v%d): %d prestecs, %d devolucions, "
                            + "%d consultes, %.2f EUR",
                    idEmpleat, diaObertura, versioDetectada, prestecsRealitzats,
                    devolucionsRealitzades, materialsConsultats, multesCobrades);
        }
    }

    // ------------------------- OPERACIONS -------------------------

    public void desar(EstatSessio estat) throws IOException {
        Objects.requireNonNull(estat, "L'estat no pot ser nul");

        File desti = new File(directori, estat.getIdEmpleat() + EXTENSIO);
        File temporal = new File(desti.getAbsolutePath() + ".tmp");
        boolean completat = false;

        try {
            try (ObjectOutputStream sortida = new ObjectOutputStream(
                    new BufferedOutputStream(new FileOutputStream(temporal)))) {
                sortida.writeObject(estat);
            }
            if (desti.exists() && !desti.delete()) {
                throw new IOException("No s'ha pogut substituir " + desti.getName());
            }
            if (!temporal.renameTo(desti)) {
                throw new IOException("No s'ha pogut reanomenar " + temporal.getName());
            }
            completat = true;
            LOG.info(() -> "Sessio desada: " + desti.getName());

        } finally {
            if (!completat && temporal.exists() && !temporal.delete()) {
                LOG.warning(() -> "Temporal sense esborrar: " + temporal.getAbsolutePath());
            }
        }
    }

    public java.util.Optional<EstatSessio> restaurar(String idEmpleat) {
        File fitxer = new File(directori, idEmpleat + EXTENSIO);
        if (!fitxer.exists()) {
            return java.util.Optional.empty();
        }
        try {
            return java.util.Optional.of(llegir(fitxer));
        } catch (Exception e) {
            LOG.log(Level.WARNING, "No s'ha pogut restaurar " + fitxer.getName(), e);
            return java.util.Optional.empty();
        }
    }

    /** Lectura amb filtre. Llanca perque qui cridi decideixi. */
    private EstatSessio llegir(File fitxer) throws IOException, ClassNotFoundException {
        try (ObjectInputStream entrada = new ObjectInputStream(
                new BufferedInputStream(new FileInputStream(fitxer)))) {

            FiltreDeserialitzacio.aplicarA(entrada);
            return (EstatSessio) entrada.readObject();
        }
    }

    // ------------------------- INFORME I NETEJA -------------------------

    /** Estat d'un fitxer de sessio del directori. */
    public record InfoSessio(String nom, long mida, boolean llegible,
                             String detall) { }

    /**
     * Informe de totes les sessions desades.
     *
     * Intenta llegir cadascuna i classifica el resultat. NO propaga: l'informe
     * ha de poder generar-se encara que hi hagi fitxers trencats, que es precisament
     * quan mes falta fa.
     */
    public List<InfoSessio> llistarSessions() {
        List<InfoSessio> informe = new ArrayList<>();

        File[] fitxers = directori.listFiles(
                (dir, nom) -> nom.endsWith(EXTENSIO));

        if (fitxers == null) {                         // pot ser null (07-01)
            LOG.warning("No s'ha pogut llistar " + directori.getAbsolutePath());
            return informe;
        }

        Arrays.sort(fitxers);                           // ordre estable (05-09)

        for (File f : fitxers) {
            try {
                EstatSessio estat = llegir(f);
                informe.add(new InfoSessio(f.getName(), f.length(), true,
                        estat.getResum()));

            } catch (InvalidClassException e) {
                informe.add(new InfoSessio(f.getName(), f.length(), false,
                        "Versio de classe incompatible"));

            } catch (InvalidObjectException e) {
                informe.add(new InfoSessio(f.getName(), f.length(), false,
                        "Rebutjada per validacio: " + e.getMessage()));

            } catch (StreamCorruptedException | EOFException e) {
                informe.add(new InfoSessio(f.getName(), f.length(), false,
                        "Fitxer corrupte o truncat"));

            } catch (ClassNotFoundException e) {
                informe.add(new InfoSessio(f.getName(), f.length(), false,
                        "Classe no trobada: " + e.getMessage()));

            } catch (IOException e) {
                informe.add(new InfoSessio(f.getName(), f.length(), false,
                        "Fallada d'E/S: " + e.getMessage()));
            }
        }
        return informe;
    }

    /**
     * Aparta els fitxers illegibles reanomenant-los.
     *
     * NO els esborra: un fitxer illegible pot caldre per diagnosticar
     * per que va deixar de ser-ho.
     *
     * @return quants se n'han apartat
     */
    public int netejarSessionsInvalides() {
        int apartats = 0;

        for (InfoSessio info : llistarSessions()) {
            if (info.llegible()) {
                continue;
            }
            File origen = new File(directori, info.nom());
            File desti = new File(directori,
                    info.nom().replace(EXTENSIO, EXTENSIO_INVALIDA));

            if (desti.exists()) {
                desti.delete();
            }
            if (origen.renameTo(desti)) {
                apartats++;
                LOG.info(() -> "Apartada sessio illegible: " + desti.getName()
                        + " (" + info.detall() + ")");
            } else {
                LOG.warning(() -> "No s'ha pogut apartar " + origen.getName());
            }
        }
        return apartats;
    }

    // ------------------------- DEMOSTRACIO -------------------------

    public static void main(String[] args) throws IOException {
        GestorSessioVersionat gestor = new GestorSessioVersionat("dades/sessions");

        // 1. Desar tres sessions
        gestor.desar(new EstatSessio("E-001", 15,
                List.of("PRESTEC PR-0001", "DEVOLUCIO PR-0001"), 1, 1, 1.75, 4));
        gestor.desar(new EstatSessio("E-002", 15,
                List.of("PRESTEC PR-0002"), 1, 0, 0.0, 2));
        gestor.desar(new EstatSessio("E-003", 16,
                List.of(), 0, 0, 0.0, 0));

        // 2. Crear un fitxer corrupte a proposit
        File corrupte = new File("dades/sessions/E-999.sessio");
        try (FileOutputStream fos = new FileOutputStream(corrupte)) {
            fos.write("aixo no es un flux d'objectes".getBytes());
        }

        // 3. Informe
        System.out.println("=== SESSIONS DESADES ===");
        for (InfoSessio info : gestor.llistarSessions()) {
            System.out.printf("  %-18s %6d bytes  %-10s %s%n",
                    info.nom(), info.mida(),
                    info.llegible() ? "[OK]" : "[ERROR]", info.detall());
        }

        // 4. Restauracio concreta
        System.out.println();
        System.out.println("=== RESTAURACIO ===");
        gestor.restaurar("E-001").ifPresentOrElse(
                e -> System.out.println("  " + e.getResum()),
                () -> System.out.println("  No s'ha pogut restaurar E-001"));

        // 5. Neteja
        System.out.println();
        System.out.printf("=== NETEJA: %d fitxers apartats ===%n",
                gestor.netejarSessionsInvalides());
    }
}

Sortida:

=== SESSIONS DESADES ===
  E-001.sessio          276 bytes  [OK]       Sessio de E-001 (dia 15, format v2): 1 prestecs, 1 devolucions, 4 consultes, 1,75 EUR
  E-002.sessio          246 bytes  [OK]       Sessio de E-002 (dia 15, format v2): 1 prestecs, 0 devolucions, 2 consultes, 0,00 EUR
  E-003.sessio          222 bytes  [OK]       Sessio de E-003 (dia 16, format v2): 0 prestecs, 0 devolucions, 0 consultes, 0,00 EUR
  E-999.sessio           29 bytes  [ERROR]    Fitxer corrupte o truncat

=== RESTAURACIO ===
  Sessio de E-001 (dia 15, format v2): 1 prestecs, 1 devolucions, 4 consultes, 1,75 EUR

=== NETEJA: 1 fitxers apartats ===

Els quatre punts didàctics:

  1. El serialVersionUID no canvia en afegir el camp. Afegir un camp és compatible, així que mantenir-lo és el correcte. Incrementar-lo hauria invalidat innecessàriament totes les sessions existents.
  2. La migració fa servir una heurística explícita i la registra. Un fitxer de la versió 1 arriba amb materialsConsultats = 0, i no hi ha manera de distingir-lo d'un 0 real. La solució honesta és aplicar una estimació raonable, deixar-la al logger i exposar versioDetectada perquè se sàpiga que la dada és estimada. Fingir que la dada és exacta seria pitjor.
  3. llistarSessions() no propaga. Un informe de diagnòstic ha de poder generar-se precisament quan hi ha fitxers trencats. Si el primer fitxer corrupte avortés el llistat, l'eina seria inútil just quan fa falta.
  4. Els fitxers illegibles s'aparten, no s'esborren. Reanomenar-los permet investigar després què va passar sense bloquejar l'usuari. Esborrar dades perquè no s'entenen és una decisió que gairebé mai no és la correcta.

Conclusió

Saps serialitzar, i —més important— saps quan no fer-ho.

Entens que serialitzar és convertir un graf d'objectes en bytes, no un objecte solt: amb les seves referències compartides preservades mitjançant una taula interna, de manera que dos préstecs que apuntaven al mateix Empleat tornen a apuntar a la mateixa instància, i amb les referències circulars resoltes sense recursió infinita. Reproduir-ho a mà exigeix inventar identificadors i fer dues passades en carregar; aquest és l'avantatge real del mecanisme, i no és petit.

Coneixes Serializable com a interfície marcadora —el concepte de 04-01— i saps que implementar-la és un compromís seriós: la forma serialitzada d'una classe passa a formar part de la seva API pública, amb tots els seus camps privats exposats i subjectes a compatibilitat. Saps fer servir ObjectOutputStream/ObjectInputStream amb writeObject/readObject, escriure i llegir diversos objectes, i que serialitzar la col·lecció sencera és millor que portar un comptador a mà. I saps que ClassNotFoundException no estén IOException i necessita el seu propi catch.

Saps què es desa i què no: tot el d'instància, inclosos els private i els final, i res del que és static ni del que és transient. Coneixes els tres usos legítims de transient —dades derivades, secrets i recursos no serialitzables— i el detall que provoca NullPointerException la primera vegada que es fa servir un objecte restaurat: els inicialitzadors de camp no s'executen, així que un transient Map inicialitzat a la seva declaració arriba a null. Coneixes l'efecte contagi de NotSerializableException, com llegir-ne la traça de pila i les tres solucions, amb la regla que ho evita gairebé sempre: el Logger va private static final.

Domines serialVersionUID: què és, com es calcula per defecte a partir de noms, camps, mètodes —inclosos els privats— i constructors, i per què això significa que afegir un mètode auxiliar deixa illegibles tots els fitxers desats. Saps declarar-lo amb els seus quatre modificadors exactes, saps que si te'n deixes cap s'ignora en silenci, i saps que només s'incrementa davant d'un canvi incompatible. Ara entens del tot per què les teves excepcions del mòdul 6 el porten: una excepció que no es pot deserialitzar converteix un error de negoci comprensible en una fallada incomprensible d'infraestructura. I tens la taula completa de quins canvis són compatibles, amb els dos paranys: afegir un camp és compatible però el seu valor per defecte pot violar els teus invariants, i reanomenar un camp perd la dada sense cap error.

Saps personalitzar amb writeObject/readObject privats, la signatura dels quals ha de ser exacta o s'ignoren sense avisar, i coneixes el seu ús més important: validar. Perquè la deserialització no crida cap constructor, totes les comprovacions que vas escriure a 06-03 se salten, i un fitxer manipulat pot produir objectes que el teu codi considera impossibles. readObject és la porta del darrere del teu domini i cal tancar-la amb InvalidObjectException. Coneixes readResolve per preservar els singleton, Externalizable i els seus tres motius per no fer-lo servir, i les regles de l'herència, amb el constructor sense arguments que necessita tota superclasse no serialitzable.

I tens l'apartat que de veritat importa: deserialitzar dades no fiables permet executar codi arbitrari, perquè readObject instancia classes el nom de les quals ve al fitxer i executa els seus mètodes abans que el teu codi vegi res, cosa que fa inútil qualsevol comprovació posterior. Saps què es considera no fiable —bàsicament tot el que no hagis escrit tu en un directori que controles—, coneixes els ObjectInputFilter amb llista blanca i !* al final, i els seus quatre límits numèrics contra les bombes de deserialització. I saps que el filtre redueix el risc però no l'elimina, amb l'avís formal que qualsevol ús que creui una frontera de confiança l'ha de revisar el responsable de seguretat de la teva organització, no tu un dimarts a la tarda.

Tens la taula d'alternatives amb la diferència crucial: els formats de text no instancien classes. Un CSV produeix cadenes, i tu decideixes què construir amb elles passant per les teves validacions. I saps quan la serialització nativa continua sent raonable: memòria cau local, estat intern, còpia profunda en memòria, processos de confiança que controles pels dos extrems.

BiblioTech desa i restaura una sessió completa entre execucions. GestorSessio separa l'objecte viu (SessioBiblioteca, amb els seus bloqueigs i el seu logger) de l'objecte persistible (EstatSessio, només dades), declara serialVersionUID des del primer dia, valida a readObject com si fos un constructor, aplica un filtre abans de llegir res, escriu de forma atòmica, distingeix cinc tipus de fallada amb el seu tractament propi, i aparta els fitxers illegibles en lloc d'esborrar-los. I la decisió que resumeix la lliçó està presa explícitament: la sessió fa servir serialització; el catàleg, no, perquè el catàleg és la dada de valor i ha de ser llegible, versionable, importable i estable davant de canvis de codi.

Ens queda per resoldre la part més incòmoda de tot el que has fet fins aquí. Continues fent servir java.io.File —amb els seus boolean muts que diuen que alguna cosa ha fallat sense dir per què, els seus listFiles() que retornen null, i el seu renameTo que es comporta diferent a cada sistema operatiu—. Has escrit a mà la creació de directoris, l'escriptura atòmica i la rotació de fitxers, i a cadascuna has hagut de posar un comentari dient que a 07-06 es fa bé.

A la lliçó 07-06, L'API NIO.2: Path i Files, es fa bé. Veuràs per què existeix NIO.2 i quins problemes concrets de File resol; Path amb resolve, normalize, relativize i tota la seva aritmètica de camins; la classe d'utilitat Files amb createDirectories, copy, move amb ATOMIC_MOVE —que converteix la teva escriptura atòmica en una sola crida correcta—, delete davant de deleteIfExists, i excepcions específiques en lloc de boolean; la lectura i escriptura d'alt nivell amb readString, writeString i newBufferedReader, amb les StandardOpenOption que fan impossible confondre APPEND amb un charset; el recorregut d'arbres de directoris; i els atributs de fitxer. I BiblioTech migrarà per fi tota la seva capa de persistència, crearà el seu directori dades/ si no existeix, farà còpies de seguretat rotatives de veritat i localitzarà tots els seus informes en un arbre de carpetes.

Curs de Programació en Java

Mòdul 1: Introducció a Java

Mòdul 2: Flux de control

Mòdul 3: Programació orientada a objectes

Mòdul 4: Programació orientada a objectes avançada

Mòdul 5: Estructures de dades i col·leccions

Mòdul 6: Gestió d'excepcions

Mòdul 7: Entrada/sortida de fitxers

Mòdul 8: Multifil i concurrència

Mòdul 9: Xarxes

Mòdul 10: Temes avançats

Mòdul 11: Frameworks i llibreries de Java

Mòdul 12: Construcció d'aplicacions del món real

© Copyright 2026. Tots els drets reservats