今回は、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を検索し、更新するアプリを作成します。
検索するエンティティ
@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を利用することで省略しています。
実装するユースケース
/todos 検索画面を表示 [1]
-> /todo/{todoId} 詳細画面を表示 [2]
-> /todos/{todoId}?finish Todoを完了する -> redirect:/todos/{todoId} [2]詳細画面を再表示
-> /todos/{todoId}?delete Todoを削除する -> redirect:/todos [1]検索画面を再表示
検索画面から詳細画面を経由して、検索画面を再表示するとき、同じ条件で検索結果を表示します。
検索画面では表示件数を制限し、ページネーションします。
[1] /todos -> FindController
[2] /todos/{todoId} -> DetailsController
[1]と[2]は別ユースケースと捉え、上記のようにコントローラを分けます。
ベースとなるアプリを実装する
まずはベースとなる、検索条件を引き継げないアプリを実装します。
検索画面の実装
- Form
@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)で検索条件を扱うクラス
@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
@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
<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が、検索条件を引き継ぐポイントになります。
詳細画面の実装
@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
<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
を利用した引き継ぎ)
各方法に共通する実装
- ページ情報を保持するクラス
@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
<!-- (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パラメータに展開します。
todoTitle=hoge&createdAt=2000-01-01&finished=false&page=1&size=10
のようなURLパラメータに展開されます。
(2) 生成したfindCondition
変数を詳細画面のURLに付与します。
詳細画面の変更
- Controller(Todoの表示)
@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
<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の完了・削除)
@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
<!-- (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の表示)
@GetMapping
public String details(@PathVariable("todoId") String todoId, Model model) {
model.addAttribute("todo", todoService.findOne(todoId));
return "model/details";
}
FindForm
(検索条件)とPageInfo
(ページ情報)を受け取る引数を追加する必要はありません。
- JSP
<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
@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の削除)
@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
を期待通りに引き継ぐことができませんでした。
まとめ
今回は、検索画面で入力した検索条件やページ情報を引き継ぐ方法をまとめてみました。
実装の参考になると嬉しいです!