MVC via Beans/Reflection/Spring with Java
From:
"andrew cooke" <andrew@...>
Date:
Sat, 25 Feb 2006 23:04:26 -0300 (CLST)
I've been reworking the form handling in the ol' Secret Project. Below is
the handler for a page with two states ("simple" and "editing"). In the
"editing" state you can alter various fields. The form fields are a
values in a bean (the Model), the JSP page contains the necessary to logic
to display the fields appropriately (the View), and below is the
Controller.
Obviously a lot of work is handled by sub-classes. What's nice is that
it's generic. Only the logic below is specific to this form:
- reading / saving to the database
- restricting editing to the "editing" state
The switching between states is via submit buttons, with names of the form
"action:xxx:path". The xxx results in a call to setXxx(model) in the
action bean, where "path" is available in the action bean's scope and
identifies the appropriate information in the model.
So, for example, "action:delete:user.name" would call setDelete(model)
which would delete the user's name from the underlying model (via Java
Bean conventions / reflection).
public class IndexController extends BaseController {
/**
* Available bean modes
*/
private static final String SIMPLE = "simple";
private static final String EDITING = "editing";
private static final SetEnum modes = new SetEnum().
register(SIMPLE).
register(EDITING);
private InfoDao infoDao;
public void setInfoDao(InfoDao infoDao) {
this.infoDao = infoDao;
}
public InfoDao getInfoDao() {
return infoDao;
}
@Override
protected FormDisplayable newFormBean(HttpServletRequest req) {
Info info = getInfoDao().getInfo(getViewer(), getOwner(req));
info._setMembers(modes);
info._setMode(SIMPLE);
info.getDescription()._setMode(
info.isMutable() ?
FieldDisplayMode.EDITABLE
: FieldDisplayMode.READONLY);
logger.debug("new form bean: " + info);
return info;
}
@Override
protected Object newActionBean(HttpServletRequest req) {
return new ActionBean(req);
}
public class ActionBean extends BaseActionBean {
public ActionBean(HttpServletRequest req) {super(req);}
@Override
public void setEdit(FormDisplayable bean)
throws Exception {
super.setEdit(bean);
bean._setMode(EDITING);
}
public void setSave(FormDisplayable bean)
throws Exception {
check(bean.isMode(EDITING),
"can only save edited beans");
getInfoDao().save((Info)bean);
setInit(bean);
}
}
}
Andrew
A More Complex Example
From:
"andrew cooke" <andrew@...>
Date:
Mon, 27 Feb 2006 22:24:03 -0300 (CLST)
Here's a more complex example - a page with three different states, and a
variety of paths with slightly different business logic.
public class ContactsController extends BaseController {
// define the different states for the page
private static final String SIMPLE = "simple";
private static final String EDITING = "editing";
private static final String ALL = "showing-all";
private static final SetEnum modes = new SetEnum()
.register(SIMPLE)
.register(EDITING)
.register(ALL);
// and associate the different bean paths with different buisness
// rules (by tagging with string)
private static final String MAX_ONE = "max-one";
private static final String MIN_ONE = "min-one";
private static final PathIndex paths = new PathIndex()
.register("names", Name.class, MAX_ONE)
.register("emails", Email.class, MIN_ONE)
.register("addresses", Address.class)
.register("nicks", Nick.class);
private static String COUNTRIES = "countries";
// the standard spring stuff for binding values to complex
// data; in this case the country is selected from a list so
// we need to convert from string to database key
@Override
protected void initBinder(HttpServletRequest req,
ServletRequestDataBinder binder) {
CountryPropertyEditor editor = new CountryPropertyEditor();
editor.setContactsDao(getContactsDao());
binder.registerCustomEditor(Country.class, "addresses.country", editor);
}
// add the list of countries to the model so we can generate
// the list to select from
@Override
protected Map buildModel(HttpServletRequest req,
FormDisplayable formBean) {
addExtra(COUNTRIES, getContactsDao().getCountries());
return super.buildModel(req, formBean);
}
// generate and initialise the model bean
@Override
protected FormDisplayable newFormBean(HttpServletRequest req) {
Contacts contacts = getContactsDao().getContacts(getViewer(),
getOwner(req));
initialNameState(contacts.getNames());
initialState(contacts.getAddresses());
initialState(contacts.getEmails());
initialState(contacts.getNicks());
contacts._setMembers(modes);
contacts._setMode(SIMPLE);
return contacts;
}
// set view information based on model values
private void initialNameState(List<Name> names) {
for (Name name : names) {
switch (name.getState()) {
case CLOSED:
name._setMode(FieldDisplayMode.EXPIRED);
break;
case OPEN:
name._setMode(FieldDisplayMode.REPLACEABLE);
break;
case READONLY:
name._setMode(FieldDisplayMode.READONLY);
break;
default:
// error!
name._setMode(FieldDisplayMode.READONLY);
}
logger.debug(name + ": " + name.getMode());
}
}
private void initialState(List<? extends LifetimeAccessControl> items) {
for (LifetimeAccessControl item : items) {
switch (item.getState()) {
case CLOSED:
item._setMode(FieldDisplayMode.EXPIRED);
break;
case OPEN:
item._setMode(FieldDisplayMode.DELETEABLE);
break;
case READONLY:
item._setMode(FieldDisplayMode.READONLY);
break;
default:
// error!
item._setMode(FieldDisplayMode.READONLY);
}
logger.debug(item + ": " + item.getMode());
}
}
// the action bean, defined below, is used to modify the model
@Override
protected Object newActionBean(HttpServletRequest req) {
return new ActionBean(req);
}
public class ActionBean extends LifetimeActionBean {
public ActionBean(HttpServletRequest req) {
super(req);
}
// save modified data
public void setSave(FormDisplayable bean) throws Exception {
logger.debug("saving");
Contacts contacts = (Contacts) bean;
check(contacts.isMode(EDITING), "can only save mutable beans");
Path parent = Bk.parse(path).dropChild();
Contacts prev = getContactsDao().getContacts(contacts);
for (Object child : Bk.getChanged(prev, contacts, parent)) {
getContactsDao().save(contacts, (LifetimeAccessControl) child);
}
setInit(bean);
return;
}
// delete a particular path
// note how we check whether a path is associated with a
// particular rule (minimum of one value in the collection)
@Override
public void setDelete(FormDisplayable bean) throws Exception {
Path parent = new PathParser(path).getPath().dropChild();
int children = Bk.getChildren(bean, parent).size();
logger.debug("deleting at " + path + " (" + children + " children)");
if (children < 2)
paths.checkNot(parent.toString(), MIN_ONE);
check(((ViewedResource) bean).isMutable(),
"can only delete items from a mutable bean");
check(bean.isMode(SIMPLE), "can only delete in simple mode");
super.setDelete(bean);
bean._setMode(EDITING);
}
// replace an exising value with a new one
@Override
public void setReplace(FormDisplayable bean) throws Exception {
logger.debug("replacing at " + path);
check(bean.isMode(SIMPLE), "can only replace in simple mode");
super.setReplace(bean);
bean._setMode(EDITING);
}
public void setAll(FormDisplayable bean) {
logger.debug("showing all");
check(bean.isMode(SIMPLE), "can only show all in simple mode");
bean._setMode(ALL);
return;
}
// add a new value. again, check for path/rule
@SuppressWarnings("unchecked")
public void setAdd(FormDisplayable bean) throws Exception {
int children =
Bk.getChildren(bean, new PathParser(path).getPath()).size();
logger.debug("adding at " + path + " (" + children + " children)");
if (children > 0)
paths.checkNot(path, MAX_ONE);
check(((ViewedResource) bean).isMutable(),
"can only add items to mutable bean");
check(bean.isMode(SIMPLE), "can only add in simple mode");
FieldDisplayable item = (FieldDisplayable) Bk.newInstance(paths
.getClass(path));
((List) Bk.getProperty(bean, path)).add(item);
bean._setMode(EDITING);
return;
}
}
}
Modes as Enums
From:
"andrew cooke" <andrew@...>
Date:
Wed, 1 Mar 2006 09:48:53 -0300 (CLST)
Looking at the code above, the use of Strings as mode names is ugly -
they're an obvious candidate for enums. However, the relevant methods are
added to data transport classes with AspectJ Mixins, which don't support
generics. So it wasn't clear how to do this in a type-safe way.
Anyway, yesterday I found the EnumSet class, which lets me write simple
dynamic code that is pretty type-safe. Here's the mixin:
public aspect FormDisplayableMixin {
declare parents : ViewedResource
implements FormDisplayable;
private FormDisplayMode FormDisplayable.mode =
new FormDisplayMode();
public void FormDisplayable._setMode(Enum mode) {
this.mode._setMode(mode);
}
public void FormDisplayable._setMembers(Class mode) {
this.mode._setMembers(mode);
}
public boolean FormDisplayable.isMode(Enum mode) {
return this.mode.isMode(mode);
}
public int FormDisplayable.modeHashCode() {
return this.mode.hashCode();
}
public String FormDisplayable.getMode() {
return this.mode.toString();
}
}
And here's the underlying utility class that manages the mode:
public class FormDisplayMode {
private EnumSet members;
private Enum mode;
@SuppressWarnings("unchecked")
public void _setMembers(Class mode) {
this.members = EnumSet.allOf(mode);
mode = null;
}
public void _setMode(Enum mode) {
check(mode);
this.mode = mode;
}
public void check(Enum mode) {
if (! members.contains(mode))
throw new IllegalArgumentException("bad mode");
}
public boolean isMode(Enum mode) {
check(mode);
return mode.equals(this.mode);
}
public String toString() {
return mode.toString();
}
public int hashCode() {
return Tk.hashCode(mode);
}
}
See how the "check" method checks that the type is correct? In
retrospect, I could also have saved the class directly and done an
"instanceof", but I prefer this approach because I can add an extra
setMembers method that takes a set explicitly and which allows a model to
be used with only a subset of states (which is going to be useful for
models shared across several views, I think).
Rules as Enums
From:
"andrew cooke" <andrew@...>
Date:
Wed, 1 Mar 2006 10:34:29 -0300 (CLST)
And here's the same thing for the rules. In this case I can use generics,
and it all works out quite nicely:
/**
* Utility class that associates paths to bean members with classes and
* rule sets. The two uses (classes and rule sets) are unconnected and
* should perhaps be separated.
*/
public class PathIndex<E extends Enum<E>> {
private Log logger = LogFactory.getLog(getClass());
private Map<String, Class> classes = new HashMap<String, Class>();
private Map<Enum<E>, Set<String>> rules =
new HashMap<Enum<E>, Set<String>>();
public PathIndex<E> register(String path, Class clazz) {
// cannot work out how to use EnumSet.noneOf with generics
// EnumSet.noneOf(E) fails to compile
return register(path, clazz, EnumSet.copyOf(new HashSet<E>()));
}
public PathIndex<E> register(String path, Class clazz, E rule) {
return register(path, clazz, EnumSet.of(rule));
}
public PathIndex<E> register(String path, Class clazz, EnumSet<E> all) {
if (classes.containsKey(path))
throw new
IllegalArgumentException("duplicate path: " + path);
classes.put(path, clazz);
for (Enum<E> rule: all) {
if (! rules.containsKey(rule))
rules.put(rule, new HashSet<String>());
rules.get(rule).add(path);
logger.debug("registered path " + path + " as " + rule);
}
return this;
}
public String check(String path) {
if (! classes.containsKey(path))
throw new AccessControlException("bad path: " + path);
return path;
}
public String check(String path, Enum<E> rule) {
check(path);
if (! rules.get(rule).contains(path))
throw new AccessControlException("bad rule: " + rule);
return path;
}
public String checkNot(String path, Enum<E> rule) {
check(path);
if (rules.get(rule).contains(path))
throw new AccessControlException("bad rule: " + rule);
return path;
}
public Class getClass(String path) {
return classes.get(path);
}
}
Empty EnumSet
From:
"andrew cooke" <andrew@...>
Date:
Wed, 1 Mar 2006 10:41:26 -0300 (CLST)
The code above (generating EnumSet<E> from an empty collection) gives a
runtime error. I guess it's because of type erasure, but then how does
noneOf work?
I've replaced it with null and an test in the main register method.
Anyway, here's the cleaned-up controller:
public class ContactsController extends BaseController {
private static enum Mode {SIMPLE, EDITING, SHOWING_ALL};
private static enum Rule {MAX_ONE, MIN_ONE};
private static final PathIndex<Rule> paths = new PathIndex<Rule>()
.register("names", Name.class, Rule.MAX_ONE)
.register("emails", Email.class, Rule.MIN_ONE)
.register("addresses", Address.class)
.register("nicks", Nick.class);
private static String COUNTRIES = "countries";
@Override
protected void initBinder(HttpServletRequest req,
ServletRequestDataBinder binder) {
CountryPropertyEditor editor = new CountryPropertyEditor();
editor.setContactsDao(getContactsDao());
binder.registerCustomEditor(Country.class, "addresses.country", editor);
}
@Override
protected Map buildModel(HttpServletRequest req, FormDisplayable
formBean) {
addExtra(COUNTRIES, getContactsDao().getCountries());
return super.buildModel(req, formBean);
}
@Override
protected FormDisplayable newFormBean(HttpServletRequest req) {
Contacts contacts = getContactsDao().getContacts(getViewer(),
getOwner(req));
initialState(contacts.getNames());
initialState(contacts.getAddresses());
initialState(contacts.getEmails());
initialState(contacts.getNicks());
contacts._setMembers(Mode.class);
contacts._setMode(Mode.SIMPLE);
return contacts;
}
private void initialState(List<? extends LifetimeAccessControl> items) {
for (LifetimeAccessControl item : items) {
switch (item.getState()) {
case CLOSED:
item._setMode(FieldDisplayMode.EXPIRED);
break;
case OPEN:
item._setMode(FieldDisplayMode.DELETEABLE);
break;
case READONLY:
item._setMode(FieldDisplayMode.READONLY);
break;
default:
// error!
item._setMode(FieldDisplayMode.READONLY);
}
logger.debug(item + ": " + item.getMode());
}
}
@Override
protected Object newActionBean(HttpServletRequest req) {
return new ActionBean(req);
}
public class ActionBean extends LifetimeActionBean {
public ActionBean(HttpServletRequest req) {
super(req);
}
public void setSave(FormDisplayable bean) throws Exception {
logger.debug("saving");
Contacts contacts = (Contacts) bean;
check(contacts.isMode(Mode.EDITING), "can only save mutable beans");
Path parent = Bk.parse(path).dropChild();
Contacts prev = getContactsDao().getContacts(contacts);
for (Object child : Bk.getChanged(prev, contacts, parent)) {
getContactsDao().save(contacts, (LifetimeAccessControl) child);
}
setInit(bean);
return;
}
@Override
public void setDelete(FormDisplayable bean) throws Exception {
Path parent = new PathParser(path).getPath().dropChild();
int children = Bk.getChildren(bean, parent).size();
logger.debug("deleting at " + path + " (" + children + " children)");
if (children < 2) paths.checkNot(parent.toString(), Rule.MIN_ONE);
check(bean.isMode(Mode.SIMPLE), "can only delete in simple mode");
super.setDelete(bean);
bean._setMode(Mode.EDITING);
}
@Override
public void setReplace(FormDisplayable bean) throws Exception {
Path parent = new PathParser(path).getPath().dropChild();
int children = Bk.getChildren(bean, parent).size();
logger.debug("replacing at " + path + " (" + children + " children)");
if (children > 1) paths.checkNot(parent.toString(), Rule.MAX_ONE);
check(bean.isMode(Mode.SIMPLE), "can only replace in simple mode");
super.setReplace(bean);
bean._setMode(Mode.EDITING);
}
public void setAll(FormDisplayable bean) {
logger.debug("showing all");
check(bean.isMode(Mode.SIMPLE), "can only show all in simple mode");
bean._setMode(Mode.SHOWING_ALL);
return;
}
@SuppressWarnings("unchecked")
public void setAdd(FormDisplayable bean) throws Exception {
int children =
Bk.getChildren(bean, new PathParser(path).getPath()).size();
logger.debug("adding at " + path + " (" + children + " children)");
if (children > 0) paths.checkNot(path, Rule.MAX_ONE);
check(((ViewedResource) bean).isMutable(),
"can only add items to mutable bean");
check(bean.isMode(Mode.SIMPLE), "can only add in simple mode");
FieldDisplayable item = (FieldDisplayable) Bk.newInstance(paths
.getClass(path));
((List)Bk.getProperty(bean, path)).add(item);
bean._setMode(Mode.EDITING);
return;
}
}
}
Comment on this post