PureMVC è un framework open-source scritto da Cliff Hall per ActionScript, di cui è stato effettuato il porting su altri 10 linguaggi, tra cui anche Java e Php. PureMVC è un'implementazione, lo dice il nome stesso, dell'affermatissimo design pattern architetturale Model-View-Controller per la realizzazione di applicazioni basate su interfaccia grafica.
Per affrontare lo studio di PureMVC e capirne il funzionamento è necessaria una buona conoscenza di alcuni dei design patterns descritti nel libro "Design Patterns: elementi per il riuso di software ad oggetti" della cosiddetta "Gang Of Four", tra i quali Singleton, Observer, Facade, Mediator, Command e Proxy.
I tre livelli dell'applicazione sono rappresentati da tre singleton: Proxy, Mediator e Command rispettivamente per Model, View e Controller. I tre livelli non comunicano direttamente tra di loro ma sfruttano la Facade che permette ai tre livelli di interagire tra di loro senza che l'uno dipenda dall'altro, mettendo così in pratica il principio del "minimo accoppiamento" che devono rispettare le classi e gli oggetti di un'applicazione ben progettata.
La comunicazione tra i livelli è realizzata grazie al pattern Observer che prevede lo scambio di messaggi, chiamati Notification (notifiche), tra gli oggetti. Le notifiche sono costituite da un nome (identificativo) e da un corpo: nel corpo può essere incluso un oggetto di qualsiasi tipo, utile all'esecuzione di un'azione sia sul model che sull'interfaccia.
Tutte le notifiche che viaggiano tra componenti diversi della nostra applicazione devono essere definite univocamente e dichiarate nella Facade, che è l'unico oggetto con cui tutti gli altri oggetti dell'applicazione possono comunicare direttamente.
Nel seguito dell'articolo ci orienteremo tra i meandri di PureMVC costruendo una semplice applicazione di esempio, cercando di chiarire, ove necessario, gli aspetti teorici del framework pur non soffermandoci eccessivamente sui dettagli funzionali.
La prima applicazione con PureMVC
Come prima applicazione con PureMVC, vogliamo sviluppare un semplice gestore di un datagrid che permetta di inserire, modificare o eliminare elementi all'interno di un component Datagrid
.
Prima di iniziare lo sviluppo della nostra applicazione includiamo, tra le librerie del progetto, la libreria swc
di PureMVC, che possiamo ottenere dalla sezione Download del sito del progetto. Il file swc
può essere copiato nella cartella libs oppure aggiunto manualmente al build path, come mostrato in figura.
La prima cosa che dobbiamo fare è creare la struttura del progetto. Utilizzando Flash Builder 4 creiamo tre package: model
, view
e controller
. Model conterrà le classi di tipo Proxy, view le classi di tipo Mediator e controller le classi di tipo SimpleCommand o MacroCommand.
All'interno di view
creiamo un altro package "components" che conterrà i component mxml
dell'interfaccia grafica della nostra applicazione. All'interno del package model
, invece, creiamo un sottopackage con nome vo
che conterrà tutti i cosiddetti "value objects", che altro non sono che classi che rappresentano il modello dei dati su cui lavora l'applicazione.
Una volta che la struttura del progetto è pronta, creiamo la facade che deve estendere la classe Facade
ed implementare l'interfaccia IFacade
di PureMVC (la chiamiamo ApplicationFacade
e la posizioniamo all'interno del default package del progetto).
È necessario fare in modo che la classe facade sia gestita come un singleton, quindi in tutta l'applicazione è disponibile al più una sola instanza della classe ApplicationFacade
. A tal proposito dobbiamo fornire la classe ApplicationFacade
di un metodo getInstance()
che restituisce l'unica instanza della classe stessa, così come previsto dal pattern Singleton. Inoltre, è una "best practice" definire nella classe Facade tutte le notifiche che potranno essere inviate intercettate dagli oggetti della nostra applicazione.
Inizialmente, ad esempio, la classe ApplicationFacade
avrà il seguente codice:
import org.puremvc.as3.interfaces.IFacade; import org.puremvc.as3.patterns.facade.Facade; public class ApplicationFacade extends Facade implements IFacade { public static const STARTUP:String = "Startup"; public static function getInstance() : ApplicationFacade { if (instance == null) instance = new ApplicationFacade(); return instance as ApplicationFacade; } override protected function initializeController():void { super.initializeController(); } }
Una cosa importante da notare è che abbiamo effettuato l'override del metodo initializeController()
: questo metodo viene invocato, automaticamente, quando viene istanziato l'oggetto ApplicationFacade
e si dovrà preoccupare di registrare le classi Command
su determinate notifiche (più avanti chiariremo come vengono eseguite le azioni previste dalle classi Command
).
Cerchiamo di definire un workflow per la creazione di un'applicazione basata su PureMVC, anche se la sequenza di passi e i procedimenti illustrati in questo articolo non devono essere presi come una ricetta da seguire strettamente ma possono essere adattati e modificati a seconda delle necessità.
Creare l'interfaccia utente
Dopo aver definito la facade, possiamo iniziare a "disegnare" l'interfaccia grafica della nostra applicazione, che sarà accompagnata dalle classi Mediator che avranno il compito di gestire le funzionalità dei singoli component e lo scambio di messaggi tra essi.
L'interfaccia grafica che vogliamo creare dovrà essere simile a quella rappresentata in figura.
Abbiamo realizzato un component per la gestione di una riga del datagrid, costituito da due campi di testo e da un pulsante "Aggiungi". Quando viene selezionata una riga del datagrid, il component mostrerà i pulsanti "Modifica" ed "Elimina", per poter rispettivamente modificare o eliminare dal datagrid l'elemento selezionato.
Di seguito è riportato il codice del component.
<?xml version="1.0" encoding="utf-8"?>
<s:Group xmlns:fx="http://ns.adobe.com/mxml/2009"
xmlns:s="library://ns.adobe.com/flex/spark"
xmlns:mx="library://ns.adobe.com/flex/halo" width="273" height="164">
<s:Panel x="0" y="0" width="274" height="164" title="Gestione utenti">
<mx:Form x="0" y="0" width="100%" height="85">
<mx:FormItem label="Nome">
<s:TextInput id="tiNome"/>
</mx:FormItem>
<mx:FormItem label="Cognome">
<s:TextInput id="tiCognome"/>
</mx:FormItem>
</mx:Form>
<s:HGroup x="10" y="93" width="254">
<s:Button id="btnAggiungi" label="Aggiungi"/>
<mx:HBox id="boxOperazioni" visible="false">
<s:Button id="btnDeseleziona" label="Annulla"/>
<s:Button id="btnModifica" label="Modifica"/>
<s:Button id="btnElimina" label="Elimina"/>
</mx:HBox>
</s:HGroup>
</s:Panel>
</s:Group>
È particolarmente importante notare che il component non contiene una parte di logica (Actionscript) per poter gestire il riempimento dei TextInput
e gli eventi associati ai pulsanti. Gran parte della logica per la gestione dell'interfaccia grafica deve essere gestita dalle classi mediator.
I component mxml dovrebbero soltanto effettuare il dispatch degli eventi, che devono essere intercettati e gestiti dai mediator. In generale, il codice actionscript deve essere ridotto al minimo all'interno dei file mxml.
I mediator
Le classi che hanno il compito di gestire l'interfaccia grafica e l'interazione tra i component mxml devono estendere la classe Mediator del framework. Devono avere una struttura di base simile a quella mostrata di seguito.
È importante che il costruttore invochi, con super()
, il costruttore della classe padre passando come parametri il nome del mediator (definito univocamente all'interno di tutta l'applicazione) e l'oggetto che dovrà contenere il riferimento al component dell'interfaccia grafica. Il nome è un identificativo e serve a recuperare l'istanza del mediator dalla facade ogni volta che, da un punto della nostra applicazione, bisognerà effettuare delle operazioni sull'interfaccia.
public class UserEditMediator extends Mediator implements IMediator { public static var NAME:String = "UserEditMediator"; public function UserEditMediator(view:Object) { super(NAME, view); } override public function handleNotification(notification:INotification) : void { switch (notification.getName()) { } } override public function listNotificationInterests() : Array { return [ ]; } public function get userEditForm() : UserEdit { return viewComponent as UserEdit; } }
Man mano che sviluppiamo le funzionalità del nostro software, aggiungiamo al mediator i metodi necessari alla gestione degli eventi che si verificano durante l'interazione dell'utente con l'applicazione. Due metodi fondamentali per il funzionamento del mediator sono handleNotifications()
e listNotificationInterests()
che hanno rispettivamente il compito di intercettare le notifiche e di fornire alla facade l'elenco delle notifiche per cui il component è in ascolto (più avanti ne discuteremo in maniera più dettagliata).
Terminiamo la costruzione della nostra interfaccia, posizionando nell'applicazione principale il datagrid e il component custom che abbiamo creato.
Il file MainApplication.mxml
avrà il seguente codice:
<?xml version="1.0" encoding="utf-8"?> <s:Application xmlns:fx="http://ns.adobe.com/mxml/2009" xmlns:s="library://ns.adobe.com/flex/spark" xmlns:mx="library://ns.adobe.com/flex/halo" minWidth="500" minHeight="400" xmlns:components="view.components.*" height="244" creationComplete="facade.sendNotification(ApplicationFacade.STARTUP, this);"> <fx:Declarations></fx:Declarations> <fx:Script> <![CDATA[ import model.vo.UserVO; import mx.collections.ArrayCollection; private var facade:ApplicationFacade = ApplicationFacade.getInstance(); public static const SELECT_USER:String = "UserSelectedEvent"; [Bindable] public var utenti:ArrayCollection; [Bindable] public var utenteSelezionato:UserVO; private function sendEvent(name:String) : void { dispatchEvent(new Event(name)); } ]]> </fx:Script> <fx:Binding source="dgUtenti.selectedItem as UserVO" destination="utenteSelezionato"/> <mx:DataGrid x="10" y="10" id="dgUtenti" dataProvider="{ utenti }" click="if (dgUtenti.selectedItem != null) sendEvent(SELECT_USER);"> <mx:columns> <mx:DataGridColumn headerText="Nome" dataField="nome"/> <mx:DataGridColumn headerText="Cognome" dataField="cognome"/> </mx:columns> </mx:DataGrid> <components:UserEdit id="formUserEdit" x="320" y="10"></components:UserEdit> </s:Application>
Al creationComplete
dell'applicazione viene inviata alla facade la notifica STARTUP
: precedentemente, nella dichiarazione della classe ApplicationFacade
, in particolare nel metodo initializeController() abbiamo registrato il command StartupCommand in risposta alla notifica STARTUP
. Quindi, quando viene lanciata la notifica STARTUP
verrà eseguito il command StartupCommand
.
Il pattern Mediator risolve il problema della comunicazione tra component dell'interfaccia grafica, garantendo l'indipendenza e il minimo accoppiamento tra i component. Nel seguito dell'articolo, quando andremo a mettere insieme i diversi pezzi della nostra applicazione verrà chiarito come i diversi oggetti comunicano.
Nella seconda parte dell'articolo completeremo l'applicazione ed esamineremo l'implementazione dei componenti model e controller di PureMVC
I Proxy e il Model
Prima di continuare con lo sviluppo del nostro software, è bene soffermarci sullo strato denominato Model che sarà composto dai "Proxy". Generalmente, il design pattern Proxy prevede lo sviluppo di classi che devono svolgere la funzione di interfaccia di comunicazione verso altri sistemi (filesystem, web-services, grandi aree di memoria etc.).
All'interno del framework PureMvc, oltre ad avere queste funzionalità, le classi proxy fungono anche da "data carrier", ovvero si occupano di conservare e gestire i dati su cui lavora la nostra applicazione.
Quindi, nel costruttore di ogni classe che estende Proxy dobbiamo effettuare una chiamata al costruttore della classe padre specificando due parametri: il nome dell'oggetto che viene istanziato (in modo da poterlo recuperare dalla facade quando necessario), e il tipo di dati su cui il proxy lavora.
Definiamo, per la nostra applicazione, la classe UserProxy
: per la costante NAME
vale lo stesso discorso fatto per le classi mediator, ovvero che è necessario un identificativo per recuperare il proxy dalla facade tramite il metodo retrieveProxy()
.
Di seguito è riportato il codice della classe UserProxy
:
public class UserProxy extends Proxy implements IProxy { public static const NAME = "UserProxy"; public function UserProxy() { super(NAME, ArrayCollection); } public function get users() : ArrayCollection { return data as ArrayCollection; } }
Da notare l'invocazione del costruttore della classe padre proxy, a cui passiamo il nome (identificativo del proxy all'interno della facade) e la struttura date su cui il proxy dovrà lavorare. Il proxy possiede una variabile di nome "data" con scope protected che contiene la struttura dati gestita.
Andiamo, quindi, a definire le operazioni che possono essere effettuate sull'ArrayCollection degli utenti. Le operazioni saranno dichiarate con scope public in modo che, attraverso i Command, sarà possibile gestire i dati contenuti nei proxy in relazione alle operazioni effettuate sull'interfaccia grafica.
Di seguito è riportato il codice della classe UserProxy completa:
public class UserProxy extends Proxy implements IProxy { public static const NAME = "UserProxy"; public function UserProxy() { super(NAME, new ArrayCollection()); } public function addItem(item:UserVO) : void { users.addItem(item); } public function setItemAt(item:UserVO, index:int) : void { users.setItemAt(item, index); } public function removeItemAt(index:int) : void { users.removeItemAt(index); } public function get users() : ArrayCollection { return data as ArrayCollection; } }
Sono state implementate le funzioni di gestione dell'ArrayCollection
degli utenti. Queste funzioni sono, in questo caso, solo dei wrapper alle operazioni già definite nel Flex SDK sull'ArrayCollection
. È buona pratica definire queste operazioni: innanzitutto perchè, per come sono state definite, all'interno dell'ArrayCollection
potranno essere inseriti solo oggetti di tipo UserVO
e, in secondo luogo, perchè per utilizzi più avanzati del proxy queste funzioni saranno comunque presenti eventualmente con complessità maggiore.
Ad esempio il metodo addItem()
potrà preoccuparsi di interagire direttamente con un RemoteObject
o con un WebService
: in questo caso parliamo di proxy remoti.
I "Command" che implementano il Controller
Il livello Controller del pattern MVC è implementato, nel framework, attraverso le classi SimpleCommand
e MacroCommand
. Il pattern Command
è stato formalizzato per risolvere il problema dell'accoppiamento del codice che esegue delle operazioni sui dati con il codice che gestisce l'interfaccia grafica.
Così, SimpleCommand
di PureMvc ha lo scopo di gestire l'esecuzione di una singola azione. Il punto di forza di questo approccio è che il disaccoppiamento è massimo, visto che la richiesta di esecuzione di un'operazione non deve assolutamente preoccuparsi di come l'operazione viene eseguita, poiché tutta la logica è confinata nei Proxy, mentre le classi che estendono SimpleCommand
realizzano una sorta di interfaccia per l'esecuzione delle operazioni sui dati.
Dal punto di vista pratico, l'implementazione di un SimpleCommand
è molto semplice.
public class AddUserCommand extends SimpleCommand implements ICommand { override public function execute(notification:INotification) : void { var userProxy:UserProxy = facade.retrieveProxy(UserProxy.NAME) as UserProxy; userProxy.addItem(notification.getBody() as UserVO); } }
Notiamo subito che la classe AddUserCommand
estende SimpleCommand
e implementa ICommand
(entrambe presenti nel framework). Un SimpleCommand
deve implementare (tramite la tecnica dell'override) un solo metodo: il metodo execute, che riceve come parametro un oggetto con interfaccia INotification
e che viene eseguito automaticamente quando il comando viene lanciato.
Ogni classe che estende SimpleCommand
contiene, quindi, il codice per eseguire un'operazione (che in molti casi è atomica) e deve essere eseguito in risposta ad un evento che, ricordo, in PureMvc è rappresentato da una Notification
.
Occorre, a questo punto, "registrare un Command" in risposta al lancio di una notifica da parte di uno dei componenti dell'applicazione.
Per fare ciò, utilizziamo il metodo registerCommand
che ci viene messo a disposizione dalla Facade
. I comandi possono essere registrati in punti diversi del codice: ad esempio nel metodo inizializeController()
della nostra classe ApplicationFacade
, di cui abbiamo già effettuato l'override, o all'interno di un altro Command.
Anche se effettuare la registrazione di un Command all'interno di un altro Command può sembrare una pratica anomala, in realtà questa è una tecnica molto comoda se si utilizzano dei command che hanno l'obiettivo di effettuare lo startup di un component, di uno state o di un modulo.
Ad esempio, possiamo osservare il Command che si preoccupa di gestire lo startup dell'applicazione:
public class StartupCommand extends SimpleCommand implements ICommand { override public function execute(notification:INotification) : void { var userProxy:UserProxy = new UserProxy(); facade.registerProxy(userProxy); var mainApplication:MainApplication = notification.getBody() as MainApplication; // leghiamo la lista di utenti dell'interfaccia con // la lista degli utenti contenuta nel proxy UserProxy // mainApplication.utenti è una variabile [Bindable] mainApplication.utenti = userProxy.users; facade.registerMediator(new MainApplicationMediator(mainApplication)); facade.registerMediator(new UserEditMediator(mainApplication.formUserEdit)); facade.registerCommand(ApplicationFacade.USER_ADD, AddUserCommand); } }
Oltre alla registrazione dei Mediator
, che si preccoccupano di gestire l'interazione dell'utente con l'interfaccia grafica, il Command esegue la registrazione di un ulteriore command in risposta alla notifica ApplicationFacade.USER_ADD
: quando viene lanciata la notifica ApplicationFacade.USER_ADD
, viene invocato il Command AddUserCommand
.
Così come vengono registrati, i Command possono anche essere rimossi: basta utilizzare il metodo di Facade removeCommand()
.
Le operazioni sull'interfaccia
Per gestire l'interazione con l'interfaccia grafica dobbiamo intercettare gli eventi Flex che vengono generati dai component grafici quando l'utente interagisce con l'applicazione.
È fondamentale che questi eventi siano intercettati solo ed esclusivamente dalla classe Mediator
che gestisce il component. Questo è importante perchè, in questo modo, si evita di legare l'interfaccia grafica con il resto dell'applicazione (Command e Proxy) e le stesse business logic e data logic possono essere utilizzate con un'interfaccia costruita in Adobe AIR, Flex o Flash.
Il mediator ha il compito, importantissimo, di dichiarare tutti gli EventListener sull'interfaccia grafica e gestire l'invio delle notifiche al resto dell'applicazione.
Ogni mediator dovrà estendere la classe Mediator
e implementare l'interfaccia Imediator
. Così come per i Proxy, anche per i Mediator dobbiamo definire un NAME
che fa da identificatore all'interno di tutta l'applicazione. Di seguito è riportata l'implementazione, parziale, del Mediator dell'interfaccia principale dell'applicazione.
public class MainApplicationMediator extends Mediator implements IMediator { public static const NAME:String = "MainApplicationMediator"; public function MainApplicationMediator(component:MainApplication) { super(NAME, component); mainApp.addEventListener(MainApplication.SELECT_USER, onUserSelected); } private function onUserSelected(evt:Event) : void { sendNotification(ApplicationFacade.USER_SELECT, mainApp.utenteSelezionato); } public function get mainApp() : MainApplication { return viewComponent as MainApplication; } }
Il costruttore del mediator riceve come parametro il riferimento al component e lo salva all'interno della proprietà protetta viewComponent
, invocando il costruttore della classe padre. Inoltre, nel costruttore, andiamo a definire gli event listener che intercettano gli eventi generati dall'interazione con l'interfaccia grafica.
Da notare il listener onUserSelected
che ha il compito di "trasformare" l'evento Flex in una Notification PureMVC: questo approccio permette di confinare gli eventi Flex all'interno dei Mediator e di garantire la comunicazione con gli altri strati del framework attraverso le Notification, che rappresentano un sistema di comunicazione "neutro", non legato al linguaggio specifico e indipendente dalla piattaforma utilizzata.
Buona pratica è la definizione di una get function che restituisce un riferimento al component grafico con il quale il mediato interagisce. Il riferimento al component grafico è contenuto nella variabile protetta viewComponent della classe padre Mediator.
Un'importantissima funzionalità dei Mediator, come già detto in precedenza, è l'invio e la ricezione dei messaggi Notification. Per inviare una notifica è necessario solo invocare il metodo sendNotification()
. Per ricevere le notifiche (e agire di conseguenza), è necessario implementare, tramite l'override, due metodi della classe padre Mediator: handleNotification()
e listNotificationInterests()
.
Lo vediamo nell'implementazione della classe UserEditMediator:
public class UserEditMediator extends Mediator implements IMediator { public static var NAME:String = "UserEditMediator"; public function UserEditMediator(view:Object) { super(NAME, view); userEditForm.btnAggiungi.addEventListener(MouseEvent.CLICK, onAddClick); } override public function handleNotification(notification:INotification) : void { switch (notification.getName()) { case ApplicationFacade.USER_SELECT: var userVO:UserVO = notification.getBody() as UserVO; userEditForm.tiNome.text = userVO.nome; userEditForm.tiCognome.text = userVO.cognome; userEditForm.btnAggiungi.visible = false; userEditForm.btnAggiungi.includeInLayout = false; userEditForm.boxOperazioni.visible = true; userEditForm.boxOperazioni.includeInLayout = true; break; } } override public function listNotificationInterests() : Array { return [ ApplicationFacade.USER_SELECT ]; } private function onAddClick(evt:MouseEvent) : void { var userVO:UserVO = new UserVO(); userVO.nome = userEditForm.tiNome.text; userVO.cognome = userEditForm.tiCognome.text; sendNotification(ApplicationFacade.USER_ADD, userVO); } public function get userEditForm() : UserEdit { return viewComponent as UserEdit; } }
Il metodo listNotificationInterests()
rende noto alla facade l'elenco delle Notification per le quali il Mediator è in ascolto: è fondamentale definire la lista delle notifiche perchè la facade, quando viene registrato il Mediator, si occupa di gestire la lista delle notifiche. Il metodo handleNotification()
, invece, viene invocato dalla facade ogni volta che viene inviata una Notification (presente nella lista definita dal metodo listNotificationInterests
) al Mediator. Facciamo un controllo sul nome (i nomi delle notifiche sono definiti univocamente nella Facade) della notifica che abbiamo ricevuto per eseguire le operazioni corrette.
Nota: in allegato all'articolo c'è il progetto Flex sviluppato in questo articolo. Non sono implementate le funzionalità di cancellazione e di modifica. Volutamente non ho voluto completare l'esempio in modo da dare a voi il compito di esercitarvi e di implementare le funzionalità mancanti.
Tiriamo le somme...
Lo sviluppo di web application di medie e grandi dimensioni o complessità deve essere affrontato seguendo una metodologia ed effettuando delle scelte progettuali che devono garantire una semplificazione del lavoro ed un'ottimizzazione di tutto il processo di sviluppo. Inoltre è quasi obbligatorio rendere tutto il software, in ogni sua parte, testabile attraverso test unitari sia automatici che manuali.
La scelta di PureMVC come framework di sviluppo garantisce il minimo accoppiamento tra i moduli che a sua volta permette di testare separatamente le singole classi. Inoltre il meccanismo delle notifiche, anche se inizialmente può sembrare contorto o inefficiente, in realtà è un'arma potentissima nella mani degli sviluppatori. Molti meccanismi (difficilmente implementabili in maniera "tradizionale") sono di semplicissima implementazione sfruttando le notifiche come sistema di comunicazione tra le classi.
Inoltre, il framework permette lo sviluppo di software scalabile e manutenibile, in quanto è molto semplice aggiungere funzionalità e correggere eventuali bug.