Dr. Carlo Pescio
Architettura: un esempio

Pubblicato su Computer Programming No. 40


Consideriamo diverse alternative di design architetturale su un esempio concreto, confrontandone le caratteristiche di mantenibilita', estendibilita', complessita' delle possibili soluzioni.

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
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.

Meno accoppiamento con le Classi Interfaccia
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.

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'.
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.

Estendibilita' con l'Integratore
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.

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.
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.

Scalabilita' con gli Advise Sink
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.

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.
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.

Conclusioni
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.

Note:
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]

Reader's Map
Molti visitatori che hanno letto
questo articolo hanno letto anche:

Bibliografia
[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.

Biografia
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.