スポンサーサイト

上記の広告は1ヶ月以上更新のないブログに表示されています。
新しい記事を書く事で広告が消せます。

Spring MVC PRGパターンでリロード対策を行う

今回は、PRGパターンを使ってリロード対策をしてみたいと思う。

PRGは、POST、REDIRECT、GETの頭文字をそれぞれ取っており、重要なのは、この順番で処理を行う点にある。

実際に、PRGパターン適用前後でどうなるのかもあわせて確認してみたい。

◎動作検証にあたっての各バージョンは以下の通り
  • SpringFramework 3.2.8.RELEASE
  • Java 1.7
  • Tomcat 7.0

1.BookController.java 修正前(関連箇所のみ掲載)

@Controller
@RequestMapping("/book")
@SessionAttributes("bookSearchForm")
public class BookController {

private static Logger logger = LoggerFactory.getLogger(BookController.class);

@Value("${upload.fileSize}")
private int uploadFileSize;

@Autowired
protected BookService<BookEntity> bookService;

@RequestMapping(value = "/edit/update", method = RequestMethod.POST)
public String update(@Valid BookForm form, BindingResult result, Model model,
@RequestParam(value="uploadFile",required=false) MultipartFile file) {

logger.info("update start");
if (file.getSize() > uploadFileSize) {
result.rejectValue("cover", "error.fileSize",
new Object[]{uploadFileSize},"File size error");
}
if (result.hasErrors()) {
return "editBook";
}

if (!file.isEmpty()) {
byte[] fileContent = null;
InputStream is = null;
try {
is = file.getInputStream();
fileContent = IOUtils.toByteArray(is);
form.setCover(fileContent);
} catch (IOException e) {
e.printStackTrace();
} finally {
IOUtils.closeQuietly(is);
}
}

BookEntity entity = BookUtil.copyProperties(form);
//登録・編集の判定
if (entity.getBookId() > 0) {
bookService.editEntity(entity);
} else {
bookService.addEntity(entity);
}
model.addAttribute("book", entity);
return "result";
}

//他のメソッド省略
}

今回対象となるメソッドは、新規登録・編集画面の更新処理であるupdate(15行目)となる。現在このupdateメソッドはPOST(14行目)であり、入力チェックで問題なければ、DBの更新処理を行い、49行目の「return "result";」で結果画面を返却している。
これの何が問題となるのか、最初に「リロード対策」と記述したように、新規登録完了画面でF5キーを押下して、確認してみたい。

2.新規登録完了画面
①新規登録画面で「更新」ボタンを押下した後の新規登録完了画面
PRG_1.png

ユーザーの意図によって「更新」ボタンが押下され、新規登録されており、問題ない。

②そのまま新規登録完了画面で「F5」キーを押下
PRG_2.png

すでに登録が完了しているにも関わらず、誤って「F5」キーを押下してリロードした場合、IDがインクリメントされていることから分かるように、同じ内容のものが再び登録されている。

今回は、データの新規登録を例としているが、商品の購入画面などでも同様で、リロードによって同じ商品を購入する状況は避けなければならない。その対策手段として、PRGパターンを適用することとなる。

それでは、どうすればPRGパターンになるのか実装してみる。

3.BookController.java 修正後(関連箇所のみ掲載)

@Controller
@RequestMapping("/book")
@SessionAttributes("bookSearchForm")
public class BookController {

private static Logger logger = LoggerFactory.getLogger(BookController.class);

@Value("${upload.fileSize}")
private int uploadFileSize;

@Autowired
protected BookService<BookEntity> bookService;

@RequestMapping(value = "/edit/update", method = RequestMethod.POST)
public String update(@Valid BookForm form, BindingResult result, Model model,
@RequestParam(value="uploadFile",required=false) MultipartFile file) {

logger.info("update start");
if (file.getSize() > uploadFileSize) {
result.rejectValue("cover", "error.fileSize",
new Object[]{uploadFileSize},"File size error");
}
if (result.hasErrors()) {
return "editBook";
}

if (!file.isEmpty()) {
byte[] fileContent = null;
InputStream is = null;
try {
is = file.getInputStream();
fileContent = IOUtils.toByteArray(is);
form.setCover(fileContent);
} catch (IOException e) {
e.printStackTrace();
} finally {
IOUtils.closeQuietly(is);
}
}

BookEntity entity = BookUtil.copyProperties(form);
//登録・編集の判定
if (entity.getBookId() > 0) {
bookService.editEntity(entity);
} else {
bookService.addEntity(entity);
}
//修正前のソースをコメント化している
//model.addAttribute("book", entity);
//return "result";
return "redirect:/book/edit/" + entity.getBookId() + "/update";
}

@RequestMapping(value = "/edit/{id}/update", method = RequestMethod.GET)
public String show (@PathVariable("id") long bookId, BookForm form, Model model) {
logger.info("show start");
BookEntity entity = bookService.findById(bookId);
model.addAttribute("book", entity);
return "result";
}

//他のメソッド省略
}

変更点はupdateメソッドの49~51行目と新たに追加した54~60行目までのshowメソッドとなる。
最初にPOST→REDIRECT→GETの順番が重要と記載したが、その通りに実装している。修正前はPOSTのupdateメソッドで、結果画面を返却していたが、51行目のredirectで一回クライアントに返し、GETのshowメソッドで該当エンティティを取得するように変更している。DB更新処理までをupdateメソッドで行い、登録した情報をshowメソッドで取得するように分割した形となる。PRGパターンを適用することにより、登録したデータを再度取得する処理が増えることにはなるが、誤って「F5」キーを押下された場合でも、実行される処理は最後のshowメソッドとなり、データを取得するだけの安全サイドに倒すことができる。

4.新規登録完了画面
①新規登録画面で「更新」ボタンを押下した後の新規登録完了画面
PRG_3.png

URLが修正前と変わっていることが確認できる。

②そのまま新規登録完了画面で「F5」キーを押下
PRG_3.png

IDがインクリメントされることなく、同じデータが読み込まれているが分かる。

■過去のSpring関連記事
Spring関連記事 Index

スポンサーサイト

Spring MVC @SessionAttributesアノテーションとログアウト時のセッション破棄

今回は、Webシステムには欠かせない、セッションを扱ってみたいと思う。

セッションは、複数画面に渡って状態を保持したい場合に使用する。
使用例としてショッピングカートなどが挙げられるが、今回は、検索画面の一覧から編集画面を呼び出して、再び検索画面に戻っても、検索条件が保持されるようにしたいと思う。このような要件はよくあるのではないだろうか。

なお、今回の記事でベースとなっている過去の記事は以下の通り
■前回の記事
Spring MVC ログインユーザー権限によって表示を切り替える

◎動作検証にあたっての各バージョンは以下の通り
  • SpringFramework 3.2.8.RELEASE
  • Java 1.7
  • Tomcat 7.0

1.security-db.xml(関連箇所のみ掲載)

<sec:http auto-config="true">
<sec:intercept-url pattern="/book/list/**" access="ROLE_ADMIN,ROLE_USER"/>
<sec:intercept-url pattern="/book/edit/**" access="ROLE_ADMIN"/>
<sec:form-login login-page="/login.jsp" default-target-url="/book"
authentication-failure-url="/login.jsp?error=true"/>
<sec:logout logout-url="/logout" logout-success-url="/login.jsp" />
<sec:access-denied-handler error-page="/error.jsp" />
</sec:http>

前回から変更はないが、6行目の「sec:logout」タグについて補足しておきたいと思う。「sec:logout」タグに明示的に定義していないのだが、デフォルトで「invalidate-session="true"」が定義されている。「invalidate-session」属性はセッション破棄するか否かで、trueなので、ログアウト時にセッションを破棄することを意味している。セッションはメモリ上の生存期間が長いため、どこで破棄するのかを考慮する必要がある。今回はログアウト時にセッション破棄が行われていることもあわせて確認してみたい。

2.web.xml(関連箇所のみ掲載)

<filter>
<filter-name>characterEncodingFilter</filter-name>
<filter-class>
jp.co.sample.filter.EncodingFilter
</filter-class>
<init-param>
<param-name>encoding</param-name>
<param-value>UTF-8</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>characterEncodingFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<filter>
<filter-name>logFilter</filter-name>
<filter-class>
jp.co.sample.filter.LogFilter
</filter-class>
</filter>
<filter-mapping>
<filter-name>logFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<filter>
<filter-name>springSecurityFilterChain</filter-name>
<filter-class>
org.springframework.web.filter.DelegatingFilterProxy
</filter-class>
</filter>
<filter-mapping>
<filter-name>springSecurityFilterChain</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>

ログアウト時のセッション破棄を確認する方法として、Filterを使うことにしてみた。(Filterについては過去の記事を参照してほしい。)
Filterを3つ用意しているが、2番目(16行目)のlogFilterにセッション情報を取得するロジックを追加している。3番目(26行目)のspringSecurityFilterChainより前に置いているのがポイントとなる。

■Filterに関する過去の記事
Spring MVCとFilterを使ってみる
Spring MVCとFilterを使ってみる(その2)

3.LogFilter.java

package jp.co.sample.filter;

import java.io.IOException;
import java.util.Enumeration;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class LogFilter implements Filter {

private static Logger logger = LoggerFactory.getLogger(LogFilter.class);

public void init(FilterConfig config) throws ServletException {
}

public void doFilter(ServletRequest req, ServletResponse res,
FilterChain chain) throws IOException, ServletException {

logger.info("doFilter start");
HttpSession session = ((HttpServletRequest) req).getSession();
Enumeration<String> e = session.getAttributeNames();
while(e.hasMoreElements()) {
String key = (String)e.nextElement();
logger.info( "key: {}",key);
logger.info( "value: {}",session.getAttribute(key));
}

chain.doFilter(req, res);
logger.info("doFilter end");
}

public void destroy() {
}
}

25~35行目が、全セッションのキーと値をログ出力している処理となる。

4.BookController.java(関連箇所のみ掲載)

package jp.co.sample.book.controller;

import org.springframework.web.bind.annotation.SessionAttributes;
//他のimport文省略

@Controller
@RequestMapping("/book")
@SessionAttributes("bookSearchForm")
public class BookController {

@Autowired
protected BookService<BookEntity> bookService;

//他の変数定義省略

@RequestMapping(value = "/list/search", method = RequestMethod.GET)
public String search(BookSearchForm form, Model model) {
logger.info("search start");
List<BookEntity> entites = bookService.findByNameLike(form.getBookName());
model.addAttribute("books", entites);
//model.addAttribute("bookSearchForm", form);
return "list";
}

//他のメソッド省略
}

16行目の検索用URLを編集画面から戻る場合にも使用するよう、後述するJSPにてマッピングする。
17行目の「public String search(省略)」メソッドが、検索処理となり、特に変更はない。前回からの変更点は、8行目の「@SessionAttributes("bookSearchForm")」だけとなる。これで、bookSearchFormオブジェクトがセッションとして扱われる。@SessionAttributesアノテーションの属性には、ModelAttributeオブジェクトの名前を設定する。今回の場合、コメント化している21行目の「bookSearchForm」がModelAttributeオブジェクトの名前となる。ModelAttributeオブジェクトについては、過去の記事を参照してほしい。
補足となるが、戻る場合のURLを別に用意した場合、16行目を「value = {"/list/search","/edit/back"}」ように、複数定義することもできる。

■ModelAttributeオブジェクトに関する過去の記事
Spring MVC リクエストパラメータ格納オブジェクトの検証
Spring MVC Spring Data JPAを使って検索する

5.editBook.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c"%>
<%@ taglib uri="http://www.springframework.org/tags" prefix="spring"%>
<%@ taglib uri="http://www.springframework.org/tags/form" prefix="form"%>
<link href="<c:url value="/css/bootstrap.min.css" />" rel="stylesheet">
<link href="<c:url value="/css/bootstrap-theme.min.css" />" rel="stylesheet">
<script src="<c:url value="/js/bootstrap.min.js" />"></script>
<title>editBook</title>
</head>
<body>
<spring:url value="/book/list/search" var="varBookEditBackUrl"/>
<spring:message code="bookId" var="varBookId"/>
<spring:message code="bookName" var="varBookName"/>
<spring:message code="price" var="varPrice"/>
<spring:message code="cover" var="varCover"/>
<spring:message code="button.update" var="varButtonUpdate"/>
<spring:message code="link.edit.back" var="varLinkEditBack"/>

<c:if test="${not empty book.bookName}">
<c:set var="varBookNameValue" value="${book.bookName}" />
</c:if>
<c:if test="${not empty book.price}">
<c:set var="varPriceValue" value="${book.price}" />
</c:if>

<div class="container-fluid">
<form:form
action="${pageContext.request.contextPath}/book/edit/update"
method="post" modelAttribute="bookForm" class="well" enctype="multipart/form-data">
<c:if test="${not empty book.bookId}">
<div class="form-group">
<form:label path="bookId" class="control-label">${varBookId}</form:label>
<form:input path="bookId" value="${book.bookId}" class="form-control" readonly="true"/>
</div>
</c:if>
<div class="form-group">
<form:label path="bookName" class="control-label">${varBookName}</form:label>
<form:input path="bookName" value="${varBookNameValue}" class="form-control" />
<p class="help-block"><font color="red"><form:errors path="bookName" /></font></p>
</div>
<div class="form-group">
<form:label path="price" class="control-label">${varPrice}</form:label>
<form:input path="price" value="${varPriceValue}" class="form-control"/>
<p class="help-block"><font color="red"><form:errors path="price" /></font></p>
</div>
<div class="form-group">
<label for="uploadFile" class="control-label">${varCover}</label>
<input name="uploadFile" type="file" class="form-control"/>
<p class="help-block"><font color="red"><form:errors path="cover" /></font></p>
</div>
<div class="form-group">
<input type="submit" value="${varButtonUpdate}" class="btn btn-primary"/>
<a href="${varBookEditBackUrl}" class="btn btn-info">${varLinkEditBack}</a>
</div>
</form:form>
</div>
</body>
</html>

今回追加したのは、15行目、21行目、57行目となる。
15行目、21行目のspringタグについては過去の記事を参照してほしい。
57行目はBootstrapのCSSを適用して、リンクをボタンとして見せている。
また、今回掲載はしないが、「link.edit.back」キーはプロパティファイルに定義しており、、日本語の値は「一覧へ戻る」としている。

■springタグに関する過去の記事
Spring MVC メッセージの国際化に対応する

6.BookSearchForm .java

package jp.co.sample.book.form;

import java.io.Serializable;

public class BookSearchForm implements Serializable {

private static final long serialVersionUID = 1L;

private String bookName ="";

    //getter・setterは省略
}

本件を実装するにあたって、1~5までで対応完了と思っていたが、落とし穴が待っていた。
それは、TOPの検索画面で何も検索しないで、任意の編集画面を呼び出し、戻った場合、検索画面の取得件数が0件で何も表示されなくなってしまった。原因は、bookNameがnullのため、「"from BookEntity where bookName like '%" + name + "%'");」のLike検索でレコードが取得できなかったことによる。(1回でも検索ボタンを押してから、編集画面を呼び出す場合は問題ない。)修正方法はいくつかあると思うが、今回は9行目を「private String bookName;」→「private String bookName ="";」の初期値を空文字にする簡単な方法で対応することにした。

7.画面
①検索画面(TOP) 
一覧_検索

②「検索」ボタンクリック時のログ

INFO EncodingFilter - doFilter start
INFO LogFilter - doFilter start
INFO LogFilter - key: SPRING_SECURITY_CONTEXT
INFO LogFilter - value: org.springframework.security.core.context.SecurityContextImpl@bd0264d3: (省略)
INFO LogFilter - key: bookSearchForm
INFO LogFilter - value: jp.co.sample.book.form.BookSearchForm@31c431
INFO BookController - search start
INFO BookDaoImpl - findByNameLike start
INFO LogFilter - doFilter end
INFO EncodingFilter - doFilter end

5,6行目にて、「bookSearchForm」がセッションとして格納されていることが確認できる。

③編集画面
一覧へ戻る

④「編集」リンククリック時のログ

INFO EncodingFilter - doFilter start
INFO LogFilter - doFilter start
INFO LogFilter - key: SPRING_SECURITY_CONTEXT
INFO LogFilter - value: org.springframework.security.core.context.SecurityContextImpl@bd0264d3: (省略)
INFO LogFilter - key: bookSearchForm
INFO LogFilter - value: jp.co.sample.book.form.BookSearchForm@31c431
INFO BookController - edit start
INFO BookDaoImpl - findById start
INFO LogFilter - doFilter end
INFO EncodingFilter - doFilter end

6行目にて、「bookSearchForm」キーの値が①と同じであることが確認できる。

⑤検索画面(編集画面から遷移)
一覧_戻り後

⑥「一覧へ戻る」ボタンクリック時のログ

INFO EncodingFilter - doFilter end
INFO EncodingFilter - doFilter start
INFO LogFilter - doFilter start
INFO LogFilter - key: SPRING_SECURITY_CONTEXT
INFO LogFilter - value: org.springframework.security.core.context.SecurityContextImpl@bd0264d3: (省略)
INFO LogFilter - key: bookSearchForm
INFO LogFilter - value: jp.co.sample.book.form.BookSearchForm@31c431
INFO BookController - search start
INFO BookDaoImpl - findByNameLike start
INFO LogFilter - doFilter end
INFO EncodingFilter - doFilter end

ここでも②同様、6行目にて、「bookSearchForm」キーの値が①と同じであることが確認できる。

⑦「Logout」リンククリック時のログ

INFO EncodingFilter - doFilter start
INFO LogFilter - doFilter start
INFO LogFilter - doFilter end
INFO EncodingFilter - doFilter end

セッションがすべて破棄されていることが確認できる。

⑧testadminで再ログインして、「編集」リンクをクリックした時のログ

INFO EncodingFilter - doFilter start
INFO LogFilter - doFilter start
INFO LogFilter - key: SPRING_SECURITY_CONTEXT
INFO LogFilter - value: org.springframework.security.core.context.SecurityContextImpl@bd01cf9b: (省略)
INFO LogFilter - key: bookSearchForm
INFO LogFilter - value: jp.co.sample.book.form.BookSearchForm@754e47
INFO BookController - edit start
INFO BookDaoImpl - findById start
INFO LogFilter - doFilter end
INFO EncodingFilter - doFilter end

6行目で、「bookSearchForm」キーの値が前回と異なる値で生成されていることが確認できる。

長くなったが、@SessionAttributesアノテーションを追加するだけで、既存のソースに手を加えることなくセッションとして扱うことができた。

■過去のSpring関連記事
Spring関連記事 Index

プロフィール

bookmount8

Author:bookmount8
システムエンジニア。サーバーサイドでjavaを扱うことが多い。最近は、ミドルやフロント周りも関心あり。

最新記事
カテゴリ
検索フォーム
最新コメント
月別アーカイブ
これまでの訪問者数
ブロとも申請フォーム

この人とブロともになる

RSSリンクの表示
上記広告は1ヶ月以上更新のないブログに表示されています。新しい記事を書くことで広告を消せます。