EclipseLink Historical Session: come tenere traccia dei cambiamenti di una tabella – Parte I

Tutti coloro che sono soliti conservare ogni cosa, dai biglietti del cinema usati alle magliette di quando erano quindicenni, pensando ad un database applicherebbero la stessa logica e non si sognerebbero mai di cancellare nemmeno una riga! Ammesso che sia discutibile conservare ogni cosa (ma non è questa la sede), nel caso della cancellazione dei dati non posso che essere d’accordo.
Quando si ha a che fare con un grande quantitativo di informazioni modificabili da innumerevoli fonti è opportuno avere copie dei dati e tenere traccia di tutte le modifiche che essi subiscono altrimenti… come facciamo a dare la colpa a chi ha combinato un guaio? A parte la colpa (e gli scherzi) è buona norma riuscire a monitorare tutti i cambiamenti che subiscono i dati che riteniamo più sensibili. Sostenere tale ridondanza può avere dei costi, ma i vantaggi che se ne traggono possono essere diversi:

  • Opportunamente organizzati, costituiscono un sistema di logging e gestione dello storico dei dati.
  • Forniscono automaticamente una risorsa di ripristino in caso di errore software.
  • Possono diventare una banca dati su cui effettuare calcoli statistici e di business intelligence.

Tracciare le modifiche sui dati

In questo post volevo approfondire il primo tra i vantaggi sopra citati: la gestione della tracciatura dei cambiamenti sui dati. Immaginiamo di avere una tabella che memorizza i dati personali di un utente:

id first_name last_name address city state zip phone_number e_mail
1 John Smith 1234, 5th Street New York New York 10128 555-23455
2 Samantha Johnson 120 E 87th Street Palo Alto California 34332 555-678755
3 Sarah Who 1433 Broadway Albuquerque New Mexico 45433 555-423532

Ci sono vari modi in cui un sistema di tracciatura dei dati può essere progettato e implementato. Essenzialmente possiamo raggrupparli in:

Sistemi Orizzonatali
si fa in modo che ad ogni riga della tabella originale corrisponda una riga della tabella delle variazioni. In questo modo però raddoppiano il numero delle colonne, in quanto deve esistere la versione old e new di ogni valore. Possiamo aggiungere anche un campo date per sapere quando il cambiamento è stato effettuato. All’inizio questa tabella sarà vuota: ad ogni variazione verrà inserita una nuova riga in cui si memorizzerà il valore attuale nel campo old e quello nuovo nel campo new per ogni dato modificato.
Sistemi Verticali
la tabella delle variazioni è speculare alla tabella originale: vanno poi aggiunte due colonne indicanti la data inizio e data fine validità di un record. L’attenzione non è focalizzata sulle variazioni (come nel caso precedente), ma sul fatto di salvare lo stato di un record nel tempo. All’inizio, le due tabelle conterranno gli stessi dati: nella tabella delle variazioni tutti i record avranno data fine nulla. Al cambiamento di un dato, verrà impostata la data fine e verrà inserita una nuova riga contenente tutti i nuovi valori con data inizio coincidente con la data fine del record precedente. Le variazioni quindi sono visibili per differenza tra record legate dalle date inizio e fine.

Entrambe le soluzioni hanno sia pregi che difetti. La prima fondamentalmente spezza il modello concettuale del dominio, raddoppiando praticamente il numero di colonne della tabella che si vuole tracciare. Il vantaggio è che risulta chiaro cosa è cambiato e quando, ma recuperare tutto lo stato di un record ad una certa data data richiede un po’ di elaborazione. In termini di prestazioni, questa soluzione risulta efficiente in quanto il numero di record che conterrà la tabella delle variazioni sarà pari al numero dei cambiamenti effettuati sulla tabella principale.
Nella seconda soluzione il modello concettuale è preservato ed è facilmente recuperabile lo stato di un record, in quanto le due tabelle sono praticamente identiche (a parte i campi di inizio/fine validità). Con questa configurazione però risulta meno evidente capire cosa è cambiato in un record: è necessario analizzare le differenze tra più righe per riuscire a determinare i singoli cambiamenti. Dal momento che la tabella principale inizialmente ha lo stesso numero di record di quella delle variazioni, col passare del tempo e dei cambiamenti sui dati, quest’ultima rischia di crescere notevolmente. Nonostante ciò, questa soluzione appare quella più manutenibile e utilizzabile dal punto di vista della logica applicativa. Non a caso, l’implementazione JPA di EclipseLink ha scelto questa soluzione per il sistema di gestione dello storico.

Gestione dello storico con EclipseLink: configurazione del servizio

Grazie a poche configurazioni, con EclipseLink è possibile mettere in piedi rapidamente un sistema di gestione dello storico di tipo verticale che, come abbiamo detto, permette di salvare lo stato di un record nel tempo. La documentazione di EclipseLink su questo argomento è suddivisa sotto varie voci del loro wiki (come la sezione sulle query, quella sulla Session o quella sul Descriptor) per cui spesso nel leggerla si perde la linearità del discorso. Volevo quindi riassumere in due post tutto ciò che riguarda l’argomento, condito di esperienze e commenti personali.

Ammettiamo di avere quindi una tabella (per esempio quella mostrata in precedenza) e la sua tabella di storico. Questa sarà costituita da:

  • Un numero di colonne pari a quelle della tabella principale più due colonne che identificano la data inizio e data fine validità del record.
  • La chiave della tabella di storico sarà come quella principale più la colonna di data inizio. Questo è necessario perché altrimenti ad ogni modifica si potrebbe rischiare di inserire nello storico una riga con chiave duplicata.

Costruiamo poi l’entità JPA UserData relativa alla tabella:

@Entity
@Table(name="user_data")
@Customizer(value = UserDataHistoryCustomizer.class)
public class UserData implements Serializable {

        @Id
        @GeneratedValue(strategy = GenerationType.SEQUENCE)
        private long id;
        
        @Column(name="first_name")
        private String firstName;
        
        @Column(name="last_name")
        private String lastName;
        
        private String address;
        
        private String city;
        
        private String state;
        
        private int zip;
        
        @Column(name="phone_number")
        private String phoneNumber;
        
        @Column(name="e_mail")
        private String eMail;

...

}

Rispetto ad una usuale dichiarazione troviamo l’annotazione Customizer, dove UserDataHistoryCustomizer è:

public class UserDataHistoryCustomizer implements DescriptorCustomizer {

        @Override
        public void customize(ClassDescriptor descriptor) throws Exception {
                HistoryPolicy policy = new HistoryPolicy();
                policy.addHistoryTableName("user_data_hist");
                policy.addStartFieldName("start_date");
                policy.addEndFieldName("end_date");
                descriptor.setHistoryPolicy(policy);
        }

}

Al momento dell’inizializzazione di EclipseLink, viene associata ad ogni entità un ClassDescriptor contenente tutti i metadata relativi ad essa. Il framework dà la possibilità di interagire con questi metadata e personalizzarli tramite l’annotazione @Customizer che deve fare riferimento ad una classe che implementa l’interfaccia DescriptorCustomizer, come per esempio la nostra UserDataHistoryCustomizer. A questo punto abbiamo accesso al ClassDescriptor dell’entità UserData: ad esso associamo una HistoryPolicy per la quale definiamo:

  • nome della tabella di storico;
  • colonna che identifica la data inizio validità di un record;
  • colonna che identifica la data fine validità di un record.

Fin qui niente di nuovo rispetto a quanto consiglia il wiki di EclipseLink. Da una prima analisi della classe UserDataHistoryCustomizer emerge subito una considerazione: se devo gestire lo storico per 20 entità, devo creare 20 customizer e annotare 20 classi? Tra l’altro, dato che questi customizer si differiranno solo per il nome della tabella di storico, conviene davvero crearne 20? Serve quindi un meccanismo di centralizzazione che permetta di evitare tutta questa duplicazione di codice: ovviamente gli sviluppatori di EclipseLink ci hanno pensato e hanno messo a disposizione l’attributo eclipselink.session.customizer da inserire nelle proprietà del persistence.xml. Ad esso però non va associato un DescriptorCustomizer ma un SessionCustomizer attraverso il quale è possibile accedere a tutti i descriptors delle nostre entità. Se vogliamo uniformare quindi tutte le tabelle di storico tali che:

  • il nome di ogni tabella di storico ha lo stesso nome della tabella a cui si riferisce e in più termina con il suffisso _hist;
  • la colonna della data di inizio è chiamata start_date;
  • la colonna della data fine è chiamata end_date.

allora possiamo implementare la SessionCustomizer come segue:

public class EntityHistorySessionCustomizer implements SessionCustomizer {

     private static final String START_DATE = "start_date";
     private static final String END_DATE = "end_date";

     @SuppressWarnings("unchecked")
     public void customize(Session session) throws Exception {
          Map descriptors = session.getDescriptors();
          Set keySet = descriptors.keySet();

          for (Class< ?> clazz : keySet) {
               ClassDescriptor sourceDescriptor = descriptors.get(clazz);
               customize(sourceDescriptor);
          }
     }

     private void customize(ClassDescriptor sourceDescriptor) throws Exception {
          if (!sourceDescriptor.getTableNames().isEmpty()) {
               String sourceTableName = (String) sourceDescriptor.getTableNames().get(
                         sourceDescriptor.getTableNames().size() - 1);
               String historyTableName = sourceTableName + "_hist";
                        
               HistoryPolicy policy = new HistoryPolicy();
               policy.addHistoryTableName(sourceTableName, historyTableName);
               policy.addStartFieldName(START_DATE);
               policy.addEndFieldName(END_DATE);
               sourceDescriptor.setHistoryPolicy(policy);
          }
     }
}

Analizziamo il codice:

  • L’interfaccia definisce un metodo void customize(Session session) throws Exception che ci dà la possibilità di entrare in merito all’intera sessione di gestione delle nostre entità.
  • Ad essa possiamo chiedere tutti i descrittori che sono stati configurati dalle annotazioni sulle entità
  • Per ogni descrittore possiamo ripetere ciò che abbiamo fatto nell’implementazione precedente di DescriptorCustomizer.
  • Da notare che questa volta il nome della tabella viene recuperato direttamente dal descrittore attraverso sourceDescriptor.getTableNames().get(sourceDescriptor.getTableNames().size() - 1). In questo modo si riesce a generalizzare il reperimento del nome della tabella anche quando abbiamo a che fare con classi che sono legate gerarchicamente tra di loro. Se avessimo usato sourceDescriptor.getTableName() avremmo erroneamente recuperato il nome della classe base della gerarchia. Questa tecnica poteva essere usata anche nella classe precedente: come risultato avremmo avuto una sola implementazione di DescriptorCustomizer, ma avremmo dovuto annotare tutte le entità interessate dallo storico.
  • Il controllo sourceDescriptor.getTableNames().isEmpty() è necessario in quanto la condizione risulta vera nei casi in cui il descrittore sta analizzando le classi annotate come @Embedded, @EmbeddedId o le gerarchie di classi con strategia JOINED, che appunto non hanno tabelle corrispettive.

Il codice proposto però non permette di controllare di quale entità gestire lo storico. E’ necessario apportare qualche modifica per personalizzare le entità di cui tracciare i cambiamenti. Generalizziamo quindi la classe presentata in precedenza come segue:

public abstract class EntityHistorySessionCustomizer implements SessionCustomizer {

     private static final String START_DATE = "start_date";
     private static final String END_DATE = "end_date";
     protected List();

     public EntityHistorySessionCustomizer() {
          buildHistoryEntityList();
     }

     @SuppressWarnings("unchecked")
     public void customize(Session session) throws Exception {
          for (Class< ?> clazz : historyEntityList) {
               ClassDescriptor sourceDescriptor = session.getDescriptor(clazz);
               customize(sourceDescriptor);
          }
     }

     private void customize(ClassDescriptor sourceDescriptor) throws Exception {
          String sourceTableName = (String) sourceDescriptor.getTableNames().get(
                    sourceDescriptor.getTableNames().size() - 1);
          String historyTableName = sourceTableName + "_hist";
                        
          HistoryPolicy policy = new HistoryPolicy();
          policy.addHistoryTableName(sourceTableName, historyTableName);
          policy.addStartFieldName(START_DATE);
          policy.addEndFieldName(END_DATE);
          sourceDescriptor.setHistoryPolicy(policy);
     }

     public abstract void buildHistoryEntityList();
}

Cosa è cambiato? Intanto abbiamo reso la classe astratta perché gran parte di questo codice è riusabile: spetterà ad una specifica implementazione definire il body del metodo buildHistoryEntityList(), come per esempio:

...
     public void buildHistoryEntityList() {
          historyEntityList.add(UserData.class);
          historyEntityList.add(User.class);
          historyEntityList.add(Account.class);
     }
...

Quindi solo per le entità specificate nella historyEntityList sarà recuperato il descriptor e sarà aggiunta la policy di gestione dello storico.

Conclusioni

Fin qui abbiamo visto come configurare il servizio di gestione dello storico offerto da EclipseLink e di come è possibile scrivere del codice che permette di personalizzarlo e riusarlo facilmente in altri progetti. Nel vedremo tutti gli approcci che il framework mette a disposizione per recuperare lo stato delle entità ad una certa data nel passato. Infine, analizzeremo il caso particolare in cui sia attivato il sistema di creazione automatica delle tabelle del database (tramite script DDL generati dal framework) in relazione alle tabelle di storico.

Andrea Como

Sono un software engineer focalizzato nella progettazione e sviluppo di applicazioni web in Java. Presso OmniaGroup ricopro il ruolo di Tech Leader sulle tecnologie legate alla piattaforma Java EE 5 (come WebSphere 7.0, EJB3, JPA 1 (EclipseLink), JSF 1.2 (Mojarra) e RichFaces 3) e Java EE 6 con JBoss AS 7, in particolare di CDI, JAX-RS, nonché di EJB 3.1, JPA2, JSF2 e RichFaces 4. Al momento mi occupo di ECM, in particolar modo sulla customizzazione di Alfresco 4 e sulla sua installazione con tecnologie da devops come Vagrant e Chef. In passato ho lavorato con la piattaforma alternativa alla enterprise per lo sviluppo web: Java SE 6, Tomcat 6, Hibernate 3 e Spring 2.5. Nei ritagli di tempo sviluppo siti web in PHP e ASP. Per maggiori informazioni consulta il mio . - -

  • James

    Davvero molto utile ed esaustivo

  • Molto interessante.
    Mi sembra di capire che l’implementazione di Hibernate non prevede una funzionalita’ analoga. E’ cosi?

    • Probabilmente in hibernate qualcosa di simile si ottiene con l’annotation Auditing

      • Ciao, effettivamente con Hibernate puoi usare Hibernate Envers. Non l’ho mai usato, ma sulla carta sembra interessante perché puoi fare l’auditing parziale di un’entità (è una buona idea per approfondirlo e farci un post 😉 ) Di Eclipselink invece mi piace che questa gestione dei log si integra perfettamente con Oracle Flashback.

  • Pingback: ()