11.07.2009 von Andreas Ebbert-Karroum
5 Kommentare
Während unserer coding night, zu der mit Sicherheit noch ein separater Blogeintrag folgt, setzen wir voll auf Spring Technologien. Dabei hat sich eine vermutlich simple Anforderung als ziemlich halsbrecherisch herausgestellt.
Wie kann man mit Spring MVC eine Select box darstellen, bei der man multiple Elemente auswählen kann, welche dann in eine Collection der Bean hinzugefügt werden?
Wir implementiere eine einfache Zeiterfassung. In dem Domänenmodell gibt es Projekte, denen Mitarbeiter zugeordnet sind. Zudem können Projekte Aufgaben haben, denen auch Mitarbeiter zugeordnet sein können. In der View zum erstellen der Aufgaben benötigen wir also eine Selectbox aus allen verfügbaren Mitarbeitern.
Modell
Hier das wichtigste der Aufgaben und Mitarbeiter:
Task
@Entity
public class Task implements Serializable {
@Id
@GeneratedValue
private long id;
@ManyToMany
private Set<Staff> staffs = new HashSet<Staff>(0);
//...
} |
@Entity
public class Task implements Serializable {
@Id
@GeneratedValue
private long id; @ManyToMany
private Set<Staff> staffs = new HashSet<Staff>(0); //...
}
Staff
@NamedQuery(name = "staff.activeStaff", query = "select s from Staff s where s.disabled = false")
@Entity
@DiscriminatorValue("STAFF")
public class Staff extends Person {
//...
} |
@NamedQuery(name = "staff.activeStaff", query = "select s from Staff s where s.disabled = false")
@Entity
@DiscriminatorValue("STAFF")
public class Staff extends Person { //...
}
Person
@NamedQuery(name = "person.findByUsername", query = "from Person p where p.login.username = :username")
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "type", discriminatorType = DiscriminatorType.STRING)
public abstract class Person implements Serializable {
@Id
@GeneratedValue
private long id;
//...
} |
@NamedQuery(name = "person.findByUsername", query = "from Person p where p.login.username = :username")
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "type", discriminatorType = DiscriminatorType.STRING)
public abstract class Person implements Serializable { @Id
@GeneratedValue
private long id; //...
}
View
In der view, in der die Tasks erstellt werden sollen, brauchen wir also eine select box, welche alle verfügbaren Mitarbeiter anzeigt. Die ausgewählten Mitarbeiter sollen dann der Aufgabe hinzugefügt werden. Die View ist mit der Task hinterlegt, und fügt alle aktiven Mitarbeiter zu einem Attribut hinzu:
@Controller
@SessionAttributes("project")
public class ProjectController {
@RequestMapping(method = RequestMethod.GET, value = "/project/createTask.action")
public void createTaskView(@ModelAttribute Task task, Model model) {
List<Staff> activeStaff = timetrackingService.getActiveStaff();
model.addAttribute("activeStaff", activeStaff);
}
//... |
@Controller
@SessionAttributes("project")
public class ProjectController {
@RequestMapping(method = RequestMethod.GET, value = "/project/createTask.action")
public void createTaskView(@ModelAttribute Task task, Model model) {
List<Staff> activeStaff = timetrackingService.getActiveStaff();
model.addAttribute("activeStaff", activeStaff);
} //...
Die entsprechende JSP sieht folgendermaßen aus:
<td><label for="staffs">Chose Staff: </label></td>
<td><form:select path="staffs" multiple="true" items="${activeStaff}" itemLabel="fullName" itemValue="id"/></td>
<td><form:errors path="staffs" /></td> |
<td><label for="staffs">Chose Staff: </label></td>
<td><form:select path="staffs" multiple="true" items="${activeStaff}" itemLabel="fullName" itemValue="id"/></td>
<td><form:errors path="staffs" /></td>
Das Problem, sobald man die Form submitted, bekommt man eine ServletRequestBindingException, denn Spring weiß nicht wie man aus einem String (bzw. String[] wenn mehrere Personen markiert waren) ein Set erstellt.
org.springframework.web.bind.ServletRequestBindingException: Errors binding onto object 'task'; nested exception is org.springframework.validation.BindException: org.springframework.validation.BeanPropertyBindingResult: 1 errors
Field error in object 'task' on field 'staffs': rejected value [3]; codes [typeMismatch.task.staffs,typeMismatch.staffs,typeMismatch.java.util.Set,typeMismatch]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [task.staffs,staffs]; arguments []; default message [staffs]]; default message [Failed to convert property value of type [java.lang.String] to required type [java.util.Set] for property 'staffs'; nested exception is java.lang.IllegalArgumentException: Cannot convert value of type [java.lang.String] to required type [de.codecentric.timetracking.model.Staff] for property 'staffs[0]': no matching editors or conversion strategy found] |
org.springframework.web.bind.ServletRequestBindingException: Errors binding onto object 'task'; nested exception is org.springframework.validation.BindException: org.springframework.validation.BeanPropertyBindingResult: 1 errors
Field error in object 'task' on field 'staffs': rejected value [3]; codes [typeMismatch.task.staffs,typeMismatch.staffs,typeMismatch.java.util.Set,typeMismatch]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [task.staffs,staffs]; arguments []; default message [staffs]]; default message [Failed to convert property value of type [java.lang.String] to required type [java.util.Set] for property 'staffs'; nested exception is java.lang.IllegalArgumentException: Cannot convert value of type [java.lang.String] to required type [de.codecentric.timetracking.model.Staff] for property 'staffs[0]': no matching editors or conversion strategy found]
Zudem ist der generierte HTML-code lückenhaft, er enthält keine id für die einzelnen Mitarbeiter!
<select id="staffs" multiple="multiple" name="staffs">
<option value="">firstname lastname</option>
<option value="">aaaa aaaa</option>
<option value="">firstname lastName</option>
<option value="">firstname aaaa</option>
<option value="">firstname lastname</option>
</select>
<input type="hidden" value="1" name="_staffs"/> |
<select id="staffs" multiple="multiple" name="staffs">
<option value="">firstname lastname</option>
<option value="">aaaa aaaa</option>
<option value="">firstname lastName</option>
<option value="">firstname aaaa</option>
<option value="">firstname lastname</option>
</select>
<input type="hidden" value="1" name="_staffs"/>
Id als String
Um die ID des Mitarbeiters in das value-Attribut der option zu bekommen, kann man einen eigenen Getter auf der Person implementieren, und diesen dann in der JSP statt der ID verwenden:
public abstract class Person implements Serializable {
//...
public String getIdAsString() {
return new Long(id).toString();
}
} |
public abstract class Person implements Serializable {
//...
public String getIdAsString() {
return new Long(id).toString();
}
}
<form:select path="staffs" itemValue="idAsString" multiple="true" items="${activeStaff}" itemLabel="fullName"/> |
<form:select path="staffs" itemValue="idAsString" multiple="true" items="${activeStaff}" itemLabel="fullName"/>
InitBinder
Die Lösung für das Binding-Problem ist, dass wir für das Attribut ’staffs‘ einen eigenen PropertyEditor registrieren müssen. Spring bringt eine Reihe eigener PropertyEditoren mit, in diesem Falle können wir den CustomCollectionEditor wiederverwenden. Um von der String-ID wieder auf den Mitarbeiter mappen zu können, müssen wir uns auch noch eine Map initialisieren, die dieses Mapping vorhält.
public class ProjectController {
private Map<String, Staff> staffCache;
@RequestMapping(method = RequestMethod.GET, value = "/project/createTask.action")
public void createTaskView(@ModelAttribute Task task, Model model) {
List<Staff> activeStaff = timetrackingService.getActiveStaff();
staffCache = new HashMap<String, Staff>();
for (Staff staff : activeStaff) {
staffCache.put(staff.getIdAsString(), staff);
}
model.addAttribute("activeStaff", activeStaff);
}
@InitBinder
protected void initBinder(WebDataBinder binder) throws Exception {
binder.registerCustomEditor(Set.class, "staffs", new CustomCollectionEditor(Set.class) {
protected Object convertElement(Object element) {
if (element instanceof Staff) {
System.out.println("Converting from Staff to Staff: " + element);
return element;
}
if (element instanceof String) {
Staff staff = staffCache.get(element);
System.out.println("Looking up staff for id " + element + ": " + staff);
return staff;
}
System.out.println("Don't know what to do with: " + element);
return null;
}
});
} |
public class ProjectController { private Map<String, Staff> staffCache; @RequestMapping(method = RequestMethod.GET, value = "/project/createTask.action")
public void createTaskView(@ModelAttribute Task task, Model model) {
List<Staff> activeStaff = timetrackingService.getActiveStaff();
staffCache = new HashMap<String, Staff>();
for (Staff staff : activeStaff) {
staffCache.put(staff.getIdAsString(), staff);
}
model.addAttribute("activeStaff", activeStaff);
} @InitBinder
protected void initBinder(WebDataBinder binder) throws Exception {
binder.registerCustomEditor(Set.class, "staffs", new CustomCollectionEditor(Set.class) {
protected Object convertElement(Object element) {
if (element instanceof Staff) {
System.out.println("Converting from Staff to Staff: " + element);
return element;
}
if (element instanceof String) {
Staff staff = staffCache.get(element);
System.out.println("Looking up staff for id " + element + ": " + staff);
return staff;
}
System.out.println("Don't know what to do with: " + element);
return null;
}
});
}
Der Code enthält noch etwas Debug-output nach System.out, der natürlich noch entfernt werden muss. Er zeigt aber sehr schön, dass der Code sehr (zu?) häufig aufgerufen wird. Außerdem wird erwartetn, dass die Property in beide Richtungen konvertiert werden kann!
Allein wenn die select box angezeit werden soll, steht folgendes im Log:
Looking up staff for id 1: Staff(firstname lastname)
Looking up staff for id 1: Staff(firstname lastname)
Converting from Staff to Staff: Staff(firstname lastname)
Looking up staff for id 2: Staff(aaaa aaaa)
Looking up staff for id 2: Staff(aaaa aaaa)
Converting from Staff to Staff: Staff(aaaa aaaa)
Looking up staff for id 3: Staff(firstname lastName)
Looking up staff for id 3: Staff(firstname lastName)
Converting from Staff to Staff: Staff(firstname lastName)
Looking up staff for id 4: Staff(firstname aaaa)
Looking up staff for id 4: Staff(firstname aaaa)
Converting from Staff to Staff: Staff(firstname aaaa)
Looking up staff for id 5: Staff(firstname lastname)
Looking up staff for id 5: Staff(firstname lastname)
Converting from Staff to Staff: Staff(firstname lastname) |
Looking up staff for id 1: Staff(firstname lastname)
Looking up staff for id 1: Staff(firstname lastname)
Converting from Staff to Staff: Staff(firstname lastname)
Looking up staff for id 2: Staff(aaaa aaaa)
Looking up staff for id 2: Staff(aaaa aaaa)
Converting from Staff to Staff: Staff(aaaa aaaa)
Looking up staff for id 3: Staff(firstname lastName)
Looking up staff for id 3: Staff(firstname lastName)
Converting from Staff to Staff: Staff(firstname lastName)
Looking up staff for id 4: Staff(firstname aaaa)
Looking up staff for id 4: Staff(firstname aaaa)
Converting from Staff to Staff: Staff(firstname aaaa)
Looking up staff for id 5: Staff(firstname lastname)
Looking up staff for id 5: Staff(firstname lastname)
Converting from Staff to Staff: Staff(firstname lastname)
Wenn man einen Mitarbeiter aus der Select-Box auswählt und die Form submitted, findet sich dann aber wie erwartet nur ein Eintrag im Log:
Looking up staff for id 3: Staff(firstname lastName) |
Looking up staff for id 3: Staff(firstname lastName)
Fazit
Angesichts der Masse an Webframeworks, die es gibt, finde ich es erschreckend Kompliziert so etwas einfaches wie eine multiple select Box mit Spring MVC zu implementieren. Da ich mir Spring MVC aber auch erst seit gestern genauer angesehen habe, verstehe ich es vielleicht noch nicht richtig, von daher bin ich sehr für Vorschläge zu haben, wie man das beschriebene Szenario mit Spring MVC-Mitteln eleganter und einfacher implementieren kann.