Prima di concludere la serie dedicata alla programmazione di applicazioni RIA con GXT, presentiamo due soluzioni, che risultano utili per risolvere due problemi tipici che si possono incontrare durante lo sviluppo di applicazioni: il primo caso è da considerarsi il proseguimento della teoria affrontata nelle puntate precedenti e difatti parla di un caso speciale di binding; il secondo caso, invece, suggerisce una soluzione al problema della internazionalizzazione in GWT.
Binding multidimensionale
Negli articoli precedenti di questa serie si era preso in considerazione un ipotetico caso d’uso relativo allo sviluppo di una applicazione per la gestione di una scuola; in tale contesto, una delle cose che si potrebbe pensare di gestire, è la relazione fra uno studente (o la sua pagella, la sua scheda valutazione, etc…) e i tutor ad esso associati. È questa una classica relazione 1-n che in genere viene gestita in fase di editing dalla GUI tramite due passaggi differenti: in una maschera abbiamo il form per l’elenco dei vari attributi e tramite un form separato si gestisce la relazione studente-docente.
Nel primo caso infatti verranno inseriti tutti i dati relativi alla sola entità studente tramite un form, come ad esempio raffigurato nella figura 1, ripresa dalla puntata precedente.
Per quanto concerne la gestione della relazione fra studente e docente, nella maggior parte delle situazioni tale legame viene realizzato per mezzo di una apposita GUI separata dalle altre dove, ad esempio, l’operatore sceglie dall’elenco di tutti i tutor disponibili per un determinato corso quelli (o più semplicemente il solo) da associare allo studente.
Il motivo di questa separazione logica e temporale è spesso guidato dalla necessità di dividere il processo di gestione delle entità da quello di gestione delle associazioni, che viene poi affiancato dalla motivazione di voler minimizzare il tempo in cui i dati sono memorizzati in cache sul lato client. Questo problema è tipico di qualsiasi applicazione web, dove la multiutenza, unita al modello web disconnesso non garantisce la coerenza dei dati osservati o modificati da due utenti diversi: se uno dei due ad esempio effettua una modifica “sotto il naso” all’altro, solo al momento del salvataggio il secondo che arriva potrà essere notificato della non congruità dei dati. Lo sviluppo di applicazioni RIA non fa altro che amplificare questo aspetto perche’ introduce un ulteriore layer lato client dove c’è la presenza di strumenti di cache (p.e. il liststore di una grid GXT).
Nell’esempio che andiamo a proporre invece la richiesta del business era esattamente nella direzione opposta, ossia consentire con un solo form di inserimento/edit la possibilità di lavorare sugli attributi dell’entità base (lo studente) ma anche di andare a modificare la relazione con i tutor usando la stessa interfaccia grafica: in questo caso la richiesta era che la relazione 1-n fosse specificata tramite una listbox, la quale permettesse di selezionare, fra tutti quelli disponibili, uno o più tutor da associare allo studente.
La listbox ovviamente deve funzionare in questo caso in modalità biunivoca: a fronte di una prima fase di caricamento (con tutti i tutors disponibili), la lista deve offrire la possibilità di visualizzare in modalità multiselect i tutor assegnati; viceversa, nel caso in cui l’operatore modifichi uno o più valori nella lista, deve essere possibile aggiornare automaticamente l’associazione a livello di oggetti in memoria. Partendo proprio da tale aspetto, possiamo prendere in considerazione le due entità StudenteVO e TutorVO: la prima conterrà, oltre agli attributi dello studente, anche una lista di istanze di TutorVO, che poi in fase di salvataggio verranno resi persistenti nel DB sottoforma di legami relazionali fra tabelle.
Ecco il codice della classe StudenteVO:
public class StudenteVO implements Serializable { private String nome; private String cognome; private TeacherVO teacher; private List tutors; public StudentVO() { super(); } public StudenteVO(String nome, String cognome) { super(); this.nome = nome; this.cognome = cognome; } public StudenteVO(String nome, String cognome, TutorVO tutor) { super(); this.nome = nome; this.cognome = cognome; this.tutor = tutor; } public String getNome() { return nome; } public void setNome(String nome) { this.nome = nome; } public String getCognome() { return cognome; } public void setCognome(String cognome) { this.cognome = cognome; } public String getCognomeNome() { return this.getCognome() + " " + this.getNome(); } public List< TutorVO > getTutors() { return tutors; } public void setTutor(List tutor) { this.tutor = tutor; } }
Quando dallo strato di business arriva un elenco di oggetti StudenteVO, questi, essendo oggetti predisposti per il databinding (vedi gli articoli precedenti della serie) potranno essere usati per popolare la grid della figura 1. Quando un utente seleziona una riga della tabella (oggetto Grid di GXT), il meccanismo di formbinding (come ampiamente illustrato in una delle puntate precedenti) permetterà di associare i dati dell’oggetto di modello (lo StudenteVO) con il form per poter eseguire la modifica dei dati dello studente (nome, cognome etc.).
Riprendiamo brevemente in esame il codice che esegue tale binding: in questo caso si associa un listener alla grid in corrispondenza dell’evento di selezione di una riga da parte dell’utente. Tale selezione riassocia (operazione di bind) il datamodel corrispondente a tale riga con il form di edit (ricordo che il formbinding è associato da un lato a un data model, dall’altro con un form corrispondente):
grid.getSelectionModel().addListener(Events.SelectionChange, new Listener<SelectionChangedEvent>() { public void handleEvent(SelectionChangedEvent be) { if (be.getSelection().size() > 0) { ModelData selectedModel = (ModelData) be.getSelection().get(0); formBindings.bind(selectedModel); } else { formBindings.unbind(); } } });
La figura 2 mostra esattamente questo caso, in cui la selezione della decima riga nella tabella causa la visualizzazione dei dati corrispondenti nel form.
L’uso di formbindings permette in maniera molto agevole la associazione fra “un” elemento di un set di dati e il form per la sua modifica: è quindi un utile strumento per consentire la associazione view-edit di una entità nel caso in cui tale associazione sia relativa a un dato monodimensionale. Nel caso della figura 2, la riga selezionata nella griglia (uno studente) porta al popolamento del form con i dati di uno studente. Nel caso in cui volessimo inserire nel form anche un oggetto Listfield per la selezione dei tutor (quindi il form non è più monodimensionale) è necessario variare il binding in modo da gestire questo nuovo caso specifico (vedere la figura 3).
La soluzione è anche in questo caso piuttosto semplice e sfrutta nuovamente l’oggetto Formbindings all’interno del quale normalmente si inseriscono i vari bind fra l’attributo dell’oggetto in questione e il campo del form corrispondente. Ad esempio scrivendo
formBindings.bind(studenteVO.nome, "nome");
si associa l’attributo nome del VO con il campo che nel form si chiama “nome”; nel codice presentato in realtà è stato sfruttato il binding automatico che esegue l’associazione sfruttando i nomi degli attributi del bean e i nomi dei campi del form (ogni associazione non trovata verrà semplicemente ignorata):
formBindings.bind(selectedModel);
Dato che nel nostro caso dobbiamo associare un attributo lista con una listField, il binding appena visto non è valido. Si deve quindi per prima cosa creare un altro binding fra la lista (in questo caso il campo tutors in StudentVO) e l’oggetto grafico del form con il quale vorremmo eseguire la modifica (in questo caso la variabile listTutors un widget di tipo ListField);
final FieldBinding b = new FieldBinding(listTutors, "tutors") { @Override public void updateField(boolean updateOriginalValue) { Object val = onConvertModelValue(model.get(property)); listTeachers.setSelection((List<?>) val); } public void updateModel() { Object val = onConvertFieldValue(listTeachers.getSelection()); if (store != null) { Record r = store.getRecord(model); if (r != null) { r.setValid(property, field.isValid()); r.set(property, val); } } else { model.set(property, val); } } };
Come si può notare dal codice appena mostrato, la semplice definizione del formbindings non è sufficiente, dato che è necessario specificare come dovrà essere modificato il modello in corrispondenza di una modifica da parte dell’utente nel form e, viceversa, come popolare il form con i dati nel modello. Questa operazione viene specificata tramite l’overriding dei metodi updateField e updateModel all’interno della definizione nested della FieldBinding.
Di seguito è riportato il codice completo dell’esempio: si tratta di un Pannello che contiene la griglia e il form; nella parte finale del codice troviamo i metodi che simulano il caricamento dei dati da un ipotetico strato di business: in questo caso i metodi getStudents() e getTutors() restituiscono direttamente la lista dei model bean.
public class BindingPanel extends LayoutContainer { Grid grid; ComboBox comboBoxTeachers; ListField listTutors; private FormBinding formBindings; public BindingPanel() { grid = this.createGrid(); FormPanel formPanel = createForm(); final FieldBinding b = new FieldBinding(listTutors, "tutors") { @Override public void updateField(boolean updateOriginalValue) { Object val = onConvertModelValue(model.get(property)); listTeachers.setSelection((List<?>) val); } public void updateModel() { Object val = onConvertFieldValue(listTeachers.getSelection()); if (store != null) { Record r = store.getRecord(model); if (r != null) { r.setValid(property, field.isValid()); r.set(property, val); } } else { model.set(property, val); } } }; // carica i dati simulando una invocazione remota sul layer di business logic this.getStudents(); formBindings = new FormBinding(formPanel, true); // this needs to be called AFTER getStudents as you switch the stores there. formBindings.setStore((Store) grid.getStore()); formBindings.addFieldBinding(b); setLayout(new RowLayout()); this.add(formPanel, new RowData(1, .5)); this.add(grid, new RowData(1, .5)); this.add(new Button("Annulla", new SelectionListener() { @Override public void componentSelected(ButtonEvent ce) { grid.getStore().rejectChanges(); } })); this.add(new Button("Salva", new SelectionListener() { @Override public void componentSelected(ButtonEvent ce) { List modifiedRecords = grid.getStore().getModifiedRecords(); BeanModel myBeanModel; for (int i = 0; i < modifiedRecords.size(); i++) { Record record = modifiedRecords.get(i); myBeanModel = (BeanModel) record.getModel(); } // si committano i cambiamenti sullo store // per renderli persistenti nella griglia grid.getStore().commitChanges(); } })); } private Grid createGrid() { ListStore studentsListStore = new ListStore(); List configs = new ArrayList(); ColumnConfig studentFirstNameColumn = new ColumnConfig("nome", "Nome", 130); studentFirstNameColumn.setAlignment(HorizontalAlignment.LEFT); configs.add(studentFirstNameColumn); ColumnConfig studentLastNameColumn = new ColumnConfig("cognome", "Cognome", 170); studentLastNameColumn.setAlignment(HorizontalAlignment.LEFT); configs.add(studentLastNameColumn); ColumnModel columnModel = new ColumnModel(configs); Grid grid = new Grid(studentsListStore, columnModel); grid.setAutoExpandColumn("nome"); grid.setBorders(true); grid.getSelectionModel().setSelectionMode(SelectionMode.SINGLE); grid.getSelectionModel().addListener(Events.SelectionChange, new Listener<SelectionChangedEvent>() { public void handleEvent(SelectionChangedEvent be) { if (be.getSelection().size() > 0) { ModelData selectedModel = (ModelData) be.getSelection().get(0); formBindings.bind(selectedModel); } else { formBindings.unbind(); } } }); return grid; } private FormPanel createForm() { FormPanel formPanel = new FormPanel(); formPanel.setScrollMode(Scroll.AUTO); formPanel.setBorders(true); formPanel.setHeaderVisible(false); formPanel.setWidth(700); formPanel.setLabelAlign(LabelAlign.LEFT); formPanel.setButtonAlign(HorizontalAlignment.CENTER); TextField studentFirstName = new TextField(); studentFirstName.setName("nome"); studentFirstName.setFieldLabel("Nome: "); formPanel.add(studentFirstName, formData); listTeachers = new ListField(); // carica i dati simulando l'invocazione di metodo remoto listTeachers.setStore(this.getTeachers()); listTeachers.setDisplayField("cognomeNome"); // aggiunge la lista al pannello formPanel.add(listTeachers); return formPanel; } // metodo che restituisce l'elenco di tutti i tutor presenti nel sistema: // tale lista serve per il popolamento base della lista da cui selezionare // i tutor desiderati private ListStore getTutors() { ListStore tutorListStore = new ListStore(); BeanModelFactory modelFactory = BeanModelLookup.get().getFactory(TutorVO.class); ListStore corsiTutorListStore = new ListStore(); TutorVO tutor; BeanModel beanmodel; tutor = new TutorVO("Fabrizio", "Landi"); beanmodel = modelFactory.createModel(tutor); docentiListStore.add(beanmodel); tutor = new TutorVO("Lapo", "Orlissi"); beanmodel = modelFactory.createModel(tutor); docentiListStore.add(beanmodel); tutor = new TutorVO("Fabio", "Loretto"); beanmodel = modelFactory.createModel(tutor); docentiListStore.add(beanmodel); tutor = new TutorVO("Marco", "Derossi"); beanmodel = modelFactory.createModel(tutor); docentiListStore.add(beanmodel); return docentiListStore; } // restituisce un elenco di oggetti di modello di tipo StudentVO // ogni studente in questo caso è associato con uno o più TutorVO private ListStore getStudents() { BeanModelFactory modelFactory = BeanModelLookup.get().getFactory(StudentVO.class); ListStore studentsListStore = new ListStore(); BeanModel beanmodel; StudentVO student; // studente con i suoi tutor List tutors = new ArrayList(); tutors.add(new TutorVO("Fabrizio", "Landi")); tutors.add(new TutorVO("Lapo", "Orlissi")); student = new StudentVO("giovanni", "puliti"); student.settutors(tutors); beanmodel = modelFactory.createModel(student); studentsListStore.add(beanmodel); tutors = new ArrayList(); tutors.add(new TutorVO("Lapo", "Orlissi")); tutors.add(new TutorVO("Fabio", "Loretto")); tutors.add(new TutorVO("Marco", "Derossi")); student = new StudentVO("aldo", "fabrizi"); student.settutors(tutors); beanmodel = modelFactory.createModel(student); studentsListStore.add(beanmodel); // altro studente con i suoi tutor tutors = new ArrayList(); tutors.add(new TutorVO("Marco", "Derossi")); student = new StudentVO("rosa", "rosati"); student.settutors(tutors); beanmodel = modelFactory.createModel(student); studentsListStore.add(beanmodel); // altro studente con i suoi tutor tutors = new ArrayList(); tutors.add(new TutorVO("Lapo", "Orlissi")); student = new StudentVO("Fabio", "Filopini"); student.settutors(tutors); beanmodel = modelFactory.createModel(student); studentsListStore.add(beanmodel); return studentsListStore; } }
Internazionalizzazione in GWT
Esistono varie tecniche per abilitare il supporto multilingua nelle applicazione GWT, che variano da soluzioni più dinamiche ad alcune più semplici (forse più limitate) e più performanti (almeno così viene affermato sul sito di Google Web Toolkit).
Sul sito ufficiale di GWT ([i18n]) sono elencate in maniera piuttosto esaustiva le possibili tecniche alternative, che andiamo qui brevemente a riassumere:
- Static String Internationalization: questa tecnica è probabilmente la più semplice e richiede un overhead minimo in fase di esecuzione a runtime nel caso si vogliano tradurre costanti o stringhe parametriche. Si basa sull’utilizzo di file di proprietà per memorizzare e quindi ricavare le stringhe di testo con cui parametrizzare il codice, disponibili all’interno del codice tramite un rigido (nel senso buono del termine) meccanismo d’uso di tipi Java per ricavarne il valore.
- Dynamic String Internationalization: è una tecnica meno performante ma offre maggiori potenzialità e flessibilità. In questo caso la trasformazione/localizzazione delle stringhe viene effettuata prelevando i valori relativi al locale scelto, direttamente dalla hosted page, quindi senza la necessità di alcuna ricompilazione in caso di modifche o aggiunte di un nuovo locale. Viene presentata come la soluzione ottimale nel caso in cui l’applicazione GWT debba interfacciarsi con un sistema esterno di internazionalizzazione, visto che permette di incorporare meglio le variazioni (non dipendenti da chi sviluppa il codice GWT).
- Localizable Interface: è una tecnica avanzata di internazionalizzazione e che è basata sulla implementazione di interfacce passando quindi alla gestione via codice dei vari aspetti della localizzazione. Da usarsi solo per situazioni particolari.
Nella maggior parte dei casi si sceglie la prima soluzione, che è per molti versi simile alla classica tecnica di internazionalizzazione che ogni programmatore Java è abituato a realizzare nelle applicazioni enterprise e non. Il problema di base legato alla internazionalizzazione in GWT è che in ogni caso al cambio di lingua l’applicazione deve eseguire un ricaricamento della pagina HTML che contiene l’applicazione, ovvero detto in altri termini l’applicazione deve ripartire.
Di seguito è riportato un esempio di codice che, tramite una pulsantiera inserita in un panel, permette di selezionare la lingua desiderata; si noti la presenza della classe MyMessage che di fatto funziona come wrapper dei bundle di messaggi localizzati; la riga
MyMessages myMessages = (MyMessages) GWT.create(MyMessages.class);
permette di avere a disposizione tale istanza da cui ricavare poi i vari messaggi; la sua implementazione è presente più avanti nell’articolo. La tecnica base per il cambio di lingua è forzare un ricaricamento della hosted page (la pagina HTML che contiene l’applicazione GWT) appendendo in fondo all’URL di invocazione anche la stringa con il locale scelto.
Nell’esempio che segue è mostrata anche la possibilità di inserire istruzioni JavaScript all’interno del codice Java: in questo caso il JS è inserito tramite la clausola native, dato che siamo nel ramo client della applicazione, dove tutto il codice dopo la compilazione diventerà JavaScript, e che quindi il JS è visto come codice nativo (mentre Java è una sua interpretazione a un livello di astrazione più alto). Il JS in questo caso viene utilizzato per forzare il ricaricamento della pagina HTML passando il parametro del locale scelto:
public class MyInternational implements EntryPoint { public void onModuleLoad() { MyMessages myMessages = (MyMessages) GWT.create(MyMessages.class); Button enBtn = new Button(); enBtn.setHTML(myMessages.enBtn()); enBtn.addClickListener(new ClickListener() { public native void onClick(Widget sender) /*-{ var currLocation = $wnd.location.toString().split("?"); var currLocale = "?locale=en"; $wnd.location.href = currLocation[0] + currLocale; $wnd.location.replace(currLocation[0] + currLocale); }-*/; }); Button frBtn = new Button(); frBtn.setHTML(myMessages.frBtn()); frBtn.addClickListener(new ClickListener() { public native void onClick(Widget sender) /*-{ var currLocation = $wnd.location.toString().split("?"); var currLocale = "?locale=fr"; $wnd.location.href = currLocation[0] + currLocale; $wnd.location.replace(currLocation[0] + currLocale); }-*/; }); Button itBtn = new Button(); itBtn.setHTML(myMessages.itBtn()); itBtn.addClickListener(new ClickListener() { public native void onClick(Widget sender) /*-{ var currLocation = $wnd.location.toString().split("?"); var currLocale = "?locale=it"; $wnd.location.href = currLocation[0] + currLocale; $wnd.location.replace(currLocation[0] + currLocale); }-*/; }); Label lblMessage = new Label(); lblMessage.setTitle(myMessages.lblMessage()); RootPanel.get("sendButtonContainer").add(lblMessage); RootPanel.get("sendButtonContainer").add(defaultBtn); RootPanel.get("sendButtonContainer").add(enBtn); RootPanel.get("sendButtonContainer").add(frBtn); RootPanel.get("sendButtonContainer").add(itBtn); } }
L’applicazione con la variabile JavaScript $wnd.location è in grado di ricavare il nome e l’URL host page e di causarne l’apertura.
Di seguito l’implementazione della classe MyMessage: si noti come, per ogni elemento che si vuole rendere internazionalizzabile, è necessario definire una varabile istanziata staticamente:
public interface MyMessages extends Messages { String lblMessage(); String itBtn(); String enBtn(); String frBtn(); }
La classe MyMessages viene poi associata al file di properties specifico per la lingua selezionata; ad esempio quello per l’italiano, il cui nome dovrà essere MyMessages_it.properties, potrebbe contenere le seguenti definizioni:
lblMessage = Puoi cambiare la lingua enBtn = Inglese itBtn = Italiano frBtn = Francese
Come ovviare l’inevitabile
Si è detto che per modificare il locale è necessario eseguire un restart dell’applicazione, operazione che nel migliore dei casi è non gradita all’utente. Si immagini ad esempio il caso in cui un utente, dopo aver effettuato il login voglia cambiare la lingua della applicazione (ad esempio tramite la sua pagina di gestione del suo profilo utente): dopo la modifica si dovrà effettuare nuovamente il login, il che di fatto è molto scomodo.
Più genericamente possiamo dire che nella maggior parte dei casi il ricaricamento della applicazione non è compatibile con il tipo di applicazione, se ad esempio l’applicazione ha un set di valori da mantenere o un generico stato di avanzamento in un processo di elaborazione di un qualche entità. La soluzione per ovviare a questo inconveniente è semplice, anche se ha la grossa limitazione di non essere del tutto integrata con il modello GWT e in certi contesti risulta un po’ macchinosa.
Ogni applicazione GWT, il cui funzionamento e comportamento è in tutto e per tutto analogo a quello una applicazione standalone, è in grado di memorizzare lo stato complessivo dell’applicazione in modo molto semplice tramite le variabili di GUI, tramite oggetti nel remote provider o in altri costrutti (come nell’oggetto Registry tipico di una applicazione GXT). Se vogliamo che tutto ciò possa sopravvivere al ricaricamento della applicazione, non abbiamo altra alternativa che porre tali valori all’interno della sessione HTTP che di fatto può essere considerata una specie di wrapper logico della GWT.
Il login utente, un eventuale carrello della spesa o un qualsiasi set di dati potranno essere inseriti in sessione con una semplice operazione; il problema è che tale “travaso” rende più difficoltoso scrivere codice robusto, dato che ogni cambio di stato deve essere attentamente valutato per decidere se lo si vuole rendere volatile (quindi resettabile al ricaricamento della applicazione) o meno.
Nel caso del cambio di lingua nelle applicazioni in cui mi sono trovato a lavorare di recente, si è giunti a definire un piccolo protocollo operativo frutto di una serie di compromessi fra semplicità di scrittura del codice e risultato finale. In questo caso la soluzione adottata è stata la seguente.
Primo punto: l’applicazione viene pubblicata tramite un URL che punta a una comune pagina HTML (non la hosted page) la quale, prima di effettuare il redirect alla pagina HTML di GWT, esegue una serie di controlli tramite JS:
- Per prima cosa verifica se nel client (browser) è presente un cookie che identifica la lingua che l’utente ha selezionato a un precedente login; tale cookie verrà inserito al login e dice al client che l’utente Mario Rossi vuole vedere la applicazione in italiano. Se tale cookie non è presente (siamo nel caso del primo login) viene ricavato (sempre tramite JS) il locale del browser (ovvero del PC client).
- Se tale lingua è calcolabile, in un modo o nell’altro, viene quindi effettuato il redirect alla hosted page tramite l’URL contentente la lingua scelta.
Secondo punto: Quando l’utente entra nella applicazione GWT, l’operazione di login imposta nel browser un cookie che indentifica la lingua come desiderato dall’utente o in base a una qualsiasi regola di business (esempio tramite la lettura di un dato sul DB). Al login successivo tale cookie avrà la prevalenza sulla lingua client.
Di seguito il codice della pagina HTML della pagina di ingresso che esegue queste semplici operazioni:
//EN" "http://www.w3.org/TR/html4/loose.dtd">
Conclusioni
Abbiamo visto come GWT/GXT affronta la risoluzione di un problema particolare di data binding nel solco di quanto già trattato nella serie. Abbiamo poi affrontato il problema dell’i18n delle applicazioni GWT con le diverse opzioni possibili, da valutare a seconda dei casi effettivi che si affrontano. Seppur con un minimo di complessità e con la necessità di operare la scelta più oculato, ancora una volta la programmazione RIA con GWT/GXT risolve il tema dell’internazionalizzazione in maniera affidabile e lineare.
Riferimenti
[i18n] Internationalizing a GWT Application