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 usatosourceDescriptor.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 diDescriptorCustomizer
, 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.
Pingback: ()