Spring MVC + JSP で検索画面の検索条件やページ情報を引き継ぐ方法

今回は、Spring MVC(TERASOLUNA 5)アプリで、検索画面で入力した検索条件やページ情報を引き継ぐ方法を紹介しようと思います。

よくあるケースですが、紹介しているサイトが見つからなかったので記事にしました。
実装例では、TERASOLUNA 5の機能をがっつり使いますので、Spring MVCアプリにアレンジするときは適宜読み替えてください。

> 2019/11/07 URLパラメータの組み立てにTERASOLUNAの`f:query`とDozerの併用をやめ、`f:query`のみを利用するよう変更しました。

## 今回使用するライブラリ

* TERASOLUNA GFW 5.4.1.RELEASE
  - Spring MVC 4.3.14.RELEASE
  - Spring Data Commons 1.13.7.RELEASE

> 今回はフロント部分の説明に終始するため、バックエンド（O/R Mapper）には言及しません。

## アプリの仕様

例として、Todoを検索し、更新するアプリを作成します。

### 検索するエンティティ

```java
@Data
public class Todo implements Serializable {
    private static final long serialVersionUID = 1L;
    private String todoId;
    private String todoTitle;
    private boolean finished;
    private Date createdAt;
}
```

`todoId`はシステム内部項目です。
`todoTitle`、`finished`、`createdAt`はユーザが意識する項目で、検索条件とします。

> Getter、SetterなどはLombokを利用することで省略しています。

### 実装するユースケース

```text
/todos 検索画面を表示 [1]
 -> /todo/{todoId} 詳細画面を表示 [2]
     -> /todos/{todoId}?finish Todoを完了する -> redirect:/todos/{todoId} [2]詳細画面を再表示
     -> /todos/{todoId}?delete Todoを削除する -> redirect:/todos [1]検索画面を再表示
```

検索画面から詳細画面を経由して、検索画面を再表示するとき、同じ条件で検索結果を表示します。
検索画面では表示件数を制限し、ページネーションします。

```text
[1] /todos -> FindController
[2] /todos/{todoId} -> DetailsController
```

[1]と[2]は別ユースケースと捉え、上記のようにコントローラを分けます。

## ベースとなるアプリを実装する

まずはベースとなる、検索条件を引き継げないアプリを実装します。

### 検索画面の実装

* Form

```java
@Data
public class FindForm implements Serializable {
    private static final long serialVersionUID = 1L;
    @Size(max = 10)
    private String todoTitle;
    private Boolean finished;
    @DateTimeFormat(iso = ISO.DATE)
    @Past
    private Date createdAt;
}
```

検索画面で入力する検索条件をバインドするためのFormクラスです。
Todoクラスから検索条件となる項目のみ抜き出しています。

* バックエンド（Service、Repository）で検索条件を扱うクラス

```java
@Data
public class FindCondition implements Serializable {
    private static final long serialVersionUID = 1L;
    private String todoTitle;
    private Boolean finished;
    private Date createdAt;
}
```

FormはそのままServiceに渡したくないので、バックエンド連携用のConditionクラスを作ります。
項目はFormと同じです。

* Controller

```java
@Controller
@RequestMapping("/todos")
public class FindController {

    @Autowired
    private Mapper mapper;

    @Autowired
    private TodoService todoService;

    @GetMapping
    public String find(@Validated FindForm form, BindingResult bindingResult,
            @PageableDefault Pageable pageable, Model model) {

        if (bindingResult.hasErrors()) {
            return "todos/list";
        }

        FindCondition condition = mapper.map(form, TodoFindCondition.class);
        model.addAttribute("page", todoService.findAllByCondition(condition, pageable));
        return "todos/list";
    }
}
```

コントローラでは、入力フォーム（検索条件）のチェックと、Todoの検索を行います。
表示件数の制限（取得件数の制限）はバックエンド側で実施し、コントローラはModelに`Page<Todo>`オブジェクトを登録します。

> FormからConditionへのコピーには、Dozerを利用しています。
> ModelMapperとか使ってもやることは同じです。

* JSP

```html
<div id="wrapper">
  <h1>Find Todos</h1>
  <fieldset>
    <legend>Find Condition</legend>
    <form:form method="get" modelAttribute="findForm">
      <div>
        <form:label path="todoTitle">Title</form:label>
        <form:input path="todoTitle" />
        <form:errors path="todoTitle" />
      </div>
      <div>
        <form:label path="createdAt">CreatedAt</form:label>
        <form:input path="createdAt" />
        <form:errors path="createdAt" />
      </div>
      <div>
        <label>Status</label>
        <div>
          <form:radiobutton path="finished" value="" label="All" />
          <form:radiobutton path="finished" value="true" label="Finished" />
          <form:radiobutton path="finished" value="false" label="Working" />
          <form:errors path="finished" />
        </div>
      </div>
      <form:button>Find</form:button>
    </form:form>
  </fieldset>
  <hr>
  <fieldset>
    <legend>Todos by Condition</legend>
    <c:choose>
      <c:when test="${page != null && page.totalPages != 0}">
        <t:pagination page="${page}" criteriaQuery="${f:query(findForm)}" />
        <table>
          <thead>
            <tr>
              <th>No.</th>
              <th>Title</th>
              <th>Created</th>
              <th>Status</th>
            </tr>
          </thead>
          <tbody>
            <c:set var="baseCount" value="${page.number * page.size}" />
            <c:forEach var="todo" items="${page.content}"
              varStatus="status">
              <tr>
                <td>${baseCount + status.count}</td>
                <!-- (1) -->
                <td><a href="${pageContext.request.contextPath}/todos/${todo.todoId}">${f:h(todo.todoTitle)}</a></td>
                <td><fmt:formatDate value="${todo.createdAt}" pattern="yyyy-MM-dd" /></td>
                <td>${todo.finished ? 'Finished.' : 'Working.'}</td>
              </tr>
            </c:forEach>
          </tbody>
        </table>
      </c:when>
      <c:otherwise>
        Not found...
      </c:otherwise>
    </c:choose>
  </fieldset>
</div>
```

画面には、入力フォーム（検索条件）とテーブル（検索結果）を表示します。
ページネーション（ページ切り替え）には、TERASOLUNA 5の`t:pagination`タグを利用しています。
ページネーションリンクのURLで検索条件を引き継ぐため、TERASOLUNA 5の`f:query`を利用しています。

**(1)リンクで詳細画面（`/todos/{todoId}`）に遷移します。ここで指定するURLが、検索条件を引き継ぐポイントになります。**

### 詳細画面の実装

```java
@Controller
@RequestMapping("/todos/{todoId}")
public class DetailsController {

    @Autowired
    private TodoService todoService;

    // (1)
    @GetMapping
    public String details(@PathVariable("todoId") String todoId, Model model) {
        model.addAttribute("todo", todoService.findOne(todoId));
        return "todos/details";
    }

    // (2)
    @PostMapping(params = "finish")
    public String finish(@PathVariable("todoId") String todoId) {
        todoService.finish(todoId);
        return "redirect:/todos/{todoId}";
    }

    // (3)
    @PostMapping(params = "delete")
    public String delete(@PathVariable("todoId") String todoId) {
        todoService.delete(todoId);
        return "redirect:/todos";
    }
}
```

(1) Todoを取得して表示します。
(2) Todoを完了し、詳細画面にリダイレクトします。
(3) Todoを削除し、検索画面にリダイレクトします。

* JSP

```html
<div id="wrapper">
  <h1>Todo Details</h1>
  <fieldset>
    <legend>Details</legend>
    <table>
      <tbody>
        <tr>
          <th>Title</th>
          <td>${f:h(todo.todoTitle)}</td>
        </tr>
        <tr>
          <th>Created</th>
          <td><fmt:formatDate value="${todo.createdAt}" pattern="yyyy-MM-dd" /></td>
        </tr>
        <tr>
          <th>Status</th>
          <td>${todo.finished ? 'Finished.' : 'Working.'}</td>
        </tr>
      </tbody>
    </table>
    <form method="post">
      <sec:csrfInput />
      <button name="finish" ${todo.finished ? 'disabled' : ''}>Finish</button>
      <button name="delete">Delete</button>
    </form>
  </fieldset>
</div>
```

画面では、Todoの内容と、完了・削除ボタンの表示を行います。

### アプリの動作確認

検索画面 -> 詳細画面 -> 完了 -> 削除 -> 検索画面

と進むと、最後の検索画面には検索条件、ページ情報が引き継がれません。
引き継いでいないのだから当然ですが。

## 検索条件とページ情報を引き継ぐ

それでは、本題の検索条件とページ情報の引き継ぎを行いましょう。
今回は、以下の方法を紹介します。

* リクエストでの引き継ぎ（`Model`を利用した引き継ぎ）
* リクエストでの引き継ぎ（`Model`を利用しない引き継ぎ）
* セッションでの引き継ぎ（`@SessionAttributes`を利用した引き継ぎ）

### 各方法に共通する実装

* ページ情報を保持するクラス

```java
@Data
@NoArgsConstructor
public class PageInfo implements Serializable {
    private static final long serialVersionUID = 1L;
    private Integer page;
    private Integer size;
    private String sort;

    public PageInfo(Pageable pageable) {
        this.page = pageable.getPageNumber();
        this.size = pageable.getPageSize();
        this.sort = pageable.getSort() == null ? null : pageable.getSort().toString();
    }
}
```

リクエストパラメータからページ情報をバインドするためのクラスを作成します。
このクラスは必ず作らなければならないわけではありませんが、あると便利です。
ポイントは以下のとおりです。

* ハンドラメソッド引数として、リクエストパラメータからバインドできる
* TERASOLUNA 5の`f:query`でURLパラメータに展開できる
* `Pageable`から生成できる

なお、`page`、`size`、`sort`の中から引き継ぐ必要のあるプロパティのみ定義すると良いです。
今回のアプリでは、`sort`プロパティは不要です。

> リクエストパラメータからバインドするだけであれば`Pageable`で良いのですが、以下の点からJSPへの引き継ぎが面倒でした。
> * `Pageable`のプロパティ名がリクエストパラメータ名（`page`、`size`、`sort`）と異なる（`pageNumber`、`pageSize`など）
> * ハンドラメソッドの`Pageable`引数が自動的に`Model`に登録されない
> * ページネーションを行わない画面のハンドラメソッドで`Pageable`引数を生成するのはファット

### リクエストでの引き継ぎ（`Model`を利用した引き継ぎ）

`Model`を利用して、リクエストで検索条件とページ情報を引き継ぎます。
ポイントは以下のとおりです。

* JSPで、`Model`からURLに検索条件とページ情報を付与する
* コントローラで、リクエストパラメータから`Model`にURLに検索条件とページ情報を登録する

#### 検索画面の変更

* JSP

```html
<!-- (1) -->
<c:set var="findCondition" value="${f:query(findForm)}&page=${page.number}&size=${page.size}" />
<c:forEach var="todo" items="${page.content}" varStatus="status">
  <tr>
    <td>${baseCount + status.count}</td>
    <!-- (2) -->
    <td><a href="${pageContext.request.contextPath}/todos/${todo.todoId}?${findCondition}">${f:h(todo.todoTitle)}</a></td>
    <td><fmt:formatDate value="${todo.createdAt}" pattern="yyyy-MM-dd" /></td>
    <td>${todo.finished ? 'Finished.' : 'Working.'}</td>
  </tr>
</c:forEach>
```

詳細画面のURLに検索条件とページ情報を付与します。

**(1) 検索条件とページ情報を連結して`findCondition`変数に格納します。**
`Model`に登録した`FindForm`（検索条件）を、TERASOLUNA 5の`f:query`を利用してURLパラメータに展開します。
`Model`に登録した`Page`（ページオブジェクト）から、ページ番号とページサイズをURLパラメータに展開します。

```text
todoTitle=hoge&createdAt=2000-01-01&finished=false&page=1&size=10
```
のようなURLパラメータに展開されます。

**(2) 生成した`findCondition`変数を詳細画面のURLに付与します。**

#### 詳細画面の変更

* Controller（Todoの表示）

```java
    @GetMapping
    public String details(@PathVariable("todoId") String todoId, Model model,
            FindForm form, BindingResult ignoreResult,
            PageInfo pageInfo, BindingResult ignoreResult2) {
        model.addAttribute("todo", todoService.findOne(todoId));
        return "model/details";
    }
```

`FindForm`（検索条件）と`PageInfo`（ページ情報）を受け取る引数を追加します。
引数に指定したオブジェクトが、自動的に`Model`に登録されます。

**各引数の後に`BindingResult`引数を追加することがポイントです。**
`BindingResult`引数がない場合、リクエストパラメータのバインドや入力チェックでNGになると、`BindException`（400エラー）になってしまうため、必ず追加しましょう。URLにパラメータが露呈している以上、自由に変更される可能性を考慮しましょう。

> `BindingResult`は一つだけしか引数にセットできないと思われがちですが、リクエストパラメータをバインドする変数ごとにセットする必要があります。`BindingResult`はそれぞれ前引数のエラーを格納します。
> これを利用して、遷移元のFormはエラーを無視し、遷移先のFormだけエラーチェックすることも可能です。

> `PageInfo`クラスを利用しない場合、`@RequestParam("page") Integer page, BindingResult br`というかたちで、すべてのページ情報を個別に引数に追加する必要があります。

* JSP

```html
<c:set var="findCondition" value="${f:query(findForm)}&${f:query(pageInfo)}" />
<form method="post" action="${pageContext.request.contextPath}/todos/${todo.todoId}?${findCondition}">
```

Todoの完了・削除フォームのURLに検索条件とページ情報を付与します。

**検索画面と同様に検索条件とページ情報を連結して`findCondition`変数に格納します。**
`Model`に登録した`FindForm`（検索条件）を、TERASOLUNA 5の`f:query`を利用してURLパラメータに展開します。
`Model`に登録した`PageInfo`（ページ情報）を、TERASOLUNA 5の`f:query`を利用してURLパラメータに展開します。

**ここでのポイントは、フォームの隠しフィールドではなくURLパラメータで引き継ぎを行なっている点です。**
URLやフォームなど引き継ぐ方法がバラバラだと、実装が複雑になりメンテナンス性や機能性を悪化させる恐れがあります。一貫性を重視すると良いです。

> `PageInfo`クラスを利用しない場合、`page=\${page}&size=\${size}&sort=${sort}というかたちで、すべてのページ情報を個別にURLパラメータに展開する必要があります。

* Controller（Todoの完了・削除）

```java
    @PostMapping(params = "finish")
    public String finish(@PathVariable("todoId") String todoId,
            // (1)
            FindForm form, BindingResult ignoreResult,
            PageInfo pageInfo, BindingResult ignoreResult2) {

        todoService.finish(todoId);

        // (2)
        return "redirect:/todos/{todoId}?" + String.join("&", Functions.query(pageInfo), Functions.query(form));
    }

    @PostMapping(params = "delete")
    public String delete(@PathVariable("todoId") String todoId,
            FindForm form, BindingResult ignoreResult,
            PageInfo pageInfo, BindingResult ignoreResult2) {

        todoService.delete(todoId);

        return "redirect:/todos?" + String.join("&", Functions.query(pageInfo), Functions.query(form));
    }
```

Todoの完了・削除はリダイレクトのためコントローラでURLを構築する必要があり、ちょっと面倒です。

**ここでのポイントは、TERASOLUNA 5の`Functions#query`を利用してリダイレクトURLに検索条件とページ情報を引き継いでいる点です。`Functions#query`はJSPにおける`f:query`と同じです。**

ページ情報に`Pageable`ではなく`PageInfo`を利用することで、URLパラメータに流用しやすくしています。

> 以前の記事では、`FindForm`（検索条件）と`PageInfo`（ページ情報）から`Map`へ変換には、Dozerを利用していました。Date型の項目は`toString`で文字列に変換されるため、回避策を考える必要があり面倒でした。

### リクエストでの引き継ぎ（`Model`を利用しない引き継ぎ）

`Model`を利用せずに、リクエストパラメータのみで検索条件とページ情報を引き継ぎます。

> `Model`を利用した引き継ぎに比べ、JSPやコントローラの実装がより簡素になりますが、適用できるケースが限られる点に注意が必要です。

ポイントは以下のとおりです。

* JSPで、リクエストパラメータからURLに検索条件とページ情報を付与する

#### 検索画面の変更

* JSP

```html
<!-- (1) -->
<c:set var="findCondition" value="${f:query(param)}" />
<c:forEach var="todo" items="${page.content}" varStatus="status">
  <tr>
    <td>${baseCount + status.count}</td>
    <!-- (2) -->
    <td><a href="${pageContext.request.contextPath}/todos/${todo.todoId}?${findCondition}">${f:h(todo.todoTitle)}</a></td>
    <td><fmt:formatDate value="${todo.createdAt}" pattern="yyyy-MM-dd" /></td>
    <td>${todo.finished ? 'Finished.' : 'Working.'}</td>
  </tr>
</c:forEach>
```

詳細画面のURLに検索条件とページ情報を付与します。

**元リクエストのパラメータを`findCondition`変数に格納します。**
`param`変数を、TERASOLUNA 5の`f:query`を利用してURLパラメータに展開します。

**ここでのポイントは、リクエストパラメータを個別に連結するのではなく、まとめて展開している点です。**
元リクエストのパラメータに、URLに利用したくないものがある場合は、個別に連結する必要がありますので、`Model`を利用して引き継ぎを行なうほうが楽かもしれません。

#### 詳細画面の変更

* Controller（Todoの表示）

```java
    @GetMapping
    public String details(@PathVariable("todoId") String todoId, Model model) {
        model.addAttribute("todo", todoService.findOne(todoId));
        return "model/details";
    }
```

`FindForm`（検索条件）と`PageInfo`（ページ情報）を受け取る引数を追加する必要はありません。

* JSP

```html
<c:set var="findCondition" value="${f:query(param)}" />
<form method="post" action="${pageContext.request.contextPath}/todos/${todo.todoId}?${findCondition}">
```

Todoの完了・削除フォームのURLに検索条件とページ情報を付与します。

**検索画面と同様に元リクエストのパラメータを`findCondition`変数に格納します。**
`param`変数を、TERASOLUNA 5の`f:query`を利用してURLパラメータに展開します。

ここでも、元リクエストのパラメータに、URLに利用したくないものがある場合は、個別に連結する必要があります。
詳細画面の入力値をURLパラメータで送信する場合は、特に注意が必要です。

* Controller（Todoの完了・削除）

Todoの完了・削除はリダイレクトを伴うため、「`Model`を利用した引き継ぎ」と同様になります。

### セッションでの引き継ぎ（`@SessionAttributes`を利用した引き継ぎ）

`@SessionAttributes`を利用することで、検索条件とページ情報をセッションに保存し引き継ぎます。

> リクエストでの引き継ぎに比べ、URLに検索条件などが露呈しないため、より安全に引き継ぐことが可能です。
> ただし、保存したデータはセッション上に残り続けるため、保存すべきデータやユースケースが多い、利用するユーザ数が多い場合など、**メモリを圧迫する可能性**があります。データ量やユーザ数を考慮して適切にメモリ容量を計算しましょう。

ポイントは以下のとおりです。

* Controllerで、検索条件とページ情報をセッションに保存する
* 検索画面に戻る際に、検索条件とページ情報をセッションから復元するパスを用意する

#### 検索画面の変更

* Controller

```java
@Controller
@RequestMapping("/todos")
@SessionAttributes({ "condition", "pageInfo" }) // (1)
public class SessionBindFindController {

    @Autowired
    private Mapper mapper;

    @Autowired
    private TodoService todoService;

    @GetMapping
    public String find(@Validated FindForm form, BindingResult bindingResult,
            @PageableDefault Pageable pageable, Model model) {

        if (bindingResult.hasErrors()) {
            return "todos/list";
        }

        FindCondition condition = mapper.map(form, FindCondition.class);
        model.addAttribute("page", todoService.findAllByCondition(condition, pageable));
        // (2)
        model.addAttribute("condition", condition);
        model.addAttribute("pageInfo", new PageInfo(pageable));
        return "todos/list";
    }

    // (3)
    @GetMapping(params = "restore")
    public String restore(SessionStatus sessionStatus,
            @SessionAttribute("condition") TodoFindCondition condition,
            @SessionAttribute("pageInfo") PageInfo pageInfo) {

        sessionStatus.setComplete();

        return "redirect:/todos?" + String.join("&", Functions.query(pageInfo), Functions.query(form));
    }
}
```

検索条件とページ情報をセッションに保存します。

**(1) クラスレベルに`@SessionAttributes`を付与します。**
`@SessionAttributes`で指定した`condition`、`pageInfo`が`Model`に登録されると、自動的にセッションに保存されます。

**(2) 検索条件とページ情報を`Model`（セッション）に登録します。**
`FindForm`を直接セッションに保存すると、検索画面の初期表示時にセッションから検索条件が復元されてしまうので、コピーした`FindCondition`のほうをセッションに保存しています。

**(3) 検索条件とページ情報を復元するパス`/todos?restore`を新規に作成します。**
`@SessionAttribute`でセッションに保存した検索条件とページ情報を取得します。
`RedirectAttributes`で検索条件とページ情報をURLパラメータにセットしたうえで、リダイレクトして検索画面を表示します。
`@SessionAttributes`でセッションに保存した検索条件とページ情報は不要になるので、`SessionStatus#setComplete()`で削除します。

> `@SessionAttribute`で指定したオブジェクトがセッションに保存されていない場合、`ServletRequestBindingException`（400エラー）になります。通常、不正な遷移のため適切なエラー画面を表示しましょう。

> `@SessionAttribute`の代わりに`@ModelAttribute`でも良いですが、オブジェクトがセッションに保存されていない場合の例外が異なるので注意しましょう。

> 検索画面の初期表示前にセッションを初期化するのであれば、`FindForm`をセッションに保存しても良いですね。

#### 詳細画面の変更

* Controller（Todoの削除）

```java
    @PostMapping(params = "delete")
    public String delete(@PathVariable("todoId") String todoId) {
        todoService.delete(todoId);
        return "redirect:/todos?restore";
    }
```

検索画面に戻る際のURLを`/todos?restore`に変更するだけでOKです。

## 実装上の問題

今回は、TERASOLUNA 5の`f:query`を利用してオブジェクトをURLパラメータに展開しましたが、1点問題がありました。

実装例の`FindForm#finished`プロパティに`null`、`true`、`false`の3パターンが入力できるよう、`Boolean`型で定義しました。しかし、残念ながら`f:query`では`Boolean`型の`null`を期待通りに引き継ぐことができませんでした。

> 詳細は[github@terasolunaorg/terasoluna-gfw#846](https://github.com/terasolunaorg/terasoluna-gfw/issues/846)

## まとめ

今回は、検索画面で入力した検索条件やページ情報を引き継ぐ方法をまとめてみました。
実装の参考になると嬉しいです！