[JAVA] When using Map for form in Spring, order is not guaranteed when submitting even LinkedHashMap

When creating a Map field in a form in Spring and using it, even if I intended to specify the order using LinkedHashMap, the order was changed when I submitted the value.

Element technology

Event that occurred

01_初期表示.PNG At this time, if you click the submit button, the following screen will appear.

02_サブミット後.PNG

If "spring" and "java" are swapped ...

Created code (bad code)

■ Controller The point is the init method

  1. I am creating a Map using LinkedHashMap and am aware of the order.
  2. The form is registered in the model with model.addAttribute
@RequestMapping("/contents/convertor/app")
@Controller
public class ConvertorController {

	private ConvertorForm form;

	@Autowired
	public ConvertorController(ConvertorForm form){
		this.form = form;
	}

	/**
	 *For initial display
	 */
	@GetMapping
	public void init(Model model) {
		Map<String, String> map = new LinkedHashMap<String, String>(); //Try to register in order with LinkedHashMap
		map.put("spring", "SPRING");
		map.put("java", "JAVA");
		form.setMap(map);
		model.addAttribute("convertorForm", form); //Register form in model
	}

	/**
	 *Works when submitted.
	 */
	@PostMapping
	public void submit(Model model, ConvertorForm form) {
	}
}

■ Form (SessionScope bean with only Map)

@Component
@SessionScope
public class ConvertorForm {
	private Map<String, String> map;

	public Map<String, String> getMap() {
		return map;
	}
	public void setMap(Map<String, String> map) {
		this.map = map;
	}
}

■ HTML(Thymeleaf)

<form th:action="@{/contents/convertor/app}" method="post" th:object="${convertorForm}">
  <th:block th:each="element : *{map}">
    <span th:text="${element.getKey()}"></span>
    <input type="text" th:field="${convertorForm.map[__${element.getKey()}__]}"/>
    <br />
  </th:block>
  <button>submit</button>
</form>

Solutions

Modify the Controller as follows. To modify, just set the part where map was added to the model with model.addAttribute with @ModelAttribute. I knew that I could add attributes to the model by using @ModelAttribute, but I thought that the difference from model.addAttribute was not so different because of the timing of registration. The explanation will continue below, so please read it.

@Controller
public class ConvertorController {

	private ConvertorForm form;

	@Autowired
	public ConvertorController(ConvertorForm form){
		this.form = form;
	}

	/**
	 *Additional points
	 */
	@ModelAttribute
	public ConvertorForm setup() {
		return form;
	}

	/**
	 *For initial display
	 * @param model
	 */
	@GetMapping
	public void init(Model model) {
		Map<String, String> map = new LinkedHashMapCustom<String, String>();
		map.put("spring", "SPRING");
		map.put("java", "JAVA");
		form.setMap(map);
//Delete ↓@Make it a Model Attribute
//		model.addAttribute("convertorForm", form); //Register form in model
	}

	/**
	 *Works when submitted.
	 *
	 * @param model
	 * @param form
	 */
	@PostMapping
	public void submit(Model model, ConvertorForm form) {
	}
}

Explanation (simplified version)

In Spring, how it is mapped to the form when the value is sent in the request becomes very important. When the submit button is clicked, it tries to generate ConverterForm which is an argument of the submit method of the controller from the sent request. At this time, the following operation is performed.

Before correction

  1. There is no Form! Let's create a new form!
  2. Of course there is no Map, so let's create it!
  3. Put the value to put in the map! If you want to pack it anyway, sort the values ​​that come in the request and store them in the Map!

As a result, "spring" and "java" are sorted, so the order is changed.

Revised

  1. Fill the model with Autowired Form with @ModelAttribute!
  2. There is a Form. Alright, you don't have to do anything!
  3. There is also a Map. Alright, you don't have to do anything!
  4. Put the value to put in the map!

As a result, "spring" and "java" are only put, so the order is not changed. Obviously, @ModelAttribute works before binding the value. This was the key. When I'm addicted, I don't notice this kind of thing ...

Commentary (a little more detail)

Personally, the storage in Map is in the order of request. In other words, I just thought that Spring would put it in the order stored in ServletRequest. So why is it sorted in the first place? Why not the order of requests? I was curious about it, so I investigated it.

In Spring, value binding is done in org.springframework.web.bind.ServletRequestDataBinder.bind.

public void bind(ServletRequest request) {
    MutablePropertyValues mpvs = new ServletRequestParameterPropertyValues(request); //← here
    MultipartRequest multipartRequest = WebUtils.getNativeRequest(request, MultipartRequest.class);
    if (multipartRequest != null) {
        bindMultipart(multipartRequest.getMultiFileMap(), mpvs);
    }
    else if (StringUtils.startsWithIgnoreCase(request.getContentType(), "multipart/")) {
        HttpServletRequest httpServletRequest = WebUtils.getNativeRequest(request, HttpServletRequest.class);
        if (httpServletRequest != null) {
            StandardServletPartUtils.bindParts(httpServletRequest, mpvs, isBindEmptyMultipartFiles());
        }
    }
    addBindValues(mpvs, request);
    doBind(mpvs);
}

Following the constructor of ServletRequestParameterPropertyValues ​​on the first line of this bind method is implemented as follows. Although it is described in the comment, it seems that it is sorted because the request.getParameter is stored in TreeMap.

public static Map<String, Object> getParametersStartingWith(ServletRequest request, @Nullable String prefix) {
    Assert.notNull(request, "Request must not be null");
    Enumeration<String> paramNames = request.getParameterNames();
    Map<String, Object> params = new TreeMap<>(); //← I'm using TreeMap! !! !! !!
    if (prefix == null) {
        prefix = "";
    }
    while (paramNames != null && paramNames.hasMoreElements()) {
        String paramName = paramNames.nextElement();
        if (prefix.isEmpty() || paramName.startsWith(prefix)) {
            String unprefixed = paramName.substring(prefix.length());
            String[] values = request.getParameterValues(paramName);
            if (values == null || values.length == 0) {
                // Do nothing, no values found at all.
            }
            else if (values.length > 1) {
                params.put(unprefixed, values);
            }
            else {
                params.put(unprefixed, values[0]);
            }
        }
    }
    return params;
}

Hmmm, personally, it's more intuitive not to sort, but what about? Please let me know if anyone has any opinions. .. .. ..

Summary

--The execution order is natural, but @ModelAttribute⇒DataBinder⇒Controller method (model.addAttribute) --Basically, I think it's safer to use @ModelAttribute. -Be careful because it is related to @InitBinder (maybe I will write an article soon)

Recommended Posts

When using Map for form in Spring, order is not guaranteed when submitting even LinkedHashMap
Initial value when there is no form object property in Spring request
When submitting form, no error message is displayed even though validation failed
When the project is not displayed in eclipse
When Spring Batch is executed continuously in Oracle, ORA-08177
How to solve the problem when the value is not sent when the form is disabled in rails and sent