1
1

More than 3 years have passed since last update.

SpringでフォームにMapを使うとLinkedHashMapであってもサブミット時に順序が保証されない

Posted at

SpringでフォームにMapのフィールドを作って使う場合、LinkedHashMapを使って順序を指定したつもりでも、その値をサブミットすると順番が入れ替わってしまった。

要素技術

  • Java
  • Spring
  • Thymeleaf

発生した事象

01_初期表示.PNG
このとき submit ボタンをクリックすると以下のような画面になる。

02_サブミット後.PNG

「spring」と「java」が入れ替わっているだと・・・

作成したコード(ダメなコード)

■ コントローラ
ポイントはinitメソッドで
1. LinkedHashMapを使ってMapを作成しており、順序を意識(しようと)している
2. model.addAttributeでフォームをモデルに登録しているところ

@RequestMapping("/contents/convertor/app")
@Controller
public class ConvertorController {

    private ConvertorForm form;

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

    /**
     * 初期表示用
     */
    @GetMapping
    public void init(Model model) {
        Map<String, String> map = new LinkedHashMap<String, String>(); // LinkedHashMapで順番に登録しようと試みる 
        map.put("spring", "SPRING");
        map.put("java", "JAVA");
        form.setMap(map);
        model.addAttribute("convertorForm", form); // フォームをモデルに登録
    }

    /**
     * サブミットしたときに動作する。
     */
    @PostMapping
    public void submit(Model model, ConvertorForm form) {
    }
}

■ フォーム(Mapだけを持つSessionScopeのBean)

@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>

解決方法

Controller を以下のように修正する。
修正箇所は model.addAttribute でモデルに map を追加していた箇所を@ModelAttribute で設定するだけ。
@ModelAttributeを使うことで、モデルに属性を追加できることは知っていたのですがmodel.addAttributeとの違いは登録するタイミングだけの問題で大差ないと思っておりました。以下解説が続きますので、是非ご一読ください。

@Controller
public class ConvertorController {

    private ConvertorForm form;

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

    /**
     * 追加箇所
     */
    @ModelAttribute
    public ConvertorForm setup() {
        return form;
    }

    /**
     * 初期表示用
     * @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);
//      ↓を削除して@ModelAttributeにする
//      model.addAttribute("convertorForm", form); // フォームをモデルに登録
    }

    /**
     * サブミットしたときに動作する。
     *
     * @param model
     * @param form
     */
    @PostMapping
    public void submit(Model model, ConvertorForm form) {
    }
}

解説(簡易版)

Springにおいて、リクエストで値が送られてきたときにどのようにフォームにマッピングされるのかが非常に重要になってくる。
submit ボタンをクリックしたときに、送られてきたリクエストからコントローラのsubmitメソッドの引数であるConvertorFormを生成しようとする。この時以下のような動作をする。

修正前

  1. Formがない!Formを新たに作成しよう!
  2. 当然Mapもないから作成しよう!
  3. mapに入れる値はput しておこう!どうせ詰めるなら、リクエストで来た値はソートして Map に格納してあげよう!

結果、「spring」と「java」はソートされるので順序が入れ替わっている。

修正後

  1. @ModelAttribute でモデルに Autowired したFormを詰めよう!
  2. Form がある。よし、何もしなくて良いな!
  3. Map もある。よし、何もしなくて良いな!
  4. map に入れる値は put しておこう!

結果、「spring」と「java」は put されるだけであるため順序が入れ替わらない。

当たり前かもしれないが、@ModelAttribute は値をバインドする前に動作する。これが肝になっていた。
はまっているときはこういうところに気がつかないんだよなぁ・・・

解説(もう少し詳細)

私個人的には、Map への格納はリクエストで来た順。つまりServletRequest に格納されている順序でSpringさんが勝手にput していってくれるものとばかり考えていたた。
そこで、そもそもなぜソートされるのか?なぜリクエストの順序ではないのか?が気になったため調査してみた。

Spring で値のバインドは org.springframework.web.bind.ServletRequestDataBinder.bindで行われている。

public void bind(ServletRequest request) {
    MutablePropertyValues mpvs = new ServletRequestParameterPropertyValues(request); // ← ここ
    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);
}

この bind メソッドの 1 行目 ServletRequestParameterPropertyValues のコンストラクタを少したどるとは以下のような実装になっている。
コメントで記載しているが、request.getParameterしたものを 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<>(); // ← 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;
}

うーーーーん個人的にはソートされない方が直感的なのですが、どうなのでしょうか?

どなたかご意見ある方教えてください。。。。

総括

  • 実行順は当たり前だが、@ModelAttribute⇒DataBinder⇒ コントローラメソッド(model.addAttribute)
  • 基本的に@ModelAttribute を使う方が無難な気がする
  • @InitBinder とも関連があるため要注意(そのうち記事を書くかも)
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1