|
Dr. Carlo Pescio Architettura: un esempio |
Pubblicato su Computer Programming No. 40
Introduzione
In questa puntata avremmo dovuto inziare l'approfondimento dei
diversi componenti del design (dominio del problema, interfaccia
utente, gestione dei task, gestione dei dati); rivedendo pero'
l'insieme delle puntate precedenti, e soprattutto l'ultima puntata
sul design architetturale, ho ritenuto piu' opportuno concentrare
l'attenzione su un singolo esempio. Cio' non solo per riassumere
i risultati dell'analisi, ma anche e soprattutto per rimarcare
alcune caratteristiche del design architetturale, che possono
essere meglio apprezzate su un esempio comparativo piuttosto che
attraverso una lunga discussione teorica.
Pertanto in questa puntata presenteremo un piccolo (ma sufficientemente
sfaccettato) problema, passeremo rapidamente all'analisi, e confronteremo
alcune alternative a livello architetturale, cercando di compararne
le caratteristiche in termini di semplicita' implementativa, estendibilita',
mantenibilita', e cosi' via. Proprio per la centralita' dell'esempio,
avremo eccezionalmente una bibliografia molto scarna, in quanto
e' molto raro (purtroppo) trovare riferimenti a case-study ben
fatti e significativi.
Il problema
Vediamo ora il problema che intendiamo modellare e risolvere (a
livello di architettura): si tratta di un esempio piuttosto semplice,
che puo' quindi essere analizzato con sufficiente completezza
nello spazio di un articolo; in effetti, e' una versione ridotta
ai minimi termini di un singolo componente di un progetto reale.
La descrizione informale del sistema, che possiamo ipotizzare
proveniente da una discussione preliminare con il committente,
e' la seguente: "Un insieme di dati proveniente da file
o da memoria condivisa deve essere rappresentato sotto forma di
grafico a linee; piu' aree dati ("canali") possono essere
rappresentate sullo stesso grafico, al quale e' associata una
scala verticale per ogni canale. La scala determina posizione
e dimensione di ogni canale sul grafico, oltre ad un valore minimo
e massimo da rappresentare. Ogni canale puo' essere aperto o chiuso
con un doppio click nella corrispondente sezione della scala verticale.
Al grafico e' altresi' associata una barra di valori, anch'essa
verticale: un singolo click nel grafico fara' comparire nella
barra i valori assunti in ogni canale alla coordinata x cosi'
selezionata. Un esempio dell'interfaccia e' dato in figura 1,
dove i canali 1 e 3 sono aperti ed il canale 2 e' chiuso. I singoli
componenti devono essere riutilizzabili in altre situazioni, ad
esempio i canali del grafico devono poter essere allineati ad
elementi di un'immagine anziche' ad una scala verticale."
La situazione descritta e' obiettivamente piuttosto semplice, e probabilmente molto piu' precisa di quanto sia di norma ottenibile da una discussione informale con il committente: una possibile rappresentazione del corrispondente modello object oriented (usando la rappresentazione di Coad/Yourdon) e' visibile in figura 2, che costituisce il risultato del nostro ipotetico processo di analisi.
Come vedete, gli elementi fondamentali (il grafico, la scala,
la barra dei valori) sono stati modellati come classi; allo stesso
modo, il file e la memoria condivisa sono classi, ed essendo implicito
nella descrizione informale che dal punto di vista della rappresentazione
la provenienza dei dati e' irrilevante, le caratteristiche comuni
di file e memoria condivisa possono essere astratte in una classe
base "stream".
Inoltre, ho esplicitamente inserito l'utente come parte del dominio
del problema: anche se non era esplicitamente nominato nella descrizione
informale, e' evidente che i "click" ed i "doppi
click" dovevano avere un'origine. E' buona norma rappresentare
nel modello analitico anche gli elementi del mondo esterno che
interagiranno con il sistema informatico: cio' consente una migliore
vista di insieme, e permette di individuare a colpo d'occhio (con
un po' di esperienza) le situazioni in cui l'interazione tra alcuni
elementi dell'ambiente ed il sistema e' troppo debole o troppo
forte.
Notate i raggruppamenti in diversi soggetti: da una parte la sorgente
dei dati, poi le classi coinvolte nella rappresentazione dei dati
stessi, ed infine il mondo esterno; i soggetti, come richiesto
in un buon modello, sono debolmente accoppiati.
Naturalmente il modello proposto non e' l'unico possibile: ad
esempio, non e' stata data alcuna enfasi al concetto di "canale",
che e' stato sostanzialmente distribuito (come attributo) tra
le diverse classi legate alla rappresentazione. Un modello totalmente
diverso potrebbe invece essere incentrato sul concetto di canale,
anche se la complessita' risultante (se pensate alla figura 1
ed al requisito di riusabilita' dei componenti) e' di gran lunga
maggiore: ricavare questo modello alternativo, e magari confrontarlo
con quello proposto, potrebbe essere un ottimo esercizio di analisi.
Design Architetturale
In quanto segue vedremo alcune alternative per l'architettura
del soggetto (2), ovvero degli elementi coinvolti nella rappresentazione
dei dati. Prima di concentrarci su di esso, vorrei comunque dedicare
alcune righe all'architettura dell'insieme, proprio per meglio
illustrare la complessita' del design architetturale rispetto
all'analisi.
Nel modello proposto, i soggetti (1) e (2) sono connessi da una
semplice relazione di appartenenza: il grafico possiede diversi
stream, uno per canale; nel design architetturale il legame tra
dati e rappresentazione e' spesso piu' complesso, ed in molti
casi e' un concetto centrale per l'intera architettura. Ad esempio,
il paradigma model/view/controller di Smalltalk [1] e' interamente
basato sulla distinzione tra i dati, le loro rappresentazioni,
e l'interazione con l'utente; il paradigma MVC e' alla base di
molte librerie di classi per la creazione di GUI, dove di norma
il termine "model" viene modificato in "document"
(piu' semplice ma meno preciso) e il "controller" viene
eliminato, in virtu' di tentativi di standardizzazione dell'interazione
con l'utente. Si ottiene cosi' il modello Document/View alla base,
ad esempio, delle Microsoft Foundation Classes.
E' altresi' chiaro, come abbiamo visto nella puntata precedente,
che le scelte architetturali tendono ad essere pesantemente influenzate
dalla piattaforma software sulla quale intendiamo implementare
l'applicazione: ad esempio, scendendo nei dettagli del soggetto
(1), una piattaforma nella quale esista gia' una astrazione dei
file e della memoria condivisa, in termini di memory-mapped-file,
porterebbe ad una struttura molto diversa da quella richiesta
da una piattaforma con una distinzione netta tra memoria condivisa
e file, e dove sia quindi compito del progettista fornire la necessaria
astrazione. Per questo e' cosi' frequente che il lavoro degli
analisti sia ignorato dai progettisti, ed il lavoro di questi
ultimi sia ignorato dai programmatori: un grado di astrazione
cosi' alto da consentirci di rimanere totalmente indipendenti
dalla piattaforma, sino alla fase di implementazione, e' altamente
desiderabile ma anche molto rischioso: un buon progettista capisce
quando e' necessario rinunciare all'astrazione per evitare ai
programmatori il compito di ri-progettare il sistema.
In quanto segue, il legame tra i soggetti (1), (2) e (3), cosi'
come i dettagli interni del soggetto (1) e (3) non verranno piu'
considerati: dedicheremo invece la nostra attenzione al soggetto
(2), ed alle conseguenze in termini di flessibilita', riusabilita',
mantenibilita', tempo di sviluppo di alcune diverse soluzioni
architetturali.
L'architettura banale
Meno accoppiamento con le Classi Interfaccia
Osserviamo che le classi implementazione non sono piu' accoppiate
tra loro: sono solo accoppiate con classi interfaccia. Cio' significa
che e' possibile creare una nuova classe, che implementi l'interfaccia
di I-Scale Bar, e sostituirla ad essa senza problemi. In effetti,
i diversi componenti concreti (le implementazioni) sono ora completamente
disaccoppiati, e possono essere riutilizzati con maggiore semplicita'.
Estendibilita' con l'Integratore
A differenza della soluzione basata su classi interfaccia, una
soluzione basata su integratore elimina l'accoppiamento sulle
istanze: se aggiungessimo un ulteriore componente che necessita
di un ToggleMessage, sarebbe sufficiente modificare l'integratore
e non la Scale Bar. In questo senso, e' molto piu' estendibile
di una soluzione basata su classi interfaccia.
Scalabilita' con gli Advise Sink
Alcune delle classi nel diagramma di figura 2 sono dipendenti
da altre: ad esempio, Linear Graph e' dipendente da Scale Bar,
perche' quando lo stato di Scale Bar cambia e' necessario che
lo stato di Linear Graph cambi per riflettere a sua volta l'apertura/chiusura
di un canale. Viceversa, possiamo dire che Scale Bar e' un fornitore
di dati rispetto a Linear Graph; notiamo che alcune classi sono
solo clienti (ad esempio Value Bar) altre solo fornitori (Scale
Bar) altre possono avere entrambe le funzioni (Linear Graph).
Proprio questa idea di clienti e fornitori rivela la chiave per
la tecnica degli advise sink: ogni server e' derivato da una classe
Data-Server, ed ogni cliente che debba essere notificato dal server
quando i dati cambiano e' derivato da una classe AdviseSink. Un
server puo' essere collegato (attraverso il messaggio SetAdvise)
ad un numero qualunque di AdviseSink (e viceversa); al momento
del collegamento, il cliente stabilisce anche quando vuole
essere notificato: ad esempio, la Value Bar vuole essere notificata
quando i valori da mostrare cambiano, ma non se il colore del
grafico cambia. Quando si verifica un evento, il server verifica
se uno dei clienti collegati desidera essere notificato del particolare
evento: in caso positivo, crea un oggetto "dati" apposito
e lo invia al cliente tramite l'interfaccia di AdviseSink. Pertanto,
devono esistere oggetti in grado di rappresentare i dati per ogni
possibile evento: le relative classi sono rappresentate in alto
a destra nella figura 5.
Conclusioni
Note:
Bibliografia
Biografia
La figura 2 suggerisce immediatamente una architettura per il
soggetto (2): trasferire direttamente il risultato dell'analisi
dallo spazio del problema a quello della soluzione. In pratica,
si tratta di definire semplicemente come ogni oggetto coinvolto
venga a conoscenza degli altri: per spedire un messaggio (ovvero
chiamare un metodo) ad un oggetto di classe Linear Graph, un oggetto
di classe Scale Bar deve necessariamente avere un riferimento
all'oggetto grafico. In sostanza, tuttavia, in questo caso il
modello del design e' identico al modello ottenuto al termine
dell'analisi, almeno per quanto riguarda il soggetto (2).
Molto spesso l'architettura "banale", cosi' chiamata
in quanto deriva in modo immediato e (appunto) banale dal risultato
dell'analisi, e' in effetti l'architettura che viene scelta per
l'applicazione; e' altrettanto vero che molto spesso non corrisponde
alla migliore architettura possibile.
Vi sono tuttavia molte ragioni che portano alla scelta della soluzione
banale: la mancanza di tempo per considerare strutture alternative,
la mancanza di esperienza dei progettisti, un ciclo di sviluppo
piu' orientato all'implementazione che al design, o troppo guidato
dall'interfaccia utente. Quest'ultimo caso si presenta non di
rado, in questi tempi in cui il "Rapid Application Development"
viene venduto come la nuova promessa dell'informatica; come il
paradigma a prototipi, di cui costituisce una nuova formulazione
ma di cui condivide pregi, difetti e campi di applicabilita',
il RAD tende spesso a generare prodotti under-architectured,
ovvero con una architettura banale, ricavata di norma mediando
tra l'interfaccia utente ed il dominio del problema.
Quali sono i difetti dell'architettura banale, nell'esempio specifico
del soggetto (2)? Riconsideriamo la specifica informale, nella
sua parte finale: "I singoli componenti devono essere
riutilizzabili in altre situazioni, ad esempio i canali del grafico
devono poter essere associati ad elementi di un'immagine anziche'
ad una scala verticale."1.
Consideriamo ora i legami tra le classi nell'architettura banale:
Cio' significa che non e' possibile utilizzare direttamente la
classe Linear Graph indipendentemente da una Scale Bar e da una
Value Bar (o viceversa), in quanto sono direttamente accoppiate.
Significa anche che se volessimo aggiungere un quarto elemento
che richieda un messaggio ToggleChannel, dovremo modificare la
Scale Bar per inviare anche ad esso il messaggio. Il problema
delll'accoppiamento sulle istanze si potrebbe parzialmente ignorare
se il linguaggio scelto per l'implementazione non eseguisse un
type-checking statico (ad esempio Smalltalk), nel qual caso una
qualunque classe che risponda ai messaggi voluti potrebbe essere
sostituita a Scale Bar. In un linguaggio con type-checking statico
(ad esempio C++) una simile soluzione non e' praticabile.
Il problema maggiore della soluzione banale e' in genere proprio
questo: l'eccessivo accoppiamento tra le classi, che si ripercuote
in una mancanza di flessibilita' e di riusabilita' del codice
e del design dettagliato. Cio' dovrebbe far seriamente riflettere
chi sostiene che le tecniche object oriented non mantengono "per
se'" la promessa del riutilizzo: nessuna tecnica puo'
riparare grossolani errori di progettazione dei componenti, ed
una architettura carente impedira' il riutilizzo del codice in
qualunque paradigma di programmazione. In tal senso, un programma
object oriented dove i componenti siano accoppiati troppo strettamente,
non e' diverso da un programma nel paradigma strutturato dove
le procedure siano troppo interdipendenti: il codice non potra'
essere riutilizzato perche' non e' stato progettato in modo appropriato.
Un passaggio del paragrafo precedente rivela in realta' una possibile
alternativa: se in un linguaggio con type-checking dinamico l'accoppiamento
e' meno stretto rispetto ad un linguaggio con type-checking statico,
possiamo studiare una architettura che riduca l'accoppiamento
indipendentemente dal linguaggio, introducendo delle classi piu'
astratte che operino da interfaccia rispetto all'implementazione.
Una architettura basata su classi interfaccia per il soggetto
(2) e' mostrata in figura 3: per ogni classe dell'architettura
banale, introduciamo una corrispondente classe interfaccia, il
cui compito e' appunto di astrarre l'interfaccia della classe
(i suoi metodi pubblici) dall'implementazione concreta dei metodi
stessi. A livello implementativo, le classi interfaccia non implementano
i metodi, ma (usando gli appositi costrutti del linguaggio) delegano
alla classe implementazione la gestione degli stessi.
E' importante capire che la nuova architettura aggiunge elementi
che non fanno parte del dominio del problema: le classi interfaccia
appartengono solo al dominio della soluzione (anzi, di questa
particolare soluzione). Non si tratta quindi di una carenza dell'analisi
se il modello banale soffre delle pesanti limitazioni di cui sopra,
che sono solo conseguenza di un design affrettato.
Il modello basato su classi interfaccia non e' indenne da critiche:
da una parte, richiede di raddoppiare il numero delle classi,
e di utilizzare meccanismi del linguaggio (come le funzioni virtuali
pure, o la delegation) che non sarebbero necessari in una soluzione
banale. Il raddoppio delle classi significa anche che, ogniqualvolta
si modifichi l'interfaccia di una classe (ad esempio, per permettere
di cambiare colore ad un canale nel grafico ed alla corrispondente
scala) e' necessario modificare il codice di due classi anziche'
di una sola.
Infine, mentre risolve il problema dell'accoppiamento sull'interfaccia,
non risolve il problema dell'accoppiamento sulle istanze: la classe
Scale Bar deve comunque essere a conoscenza degli oggetti cui
deve inviare il messaggio ToggleChannel (in questo caso, un oggetto
di classe I-Linear Graph ed un oggetto di classe I-Value Bar).
Se vogliamo utilizzare la classe Scale Bar con un solo oggetto,
o con tre, dobbiamo necessariamente modificarne il codice.
Possiamo quindi identificare con sufficiente precisione i casi
in cui la soluzione basata su classi interfaccia si rivela adeguata:
quando vogliamo svincolare l'interfaccia dall'implementazione,
mantenendo comunque inalterate nel tempo le connessioni di messaggio.
Potremmo dire che si rivela piu' riutilizzabile di una soluzione
banale, ma non necessariamente piu' estendibile.
Una soluzione molto diversa e' quella rappresentata nella figura
4, basata su un controllore centralizzato detto integratore. L'idea
di fondo e' molto semplice: avere una classe che riceve i messaggi
e le richieste dai vari oggetti, e li smista secondo un criterio
interno. Tutti gli oggetti sono accoppiati solo con la
classe integratore, e la decisione finale su ogni problematica
di controllo e' eseguita all'interno dell'integratore. Ad esempio,
quando l'integratore riceve il messaggio ToggleChannel dalla Scale
Bar, e' compito suo distribuire lo stesso messaggio sia al Linear
Graph che alla Value Bar. L'integratore potrebbe avere anche altre
funzionalita': nel caso specifico, potrebbe essere associato ad
un elemento dell'interfaccia utente (la finestra che contiene
tutti i sotto-oggetti) che non solo ne riflette il ruolo centrale
di gestore, ma che gli consente anche di distribuire altri messaggi
(conseguenti ad esempio ad un ridimensionamento della finestra)
a tutti gli oggetti controllati.
Nuovamente, non e' una soluzione perfetta per ogni evenienza:
la concentrazione del controllo e delle responsabilita' tende
a far esplodere la complessita' dell'integratore, quando si aggiungono
troppi componenti. Pensate alla complessita' di un integratore
che sia responsabile di diverse decine di componenti, che viceversa
potrebbero dialogare direttamente tra loro.
Inoltre, esiste pur sempre un accoppiamento sull'interfaccia tra
i singoli componenti e l'integratore, e questo riduce la possibilita'
di riutilizzo dei singoli oggetti: per eliminare questo problema,
e' buona norma introdurre una classe interfaccia 2 per l'integratore:
in questo modo, i diversi componenti saranno solo accoppiati con
la classe I-Integrator (non mostrata nella figura 4) e diversi
integratori potranno essere utilizzati in contesti diversi.
La tecnica dell'integratore si dimostra vincente quando le interfacce
sono abbastanza piccole (notiamo infatti che l'interfaccia dell'integratore
e' la somma delle interfacce che avrebbero le classi interfaccia),
quando si presume che il numero ed il tipo di oggetti da integrare
possa variare in manutenzione o durante il riuso, e quando la
complessita' dei compiti sia tale da non precludere una soluzione
centralizzata.
Tornando al nostro esempio concreto, la soluzione con integratore
(piu' una classe interfaccia per esso) e' probabilmente la piu'
indicata, essendo il numero di oggetti coinvolti piuttosto basso
ed i compiti piuttosto semplici; in genere, intorno ad una decina
di oggetti comincia una esplosione nelle dimensioni dell'integratore
che pregiudica seriamente la possibilita' di un suo utilizzo.
Quando ci troviamo di fronte a numerosi oggetti, nessuna delle
soluzioni precedenti e' particolarmente indicata: la tecnica delle
classi interfaccia farebbe raddoppiare un gia' consistente numero
di classi, e non risolverebbe il problema dell'accoppiamento sulle
istanze; la tecnica dell'integratore tenderebbe ad esplodere in
complessita', dimensioni, e difficolta' di testing.
In questi casi, si puo' utilizzare una tecnica piu' complessa,
che chiamero' degli Advise Sink: il grafo relativo e' mostrato
in figura 5, ma e' necessaria in questo caso una spiegazione piu'
approfondita.
Osserviamo che gli accoppiamenti tra le classi sono piuttosto
bassi, e potrebbero essere ridotti ulteriormente introducendo
delle classi interfaccia per i server, ad esempio tra la classe
Data-Server e la classe Scale Bar. In tal modo, i clienti manderebbero
il messaggio SetAdvise alle classi interfaccia e non alle classi
implementazione.
Inoltre, la tecnica consente di estendere il numero di clienti
e di fornitori a piacere, senza aumentare le dimensione dell'interfaccia
e senza esplosioni nella complessita' di una classe, come avviene
invece nella soluzione basata su integratore: con gli advise sink,
la responsabilita' ed il controllo restano distribuiti.
Per contro, la tecnica e' molto piu' complessa da implementare,
e richiede anche la creazione di una classe per gestire i dati
associati ad ogni possibile evento: in casi di interazione complessa,
il numero di classi derivate da EventData puo' essere realmente
alto. Per contro, si tratta di solito di classi molto semplici,
il cui unico compito e' di incapsulare un insieme di dati omogeneo,
spesso senza metodi associati; e' in questo caso buona norma definire
sempre delle classi di uso generale per i dati piu' comuni, evitando
il proliferare di soluzioni ad-hoc. Il design dettagliato di questa
soluzione rivela anche la necessita' di gestire particolari valori
di event al momento della registrazione di un advise sink
con un server: basti pensare all'evento "all" per gestire
tutti gli eventi, o casi particolari come la distruzione. Un vantaggio
della soluzione basata su advise sink e' invece la possibilita',
se correttamente progettata nei dettagli, di incapsulare una possibile
architettura distribuita dell'applicazione: i server ed i client
potrebbero di fatto risiedere su macchine diverse, e gli EventData
potrebbero viaggiare su rete ed essere soggetti a conversioni
per consentire anche l'interoperabilita' tra architetture hardware
differenti.
La tecnica degli advise sink e' la piu' indicata quando il numero
degli oggetti sia grande o non prevedibile a priori, quando le
responsabilita' degli oggetti siano complesse al punto da scoraggiare
una soluzione centralizzata, e quando gli eventi siano piuttosto
stabili: notiamo infatti che le classi client e server sono anche
accoppiate con le classi derivate da EventData, anche se l'accoppiamento
non e' visibile in figura 5.
Nessuna delle soluzioni proposte e' migliore in assoluto:
ogni alternativa ha vantaggi e svantaggi sulle altre, in termini
di tempo di sviluppo, riusabilita', generalita', estendibilita'.
E sicuramente, esistono molte altre possibilita' architetturali
che non sono state esplorate in questo articolo, oltre a differenti
modelli del dominio del problema; tutto cio', nonostante l'esempio
proposto fosse piccolo, molto ben definito e caratterizzato.
In applicazioni reali, e' fondamentale che il progettista sappia
considerare alternative diverse, per poi scegliere la piu' adeguata
per le esigenze concrete, secondo una priorita' che puo' dipendere
dai singoli casi: talvolta, la generalita' puo' essere piu' importante
di un tempo di sviluppo limitato, o la mantenibilita' piu' importante
della generalita'. Il progettista esperto spesso non ha bisogno
di considerare troppe alternative: non di rado puo' identificare
immediatamente la soluzione piu' appropriata per ogni singola
problematica di design. In generale, tuttavia, un buon progettista
deve essere in grado di individuare, anche con grande severita',
i punti deboli delle soluzioni proposte: come ho gia' avuto occasione
di scrivere nelle puntate precedenti, l'incapacita' di identificare
e prevenire i problemi, cosi' come l'incapacita' di vedere soluzioni
alternative, coincide sempre con l'incapacita' di formulare un
design vincente
Il mese prossimo ricominceremo a parlare di design partendo da
dove abbiamo interrotto la scorsa puntata, ovvero dall'analisi
dei diversi componenti che contribuiscono alla definizione di
un design orientato agli oggetti.
1) in realta’, difficilmente il committente riesce ad esprimere un vincolo di riutilizzo cosi’ ben definito: e’ piu’ probabile che dalla discussione preliminare l’analista riesca ad interpretare la necessita’ di riutilizzare lo stesso componente in un contesto leggermente diverso da quello descritto
[back]
2) o piu’ di una, al limite anche una per ogni sotto-oggetto: in questo caso dovremo usare l’ereditarieta’ multipla per l’integratore, ma ogni sotto-oggetto sara’ accoppiato solo con una classe interfaccia piu’ ristretta.
[back]
[1] Krasner, Pope: "A cookbook for using the model-view-controller
user interface paradigm in Smalltalk-80", Journal of Object
Oriented Programming, Agosto-Settembre 1988.
Carlo Pescio (pescio@acm.org) svolge attività di consulenza in ambito internazionale nel campo
delle tecnologie Object Oriented. Ha svolto la funzione di Software Architect in grandi progetti per
importanti aziende europee e statunitensi. È incaricato della valutazione dei progetti dal
Direttorato Generale della Comunità Europea come Esperto nei settori di Telematica e Biomedicina.
È laureato in Scienze dell'Informazione ed è membro dell'ACM, dell'IEEE e della New York Academy
of Sciences.