|
Dr. Carlo Pescio Architettura di Sistemi Record-Oriented, Parte 2 |
Iniziamo a delineare la nuova architettura, analizzando sia l’accesso ai dati basato sull’idea di reflection, sia il livello di presentazione, anche questo pensato per rendere il sistema immune dai problemi di manutenzione tipici delle soluzioni tradizionali.
Nello scorso numero abbiamo visto come le architetture più
tradizionali per i sistemi record-oriented vadano facilmente
incontro ad alcuni problemi di manutenzione, legati ad esempio agli
eventi (piuttosto frequenti) di aggiunta o rimozione di campi dalle
tabelle relazionali. Ho anche accennato ad una possibile via di
uscita, basata sul concetto di reflection, ovvero sulla
capacità di alcuni oggetti di "spiegare" al resto del sistema
la loro struttura interna.
In questa puntata vedremo come costruire intorno a tale idea una
architettura diversa da quelle più tradizionali, che risolve
molti dei problemi sollevati nella puntata precedente.
Dal recordset al campo
Il recordset rappresenta il punto di ingresso per le parti
applicative. Un recordset è il risultato di una query, ad
esempio di uno statement SQL del tipo SELECT <...> FROM
<...> WHERE <...>. Un recordset è composto da un
certo numero di record, ognuno dei quali è composto da un
certo numero di campi. Ogni campo ha un valore di un certo tipo
(intero, stringa, data/ora, ecc.). Il nostro primo obiettivo
è strutturare questi semplici concetti in una gerarchia di
classi che sia estendibile e che non ci richieda variazioni sul
lato applicativo se modifichiamo la struttura di una o più
tabelle, o se cambiamo il motore di database. Per contro, il codice
applicativo deve poter navigare il recordset ed estrarre
informazioni sui singoli campi. Su questa gerarchia costruiremo poi
il resto del framework.

Una possibile soluzione è data in figura 1: la notazione
utilizzata è UML, con la sola variante dell’uso dei
colori (si veda il riquadro 1). La classe RecordSet è
astratta, ma non necessariamente una interfaccia pura: di questo ci
renderemo conto meglio andando avanti nel design. Per ora,
RecordSet è un punto di estendibilità
dell’architettura, da cui derivare classi concrete che si
interfacciano a database reali: ad esempio, in figura 1 ho inserito
due classi che "parlano" con un DB tramite ODBC o tramite DAO.
Possiamo naturalmente ipotizzare di derivare classi che
interagiscano tramite interfacce proprietarie. Le classi concrete
contengono la logica necessaria, ad esempio, ad ottenere una
connessione con il DB, l’apertura delle tabelle coinvolte
nella query, l’estrazione del risultato della query,
ecc.
Dobbiamo ora affrontare il primo problema, ovvero la navigazione
sul recordset. Il problema è meno ovvio di quanto sembri, in
quanto le diverse classi concrete potrebbero avere requisiti del
tutto differenti circa la navigazione. Impostare una interfaccia a
livello di RecordSet del tipo GetRecord( int i ) renderebbe
RecordSet troppo rigida nei casi in cui la navigazione possa essere
solo sequenziale, o l’indice debba essere qualcosa di diverso
da un intero (ad es. una struttura Position nota solo alla classe
concreta). Per contro, non vogliamo certamente legare il lato
applicativo ai dettagli delle classi concrete. Ci troviamo in
classico caso in cui non si deve violare la legge di Demeter
([LH93], [Pes95]).
Fortunatamente, esistono diverse tecniche per risolvere il
problema. Due in particolare potrebbero essere adatte, e si
differenziano per il posizionamento dell’iniziativa. La prima
(che per ora non vedremo, ma che potete pensare come ad una
interfaccia di enumerazione) lascerebbe tutta l’iniziativa di
navigazione all’interno del recordset. La seconda, che ho
deciso di utilizzare nel diagramma di figura 1, lascia
l’iniziativa al chiamante (tipicamente altre parti del
framework, o il lato applicativo). In effetti, la navigazione sul
recordset può essere complessa a piacere, e mi sembra
preferibile lasciarne l’iniziativa fuori dalla classe
RecordSet.
La soluzione è l’uso di un iteratore [GoF94], ovvero una
classe ausiliaria che ha il compito di muoversi sui singoli record
all’interno del recordset. Notiamo che ancora una volta il
RecIterator è una classe astratta, il cui compito principale
è di restituire il record "puntato" dall’iteratore. Da
questa possiamo derivare classi astratte legate a particolari tipi
di accesso al database (es. RecFWIterator per l’accesso solo
"in avanti", o RecRndIterator per l’accesso random). Da
queste deriviamo finalmente le classi concrete, che sanno come
iterare su un singolo recordset. Le classi iteratore concrete
saranno tipicamente accoppiate in modo circolare con le classi
recordset concrete: il recordset crea l’iteratore,
l’iteratore si sposta sul recordset. Questo rende le due
classi non riusabili individualmente [Pes99], ma si tratta di un
caso in cui non ritengo che l’impossibilità di riuso
separato costituisca un problema.
L’iteratore può essere internamente molto semplice (ad
es. può risolversi in una semplice chiamata ad una funzione di
libreria per spostarsi nel database), o essere piuttosto complesso
da realizzare (si veda poco oltre). L’importante è che
rispetti la sua interfaccia e, nell’insegnamento di STL,
anche i suoi vincoli computazionali: ad esempio, dovremmo imporre
che il RecFWIterator si sposti sul prossimo in tempo costante, e
che il RecRndIterator si sposti in tempo costante o logaritmico.
Queste decisioni sono importanti per permettere a chi utilizza i
nostri iteratori di progettare una soluzione scalabile.
Una nota implementativa: realizzare un iteratore su un recordset
può non essere banale come sembra. Se il database sottostante
"fonde" i concetti di recordset e di cursore sul recordset,
mettendoli in relazione 1-1, chiedere un nuovo iteratore su un
recordset esistente significa aprire un nuovo recordset come
risultato della stessa query, ottenendo così un nuovo cursore,
e poi garantire la coerenza tra i record "visti" a livello
applicativo attraverso i diversi cursori. Ma proprio perché
non è banale, il problema è un ottimo candidato ad essere
risolto una volta per tutte ed incorporato nel framework, piuttosto
che essere lasciato alla responsabilità dei programmatori che
lavorano a livello applicativo.
Torniamo alla figura 1: dereferenziando un RecIterator otteniamo il
Record puntato. La classe Record è nuovamente astratta, in
quanto potrebbero esservi degli aspetti implementativi che vanno
lasciati alle classi derivate. Tipicamente, avremo quindi una
classe concreta derivata da Record per ogni classe concreta
derivata da RecordSet, e gli iteratori concreti, quando
dereferenziati, restituiranno istanze di classi Record concrete,
pur se viste attraverso la loro interfaccia astratta.
Un Record, a livello strutturale, non è molto diverso da un
RecordSet. In fondo, in entrambi i casi ci troviamo di fronte ad
una aggregazione di elementi più semplici, in questo caso i
campi (Field). Nuovamente, il lato applicativo avrà interesse
ad esaminare uno o più campi. Qui entra decisamente in gioco
il concetto di reflection: vogliamo evitare la comparsa di classi
del tipo RecordFornitore con funzioni del tipo Get/SetNome,
Get/SetIndirizzo, ecc, che come abbiamo già visto non portano
ad una interfaccia stabile.
Ciò che è stabile è invece la capacità del
Record di esporre il suo contenuto: di "rivelare" quanti campi ha e
come si chiamano, nonché restituire il campo desiderato dato
il nome. Talvolta saremo interessati a compiere la stessa azione su
tutti i campi, e potrà quindi tornare utile un FieldIterator.
In questo caso ho previsto solo un iteratore del tipo forward, ed
in pratica ho visto che si riesce a realizzare in funzione della
parte stabile di Record, senza necessità di avere classi
FieldIterator separate per ogni database concreto. Non è detto
che con particolari librerie non si debba arrivare a derivare nuove
classi da FieldIterator, ma del resto nulla nella struttura di
figura 1 lo impedirebbe.
L’interfaccia precisa della classe Record dipende
inevitabilmente dal linguaggio utilizzato per
l’implementazione. Ad esempio, in C++ ho utilizzato con
soddisfazione l’overloading degli operatori, per poter
scrivere codice del tipo:
Record& r = .... r[ "campo1" ] = 1234 ; r[ "campo2" ] = "abc" ; date d = r[ "campo3" ] ;
Ovvero accedere ai campi di un record tramite indicizzazione sul
loro nome (visto come stringa, perché la maggior parte dei
database reali memorizzano così il nome del campo). Notiamo
che il pregio di questo approccio rispetto alla soluzione
GetNome/GetIndirizzo/ecc è che i nomi dei campi non sono
hard-coded, ma si possono facilmente mantenere in un file esterno,
dove è facile cambiarli se cambia il database. Su questo punto
torneremo ancora più avanti.
Vediamo finalmente la classe Field. Anche in questo caso esistono
diverse possibilità realizzative, e quella che vi presento
è solo una che ho utilizzato e che ho visto funzionare a
dovere. Esistono alternative più complesse che a mio avviso
non ripagano, ma che potrebbero risultare più adatte in altri
contesti. Probabilmente vi servirà una classe concreta per
ogni tipo di database. Dico "probabilmente" perché molto
dipende da come deciderete di implementare la classe Record, e da
come deciderete di partizionare l’intelligenza tra Record e
Field. Ad esempio, se volessimo lasciare a Field l’iniziativa
di estrarre il valore dal Record concreto (ancora in formato nativo
del DB) e di effettuare on-demand la conversione ai tipi stabili
che vogliamo esporre (esempio concreto: da una data memorizzata in
un qualunque formato ad una classe standard Date), seguendo un
criterio di lazy evaluation che eviti la conversione inutile dei
campi a cui non si accede, avremmo classi Field concrete derivate
da Field. Ma se decidessimo di posizionare tutta la logica di
conversione dentro le classi Record concrete, potremmo avere una
sola classe Field che ridirige le richieste su uno o più
metodi di Record.
Di norma, io preferisco lasciare le conversioni al Field,
perché in questo caso aggiungere il supporto per un nuovo tipo
di campo significa modificare la sola gerarchia dei Field lasciando
immutata la gerarchia dei Record. Ma nuovamente, si tratta di
decisioni di design di livello piuttosto dettagliato, che potrete
prendere caso per caso se decideste di mettere in pratica una
architettura simile a quella che stiamo discutendo.
Riassumendo, quale risultato abbiamo raggiunto sinora? Possiamo
creare un RecordSet in base ad una query, i cui elementi essenziali
possono facilmente essere resi esterni al programma (nei casi
più semplici, basterà spostare la stringa SQL in un file
di testo). Potremo poi muoverci sui record, secondo un criterio
arbitrario. Per ogni record, potremo spostarci sui campi in modo
uniforme (con un iteratore), indipendentemente dalla struttura del
record stesso, oppure indicizzare i diversi campi, nuovamente
attraverso un indice che possiamo facilmente spostare fuori dal
codice. Rimane ancora un po’ scoperto il trattamento (ad
esempio a livello di presentazione) dei valori dei singoli campi,
che sono di tipo diverso. Questo sarà l’argomento del
prossimo paragrafo.
Campi e presentazione
Per comprendere al meglio il supporto alle funzionalità di
presentazione credo sia opportuno fare riferimento ad un semplice
esempio concreto: una griglia di visualizzazione del recordset,
naturalmente in grado di adattarsi alla visualizzazione di un
recordset qualunque, con formattazioni differenti nelle varie
situazioni, senza necessità di modificare il codice della
classe Grid stessa. La classe Grid, che rispetto al nostro
framework appartiene già al livello applicativo, è solo
un caso di presentazione di un intero recordset.
Situazioni analoghe possono essere rappresentate da un semplice
report (tabulato), da un file che esporta i dati di un intero
recordset, eccetera. Osserviamo che per ora non intervengono le
problematiche di modifica dei dati, di cui parlerò
nella prossima (e conclusiva) puntata.

Il supporto alla presentazione è visibile in figura 2. Notiamo
che alcune classi (Field, Record, ecc) provengono dal diagramma di
figura 1. Ho evitato la notazione "from ..." per mantenere il
diagramma più compatto; non credo emergano comunque problemi
di comprensione.
Partiamo dal basso: la Grid, così come ExportedFile,
SimpleReport, ecc, altro non è che un RecordSetPresentation.
Questa è una classe astratta (ma non interfaccia), che
contiene logica sufficiente a ricevere un RecordSet, ottenere un
forward iterator su di esso, e iterare su tutti i record
processandoli secondo alcuni criteri. Criteri che ancora una volta
vanno possibilmente estratti dal codice, e spostati invece in un
supporto esterno (ancora una volta, ipotizziamo il classico file di
testo). Osserviamo infatti che un RecordSetPresentation fa
riferimento al proprio profilo, dove troveremo alcune parti custom
(tipiche del RecordSetPresentation concreto, ad esempio
l’header da utilizzare nel caso di un report) ed altre
standard, che possono essere processate dalla classe base. Tra
queste informazioni standard troviamo sicuramente il nome della
classe concreta da utilizzare per la presentazione di ogni singolo
Record, e del profilo da passare a tale classe. Nel caso di una
griglia, il RecordSetPresentationProfile potrebbe riferirsi ad un
file che, nell’ipotesi più semplice, contiene solo le
informazioni standard.
RecordSetPresentation deve quindi essere in grado di ottenere, dato
il nome della classe concreta (es. "SimpleRecordPresentation"), una
istanza di tale classe vista attraverso la sua base
RecordPresentation. Il modo in cui questa istanza viene ottenuta
è del tutto irrilevante. In Java, potremmo usare
forname/newinstance; in Windows, potremmo usare LoadLibrary per
caricare una DLL; e così via. In futuro vedremo che anche in
questo caso si utilizza una tecnica di trasformazione tipica del
SysOOD (Indexed Creation); per ora potete fare riferimento al
pattern da questa generato (Product Trader, si veda
[PloPD3]).
La classe RecordPresentation (e le sue classi derivate) contengono
la logica per realizzare la formattazione di un singolo record, in
base ad un profilo che possiamo nuovamente ipotizzare di mantenere
in un file di testo separato, ed a cui accediamo tramite la classe
RecordPresentationProfile. Tra le informazioni fondamentali, oltre
ad eventuali parametri custom utilizzati dalla classe concreta di
presentazione del record, troviamo sicuramente una lista di
specifiche di formattazione per ogni campo, ovvero qualcosa del
tipo:
Field1, ConcreteFieldPresentation1, profile1
Field2, ConcreteFieldPresentation2, profile2
Field3, ConcreteFieldPresentation1, profile2
(notiamo che righe diverse possono condividere la stessa classe
di presentazione del campo, e/o lo stesso profilo).
RecordPresentation, nella sua versione più semplice,
otterrà dal Record un iteratore sui campi, e li
processerà in sequenza in base alla lista di cui sopra.
Ovvero, otterrà in modo del tutto simile a RecordPresentation
una istanza alla classe concreta per la presentazione del campo, e
richiederà il processing del campo in base al profilo
specificato.
Ci spostiamo quindi a livello di presentazione del singolo campo.
In moltissimi casi reali, come quello della griglia, avremo una
sola classe concreta (ConcreteFieldPresentation) adatta a tutti i
campi, o al più un piccolo insieme di classi. Tuttavia,
l’introduzione di nuovi tipi di campo (immagini, suoni,
filmati, ecc) potrebbe richiedere l’introduzione di nuove
classi derivate; lo stesso potrebbe accadere se vogliamo introdurre
alcune nuove funzionalità di formattazione, magari solo in
certe versioni custom del prodotto. Come sempre, l’importante
è che il framework preveda gli opportuni punti di
estendibilità.
Cosa deve saper fare la classe concreta di presentazione del campo?
Fondamentalmente, leggere il profilo, accedere al campo, ottenere
il valore, formattarlo secondo il profilo e restituirlo. Possiamo
quindi ipotizzare di avere una classe "di libreria" (ovvero
concreta ma generalmente utile agli utilizzatori del framework) che
sia in grado di formattare i campi dei tipi primitivi secondo gli
schemi più comuni (valuta, data nei vari formati specificabili
nel profilo del campo, ecc). Ma come accennavo poco sopra, nulla
impedisce di introdurre nuove classi in grado, ad esempio, di
"formattare" un campo importo secondo una particolare regola di
arrotondamento inventata al momento da qualche analista. Il tutto
senza dover modificare una riga del codice esistente, ma
aggiungendo una classe (che se la piattaforma/linguaggio lo
consentono sarà opportuno separare come componente a livello
binario) e modificando il profilo, che avevamo spostato apposta
fuori dal codice.
Considerazioni
Prima di concludere la puntata, vi sono almeno due argomenti
che vorrei sollevare. Il primo, che tuttavia discuterò nel
prossimo numero, riguarda la complessità strutturale della
soluzione che stiamo impostando. È evidente che si tratta di
qualcosa piuttosto lontano dalla "classe base RecordSet da cui
deriviamo i recordset concreti" tipica degli approcci più
tradizionali, e sicuramente la struttura è più ricca e
più complessa da capire. Sui vantaggi e svantaggi che questo
comporta mi soffermerò più a lungo nel prossimo
numero.
Un argomento sui cui vorrei invece spendere sin d’ora alcune
parole è l’efficienza della soluzione. Quando, durante
il mio lavoro di consulente, mi capita di osservare alcune
soluzioni particolarmente rigide e poco manutenibili, la
seconda risposta (in ordine di frequenza) che ricevo è
"l’abbiamo fatto così per ragioni di efficienza"; per i
curiosi, la prima è ovviamente "l’abbiamo fatto
così perché non c’era tempo per farlo meglio". In
realtà l’efficienza delle soluzioni ben strutturate non
ha nulla da invidiare a quella delle soluzioni custom: occorre
semplicemente aggiungere, dietro la stabilità delle
interfacce, una maggiore intelligenza alle classi coinvolte. I
lettori più fedeli ricorderanno simili argomentazioni a
proposito della soluzione proposta nella miniserie sui sistemi
reattivi.
Vediamo un esempio concreto: se devo visualizzare (o più
ragionevolmente esportare) 500.000 record, con una decina di campi
ciascuno, la struttura di figura 2 sembra introdurre un notevole
rallentamento, dovuto al continuo ripetersi di alcuni passi, come
la creazione di un RecordPresentation concreto per ogni record, ed
addirittura di un FieldPresentation per ogni campo. Questo
ovviamente nel caso in cui l’implementazione sia
particolarmente banale, e crei/distrugga gli oggetti dopo ogni
singola chiamata. Di fatto implementare un meccanismo di cache
efficiente per un piccolo numero di oggetti (in fondo, quanti
formattatori concreti avremo in un caso reale?) è
ragionevolmente semplice, e questo ci consente di ottenere il
nostro oggetto concreto (dopo la prima creazione) in pochi cicli di
clock. Una implementazione con hash table ben fatta può
ridurre ad una ventina di cicli di clock il costo del lookup, ma
supponiamo che siano cento. Moltiplichiamo per 500.000 record, e
per i dieci campi, ottenendo 550 milioni di cicli di clock. Ovvero
un paio di secondi di una CPU moderna, su un processo che
durerà diversi minuti (estrarre 500.000 record da un DB non
è proprio questione di secondi).
Vediamo peraltro cosa succede nella situazione in cui si voglia
esportare solo 5 campi. Se usiamo ad es. i recordset di MFC, questi
faranno comunque le conversioni da "formato db" a "formato C++" per
ogni campo. I field "lazy" di figura 1 no. Questo, da solo,
può comportare un risparmio di tempo superiore ai pochi
secondi di cui sopra.
Ma soprattutto, pensiamo ad un caso reale, in cui il programma
esiste già, nasce l’esigenza di supportare un nuovo
campo e di rendere nuovamente operativo, il prima possibile, il
processo di esportazione. Se il tipo del campo è già
supportato dalla classe Field e dai nostri formattatori (un evento
che mi sento di definire alquanto probabile), dobbiamo aggiornare
gli script dei profili e basta. Se quello che conta
realmente è il throughput del programma, dobbiamo considerare
anche i tempi di fermo dovuti a manutenzione: a questo punto,
facendo bene i calcoli, è facile concludere che minimi
rallentamenti come quello di cui sopra sono del tutto trascurabili
rispetto ai vantaggi complessivi di una soluzione che non richiede
modifiche al codice in un numero molto frequente di situazioni.
Cosa manca
Non avendo discusso la parte di modifica, manca anche ogni
supporto alla coerenza tra viste diverse. Manca inoltre una
discussione più approfondita di un altro tipo di presentazione
molto comune (la form), con tanto di campi calcolati, validazione e
più in generale di business rule. Di tutto questo parleremo
nella prossima, conclusiva puntata della miniserie. Nel frattempo,
se avete commenti, suggerimenti, eccetera, non esitate a mandarmi
qualche riga via email.
Bibliografia
[GoF94] Gamma ed altri, "Design Patterns", Addison-Wesley,
1994.
[LH93] Karl Lieberherr, Ian Holland, "Assuring Good Style for
Object-Oriented Programs", Technical Report, Northeastern
University, 1993.
[Pes95] Carlo Pescio, "C++ Manuale di Stile", Edizioni Infomedia,
1995.
[Pes99] Carlo Pescio, "Systematic Object Oriented Design", Computer
Programming No. 76.
[PloPD3] Autori vari, "Pattern Languages of Program Design, Vol.
3", Addison-Wesley, 1998.
Biografia
Carlo Pescio (pescio@eptacom.net) svolge attività di consulenza, progettazione e formazione in ambito internazionale. Ha svolto la funzione di Software Architect in grandi progetti per importanti aziende europee e statunitensi. È autore di numerosi articoli su temi di ingegneria del software, programmazione C++ e tecnologia ad oggetti, apparsi sui principali periodici statunitensi, nonché dei testi "C++ Manuale di Stile" ed "UML Manuale di Stile". Laureato in Scienze dell'Informazione, è membro dell'ACM, dell'IEEE e dell'IEEE Technical Council on Software Engineering.