Dr. Carlo Pescio
Architettura di Sistemi Reattivi, Parte 3

Pubblicato su Computer Programming No. 70

Terza ed ultima puntata sui sistemi reattivi: completiamo il modello e risolviamo gli interrogativi sinora lasciati in sospeso.

Siamo finalmente arrivati al termine della mini-serie dedicata ai sistemi reattivi. In questa puntata riprenderemo i punti lasciati in sospeso, rivedendo il diagramma delle classi e completando il modello impostato. Discuteremo anche la complessità concettuale del sistema, e affronteremo i temi sinora solo accennati di efficienza, di distribuzione del carico e di configurazione. Come sempre, cercherò anche di andare al di là del problema in sé e di parlare più in generale di design ad oggetti, del ruolo del progettista, e così via.

Completiamo il diagramma
Al termine della scorsa puntata, come ricorderete, erano emerse alcune disomogeneità nella struttura. In particolare, ogni dispositivo esterno era mappato su due classi distinte, ognuna delle quali faceva poi riferimento ad un diverso oggetto di I/O, utilizzato in un solo verso. Si poneva inoltre il problema di aggiornare lo stato dei DeviceDependentParser all’interno di una classe Activity concreta.

La soluzione che adotto normalmente è quella riportata in figura 1, dove ho riassunto l’intera architettura (con l’occasione ho anche stratificato meglio il diagramma, cercando di riflettere i livelli di astrazione ed allo stesso tempo di evitare incroci incomprensibili). In pratica, ho eliminato le classi derivate da CommandSink (come Diagnostica e Produzione), che possedevano un oggetto I/O. Al loro posto ho introdotto una classe ConcreteDevice, che deriva da CommandSink e da DeviceDependentParser, e che rappresenta un "intero" device, non più suddiviso in due unità indipendenti come avveniva nel diagramma precedente. Notiamo che ConcreteDevice è un nome-placeholder, così come DeviceDependentParser: in un diagramma reale, avremo classi come PLCSmistamento o MainframeProduzione, così come dei Parser corrispondenti. Addirittura, in diversi casi reali, risulta più pratico fondere insieme DeviceDependentParser e ConcreteDevice: ho lasciato le due classi distinte perché questa è la soluzione più generale, che permette il massimo riuso.
La nuova struttura permette alle ConcreteActivity di parlare con il ConcreteDevice che le ha create (tramite Policy) in modo molto semplice: basta che il device passi se stesso, come CommandSink, alla ConcreteActivity. Poiché il ConcreteDevice è derivato da DeviceDependentParser, esso può modificarne lo stato interno, fornire feedback al dispositivo fisico (tramite la porta di I/O del parser), e così via.
Notiamo che i metodi di ConcreteDevice verranno chiamati all’interno di contesti di esecuzione diversi: questo è il comune problema dell’approccio "classico" agli oggetti attivi, che ho discusso a fondo in [Pes98]. Tipicamente, le letture avverranno in un thread (Parser è un oggetto attivo), mentre le scritture saranno invocate da un altro (normalmente una ConcreteActivity). In pratica, questo significa che ogni ConcreteDevice dovrà essere realizzato con le dovute cautele di sincronizzazione, soprattutto nell’uso dell’oggetto I/O.
Ho anche apportato un’ulteriore modifica (la classe Activity ora ha-un Thread, invece di essere derivata da Thread), che verrà spiegata più avanti, al paragrafo "Efficienza".

Diagramma degli oggetti
Durante lo sviluppo di un progetto reale, non ci si limita a produrre il solo diagramma delle classi, che come ricorderete fornisce solo una visione statica del sistema. È pressoché indispensabile passare anche ad altri tipi di diagrammi (es. sequence diagram), che forniscono una visione dinamica. Inoltre, in alcune situazioni è utile ricorrere ad un diagramma degli oggetti, anziché limitarsi ad un diagramma delle classi come quelli visti sinora. Per esigenze di spazio, non vedremo alcun diagramma di sequenza per il sistema; i sequence diagram di UML non sono dissimili dai "diagrammi degli eventi" da me presentati in [Pes95], peraltro liberamente accessibile tramite la mia home page.
Credo sia invece utile vedere un esempio, ridotto all’osso, di diagramma degli oggetti. La ragione principale è la facilità con cui la struttura statica di questo tipo di sistemi può ingannare chi non sia abituato a "ricostruire" mentalmente un diagramma degli oggetti, problema che avevo già fatto notare nella prima puntata. In un diagramma degli oggetti, ogni rettangolo rappresenta un singolo oggetto, identificato da un nome e dalla classe. In UML, la sintassi adottata è "nome oggetto : classe", una scelta purtroppo non molto azzeccata per chi usa il C++, in quanto porta spesso ad una confusione con la notazione di ereditarietà. Il nome dell’oggetto è opzionale, e spesso si riporta solo per gli elementi più importanti del diagramma.

La figura 2 mostra il diagramma degli oggetti per un semplice sistema, nel quale esistono solo due dispositivi (un PC Baia che controlla le baie di confezionamento, collegato su porta seriale, ed un mini Produzione che gestisce i dati di produzione, collegato via TCP/IP). Il PC può inviare solo due messaggi, "Nuovo Pezzo" e "Pezzo Prelevato". La policy concreta è banale, e crea semplicemente un oggetto Activity corrispondente al messaggio in arrivo, senza pooling o simili. Esisteranno quindi degli oggetti di classe ArrivoPezzo e PrelievoPezzo che svolgono le attività richieste. In entrambi i casi è necessario mandare un comando di Acknowledge al PC origine, e nell’esempio ho anche inviato un messaggio "Pezzo Spedito" al mini di produzione.
Osserviamo che PC Baia appare due volte nel diagramma, una volta come oggetto di classe ParserBaia (che sarà derivata da Parser), una volta come oggetto di classe DeviceBaia (che sarà a sua volta derivata da ParserBaia e da CommandSink). Questa possibilità è direttamente prevista da UML, e consente di far vedere i diversi ruoli di un singolo oggetto all’interno di un sistema.

Complessità Concettuale
Il diagramma degli oggetti ribadisce alcuni punti tutt’altro che evidenti in figura 1, ovvero la presenza di più istanze per ogni classe, e come queste istanze siano tra loro connesse. Nel caso del nostro sistema, il diagramma di figura 2 chiarisce anche un ulteriore punto: per gestire una situazione banale, abbiamo messo in piedi una soluzione piuttosto complicata.
La complessità concettuale della soluzione è un punto molto importante, su cui vale la pena di spendere alcune parole. Ovviamente, se il nostro obiettivo fosse stato la gestione del semplice caso descritto in figura 2, la soluzione di figura 1 sarebbe stata quanto meno sovradimensionata. Tuttavia, il nostro obiettivo era diverso, ovvero la realizzazione di un framework in grado di operare in situazioni reali. La soluzione adottata, che distribuisce le responsabilità tra molti oggetti (ognuno dei quali rimane quindi relativamente semplice) ha una soglia di ingresso piuttosto alta, ma anche una scalabilità eccellente.
Aggiungere un PC di diagnostica al sistema di figura 2, ad esempio, significa sostanzialmente implementare una nuova classe ConcreteDevice e far sì (tramite configurazione) che le Activity concrete notifichino anche questo elemento (visto sempre come un CommandSink) degli eventi occorsi. Gestire nuovi eventi significa sviluppare delle (ragionevolmente piccole) classi Activity corrispondenti.
Le singole Activity diventano, nel nostro sistema, l’equivalente dei Business Object in architetture transazionali. Peraltro, in completa analogia, potremmo anche pensare ad un loro sviluppo basato su Custom Wizard. In fondo, in molti sistemi industriali, le Activity concrete cambiano con relativa frequenza, e in determinate situazioni la scelta di svilupparle in modo "usa e rimpiazza" può anche essere quella complessivamente migliore. Osserviamo, tra l’altro, che i vari algoritmi concreti finiranno per essere implementati a livello di ConcreteActivity e ConcretePolicy: essi avranno, come ho più volte fatto osservare, un ruolo di servizio. In effetti, nonostante qualcuno affermi che i grandi sistemi sono algoritmici per natura, e di conseguenza tenda a programmare a suon di algoritmi, noi abbiamo modellato l’intera struttura relegando sin dall’inizio gli algoritmi ad un livello di "programming in the small", dove è giusto che stiano. Il sistema sarà algoritmico nei suoi dettagli, non nella sua natura.
Tornando alla complessità del sistema, va quindi osservato come la struttura impostata, che sembra eccessiva su casi banali, sia utile per gestire i casi reali. Ci fornisce una collocazione naturale per ogni elemento che si verrà ad aggiungere, permettendo al sistema di crescere senza deformità. Questo a differenza di soluzioni sub-ingegnerizzate, tipiche dei sistemi nati senza un reale progetto architetturale, che spesso non permettono una reale scalabilità e tendono frequentemente a degenerare nel caos.

Efficienza
Le vecchie volpi dell’automazione, parzialmente convinte da quanto sopra, passeranno probabilmente al contrattacco affermando che la soluzione di figura 2 è inefficiente. Impossibile dar loro torto: senza le opportune misure, per rispondere con un acknowledge dovremmo:

In una struttura "tradizionale", con intelligenza centralizzata, avremmo semplicemente letto il messaggio, scelto l’azione con uno switch/case, e mandato l’acknowledge. Se vogliamo che il nostro sistema abbia le prestazioni desiderate, dobbiamo necessariamente aggiungere alcuni ingredienti.
Iniziamo dall’operazione sicuramente più costosa, ovvero la creazione e distruzione del thread che accompagna ogni Activity. Il modo migliore per velocizzare questa operazione è... non farla. Poco fa ho accennato alle architetture transazionali, ed alla somiglianza tra le nostre Activity ed i Business Object. Una delle lezioni apprese sin dai primi transaction monitor implementati su mainframe è che la continua acquisizione (ed il conseguente rilascio) di risorse è deleteria per le prestazioni. Ogni sistema transazionale degno di questo nome opera con una politica di pooling delle risorse, ed il nostro sistema reattivo farà bene a seguire la stessa strategia. In pratica, questo significa che i thread non verranno creati e distrutti dinamicamente: verranno creati allo startup, in misura determinata dal componente di Policy, e lasciati in stato di sleep sino a quando si crea un oggetto Activity, cui viene assegnato il primo thread disponibile. Questa è la ragione che mi ha portato a modificare il legame tra Activity e Thread da ereditarietà a contenimento: il contenimento permette una riassegnazione dinamica del contenuto, l’ereditarietà lega al sotto-oggetto padre per l’intera lifetime dell’oggetto. Chi si occupa di legare un Thread ad una Activity? Naturalmente il componente Policy, che può usare una eventuale conoscenza del dominio per scegliere la priorità del Thread, selezionare il miglior candidato a partire tra i DeviceDependentMessage, eccetera. Questo coincide con il ruolo "politico" di Policy (da cui il nome) discusso nelle puntate precedenti. Notiamo che il pooling non è necessariamente limitato ai thread: in funzione dell’impianto, possiamo facilmente ipotizzare anche un pool di ConcreteActivity, magari solo per certi tipi di attività. Sarà sempre il componente di Policy a nascondere questi dettagli al resto del sistema.
Passiamo ora ai DeviceIndependentMessage/Command. Una caratteristica frequente di tali elementi è di avere uno stato intrinseco che domina su quello estrinseco. Ciò significa che, di norma, ogni oggetto DeviceIndependentCommand che rappresenta un comando di Acknowledge tenderà ad avere esattamente lo stesso stato interno. In questi casi, si può utilizzare il pattern FlyWeight [GoF95], creando dei "rappresentanti" per ogni tipo di messaggio e comando, e limitandosi a "rivestirli" con lo stato estrinseco nei pochi casi in cui sia realmente necessario.
A questo punto, rimane fondamentalmente l’onere di un mapping tra messaggio e activity, e l’overhead della chiamata a funzione virtuale necessaria a scatenare le singole ConcreteActivity. Ricordiamo che in un programma "tradizionale", questa parte viene normalmente svolta a suon di switch/case, o con una tavola di puntatori a funzione gestita da una ricerca binaria. Nel nostro caso, emerge l’utilità dello strato di device-independence per i messaggi. I messaggi device-independent che gestiscono i diversi eventi sono relativamente stabili, anche perché il dominio del problema tende a stabilizzarsi dopo un certo numero di impianti. In questa situazione, il mapping tra messaggi ed activity può facilmente essere implementato come funzione hash perfetta [Pes96], che richiede pochi cicli di clock per essere valutata, sicuramente meno di uno switch/case o di una ricerca binaria. Arriviamo ora alla chiamata di funzione virtuale: su una CPU moderna, una chiamata di funzione virtuale ha un overhead di circa 2 cicli di clock se paragonato ad una normale chiamata di funzione. Se poi lo paragoniamo al costo delle sue alternative logiche (uso di puntatori a funzione o di uno switch/case con codice in linea) ci rendiamo conto che un puntatore a funzione ha esattamente lo stesso costo, mentre uno switch/case è raramente più veloce, spesso più lento. In diverse prove, ho osservato che sopra i 4 "case" si ha un vantaggio notevole ad usare una funzione virtuale, tranne per alcuni "numeri magici" (es. potenze di 2) che il compilatore ottimizza al meglio, nel qual caso l’overhead di una funzione virtuale è comunque di pochi cicli, ed il risparmio reale è dovuto all’espansione delle operazioni nel corpo del case.
In conclusione, rendere efficiente l’architettura di figura 1 è sicuramente possibile, ma è necessario adottare delle tecniche di programmazione più sofisticate, che devono essere tenute ben presenti in fase di design dettagliato. Un lato decisamente positivo è che tali tecniche possono essere nascoste in apposite classi, senza che ad esempio le singole Activity (che sono gli elementi più soggetti a modifiche ed estensioni) debbano esserne a conoscenza. Nuovamente, abbiamo un livello di complessità iniziale aggiuntiva, che serve a bilanciare la futura espandibilità e maneggevolezza del sistema.

Completiamo il sistema
Stiamo ormai tirando le fila del discorso, e possiamo quindi completare i punti lasciati in sospeso nelle precedenti puntate. Più per caso che per volontà, un tema comune a tali situazioni sarà la scelta fra una struttura completamente strongly-typed, oppure una alternativa con un parziale controllo dinamico sui tipi.
Iniziamo dal caso più semplice: i vari elementi concreti sono "collegati" fra loro solo attraverso interfacce/classi astratte. Questo è un elemento fondamentale dell’architettura, che ci permette di sostituire a piacimento i componenti concreti. D’altro canto, questo significa anche che le varie classi concrete non potranno sfruttare le particolarità di altre classi concrete: per farlo occorrerebbe modificare le interfacce, e questo va fatto solo quando è assolutamente giustificato ed importante (pena la ricompilazione di tutte le classi accoppiate con l’interfaccia). Vediamo un esempio concreto: una delle nostre classi concrete di I/O (FailSafeIO) supporta una funzione aggiuntiva per lo switch a caldo. Purtroppo non abbiamo previsto tale funzione nell’interfaccia I/O, quindi il nostro ConcreteDevice non potrà chiamarla. Abbiamo ora due alternative: la prima è di modificare l’interfaccia, e dobbiamo essere pronti a pagarne il costo. La seconda è di realizzare una nuova classe, ulteriormente derivata dal nostro ConcreteDevice (FailSafeDevice), che "conosca" la classe del suo oggetto di I/O. In tale classe, possiamo prendere l’oggetto di I/O, farne un cast dinamico a FailSafeIO, e chiamare la funzione desiderata. Ovviamente il cast può fallire: se usiamo FailSafeDevice con un oggetto di I/O diverso da FailSafeIO avremo un errore a run-time, come in tutti i sistemi con controllo dinamico dei tipi.
In tutta onestà, questa situazione è decisamente "sicura" se la riconduciamo entro i limiti molto controllati descritti sopra: due gerarchie di derivazione parallele, le cui classi foglia usano un cast per parlarsi direttamente. Con un codice così delimitato, le possibilità di non riscontrare immediatamente eventuali errori sono molto basse.
Questo è ancora più importante quando pensiamo al secondo caso, ovvero la condivisione di uno "stato del sistema" da parte delle varie classi concrete. Ad esempio, il nostro sistema reattivo potrebbe contenere un database in RAM, che deve essere manipolato da molte Activity concrete. In generale, è difficile trovare un’interfaccia stabile per "lo stato del sistema", che tende a variare parecchio da un impianto all’altro.
Di nuovo le soluzioni sono fondamentalmente due, con qualche gradazione intermedia. La prima è accettare di creare una diversa versione del framework in ogni impianto, salvando molto codice ma ricompilando i vari moduli perché l’interfaccia InternalState cambia da una versione all’altra. La seconda è di avere un’interfaccia InternalState minimale (praticamente vuota) e nelle varie classi concrete (che sono specifiche dell’impianto) fare un cast dinamico alla classe ConcreteState tipica di quell’impianto.
Una via di mezzo è l’uso di una interfaccia Broker che consenta di interrogare lo stato del sistema con una tecnica "a property", del tipo QueryProperty( string propertyName ). Questo consente in una discreta percentuale di casi di evitare i cast, ma va comunque previsto il caso di property sconosciuta. Personalmente, non appena lo stato del sistema diventa un elemento non banale, preferisco passare alla soluzione basata sui cast dinamici. L’uso di un Broker con get/set porta a una pessima distribuzione dell’iniziativa e dell’intelligenza (chi ha letto il mio C++ Manuale di Stile o ha seguito il mio WorkLab sul SysOOD riconoscerà una violazione della Legge di Demeter nel concetto stesso di classe Broker).

Distribuzione del carico
Come avevo accennato nella prima puntata, spesso un sistema reattivo richiede una potenza computazionale notevole, ed in molte situazioni è più vantaggioso utilizzare più macchine anziché una sola unità di elaborazione molto potente. Questo richiede un mapping degli oggetti sulle diverse macchine, ed ovviamente richiede anche una tecnica per far "parlare" tra loro i vari oggetti.
In queste situazioni, il criterio di partizionamento che adotto abitualmente è il trade/off tra il bilanciamento del carico e la minimizzazione del data flow distribuito. In parole più semplici, un obiettivo deve essere la distribuzione di un carico il più possibile uniforme sulle diverse macchine (e questo tende a farci "sparpagliare" gli oggetti), ma un altro obiettivo deve essere la minimizzazione del traffico di rete, sia per ragioni di reliability che di overhead (e questo tende a farci "concentrare" gli oggetti). In pratica, una strategia molto semplice che tende a funzionare bene è la separazione tra I/O e Processing, seguita eventualmente da un partizionamento delle diverse Activity. Vediamo un caso concreto: le interfacce seriali sono lente e tendono a "rubare" parecchio tempo di CPU se confrontate con una rete. Spostare su un’unica macchina la gestione di un buon numero di porte seriali, facendo vedere alle macchine di Processing solo una o più schede di rete, è un’ottima (e semplice) tecnica di distribuzione del carico. Analogamente, se il nostro sistema richiede un alto numero di Activity, è ragionevole partizionarle su più macchine, seguendo anche in questo caso la tecnica di minimizzazione del data-flow distribuito.
Definito il partizionamento, occorre individuare una tecnica per distribuire gli oggetti. Fondamentalmente abbiamo due scelte: una distribuzione "manuale" o l’uso di un middleware, ad es. CORBA o DCOM. Distribuzione "manuale" significa sostanzialmente scrivere un oggetto Proxy che gira sulla macchina locale, scrivere un oggetto Stub che gira sulla macchina remota, e far parlare i due attraverso qualche protocollo, normalmente sfruttando una delle primitive di comunicazione del sistema target (socket, pipe, NetBIOS, ecc). Si tratta di una soluzione ampiamente adottata, tanto da essere classificata come pattern ("Half Object + Protocol", [Mes95]). Ha l’innegabile vantaggio di permettere il totale controllo, caratteristica che si rivela molto importante in fase di testing e messa in opera, e l’innegabile svantaggio di richiedere la scrittura (ed il test) di due classi aggiuntive per ogni classe distribuita.
L’uso di un middleware ha vantaggi e svantaggi esattamente opposti, ovvero otteniamo più o meno gratuitamente la creazione dei proxy/stub, normalmente al solo costo di definire l’interfaccia in un IDL, e poi dobbiamo compiere un "salto nella fede" e sperare che le chiamate di funzione... funzionino. Non di rado, se qualcosa va storto diventa un po’ difficile scoprire l’origine esatta del problema e porvi rimedio.
Come dico spesso in questi casi, dal mio punto di vista è un caso di gap generazionale: spesso incontro neolaureati cui brillano gli occhi a parlare di CORBA, mentre a chi ha un po’ più di esperienza su casi reali calano gocce di sudore pensando ai grattacapi di testing, debugging ed assistenza remota. Personalmente, per almeno un altro paio di anni, credo che continuerò a puntare su soluzioni che mi garantiscono il controllo che desidero, anche a costo di dover scrivere un po’ più di codice (spesso peraltro molto semplice).
Infine, vorrei ricordare che la gestione "manuale" consente anche di ottimizzare ulteriormente il sistema, ad esempio accorpando in un unico invio su rete più messaggi provenienti da dispositivi diversi, in funzione di un sistema a priorità e dimensione dei pacchetti studiato a puntino.

Configurazione e Creazione
L’ultimo, importante punto lasciato in sospeso riguarda la configurazione del sistema, ed in particolare la gestione delle dipendenze di creazione. Come avevo accennato nella scorsa puntata, il diagramma di figura 1 "non funziona", ovvero non può essere direttamente implementato così com’é. Questo è peraltro un rischio sempre presente a livello di progettazione, ed è una delle ragioni per cui, a mio avviso, i progettisti dovrebbero sempre avere una reale esperienza di programmazione alle spalle, e dovrebbero cercare di mantenerla sempre viva: aiuta a capire più rapidamente eventuali "buchi" nei diagrammi.
In particolare, i nostri problemi sono i seguenti:

  1. Ogni DeviceDependentParser conosce solo l’interfaccia I/O, ma deve essere associato con un oggetto di classe derivata da I/O, classe che gli è sconosciuta.
  2. Analogamente, il DeviceDependentParser conosce solo la classe astratta Policy, ma deve essere associato con un oggetto di classe derivata da Policy, che nuovamente gli è sconosciuta.
  3. L’oggetto Policy deve creare degli Activity concreti, ma conosce solo la classe astratta Activity
  4. Ad ogni ConcreteActivity devono essere associati un certo numero di oggetti di classe derivata da CommandSink, ma ConcreteActivity non conosce tali classi.

Chi ha seguito il mio corso o worklab sul Systematic OOD riconoscerà immediatamente una serie di dipendenze di creazione che devono essere gestite. Le tecniche possibili sono diverse, in funzione del grado di flessibilità desiderato. In particolare, dobbiamo decidere se una variazione nelle associazioni discusse sopra è passibile di ricompilazione, o se deve essere possibile riconfigurare il sistema senza mettere mano al codice.
In impianti di controllo industriale, va detto che l’ipotesi di ricompilazione non è così disastrosa, purché venga confinata a moduli molto delimitati (per evitare di dover ri-testare a fondo moduli complessi). In fondo, se per associare un DeviceDependentParser ad un I/O su TCP/IP anziché su seriale dobbiamo modificare una linea e ricompilare un piccolo modulo-factory, il costo è quasi trascurabile rispetto alle operazioni da compiere sui dispositivi fisici per cambiare le schede, stendere i cavi, eccetera.
In queste situazioni, l’opzione migliore resta quella della Class Factory [GoF95], ovvero una classe particolare che conosce la topologia dell’impianto ed è in grado di creare gli oggetti di classe "giusta" per ogni elemento visto sopra (tranne il punto 3 che vedremo tra poco).
In situazioni particolarmente dinamiche, o quando si prevede di installare molti impianti diversi e si vuole evitare l’inferno delle versioni multiple, può essere opportuno usare una strategia differente. La tecnica che uso abitualmente è di isolare ogni classe concreta in una DLL, che esporta delle funzioni di creazione e distruzione degli oggetti. Gli oggetti vengono esposti come puntatori alle classi base, anche se internamente alla DLL vengono creati come oggetti di classe derivata.
A questo punto è sufficiente che ogni elemento conosca il nome della DLL che implementa gli oggetti associati (nel SysOOD, questa tecnica è chiamata Indexed Creation): ad esempio, per quanto riguarda il punto 1 ogni DeviceDependentParser dovrà conoscere il nome della DLL che implementa la classe di I/O associata, ed i parametri di configurazione. Per quanto riguarda il punto 3, di solito una tecnica di Indexed Creation è praticamente indispensabile, in quanto si tratta di una associazione dinamica e soggetta a frequenti cambiamenti. Possiamo ipotizzare di memorizzare proprio questa associazione nella tavola hash discussa al paragrafo "Efficienza".
I dati di configurazione possono essere facilmente memorizzati in un file "a sezioni" (stile .INI), in un database, o in uno script. Avendo provato le tre soluzioni, mi sento di affermare che (salvo situazioni particolari) il file INI funziona al meglio, seguito dallo script in alcune circostanze, ed a larga distanza dal database, che mal si presta a gestire i vari parametri custom associati ad ogni elemento.
In fase di installazione, talvolta è anche ipotizzabile la scrittura manuale di un file di configurazione. In fondo, in alcuni impianti non troppo complessi ci si trova di fronte a trenta-cinquanta oggetti da configurare, una quantità facilmente gestibile con un editor. In altri casi, tuttavia, è necessario dedicare la giusta attenzione ad un programma di configurazione.
Ad esempio, alcuni anni fa ho progettato ed in gran parte implementato una piattaforma aperta per l’acquisizione, l’analisi in tempo reale e la rappresentazione di dati fisiologici. In quel caso, il numero di oggetti da connettere e configurare era tipicamente esiguo, ma volevamo che il processo fosse svolto con facilità dall’utente finale. Solo attraverso una GUI decisamente sofisticata abbiamo ottenuto il risultato voluto.
Altre volte è il numero degli oggetti a sconsigliare una gestione manuale. Scrivo queste note proprio dopo alcuni giorni passati presso un’azienda, durante i quali abbiamo progettato l’architettura di un concentratore di terminali di pagamento. Credo non vi sorprenderà troppo sapere che la struttura emersa ha diversi punti in comune con quella di figura 2, anche se la differente natura del sistema ha portato anche ad importanti scostamenti, che riflettono un diverso dominio del problema. Tuttavia, le problematiche di configurazione erano del tutto analoghe a quelle viste poco sopra. Da una sommaria analisi quantitativa, abbiamo visto che in un sistema piuttosto "carico" gli oggetti da configurare erano circa 1600, e si richiederà indubbiamente una interfaccia ben studiata per rendere ragionevolmente semplice (e ragionevolmente veloce) l’intero processo di configurazione del sistema.

Conclusioni
In queste tre puntate abbiamo affrontato un problema non banale, arrivando ad una architettura in grado di accomodare esigenze diverse ed in parte anche contrastanti. Strada facendo abbiamo dovuto prendere molte decisioni, che hanno condizionato non poco la struttura finale. Prendere simili decisioni non è facile, ed è il ruolo principale di un software architect, che deve essere in grado di prevedere le conseguenze a lunga distanza delle sue scelte. Naturalmente, in questi casi, l’esperienza diretta è insostituibile. Possiamo però imparare molto anche dalle esperienze altrui. A chi voglia approfondire i temi di progettazione di framework applicativi, posso sicuramente consigliare l’intero numero di Ottobre 97 di Communications of ACM, che riporta parecchi articoli interessanti. Sempre meritevoli di una lettura sono anche i vari experience report e case study reperibili in letteratura ([Bir93], [HRE95], [BKGZ96]), senza dimenticare [Par76] che è a mio avviso l’archetipo (stranamente ignorato da molti progettisti) dell’idea stessa di framework applicativo.

Bibliografia
[Bir93] E. T. Birrer, "Frameworks in the financial engineering domain", Proceedings of ECOOP ’93, Springer-Verlag.
[BKGZ96] D. Baumer, R. Knoll, G. Gryczan, H. Zullighoven, "Large-scale object oriented software development in a banking environment", Proceedings of ECOOP ’96, Springer-Verlag.
[GoF95] Gamma ed altri, "Design Patterns", Addison-Wesley, 1995.
[HRE95] H. Hueni, R. Johnson, R. A. Engel, "A framework for network protocol software", Proceedings of OOPSLA ’95, ACM Press.
[Mes95] Gerard Meszaros, "Pattern: Half-object + Protocol", in Coplien, Schmidt, "Pattern Languages of Program Design", Addison-Wesley, 1995.
[Par76] D. L. Parnas, "On the Design and Development of Program Families", IEEE Transactions on Software Engineering, March 1976.
[Pes95] Carlo Pescio, "Design: Dominio del Problema e della Soluzione", Computer Programming No. 41.
[Pes96] Carlo Pescio, "Minimal Perfect Hashing", Dr. Dobb's Journal, No. 249, July 1996.
[Pes98] Carlo Pescio, "Oggetti e Thread", Computer Programming No. 65.

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: