Problema: abbiamo un nutrito insieme di tabelle anagrafiche con struttura simile (solitamente ID, DESCRIZIONE e pochi altri campi), e vorremmo offirire nella nostra applicazione Web le funzionalità CRUD su di esse. Quello che vogliamo assolutamente evitare è la replicazione del codice che offre queste funzionalità per ogni tabella da gestire: in sostanza, si desidera un approccio quanto più generico possibile al problema.
Voglio condividere con voi una soluzione a questo problema a mio avviso molto interessante, che fa uso estensivo delle Reflection API di Java, EJB 3.0, JPA (con EclipseLink 1.20 come persistence engine) per il backend, e IceFaces 1.8 per la parte grafica.
Innanzitutto è stata mappata ogni tabella anagrafica con un Entity Bean 3.0. Ogni entity avrà la seguente struttura:
@Entity
@Table(name = "ANAG_NAZIONE")
public class AnagNazione extends AnagBaseBean implements Serializable {
@Id
@Column(name = "ID_NAZIONE", nullable = false)
private String id;
@Column(name = "DESC_CHIAVE", nullable = false)
private String descrizione;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getDescrizione() {
return descrizione;
}
}
Ogni entity estende un bean comune AnagBaseBean che definisce la proprietà 'selected’ (servirà implementare la selezione di un item dalla tabella stessa nel backing bean di IceFaces) ed i relativi accessors:
public class AnagBaseBean {
@Transient
private boolean selected;
public boolean isSelected() {
return selected;
}
public void setSelected(boolean selected) {
this.selected = selected;
}
}
Come si può vedere, la naming convention che è stata utilizzata per definire i field della nostra entity è la seguente:
- Il nome della entity deve iniziare per ‘Anag’
- Il field che mappa la primary key si deve chiamare ‘id’
- E’ fondamentale che siano presenti i metodi getter dei campi che ci interessano per le operazioni di CRUD, perché da essi desumeremo i nomi dei field corrispondenti , nonché i nomi delle colonne sulla tabella.
Inoltre sono state definite le named query (nel nostro caso all’interno del file orm.xml) di tipo ‘find all’ per le nostre entity, utilizzando il linguaggio JPQL. Ad esempio, per la nostra entity AnagNazione, la relativa named query sarà:
<named-query name="AnagNazione.findAll">
<query>
select o from AnagNazione o
</query>
</named-query>
E’ importante che sia rispettata la naming convention per le named query:
<NomeEntityClass>.findAll
Per quanto riguarda le operazioni di creazione, modifica e cancellazione, in realtà non è stato necessario nulla di particolare: nel nostro EJB Session Bean è stato necessario definire i metodi merge, remove e find nel modo seguente:
@PersistenceContext(unitName = "GestioneAnagraficheFacade")
private EntityManager em;
public Object merge(Object object) {
return em.merge(object);
}
public void remove(Object object, Class<?> clazz, Object pk) {
Object toRemove = em.find(clazz, pk);
em.remove(toRemove);
public Object find(Class<?> clazz, Object pk) {
Object obj = em.find(clazz, pk);
em.refresh(obj);
return obj;
}
Occorre sottolineare che, quando invoco merge, non occorre specificare in alcun modo il reale tipo dell’oggetto che stiamo passando come parametro: EclipseLink, tramite reflection, capirà che l’Object altro non è che un AnagNazione, quindi i campi in esso contenuti sono visibili, e le relative modifiche verranno riportate su DB. Dio benedica il polimorfismo.
Front-End
Una volta creato il backend come descritto, definiamo in un file di risorse le property che associano al nome della classe il rispettivo fully qualified name, indispensabile per ottenere tramite reflection l’oggetto Class corrispondente:
AnagNazione=com.myapp.model.persistence.data.AnagNazione
Quindi dalla nostra pagina web popoleremo una dropdown list con una lista di SelectItem costruiti nel seguente modo:
anagraficheItems.add(new SelectItem(entry.getKey().toString(),
entry.getValue().toString()));
Una volta selezionato un elemento dalla dropdown list, faremo scattare un valueChangeListener che imposterà l’anagrafica selezionata come anagrafica corrente, e che quindi genererà il dataModel di conseguenza:
anagraficaSelezionata = (String) event.getNewValue();
generateDataModels();
La tabella verrà quindi mostrata come segue:
Prima di illustrare il comportamento di generateDataModels, è necessario fare una premessa sul componente ICEFaces che andremo a utlilizzare per mostrare i dati: il componente dataTable che utilizzeremo avrà bisogno di un oggetto rowDataModel e di un columnDataModel per popolare dinamicamente la tabella in modo corretto. Come si costruiscono questi oggetti, e cosa rappresentano?
I Data Model
Il rowDataModel rappresenta l’elenco dei record della tabella stessa, generati quindi tramite l’esecuzione di una delle query “find all” che ho illustrato in precedenza. La query ritorna una lista di entity (nel nostro esempio un elenco di AnagNazione), che però saranno accessibili mediante reference variables di tipo Object (avendo come obiettivo la genericità e il loose coupling, il nostro backing non deve conoscere alcun dettaglio degli oggetti su cui farà CRUD):
private List<Object> rowList;
rowList = em.createNamedQuery(anagraficaSelezionata+".findAll").getResultList();
if (rowDataModel == null) {
rowDataModel = new ListDataModel(rowList);
}
else {
rowDataModel.setWrappedData(rowList);
}
Il columnDataModel invece rappresenta i nomi delle colonne della nostra tabella, che noi ricaveremo (tramite reflection) dai metodi getter del nostro oggetto, privati del prefisso ‘get’:
List<Method> metodi = Arrays.asList(obj.getDeclaredMethods());
List<String> getterMethodsList = new ArrayList<String>();
for (Method m : metodi) {
if (m.getName().startsWith("get")) {
getterMethodsList.add(m.getName().substring(3));
}
}
if (columnDataModel == null) {
columnDataModel = new ListDataModel(getterMethodsList);
}
else {
columnDataModel.setWrappedData(getterMethodsList);
}
In questo modo escluderemo dal columnsDataModel tutti i field definiti nella entity che però non hanno un metodo getter, e che quindi non abbiamo interesse a coinvolgere dalla funzionalità CRUD.
Avendo calcolato il rowDataModel ed il columnDataModel, è facile popolare la nostra tabella:
<ice:dataTable id="dataTbl" var="item"
value="#{anagraficheBacking.rowDataModel}">
<ice:column style="width:0px;">
<ice:rowSelector value="#{item.selected}"
selectionListener="#{anagraficheBacking.rowSelectionListener}" />
</ice:column>
<ice:columns id="columnDataModel" var="column"
value="#{anagraficheBacking.columnDataModel}">
<f:facet name="header">
<ice:panelGroup >
<ice:outputText id="rowData"
value="#{anagraficheBacking.columnDataModel.rowData}"/>
</ice:panelGroup>
</f:facet>
<ice:panelGroup>
<ice:outputText id="cellValue"
value="#{anagraficheBacking.cellValue}"/>
</ice:panelGroup>
</ice:columns>
</ice:dataTable>
Nel nostro backing bean, il metodo getCellValue, scorrendo il rowDataModel ed il columnsDataModel, capirà dinamicamente quale cella deve essere renderizzata ed il rispettivo valore, invocando tramite reflection, per ogni colonna censita nel columnsDataModel, il rispettivo getter method:
public String getCellValue() throws IllegalArgumentException, IllegalAccessException {
Object column = columnDataModel.getRowData();
int currentColumn = ((ArrayList) columnDataModel.getWrappedData()).indexOf(column);
String ret = null;
if (rowDataModel.isRowAvailable() && columnDataModel.isRowAvailable()) {
// get the index of the row and column for this cell
Object r = (Object) rowDataModel.getRowData();
List<Method> metodiFiltrati = getMetodiGetter(r.getClass());
for (int i = 0; i < metodiFiltrati.size(); i++) {
if (i == currentColumn)
{
Method m = metodiFiltrati.get(i);
Object o = null;
try { o = m.invoke(r, new Object[] {});
} catch (InvocationTargetException e){
e.printStackTrace();
}
if (o != null)
ret = o.toString();
}
}
return ret;
}
}
Il metodo tiene traccia della riga e della colonna corrente, quindi è in grado di calcolare il valore da mostrare in ogni cella: per ogni riga viene scorso l'elenco delle colonne, che sappiamo corrispondere ai getter method del nostro oggetto: il corrispondente getter viene quindi invocato via reflection (tramite il metodo invoke) per ottenere il valore del field.
CRUD: Modifica record
Tramite il rowSelector all’interno della dataTable abilitiamo la selezione dalla tabella: il componente, per tenere traccia della selezione di un oggetto utilizza il field selected definito in anagBaseBean, ereditato da tutte i nostri Entity Bean.
Il selector utilizza un rowSelectionListener, definito nel backing bean come segue:
public void rowSelectionListener(RowSelectorEvent event) {
listaOggettiSelezionati.clear();
Object item;
boolean isSel = false;
Method m = null;
for (int i = 0, max = rowList.size(); i < max; i++) {
item = rowList.get(i);
//estraggo il metodo isSelected tramite reflection
try {
m = item.getClass().getMethod("isSelected", new Class[] {});
} catch (Exception e1) {
e1.printStackTrace();
}
//invoco isSelected tramite reflection
try {
Object retObj = m.invoke(item, new Object[] {});
isSel = (Boolean) retObj;
} catch (Exception e) {
e.printStackTrace();
}
if (isSel) {
selectedObject = rowList.get(i);
listaOggettiSelezionati.add(item);
//carico la lista di MapItem con le coppie field-valore del selectedObject
loadMap();
}
}
}
In sostanza, questo metodo cicla ogni riga del rowDataModel e invoca (via reflection) il metodo isSelected su ognuno per identificare la riga selezionata. Una volta individuata la riga selezionata e quindi l'oggetto corrispondente (selectedObject) a partire da esso viene popolata una lista di coppie (nomeField ,valoreField) relative ai field dell'oggetto stesso, mediante l'invocazione di loadMap:
private void loadMap() {
propertyMap = new ArrayList<MapItem>();
HashMap<String, Object> m = new HashMap<String, Object>();
//estraggo la lista dei filed dell'oggetto via reflection
List<Field> fieldsOfClass=Arrays.asList(currentClass.getDeclaredFields());
//per ogni field ...
for (Field f : fieldsOfClass) {
try {
//...essendo private lo devo impostare come accessibile...
f.setAccessible(true);
//...e lo inserisco nella mappa
m.put(f.getName(), f.get(selectedObject));
} catch (Exception e) {
e.printStackTrace();
}
}
Set entries = m.entrySet();
Iterator it = entries.iterator();
//scorro tutte le entry della mappa appena popolata, e per ogni entry
//creo un MapItem corrispondente e lo inserisco in propertyMap
while (it.hasNext()) {
Map.Entry entry = (Map.Entry) it.next();
MapItem mItem = new MapItem();
mItem.setItem((String) entry.getKey());
mItem.setValue(entry.getValue());
if (mItem.getItem().startsWith("id")
|| mItem.getItem().startsWith("desc")
|| mItem.getItem().startsWith("cod")
|| mItem.getItem().startsWith("tipo"))
propertyMap.add(mItem);
}
}
A partire dall'oggetto selezionato (selectedObject) viene estratta tramite reflection una lista dei field dell'oggetto stesso, e le coppie nome (nome field – valore field) vengono inserite in una Map<String,Object>; a partire da questa mappa, ogni coppia relativa a field che ci interessa visualizzare/modificare (id, descrizioni, codici, ecc...) a sua volta è trasformata in un oggetto MapItem (la cui struttura riflette quella di una entry della Map) ed inserito in una lista propertyMap.
Come anticipato, la classe MapItem è strutturata come segue:
public class MapItem implements Comparable{
private String item;
private Object value;
public String getItem() {
return item;
}
public void setItem(String item) {
this.item = item;
}
public Object getValue() {
return value;
}
public void setValue(Object value) {
this.value = value;
}
}
Riassumendo: una volta selezionata una riga dalla dalla tabella, abbiamo estratto l'oggetto selezionato e fatto introspezione su di esso per estrarne i field “interessanti” ai fini della visualizzazione; quindi a partire da essi è stata popolata la propertyMap, ovvero una lista di oggetti che rappresentano i field relativi all'oggetto selezionato. A questo punto, non rimane altro che mostrare questi field in una tabella IceFaces (con l'ausilio di HTML puro):
<ice:panelGroup>
<table border="0px" cellpadding="4px" cellspacing="2px">
<ui:repeat value="#{anagraficheBacking.propertyMap}" var="var">
<tr>
<td width="30%">
<ice:outputLabel value="#{var.item}"/>
</td>
<td width="68%" >
<ice:inputText value="#{var.value}"
readonly="#{(var.item eq 'id')and (var.value != null) }">
</ice:inputText>
</td>
<td width="2%">
<ice:outputText value="PK" rendered="#{var.item eq 'id'}"/>
</td>
</tr>
</ui:repeat>
</table>
[. . . ]
<ice:commandButton value="Modifica” action="#{anagraficheBacking.mergeObject}"/>
<ice:commandButton value="Cancella action="#{anagraficheBacking.deleteObject}"/>
<ice:commandButton value="Nuovo Record"
action="#{anagraficheBacking.creaNuovoItem}"/>
</ice:panelGroup>
Questa tabella permetterà di modificare i field dell'oggetto selezionato, a parte il campo 'id' che, essendo PK, verrà mostrato in sola lettura. Alla pressione del pulsante “modifica” viene eseguita la “riconciliazione” della propertyMap con l'oggetto selezionato, e quindi la merge dell'oggetto selezionato per rendere persistenti le modifiche.
public String mergeObject() {
//scorro i MapItem associati all'oggetto selezionato
for (MapItem m : propertyMap) {
String prop = m.getItem();
Field f;
try {
//per ogni mapitem estraggo dall'oggetto selezionato il
//field corrispondente tramite reflection
f = selectedObject.getClass().getDeclaredField(prop);
f.setAccessible(true);
//Imposto nel selectedObject il valore del MapiItem corrispondente
//se il tipo del field è di tipo Long, devo fare un'opportuna conversione
//da String, poichè il valore impostato da GUI per l'elemento della mappa
//diventa String
if (f.getType().getName().equals("java.lang.Long"))
f.set(selectedObject, Long.valueOf(m.getValue().toString()));
else
f.set(selectedObject, m.getValue());
}
catch (Exception e) {
System.out.println(e.getMessage());
}
}
//effettuo la merge dell'oggetto
facade.merge(selectedObject);
//rigenero il modello dati per aggiornare al volo la tabella
generateDataModels();
return "goAnagrafiche";
}
In sostanza, viene scorsa la propertyMap e, per ogni mapItem in esso contenuta, viene prima recuperato il field corrispondente sull'oggetto selezionato partendo dal mapItem.item (utilizzando get dalle reflection API); quindi, il field appena recuperato viene impostato (col metodo set delle reflection API) con il valore letto dalla propertyMap (mapItem.value).
Una volta “riversate” le modifiche dalla propertyMap all'oggetto selezionato, quest'ultimo può essere passato all'EntityManager di JPA che mediante merge provvederà a rendere persistenti le modifiche. Subito dopo la chiamata a merge, viene richiamato il metodo generateDataModels() che aggiorna immediatamente il dataModel sottostante la tabella (che viene quindi istantaneamente aggiornata).
CRUD: Cancellazione record
Per effettuare la cancellazione, utilizziamo il metodo deleteObject definito nel backing bean:
public String deleteObject() {
Field f = null;
try {
//chiamo la remove dell'oggetto tramite Entity Manager
f = selectedObject.getClass().getDeclaredField("id");
facade.remove( selectedObject,
selectedObject.getClass(),
f.get(selectedObject));
generateDataModels();
}
catch (Exception e) {
e.printStackTrace();
}
return "goAnagrafiche";
}
Per chiamare il metodo remove sull'EntityManager JPA, occorre passare come parametro, oltre all'oggetto da rimuovere (ovvero il selectedObject) e all'oggetto Class corrispondente, anche il valore del field che rappresenta la Primary Key: poiché la nostra naming convention stabilisce che tale field si chiamerà 'id', non abbiamo difficoltà tramite reflection (attraverso il metodo Field.get) a recuperarne il valore. Come nel caso della modifica, anche in questo caso viene aggiornato il dataModel per effettuare un refresh immediato della dataTable con i dati aggiornati.
CRUD: Inserimento nuovo record
Alla pressione del pulsante “Nuovo record”, viene invocato il metodo creaNuovoItem:
public String creaNuovoItem() {
Constructor ct;
try {
//istanzio un nuovo oggetto della classe selzionata via reflection
ct = Class.forName(anagraficaSelezionata).getConstructor(new Class[]{});
selectedObject = ct.newInstance(new Object[] {});
} catch (Exception e1) {
e1.printStackTrace();
}
//popolo la propertyMap
loadMap();
return "goAnagrafiche";
}
Il caso è analogo alla modifica, con la sola differenza che il selectedObject viene preventivamente assegnato ad un nuovo oggetto istanziato per l'occasione (attraverso il metodo Constructor.invoke delle reflection API) anziché essere letto dalla tabella. A questo punto, il metodo loadMap popola la propertyMap a partire dal selectedObject appena creato (e quindi vuoto), generando quindi coppie item-value con tutti value aventi valore null. Quando decido di salvare le modifiche, viene invocato lo stesso metodo mergeObject utilizato per la modifica di un item.
Conclusioni
Il modo appena illustrato di gestire il CRUD sulle tabelle anagrafiche ha permesso di risparmiare parecchio tempo e parecchie tonnellate di codice (fatte di moduli quasi identici e replicati per ogni anagrafica). Inoltre tutto questo mi ha permesso di addentrarmi nell’affascinante mondo delle Java Reflection API, veramente potenti e semplici da utilizzare: a tal proposito, consiglio questo interessante articolo (tratto da SDN) che ne illustra le caratteristiche a grandi linee.
Infine, chiudiamo con una riflessione: in giro per la rete si trovano molti esempi di CRUD generico che fanno uso dei Generics, una delle feature più rivoluzionarie di Java 5; eccone alcuni esempi:
Perché nel nostro caso i Generics non sono adatti? I case study linkati sopra hanno come obiettivo quello di rendere generico il “facade” EJB che si occupa di gestire il CRUD: nel nostro caso, oltre al facade vogliamo rendere generico anche il backing bean che lo invoca, e usando i Generics andremmo a creare “tight coupling” tra le classi del front-end e quelle del back-end: cosa non necessaria, vista anche la facilità con cui JPA è in grado di gestire le Entity “polimorficamente”.
Veramente interessante.... un argomento che incrocia molte delle tecnologie utili in una JEE application
ReplyDeleteCiao, approccio affascinante. Secondo te è possibile usare un approccio simile in presenza di un db molto strutturato con tabelle interconnesse tra loro tramite foreign key?
ReplyDeleteGrazie,
Ciao.
Ciao, innanzitutto grazie del commento!
ReplyDeletePremettendo che la soluzione proposta fa numerose assunzioni sulla naming convention degli Entity bean associati alle tabelle (in particolare sul nome della classe, sui field, sui metodi accessor e sulle named query), nessuna assunzione è invece fatta sulla struttura delle tabelle stesse: la soluzione (con i dovuti adattamenti) permette quindi anche di "navigare" attraverso le relazioni, se ve ne sono. Fammi sapere se pensi di adottare questo metodo e se hai altri chiarimenti da chiedere!
Abbiamo diversi software, ed ogni volta fare il gestore della banca dati è una noia.
ReplyDeletePenso che a tempo perso potrei provare a realizzare una piccola app che permetta una gestione crud delle varie tabelle.
Non saprei ancora come gestire a livello di interfaccia le chiavi esterne, ma sicuramente la strada l'hai segnata chiaramente.
Il top sarebbe un sistema di plugin. Permettendo di caricare il jar con gli entity bean creati di volta in volta. Ci penserò sù.
Per il momento grazie dell'articolo!