This is a proposal for a lightweight controller layer that would be built on top or the CRUD services layer described in the previous article. Just like we could generalize the DAO and Services layer for CRUD (create-retrieve-update-delete) operations on domain objects (or entity objects, extending DomainObject and its subclasses), we can create a parametrised framework class that implements the CRUD functionality for presentation layer. This class would have the role of Controller in the Model-View-Controller pattern; thus, the main functions of such a class would be calling the corresponding Service when a CRUD operation on a domain object is being performed. This class would be the common part of all controllers of an application that perform CRUD on domain objects, and contain all the functionality that can be generalized for all domain objects: retrieving, saving, and deleting an entity, and returning a collections of entities to create scrolling lists.
Most of the functionality is implemented by simply passing control to the Services layer; however, the scrolling lists and error handling require some logic on the controller level.
One possible approach is using NetBeans JSF CRUD generator. The disadvantages of that approach would be the dependence on one-way generator (re-generating loses all changes) and NetBeans (it's best when the project does not depend on any IDE). I submit that creating a framework class (possibly complemented by facelets and custom tags) is a more flexible approach.
The root class of the proposed controller hierarchy is trying to stay agnostic of the particular view technology being used. It passes control for most of the real operations to the service layer (via injected reference to the Service), and delegates view framework dependent operations (like displaying error messages) to its subclasses:
package com.example.crud.controller; import java.io.Serializable; import java.io.ObjectInputStream.GetField; import java.lang.reflect.ParameterizedType; import java.util.ArrayList; import java.util.Collection; import java.util.concurrent.Callable; import javax.management.RuntimeErrorException; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import com.example.crud.service.GenericCrudService; import com.example.domain.DomainObject; /** * Common session controller to implement CRUD actions. Can be used with * different view technologies. * * Does <b>not</b> make the assumption that the object type is * {@link DomainObject}, so needs subclasses to implement * {@link #getCurrentId()}: only subclasses know how to extract the ID. * * @author maxim * * @param <T> * domain object type * @param <PK> * primary key */ public abstract class GenericCrudController<T extends DomainObject<PK>, PK extends Serializable> { public static final String SUCCESS = "success"; public static final String ERROR = "error"; public static final String EDIT = "edit"; public static final String CANCEL = "cancel"; protected static Log logger = LogFactory .getLog(GenericCrudController.class); protected Class<T> type; protected T currentRecord; protected transient GenericCrudService<T, PK> service; private int pgSize = 5; private String order; private boolean sortAscending; private int firstRow = 0; /** * Default constructor. Tries to discover Class<T> */ @SuppressWarnings("unchecked") public GenericCrudController() { type = (Class<T>) ((ParameterizedType) getClass() .getGenericSuperclass()).getActualTypeArguments()[0]; if (logger.isDebugEnabled()) logger.debug("creating new " + this.getClass().getSimpleName()); } /** * Subclasses add end user messages according to view technology * * @param message * text message * @param e * exception or null if none */ protected abstract void addMessage(String message, Throwable e); /** * Subclasses add end user messages according to view technology * * @param message * text message */ protected abstract void addMessage(String message); /** * Subclass will reset the page view (e.g. after delete) */ public abstract void resetPage(); public Collection<T> getList() { try { return service.getAll(); } catch (Exception e) { addMessage("Error retrieving page", e); logger.error("error retrieving page:", e); return new ArrayList<T>(); } } /** * This is where the controller operations are really performed. Single spot * to implement error handling and returning standard outcomes and messages. * * @param controllerAction * a {@link Callable} that does the actions * @return string outcome */ protected String successAndErrorAction(Callable<String> controllerAction) { try { String ret = controllerAction.call(); logger.info("Action succeeded: " + this.getClass().getSimpleName()); return null == ret ? SUCCESS : ret; } catch (Exception e) { logger.error("Exception in controller " + this.getClass().getSimpleName(), e); addMessage(e.getMessage()); return ERROR; } } public GenericCrudService<T, PK> getService() { return service; } public void setService(GenericCrudService<T, PK> service) { this.service = service; } public void setCurrentRecord(T update) { currentRecord = update; } /** * @return id of the current record */ protected PK getCurrentId() { return null==getCurrentRecord()?null:getCurrentRecord().getId(); } /** * * @return new current record (just uses the default constructor of T) */ protected T newCurrentRecord() { try { if (logger.isDebugEnabled()) logger.debug("new current record" + type.getSimpleName()); return (T) type.newInstance(); } catch (Exception e) { logger.error("exception creating CRUD currentRecord", e); return null; } } /** * Getting records to display on a page * * @param pageSize * page size * @param firstRecord * first record number (starts from 0) * @param order * field name for order, or null * @param asc * true if ascending order, else descending * @return collection, size <= pageSize */ protected Collection<T> getRecords(int pageSize, int firstRecord, String order, boolean asc) { if (pageSize <= 0) { return service.getAll(); } else { return service.getPage(pageSize, firstRecord, order, asc); } } /** * * @return collection of records based on current pagination helper */ public Collection<T> getRecords() { return getRecords(getPageSize(), getFirstRow(), getOrder(), isSortAscending()); } /** * * @return the current record (e.g. the one being edited) */ public T getCurrentRecord() { return this.currentRecord; } /** * Called from the list view to delete selected record * * @return standard outcome from {@link #successAndErrorAction(Callable)} * @see #successAndErrorAction(Callable) */ public String delete() { return successAndErrorAction(new Callable<String>() { @Override public String call() throws Exception { setCurrentRecord(getSelectedRecord()); service.delete(getCurrentId()); addMessage("Record deleted successfully"); resetPage(); return null; } }); } /** * * @return record selected in subclass */ protected abstract T getSelectedRecord(); /** * Called from list view to show the editing view; subclasses can override * to do something more interesting. * * @return "edit" to show the editing view (without curent record this means * create new) * @see #edit() */ public String create() { currentRecord = newCurrentRecord(); return EDIT; } public int getPageSize() { return pgSize; } public void setPageSize(int pageSize) { pgSize = pageSize; } private boolean reReadOnEdit=false; /** * Called from the list view to display the record for editing: "edit" means * show the editing view * * The current record is pointed to by {@link #dataTable}. * * The content of the record is (optionally) re-read, so that the lazy loading * collections could be loaded by the DAO on findById(), if the DAO implements this * method. * * * @return */ public String edit() { T currentRecord = getSelectedRecord(); if (null != currentRecord) { if(reReadOnEdit){ currentRecord = service.get(currentRecord.getId()); } setCurrentRecord(currentRecord); } return EDIT; } /** * Save action called from edit record view to add or save a record * * @see #successAndErrorAction(Callable) * @return standard outcome from {@link #successAndErrorAction(Callable)} */ public String save() { return successAndErrorAction(new Callable<String>() { @Override public String call() throws Exception { if (null != getCurrentId()) { setCurrentRecord(service.update(getCurrentRecord())); } else { service.insert(getCurrentRecord()); } addMessage("Record saved successfully"); return null; } }); } public String getOrder() { return order; } public void setOrder(String order) { this.order = order; } public boolean isSortAscending() { return sortAscending; } public int getFirstRow() { return firstRow; } public void setFirstRow(int firstRow) { this.firstRow = firstRow; } public void setSortAscending(boolean sortAscending) { this.sortAscending = sortAscending; } public void setReReadOnEdit(boolean reReadOnEdit) { this.reReadOnEdit = reReadOnEdit; } public boolean isReReadOnEdit() { return reReadOnEdit; } }
The choice of view technology: why JSF?
JSF seems to be a natural choice of view technology for a framework that implements CRUD using JPA (or any other object-relational mapping framework, e.g. Hibernate), because it provides for easy mapping of properties of entities to view UI. This is especially important when the properties in question are collections of dependent beans (master-detail relationship) and we want to build a table-type edit form (as shown, for example, here). As opposed to other technologies, JSF requires surprisingly little coding to map a collection of objects to an (editable and scrolling) data table on the web page.
Hence, an extension of GenericCrudController that uses JSF to implement abstract methods:
package com.example.crud.controller.jsf; import java.io.Serializable; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import javax.faces.application.FacesMessage; import javax.faces.component.UIData; import javax.faces.component.html.HtmlDataTable; import javax.faces.context.FacesContext; import javax.faces.model.DataModel; import org.apache.myfaces.renderkit.html.util.AddResource; import org.apache.myfaces.renderkit.html.util.AddResourceFactory; import com.example.crud.controller.GenericCrudController; import com.example.crud.service.GenericCrudService; import com.example.domain.DomainObject; /** * Common controller type for CRUD using {@link GenericCrudService}. * * Extending this class guarantees consistency in JFS outcomes and error * handling in controller operations. * * Standard actions used by list and edit page templates are {@link #edit()}, * {@link #create()}, {@link #save()}, {@link #delete()}. * * {@link #getList()} or {@link #getPage()} is used to show the scrolling table. * * {@link #getDataTable()} is the scrolling table model. * * @author maxim * * @param <T> * currentRecord type that the controller works with * @param <PK> * primary key type * * @see GenericCrudService */ public class GenericCrudControllerJsf<T extends DomainObject<PK>, PK extends Serializable> extends GenericCrudController<T, PK> implements Serializable { private static final long serialVersionUID = -1; private Map<T, Boolean> recordSelections = new HashMap<T, Boolean>(); private String popupOptions = "dependent=yes, menubar=no, toolbar=no, height=400, width=600"; private transient UIData dataTable; private transient FacesContext facesContext; public GenericCrudControllerJsf() { if (logger.isDebugEnabled()) logger.debug("constructed " + this.getClass()); } public void submit() { logger.debug("void submit method"); } /** * @return the dataTable */ public UIData getDataTable() { return dataTable; } /** * @param dataTable * the dataTable to set */ public void setDataTable(UIData dataTable) { this.dataTable = dataTable; } /** * Add message via {@link FacesContext} * * @param message * @param e */ protected void addMessage(String message, Throwable e) { getFacesContext().addMessage( "controller", new FacesMessage(FacesMessage.SEVERITY_ERROR, message, null == e ? message : e.getMessage())); } /** * Add message via {@link FacesContext} * * @param message */ protected void addMessage(String message) { getFacesContext().addMessage("controller", new FacesMessage(FacesMessage.SEVERITY_INFO, message, message)); } public void setFacesContext(FacesContext context) { this.facesContext = context; } public FacesContext getFacesContext() { if (null != facesContext) { return facesContext; } else { return FacesContext.getCurrentInstance(); } } private AddResource addResource; public void setAddResource(AddResource addResource) { this.addResource = addResource; } public AddResource getAddResource() { if (null != addResource) { return addResource; } else { return AddResourceFactory.getInstance(getFacesContext()); } } private List<ChoiceListener> choiceListeners = new ArrayList<ChoiceListener>(); /** * Add listener to be called when we make a selection choice (e.g. check * some records and click "choose selected" or some such) * * @param listener */ public void addChoiceListener(ChoiceListener listener) { choiceListeners.add(listener); } /** * That's where we get control when the choice has been made in a pop-up * registered listeners are called; if the parent window exists and has a * "submitMainForm" element, we will close the current window and "click" * that element in the parent window. */ public void choose() { for (T record : recordSelections.keySet()) { if (null != recordSelections.get(record) && recordSelections.get(record)) { for (ChoiceListener listener : choiceListeners) { listener.processChoice(record); } } } recordSelections.clear(); dataTable = null; // The following kludge fires click event for "submitMainForm" element // in the main window, // if one is defined. String javaScriptText = " window.close(); " + " if(window.opener && window.opener.document.getElementById('submitMainForm')){" + " var fireOnThis = window.opener.document.getElementById('submitMainForm');" + " if (window.opener.document.createEvent) {" + " var evObj = window.opener.document.createEvent('MouseEvents');" + " evObj.initEvent( 'click', true, false );" + " fireOnThis.dispatchEvent(evObj);" + " } else if (window.opener.document.createEventObject){" + " fireOnThis.fireEvent('onclick');" + " }" + " }"; getAddResource().addInlineScriptAtPosition(getFacesContext(), AddResource.HEADER_BEGIN, javaScriptText); } /** * Method called by views and other controllers to open selection pop-up for * the domain objects in question * * @param event */ public void openPopup(String actionUrl) { dataTable = null; String javaScriptText = "window.open('" + actionUrl + "', 'popupWindow', '" + popupOptions + "');"; getAddResource().addInlineScriptAtPosition(getFacesContext(), AddResource.HEADER_BEGIN, javaScriptText); } /** * @return */ @Override @SuppressWarnings("unchecked") public T getSelectedRecord() { return null != dataTable ? ((T) dataTable.getRowData()) : null; } public Map<T, Boolean> getRecordSelections() { return recordSelections; } public void setRecordSelections(Map<T, Boolean> recoordSelections) { this.recordSelections = recoordSelections; } public void setPopupOptions(String popupOptions) { this.popupOptions = popupOptions; } public String getPopupOptions() { return popupOptions; } public void setChoiceListeners(List<ChoiceListener> choiceListeners) { this.choiceListeners = choiceListeners; } public List<ChoiceListener> getChoiceListeners() { return choiceListeners; } private transient DataModel pagedList; @Override public void resetPage() { dataPage = null; pagedList = null; setDataTable(null); } private DataPage<T> dataPage; /** * * @return paginating lazy-reading collection */ public DataModel getPage() { if (null == pagedList) { pagedList = new PagedListDataModel<T>(getPageSize(), getFirstRow()) { @Override public DataPage<T> fetchPage(int startRow, int pageSize) { final int start = getFirstRow(); setFirstRow(startRow); if (logger.isDebugEnabled()) logger.debug("new page: " + startRow + " old=" + start + " pageSize=" + pageSize); if (null == dataPage || startRow != dataPage.getStartRow()) { dataPage = new DataPage<T>(getService().getTotal(), startRow, (List<T>) getService().getPage( pageSize, startRow, getOrder(), isSortAscending())); } return dataPage; } }; } return pagedList; } }
The interesting part is the pagination logic that uses special class to implement lazy reading of records to fill pages.
Another part that needs clarification is how to code facelets or JSPs using this controller as a backing bean.
To be continued...
No comments:
Post a Comment