Dr. Carlo Pescio
Architettura di Sistemi Reattivi, Parte 2

Pubblicato su Computer Programming No. 69

In questa seconda puntata della mini-serie dedicata ai Sistemi Reattivi completeremo gran parte dell'architettura complessiva, gestendo i messaggi e generando il necessario feedback verso l'esterno.

Nella scorsa puntata abbiamo iniziato a progettare l'architettura del nostro sistema reattivo, introducendo via via delle classi astratte per isolare i punti di variabilità del mondo esterno. Abbiamo quindi introdotto una classe astratta I/O per virtualizzare l'interfaccia verso i dispositivi, una classe astratta Parser ed una classe DeviceIndependentMessage per virtualizzare i dispositivi stessi. Abbiamo inoltre deciso di optare per uno schema a parallelismo massimo, dove il riconoscimento dei messaggi e la gestione di ognuno di essi avviene in thread separati, eventualmente con pooling.
In questa puntata affronteremo il problema di gestire le attività connesse con ogni messaggio, nonché la politica di sistema per la supervisione dei messaggi stessi. Procederemo poi chiudendo il ciclo e fornendo il necessario feedback verso il mondo esterno. Nel fare questo, continueremo a porre la giusta attenzione ai requisiti iniziali di estendibilità, testabilità, e fault tolerance del sistema.

Gestione dei Messaggi
Il titolo di questo paragrafo è volutamente sbagliato; "gestire i messaggi" significa infatti ritornare ad una visione procedurale dell'architettura di sistema. Significa introdurre il solito, rassicurante elemento di controllo centralizzato, che riconosce i vari messaggi e svolge delle azioni in funzione di ognuno di essi. Significa anche rinunciare ai benefici di estendibilità, riusabilità e gestione della complessità che costituiscono le grandi promesse degli oggetti.
Un messaggio non deve essere gestito: dovrebbe sapersi "gestire" da sé. Questa è la prospettiva degli oggetti, semplice ed evidente quando ci si pensa, eppure così sfuggevole quando si inizia a progettare con una impostazione procedurale.
Naturalmente, il sistema può (e in molti casi deve) comunque intervenire sulla gestione dei messaggi, ma con una politica di più alto livello: ad esempio, decidere la priorità di ognuno di essi, o decidere (in caso di sovraccarico del sistema) quali oggetti-messaggio debbano essere attivati e quali semplicemente ignorati. Più in generale, il sistema deve governare le attività dei messaggi (che diventano elementi attivi) sovraimponendo alle loro "volontà individuali" una politica di sistema. Un insieme di tecniche interessanti che possono far parte di questo componente di regolamentazione è stato presentato in [Mes96].
Vediamo alcuni esempi: in generale, un sistema reattivo deve probabilmente contenere un elemento in grado di gestire un sovraccarico temporaneo, ed eventualmente uno incaricato di migliorare le prestazioni attraverso una fusione dei messaggi. Se arriva un messaggio "pezzo completato" da una macchina a controllo numerico, seguito però da un messaggio "baia di smistamento bloccata", spesso è inutile aggiornare il database di controllo della produzione (segnalando il completamento di un pezzo) e poi ri-aggiornarlo subito dopo perché, di fatto, il pezzo non è giunto a destinazione. È più conveniente considerare il pezzo come non prodotto, segnalare il problema, e lasciare alla gestione manuale (comunque necessaria) lo sblocco della baia, il recupero del pezzo ed il suo successivo reinserimento nella catena.
In effetti abbiamo diverse esigenze contrastanti da bilanciare: da un lato, vorremmo che i messaggi fossero oggetti attivi, con un loro thread di esecuzione ed una loro conoscenza interna circa i compiti da svolgere. Dall'altro, vorremmo che fosse possibile stabilirne il fato prima che inizino a svolgere i loro compiti: ad esempio, la gestione di un pool di thread di cui ho parlato nell'introduzione corrisponde ad una politica di sistema, sovraimposta alle attività singole dei messaggi. Infine, come avevo anticipato nella scorsa puntata, lo stesso messaggio deve poter essere gestito in modo diverso passando da un sistema ad un altro.
La soluzione che adotto normalmente prevede la separazione tra il messaggio device-independent (che è una rappresentazione della richiesta) e l'oggetto Activity che svolge i compiti associati a tale richiesta (compiti che variano da un sistema all'altro). Tra questi due elementi si posiziona un componente di Policy, che decide il fato dei singoli messaggi al momento della loro trasformazione in attività. Anche questo componente deve essere sostituibile: ad esempio, in caso di sovraccarico in alcuni sistemi daremo priorità ai messaggi "di progresso" di un lavoro iniziato, in altri tenderemo a privilegiare i messaggi "di inizio attività" a scapito di quelli che stanno attendendo da più tempo. La scelta della politica dipende da molti fattori al contorno e deve essere facilmente modificabile senza alterare il resto del sistema: come ricorderete, nella mia discussione con Stepanov [Pes97] ho accennato proprio a questa funzione di "commodity" degli algoritmi, che non devono influenzare la struttura ma essere semplicemente al servizio di essa.
Vediamo quindi nel dettaglio le novità della figura 1. Innanzitutto a Parser è stato associato un Thread, come discusso nella precedente puntata. Il parser concreto crea dei messaggi device-independent (che, lo ripeto, sono solo una specifica della attività da compiere) e li passa ad un oggetto Policy.

Il parser conosce solo l'interfaccia astratta Policy, e questo ci consente di cambiare la policy concreta in ogni momento, sia cambiando sistema che all'interno di uno stesso sistema. Nulla, infatti, ci impedisce di cambiarla anche a run-time: anziché prevedere un'unica politica molto complessa di gestione del sistema, potremmo prevederne diverse, in funzione di macro-stati. Ad esempio, potremmo avere una policy concreta allo startup, una a regime, una in fasi di sovraccarico, una in fase di shutdown. Questo corrisponde, in pratica, ad applicare il pattern State [GoF95] alla classe Policy. È anche un esempio di come gli oggetti, le classi astratte e la conseguente sostituibilità a run-time ci permettano di mantenere sotto controllo la complessità dei sistemi, partizionando elementi monolitici in classi separate.
Alla fine, l'oggetto Policy crea gli oggetti Activity. Osserviamo che le decisioni prese da Policy possono essere arbitrariamente complesse (fusione di più messaggi, scarto di messaggi o parcheggio in code a bassa priorità, ecc), mentre il risultato finale osservabile dall'esterno è molto semplice, ovvero viene creato un oggetto attivo (anche Activity deriva da Thread) che porterà a termine il compito richiesto.
Nuovamente, Activity è solo una classe interfaccia: questo ci permette di soddisfare l'ulteriore requisito sui messaggi, ovvero che la gestione di un identico messaggio device-independent possa cambiare da un impianto ad un altro. Nel nostro caso, questo corrisponde a dire che l'oggetto Policy creerà oggetti di classe concreta diversa. Inoltre, la presenza di una classe astratta Activity ci garantisce un ulteriore punto di estendibilità: possiamo aggiungere nuove attività concrete semplicemente derivando una nuova classe (una volta risolto il problema delle dipendenze di creazione, che vedremo nella prossima puntata).
È importante osservare che, al livello di astrazione cui ci siamo posti, i dettagli di progettazione della singola classe Activity concreta non vengono affrontati. Tuttavia, questo non significa che si tratti di classi banali (tutt'altro), né che le possibilità di riuso si limitino al nostro livello architetturale.
Un buon sistema è composto di molti strati. Progettando la singola classe concreta Activity, dovremmo sempre pensare ad isolare parti potenzialmente riutilizzabili (in questo caso tramite ereditarietà di implementazione) in una classe derivata dall'interfaccia Activity ma ancora specializzabile.
Il risultato finale è di ottenere un framework white-box stabile (quello che stiamo costruendo), più una serie di componenti riusabili (ad es. le classi di I/O), più una serie di classi che potremmo chiamare semi-lavorati (es. le classi Activity intermedie), che sono recuperabili in progetti diversi ma che sono anche troppo specializzate per fare parte del framework.
Se progettiamo impianti custom, noteremo che lo sviluppo tenderà a produrre inizialmente un certo numero di componenti e di semi-lavorati, ma in tempi ragionevoli ci muoveremo verso l'integrazione di componenti già sviluppati e l'estensione dei semi-lavorati. Al resto penserà il framework, che ovviamente subirà qualche assestamento durante l'evoluzione dei sistemi, ma se abbiamo definito bene le interfacce delle classi astratte, l'evoluzione difficilmente avrà un impatto notevole sulle parti preesistenti.

Testability
Tra gli obiettivi che ci eravamo prefissi nella prima puntata, non apparivano soltanto le caratteristiche di estendibilità e di adattabilità di cui ho parlato poc'anzi, ma anche la possibilità di testare a fondo il sistema e di simularne il comportamento.
Osserviamo che l'introduzione di una classe astratta per Policy ci consente anche di sostituire delle Policy concrete con finalità di testing e di simulazione, sia al fine di verificare il comportamento del sistema in situazioni limite, sia per una eventuale prototipazione di un componente di Policy mirato a problematiche specifiche.
In generale, possiamo pensare ad ogni classe astratta che introduciamo nel sistema come ad un hot point di testing e di simulazione. Questo è un concetto molto importante, che può dare un significato concreto a frasi come "design for testability" che restano altrimenti solo dei desideri irrealizzati.
Quando si realizza un sistema complesso (hardware o software) è pressoché impossibile verificarne la correttezza con un approccio totalmente black box. Il problema è ancora più sentito per i sistemi reattivi, il cui funzionamento dipende dal mondo esterno (a differenza dei sistemi batch, che si prestano meglio ad una verifica black-box). Così come nell'hardware si introducono dei punti di test intermedi, in modo da poter esaminare il funzionamento di sotto-circuiti, nel software si possono introdurre degli hot point in cui la sostituzione di un elemento consente di condurre un testing più approfondito, in modalità white-box.
Possiamo applicare lo stesso ragionamento alle classi Activity. All'interno di una classe Activity concreta possiamo facilmente simulare una serie di situazioni particolari che sarebbe problematico ottenere sul campo.

Fault tolerance
Ad un livello piuttosto alto come quello architetturale, le classi interfaccia hanno anche una ulteriore valenza. Di solito (come ho ripetuto più volte) esse rappresentano punti di contatto tra il framework (il cui codice è sufficientemente stabile) e le estensioni/customizzazioni, che sono invece soggette a modifiche frequenti, adattamenti, sostituzioni. La classe astratta rappresenta quindi anche un confine tra due mondi, e come spesso accade questo richiede una barriera di protezione: non vorremmo certamente che un errore in una parte esterna al framework possa compromettere l'integrità dell'intero sistema.
Il punto di contatto framework-mondo esterno è uno dei siti migliori per posizionare un firewall contro le condizioni di errore. In un framework ad oggetti come il nostro, probabilmente useremo le eccezioni come metodo di propagazione degli errori. Chi ha seguito il mio corso sull'uso delle eccezioni in C++ (vi ricordo che una versione ridotta delle slide è disponibile liberamente sulla mia home page) sa bene che rendere exception-safe ogni porzione di codice è un compito difficile e gravoso. In genere, la scelta migliore è di creare delle barriere di propagazione controllata come parte integrante dell'architettura.
Nel punto di contatto tra framework ed estensioni dovremmo quindi pensare di gestire la maggior parte delle eccezioni provenienti dai moduli esterni, e di propagare le rimanenti (incluso il re-mapping delle eccezioni entranti) in modo estremamente controllato.
Al limite, un errore irrecuperabile può essere affrontato disabilitando l'intero sottosistema (rappresentato dal modulo di estensione e da quelli strettamente dipendenti), oltre a fornire la necessaria diagnostica. Meglio ancora (seguendo una strategia tipica dei sistemi di telecomunicazioni) dovremmo pensare di adottare una tecnica di reboot selettivo dei sottosistemi in cui si verifica un errore irrecuperabile (potete consultare [ACG96] per ulteriori informazioni). Osserviamo quindi che isolare i moduli tramite classi interfaccia ci aiuta anche ad ottenere un altro degli obiettivi prefissi, l'high availability del sistema.

Performance
Ricordiamo infine che un ulteriore obiettivo del nostro sistema erano le prestazioni soft real-time. Sicuramente qualcuno obietterà che la struttura vista sopra è inefficiente rispetto ad un agglomerato informe ma ultra-ottimizzato, dove non esistono classi virtuali, non esiste separazione tra messaggio device-dependent e device-independent, e tra messaggio e richiesta attiva. Dei problemi di prestazioni mi occuperò nella prossima (ed ultima) puntata. Posso dire sin d'ora che in molti casi un uso accorto del polimorfismo porta a codice più efficiente, nonostante le tante leggende metropolitane sostengano il contrario. Il dispatch di una funzione virtuale, con un buon compilatore C++, ha un overhead di un paio di cicli di clock su un comune processore Pentium. Se per evitare la funzione virtuale, rinunciando all'estendibilità, utilizziamo un if/else o uno switch/case, rischiamo seriamente di peggiore le prestazioni anziché migliorarle. Di questo, comunque, parlerò più in dettaglio nella prossima puntata.
Un argomento strettamente legato alle performance, sul quale vi invito a riflettere prima del prossimo numero, è anche il ruolo di DeviceIndependentMessage. Possiamo ipotizzare di renderlo stabile, sotto forma di classe concreta, oppure pensarlo a sua volta come classe interfaccia, da cui derivare sotto-classi analoghe ai semi-lavorati discussi sopra: classi concrete riusabili tra un progetto ed un altro, ma comunque troppo specializzate per far parte del framework. Provate a pensare alle possibili conseguenze delle due scelte.

Chiudiamo il cerchio
Riprendiamo in esame il sistema complessivo: una volta che il messaggio è stato ricevuto, decodificato, ed infine trasformato in un oggetto Activity, questo dovrà eseguire una serie di compiti che aggiorneranno lo stato interno del sistema e agiranno sul mondo esterno. In generale, ogni Activity dovrà essere in grado di:

A rendere più complicato il nostro compito contribuiscono anche le problematiche di riuso. Non di rado, una Activity concreta potrebbe essere riutilizzata su impianti diversi. Ad esempio, a seguito di un messaggio "pezzo completato" vogliamo abitualmente notificare il controllo produzione (che in seguito ci informerà di come proseguire) ed anche dare le necessarie disposizioni ai dispositivi "a valle". Il numero di questi dispositivi, ed anche il comando concreto da inviare, può però variare (e quasi sicuramente varierà) da un impianto ad un altro.
Al solito, la soluzione più semplice se si guarda solo l'immediato è di scrivere delle Activity custom e di riciclarne delle parti tramite copy&paste. Noi cercheremo invece di prevedere queste possibilità di estensione e di adattamento all'interno della nostra architettura.

Iniziamo affrontando uno dei problemi, ovvero la comunicazione con un numero variabile di dispositivi (si veda la figura 2; la classe Activity concreta è qui chiamata ConcreteActivity). In analogia a quanto realizzato per la parte di input, introduciamo una classe DeviceIndependentCommand: gli oggetti di questa classe rappresentano comandi astratti verso la periferia, e vengono creati dalla ConcreteActivity ed inviati ad uno o più CommandSink. CommandSink è una classe interfaccia, da cui deriveremo le varie classi concrete che si interfacciano in uscita con in vari dispositivi: in figura 2 ho riportato come esempio una classe che si interfaccia al PC di Diagnostica ed una che si interfaccia al Controllo Produzione. Ognuna di esse possiede un oggetto di I/O e svolge principalmente il compito di convertire il comando device-independent in uno comprensibile dal dispositivo target.
In fase di configurazione (un tema centrale per la realizzabilità di tutto il sistema, che discuterò nella prossima puntata), decideremo quali oggetti concreti di classe derivata da CommandSink debbano essere collegati ad ogni ConcreteActivity. Questo ci consentirà di riusare la stessa ConcreteActivity in impianti diversi, notificando dispositivi differenti ed in numero differente, semplicemente variando le connessioni.
Mancano ancora degli elementi, ma prima di proseguire è necessario rivedere il risultato sinora ottenuto: come vedremo, la soluzione ad alcuni punti ancora da coprire è già presente nel sistema, ma è necessaria una riorganizzazione di alcune classi per farla emergere.

Clean-up
Una delle decisioni più difficili da prendere quando si affronta un articolo sul design di un sistema è quanto imbrogliare i lettori. Spesso si decide di presentare direttamente un design perfetto, argomentandone in modo logico la nascita, e facendo sembrare naturali anche le decisioni più sofferte [PC86]. Si tende anche a nascondere ogni necessità di revisione del design, come se tutto fosse andato nella direzione migliore sin dall'inizio.
Esempi simili sono comunque utili, ma tendono a proiettare una immagine alterata del lavoro del progettista. In realtà, via via che si aggiungono classi e si prendono decisioni più dettagliate circa il partizionamento delle responsabilità, è normale che emergano similitudini (che portano alla fusione di più classi), dissonanze (che portano alla separazione di elementi o alla modifica di un tipo di relazione in un'altra) e vere e proprie incoerenze, che richiedono interventi di maggiore portata.
In questo caso, ho scelto di seguire una strada realistica ed arrivare ad un punto in cui è necessario intervenire sul risultato con una operazione di "ripulitura". Osserviamo infatti le classi "Diagnostica" e "Produzione": esse possiedono un oggetto I/O, che utilizzano nel verso di Output. Il loro compito è trasformare comandi device-independent in comandi device-dependent. A dire il vero esistono già delle classi "simmetriche", che possiedono un oggetto di I/O, lo usano nel verso di Input, e trasformano messaggi device-dependent in messaggi device-independent: sono i vari DeviceDependentParser.
Un qualunque dispositivo periferico è attualmente mappato su due classi, completamente disgiunte e con nomi anche fortemente discordanti. Ovviamente dobbiamo intervenire sul diagramma e razionalizzarlo. Se operiamo con attenzione, potremmo anche ottenere "gratis" i punti lasciati in sospeso al paragrafo precedente (ad esempio l'aggiornamento dello stato del parser).
Vi invito a spendere un po' di tempo costruendo il diagramma delle classi completo e modificandolo in modo da ottenere una soluzione completa ed elegante. La mia personale versione, completa come sempre delle giustificazioni di progetto, apparirà sul prossimo numero,

Conclusioni
Nella prossima puntata, oltre a completare il diagramma lasciato in sospeso, affronteremo diversi temi di importanza fondamentale. Stranamente, sono anche temi che troppo spesso vengono ignorati nei vari libri ed articoli sul design. Ad esempio, nel nostro diagramma, vi sono classi concrete (Policy) che usano e creano classi astratte (Activity) . Ovviamente, a run-time, tutti gli oggetti creati devono avere classe concreta. Come si supera questo importante scoglio senza vanificare l'estendibilità del sistema? Analogamente, chi decide (e come) quali oggetti di classe derivata da CommandSink passare ad ogni ConcreteActivity? Chi decide quali classi concrete di I/O debbano essere create, e chi le crea, e come? Se Policy crea gli Activity, chi li distrugge?
Riprenderemo poi alcuni argomenti appena accennati, come la scalabilità su più macchine e l'efficienza del risultato. Questa sarà anche l'occasione per accennare agli oggetti distribuiti ed alle eventuali alternative.
Vorrei infine far notare che l'architettura che abbiamo impostato non è l'unica possibile. Si tratta solo di una struttura che ho utilizzato (con alcune variazioni) in diversi casi reali, e che ho visto funzionare bene sul campo. Esistono ovviamente approcci alternativi. Uno di questi, mirato all'integrazione con uno strumento CASE custom e basato su idee molto diverse, è stato ampiamente discusso in [MEM93] ed in [AEM95]. Un altro esempio, più simile alle idee qui presentate e più specializzato per problematiche di manufacturing (mentre gran parte del nostro framework è adattabile ad altre situazioni) è reperibile in [Sch96].

Bibliografia
[ACG96] Michael Adams, James Coplien, Robert Gamoke, Robert Hammer, Fred Keeve, Keith Nicodemus, "Fault Tolerant Telecommunication System Patterns", in Pattern Languages of Program Design Vol. 2, Addison-Wesley, 1996.
[AEM95] Amund Aarsten, Gabriele Elia, Giuseppe Menga, "G++: A Pattern Language for Computer-Integrated Manufacturing", in Pattern Languages of Program Design Vol. 1, Addison-Wesley, 1995.
[GoF95] Gamma ed altri, "Design Patterns", Addison-Wesley, 1995.
[MEM93] Giuseppe Menga, Gabriele Elia, M. Mancia, "G++: An Environment for Object Oriented Design and Prorotyping of Manufacturing Systems", in Intelligent Manufacturing: Programming Environments for CIM, Springer-Verlag, 1993.
[Mes96] Gerard Meszaros, "A Pattern Language for Improving the Capacity of Reactive Systems", in Pattern Languages of Program Design Vol. 2, Addison-Wesley, 1996.
[PC86] David Parnas, Paul Clements, "A Rational Design Process: How and Why to Fake it", IEEE Transactions on Software Engineering, February 1986.
[Pes97] Carlo Pescio, "Ipse Dixit", Computer Programming No. 64.
[Sch96] Hans Albrecht Schmid, "Creating Applications from Components: A Manufacturing Framework Design", IEEE Software, November 1996.

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.


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