Spring MVC complex object data binding

2019-08-12 04:56发布

问题:

I am still struggling with Spring MVC with what should be a fairly straightforward problem but what seems to be sparsly documented in Spring MVC documentation.

My project uses Spring MVC and Thymeleaf for the views, but the view rendering engine is not really relevant to the problem.

My application is centered around an Activity class which models an (indoor or outdoor) activity which is organized by a member and where fellow members can subscribe to. An Activity has, among others, a Category field and a Region field, which are dropdown fields which are modeled by Hibernate as many-to-one entities to DB lookup tables which contain an id and description field.

The code for the Activity entity class is as follows, the non relevant fields are omitted to shorten the code:

package nl.drsklaus.activiteitensite.model;

//imports

@Entity
@Table(name="activity")
public class Activity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) 
private Integer id;

@ManyToOne(cascade=CascadeType.ALL)
@JoinColumn(name="organizer_id")
private Member organizer;

@Size(min=5, max=50)
@Column(name = "title", nullable = false)
private String title;

@Size(min=5, max=500)
@Column(name = "description", nullable = false)
private String description;

@ManyToOne
@JoinColumn(name="category_id")
private ActivityCategory category;

@ManyToOne
@JoinColumn(name="region_id")
private ActivityRegion region;


@ManyToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER)
@JoinTable(name="member_activity_subscription", 
           joinColumns = {@JoinColumn(name="activity_id")}, 
           inverseJoinColumns={@JoinColumn(name="member_id")})
private List<Member> participants = new ArrayList<Member>();

//getters and setters   

@Override
public int hashCode() {
  ...
}

@Override
public boolean equals(Object obj) {
  ...
}
}

In the view, the user should be able to select a Region and Category from a select box. THe options are put in the Model using a @ModelAttribute annotated method on the class level.

THe problem is with the binding of the box to the lookup property fields.

For example the Category field is of the ActivityCategory type, which is an entity class containing an id and a description property.

In the view, the select box is filled with the list of possible options (allCategories which contains ActivityCategory instances), Thymeleaf takes care of selecting the current value by matching the "value" attribute value with the list:

<label>Categorie</label>
<select th:field="*{category}">
  <option th:each="cat : ${allCategories}"
          th:value="${cat}"
          th:text="${cat.description}">
  </option>
</select>

The generated HTML looks like:

<select id="category" name="category">
      <option value="nl.drsklaus.activiteitensite.model.lookup.ActivityCategory@20">Actief en sportief</option>
      <option value="nl.drsklaus.activiteitensite.model.lookup.ActivityCategory@21">Uitgaan en nachtleven</option>
      <option value="nl.drsklaus.activiteitensite.model.lookup.ActivityCategory@22" selected="selected">Kunst en cultuur</option>
      <option value="nl.drsklaus.activiteitensite.model.lookup.ActivityCategory@23">Eten en drinken</option>
      <option value="nl.drsklaus.activiteitensite.model.lookup.ActivityCategory@24" selected="selected">Ontspanning en gezelligheid</option>
</select>

As we see, the value attributes contain a string representation of the object itself which is clearly not desired, to show the id values we could use ${cat.id} instead of ${cat} but then the selection of the current value (setting the 'selected="selected"' attribute) does not work anymore. THerefore I implemented a Converter which converts an ActivityCategory object to an int (the id value). In Thymeleaf, the converter is called by using the double accolades {{}}:

th:value="${{cat}}"

THe converter is created and added to Spring:

public class LookupConverter implements Converter<LookupEntity, String> {
  public String convert(LookupEntity source) {
     return String.valueOf(source.getId());
  }
}

//In MvcConfig class

@Override
public void addFormatters(FormatterRegistry registry) {
  registry.addConverter(new LookupConverter());
}

Now the HTML shows the id values for the options, which is much more logical:

<select id="category" name="category">
      <option value="1">Actief en sportief</option>
      <option value="2">Uitgaan en nachtleven</option>
      <option value="3" selected="selected">Kunst en cultuur</option>
      <option value="4">Eten en drinken</option>
      <option value="5">Ontspanning en gezelligheid</option>
</select>

But it still wrong after submitting, the id value cannot be bound to the Activity object which expects a ActivityCategory instead if an integer value, so a typeMismatch validation error is generated.

My handler method looks like:

@RequestMapping(value = "/{id}/submit", method = RequestMethod.POST)
public String submitForm(@ModelAttribute("activity") Activity activity, BindingResult result, ModelMap model) {

    if (result.hasErrors()) {
        return "activityform";
    } else {

        if (activity.getId() == null) {
            this.service.saveActivity(activity);
        } else {
            this.service.mergeWithExistingAndUpdate(activity);
        }

        return "redirect:/activity/" + activity.getId() + "/detail";
    }
}

I have looked at many posts but still found have no solution for this IMHO pretty trivial issue. How can the String value containing the id be accepted by the handler method and properly converted? Or can we not use the id value for this purpose? Looking for some hints...

回答1:

I think you can't use your entity model to submit data from your form to the MVC controller. Try to create a separate form object that matches the form data and write a service method to translate it to entities that can be persisted in the database.



回答2:

With help from another forum I have found the most elegant solution! Instead of a Converter, we use a Formatter which can convert from specfiec Object type to a String and vice versa. The formatter is registered to Spring and automatically called from Thymeleaf and converts the id field to an ActivityCategory instance with only the id value set. So we do not lookup the actual instance from the database because we do not need the description here, for Hober ate the id is enough to create the query.

My formatter looks like:

public class ActivityCategoryFormatter implements Formatter<ActivityCategory> {

@Override
public String print(ActivityCategory ac, Locale locale) {
    // TODO Auto-generated method stub
    return Integer.toString(ac.getId());
}

@Override
public ActivityCategory parse(final String text, Locale locale) throws ParseException {
    // TODO Auto-generated method stub
    int id = Integer.parseInt(text);
    ActivityCategory ac = new ActivityCategory(id);

    return ac;
}
} 

and is registered to Spring (together with the ActivityRegionFormatter for the other lookup field) by:

@Override
public void addFormatters(FormatterRegistry registry) {
//registry.addConverter(new LookupConverter());
registry.addFormatter(new ActivityCategoryFormatter());
registry.addFormatter(new ActivityRegionFormatter());
}

And now it works as expected!

The only remaining issue is that we have some code duplication because the two Formatter classes are almost the same, they only differ in the generic class that is passed in. I tried to solve this by using a common interface LookupEntity which is implemented by the two lookup entity classes (ActivityCategory and RegionCategory) and use this common interface to define the formatter but unfortunately that did not work...