概要
登録処理でPRGパターンを使う場合と使わない場合の画面遷移の挙動を検証したときの記録です。
検証で使用したWebアプリケーションはJavaのSpring Bootで開発したものです。
検証パターン
- PRGパターンを使う場合
- PRGパターンを使わない場合
- PRGパターンを使わない + History APIを使う場合
環境
- Windows10 Professional
- Java 1.8.0_101
- Spring Boot 1.4.1
- Chrome 54
- jQuery 1.12
参考
- [MDN - Web technology For developers - Web APIs - History] (https://developer.mozilla.org/en-US/docs/Web/API/History)
- [MDN - ブラウザの履歴を操作する] (https://developer.mozilla.org/ja/docs/Web/Guide/DOM/Manipulating_the_browser_history)
- [Post/Redirect/Get (PRG) パターン] (http://qiita.com/furico/items/a32c106e9d7c4418fc9d)
1. PRGパターンを使う場合
下図の「RPGパターンを使う」ときの画面遷移の挙動を確認します。
画面補足
-
[A] /index
TOPページ -
[B] /entry
登録フォームページ -
[C] /regist
登録処理 (画面なし)、処理後に[D]へリダイレクト -
[D] /result
登録結果ページ -
[E] /other
他のページ
確認のポイント
-
[D] /result
から戻るボタンを押したときの遷移先について -
[D] /result
でページ再読み込みを行った後に戻るボタンを押したときの遷移先について -
[E] /other
から戻るボタンを押したときの遷移先について
.....................................................................
. .
[A] . [B] [C] [D] . [E]
/index . /entry /regist /result . /other
+-----------+ . +-------------+ +--------------+ REDIRECT +-----------+ . +-----------+
| Index | GET | Entry Form | POST | Registration | / GET | Result | GET | Other |
| (Page) |------>| (Page) |------->| |---------->| (Page) |------>| (Page) |
| | . | | | | | | . | |
+-----------+ . +-------------+ +--------------+ +-----------+ . +-----------+
. ^ | ^ . |
. | | | . |
. +--------------[Browser back]<------------+ +---[Browser back]<--+
. .
.....................................................................
PRG pattern
AddressバーのURL
[A]-----------------[B]--------------------[C]------------------------[D]-----------------[E]
/index /entry /result /other
スタックされたHistory
[A]---------------->[B]------------------->[C]----------------------->[D]---------------->[E]
/index /entry /result
/index /entry
/index
動作確認の結果
-
[D] /result
へ遷移すると、historyに[B] /entry
と、[A] /index
が記録されている。 -
[D] /result
からブラウザの戻るボタンで戻ると[B] /entry
へ遷移する。 ([B] /entry
へのGETリクエストは発生しない) -
[D] /result
でページ再読み込みをしてもhistoryは変わらない。 ([D] /result
へのGETリクエストが発生する) -
[E] /other
からブラウザの戻るボタンで戻ると[D] /result
へ遷移する。 ([D] /result
へのGETリクエストは発生しない)
この画面遷移の問題点
-
[D] /result
からブラウザの戻るボタンで[B] /entry
へ戻り、再度フォームをサブミットすることができてしまう。 - 対策として登録処理で2重登録のチェック処理の実装、登録時にワンタイムトークンを使用する。(PRGパターンの問題ではありません)
-
[C] /regist
から[D] /result
へリダイレクトする際にflash scopeを使ってメッセージを渡す場合、[D] /result
でページ再読み込みをするとそのメッセージが失われる。
動作確認の結果 (Chromeのdevtoolsでキャッシュを無効化した状態)
赤字のところは上記のキャッシュが有効化されている場合との相違点です。
-
[D] /result
へ遷移すると、historyに[B] /entry
と、[A] /index
が記録されている。 -
[D] /result
からブラウザの戻るボタンで戻ると[B] /entryへ遷移する。 ([B] /entry
へのGETリクエストが発生する) -
[D] /result
でページ再読み込みをしてもhistoryは変わらない。 ([D] /result
へのGETリクエストが発生する) -
[E] /other
からブラウザの戻るボタンで戻ると[D] /result
へ遷移する。 ([D] /result
へのGETリクエストが発生する)
この画面遷移の問題点
-
[D] /result
からブラウザの戻るボタンで[B] /entry
へ戻り、再度フォームをサブミットすることができてしまう。 - 対策として登録処理で2重登録のチェック処理の実装する。(PRGパターンの問題ではありません)
-
[B] /entry
が再読み込みされるのでワンタイムトークンの実装は有効ではない。 -
[C] /regist
から[D] /result
へリダイレクトする際にflash scopeを使ってメッセージを渡す場合、[D] /result
でページ再読み込みをするとそのメッセージが失われる。
検証用のコード
Form
public class RedirectForm implements Serializable {
private static final long serialVersionUID = -2487350655211970792L;
private String name;
private String email;
...getter/setter省略...
@Override
public String toString() {
return "RedirectForm [name=" + name + ", email=" + email + "]";
}
}
Controller
@Controller
@RequestMapping(value = "/redirect")
public class RedirectController {
@RequestMapping(value = "/entry", method = RequestMethod.GET)
public String entry(Model model) {
Date current = new Date();
model.addAttribute("current", current);
return "redirect/entry";
}
@RequestMapping(value = "/regist", method = RequestMethod.POST)
public String regist(RedirectForm form, RedirectAttributes attributes) {
// ここはformの内容をDBに登録する処理
// 登録IDを取得(みなし)
String id = UUID.randomUUID().toString();
// 登録メッセージ
attributes.addFlashAttribute("message", "登録完了");
return "redirect:/redirect/result/" + id;
}
@RequestMapping(value = "/result/{id}", method = RequestMethod.GET)
public String result(@PathVariable("id") String id, Model model) {
// ここはIDから登録内容を検索する処理
// 検索結果をmodelにセット
model.addAttribute("name", "rubytomato");
model.addAttribute("email", "rubytomato@example.com");
Date current = new Date();
model.addAttribute("current", current);
return "redirect/result";
}
@RequestMapping(value = "/other", method = RequestMethod.GET)
public String other(Model model) {
Date current = new Date();
model.addAttribute("current", current);
return "redirect/other";
}
}
Template
index.html
<body>
<div class="container-fluid">
<div class="row">
<div class="col-md-12 well">
<ul>
<li><a href="/redirect/entry">redirect [A] entry</a></li>
<li><a href="/redirect2/entry">redirect2 [A] entry</a></li>
<li><a href="/redirect3/entry">redirect3 [A] entry</a></li>
</ul>
</div>
</div>
</div>
</body>
entry.html
<body>
<div class="container-fluid">
<div class="row">
<div class="col-md-12 well">
<h2>[B] entry</h2>
<form action="/redirect/regist" method="POST" class="form-horizontal">
<div class="form-group">
<label for="name" class="col-sm-2 control-label">name</label>
<div class="col-sm-10">
<input class="form-control" id="name" name="name" type="text" />
</div>
</div>
<div class="form-group">
<label for="email" class="col-sm-2 control-label">email</label>
<div class="col-sm-10">
<input class="form-control" id="email" name="email" type="email" />
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-default">送信</button>
</div>
</div>
</form>
</div>
<div class="col-md-12 well">
<div>
<p>現在日時:<span th:text="${current}">current</span></p>
</div>
<div>
<button class="back btn btn-default">戻る</button>
<button class="forward btn btn-default">進む</button>
</div>
</div>
</div>
</div>
</body>
result.html
<body>
<div class="container-fluid">
<div class="row">
<div class="col-md-12 well">
<h2>[D] result</h2>
<dl>
<dt>id</dt>
<dd th:text="${id}">id</dd>
<dt>name</dt>
<dd th:text="${name}">name</dd>
<dt>email</dt>
<dd th:text="${email}">email</dd>
</dl>
<div>
<a href="/redirect/other">他のページ</a>
</div>
</div>
<div class="col-md-12 well">
<div>
<p>flash scope message</p>
<p th:text="${message}">message</p>
</div>
<div>
<p>現在日時:<span th:text="${current}">current</span></p>
</div>
<div>
<button class="back btn btn-default">戻る</button>
<button class="forward btn btn-default">進む</button>
</div>
</div>
</div>
</div>
</body>
other.html
<body>
<div class="container-fluid">
<div class="row">
<div class="col-md-12 well">
<h2>[E] other</h2>
</div>
<div class="col-md-12 well">
<div>
<p>現在日時:<span th:text="${current}">current</span></p>
</div>
<div>
<button class="back btn btn-default">戻る</button>
<button class="forward btn btn-default">進む</button>
</div>
</div>
</div>
</div>
</body>
2. PRGパターンを使わない場合
下図の「RPGパターンを使わない」ときの画面遷移の挙動を確認します。
画面補足
-
[A] /index
TOPページ -
[B] /entry
登録フォームページ -
[C] /regist
登録処理とその結果ページ -
[E] /other
他のページ
確認のポイント
-
[C] /regist
から戻るボタンを押したときの遷移先について -
[C] /regist
でページ再読み込みを行った後に戻るボタンを押したときの遷移先について -
[E] /other
から戻るボタンを押したときの遷移先について
[A] [B] [C] [E]
/index /entry /regist /other
+-----------+ +-------------+ +--------------+ +-----------+
| Index | GET | Entry Form | POST | Registration | GET | Other |
| (Page) |------>| (Page) |------->| & Result |------------------------------>| (Page) |
| | | | | (Page) | | |
+-----------+ +-------------+ +--------------+ +-----------+
^ | ^ |
| | | |
+----[Browser back]<----+ +------------[Browser back]<------------+
AddressバーのURL
[A]-----------------[B]--------------------[C]---------------------------------------------[E]
/index /entry /regist /other
スタックされたHistory
[A]---------------->[B]------------------->[C]-------------------------------------------->[E]
/index /entry /regist
/index /entry
/index
動作確認の結果
-
[C] /regist
へ遷移すると、historyに[B] /entry
と、[A] /index
が記録されている。 -
[C] /regist
からブラウザの戻るボタンで戻ると[B] /entry
へ遷移する。([B] /entry
へのGETリクエストは発生しない) -
[C] /regist
でページ再読み込みをしてもhistoryは変わらない。 ([C] /regist
へのPOSTリクエストが発生する) -
[E] /other
からブラウザの戻るボタンで戻ると[C] /regist
へ遷移する。 ([C] /regist
へのPOSTリクエストは発生しない)
この画面遷移の問題点
-
[C] /regist
からブラウザの戻るボタンで[B] /entry
へ戻り、再度フォームをサブミットすることができてしまう。 - 対策として登録処理で2重登録のチェック処理の実装、登録時にワンタイムトークンを使用する。(PRGパターンの問題ではありません)
-
[C] /regist
でページ再読み込みを行うと登録処理が再び実行されてしまう。 - 対策としてRPGパターンを適用する。
動作確認の結果 (Chromeのdevtoolsでキャッシュを無効化した状態)
赤字のところは上記のキャッシュが有効化されている場合との相違点です。
-
[C] /regist
へ遷移すると、historyに[B] /entry
と、[A] /index
が記録されている。 -
[C] /regist
からブラウザの戻るボタンで戻ると[B] /entryへ遷移する。([B] /entry
へのGETリクエストが発生する) -
[C] /regist
でページ再読み込みをしてもhistoryは変わらない。 ([C] /regist
へのPOSTリクエストが発生する) -
[E] /other
からブラウザの戻るボタンで戻ると[C] /regist
へ遷移する。 ([C] /regist
へのPOSTリクエストが発生する)
この画面遷移の問題点
-
[C] /regist
からブラウザの戻るボタンで[B] /entry
へ戻り、再度フォームをサブミットすることができてしまう。 - 対策として登録処理で2重登録のチェック処理の実装する。(PRGパターンの問題ではありません)
-
[B] /entry
が再読み込みされるのでワンタイムトークンの実装は有効ではない。 -
[C] /regist
でページ再読み込みを行う、または[E] /other
からブラウザの戻るボタンで戻ってくると登録処理が再び実行されてしまう。 - 対策としてRPGパターンを適用する。
検証用のコード
Form
PRGパターンを使う場合と同じ
Controller
@Controller
@RequestMapping(value = "/redirect2")
public class Redirect2Controller {
@RequestMapping(value = "/entry", method = RequestMethod.GET)
public String entry(Model model) {
Date current = new Date();
model.addAttribute("current", current);
return "redirect2/entry";
}
@RequestMapping(value = "/regist", method = RequestMethod.POST)
public String regist(RedirectForm form, Model model) {
// ここはformの内容をDBに登録する処理
// 登録IDを取得(みなし)
String id = UUID.randomUUID().toString();
model.addAttribute("id", id);
// 登録結果をmodelにセット
model.addAttribute("name", "rubytomato");
model.addAttribute("email", "rubytomato@example.com");
// 登録メッセージ
model.addAttribute("message", "登録完了");
Date current = new Date();
model.addAttribute("current", current);
return "redirect2/regist";
}
@RequestMapping(value = "/other", method = RequestMethod.GET)
public String other(Model model) {
Date current = new Date();
model.addAttribute("current", current);
return "redirect2/other";
}
}
Template
index.html
PRGパターンを使う場合と同じ
entry.html
<body>
<div class="container-fluid">
<div class="row">
<div class="col-md-12 well">
<h2>[B] entry</h2>
<form action="/redirect2/regist" method="POST" class="form-horizontal">
<div class="form-group">
<label for="name" class="col-sm-2 control-label">name</label>
<div class="col-sm-10">
<input class="form-control" id="name" name="name" type="text" />
</div>
</div>
<div class="form-group">
<label for="email" class="col-sm-2 control-label">email</label>
<div class="col-sm-10">
<input class="form-control" id="email" name="email" type="email" />
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-default">送信</button>
</div>
</div>
</form>
</div>
<div class="col-md-12 well">
<div>
<p>現在日時:<span th:text="${current}">current</span></p>
</div>
<div>
<button class="back btn btn-default">戻る</button>
<button class="forward btn btn-default">進む</button>
</div>
</div>
</div>
</div>
</body>
regist.html
<body>
<div class="container-fluid">
<div class="row">
<div class="col-md-12 well">
<h2>[C] regist</h2>
<dl>
<dt>id</dt>
<dd th:text="${id}">id</dd>
<dt>name</dt>
<dd th:text="${name}">name</dd>
<dt>email</dt>
<dd th:text="${email}">email</dd>
</dl>
<div>
<a href="/redirect2/other">他のページ</a>
</div>
</div>
<div class="col-md-12 well">
<div>
<p>message</p>
<p th:text="${message}">message</p>
</div>
<div>
<p>現在日時:<span th:text="${current}">current</span></p>
</div>
<div>
<button class="back btn btn-default">戻る</button>
<button class="forward btn btn-default">進む</button>
</div>
</div>
</div>
</div>
</body>
other.html
PRGパターンを使う場合と同じ
3. PRGパターンを使わない + History APIを使う場合
下図の「RPGパターンを使わない + History APIを使う」ときの画面遷移の挙動を確認します。
[C] /regist
へ遷移したときにHistory APIのreplaceStateを使って履歴を[C] /regist
から[D] /result
へ書き換えます。
これによってブラウザのアドレスバーは/resultとなります。
[D] /result
のページは、[C] /regist
(アドレスバー上は/result)でページ再読み込みを行ったときや、[E] /other
から戻ってくるときのために用意します。
画面補足
-
[A] /index
TOPページ -
[B] /entry
登録フォームページ -
[C] /regist
登録処理とその結果ページ -
[D] /result
登録結果ページ -
[E] /other
他のページ
[C] /regist
と[D] /result
は同じhtmlテンプレートを使います。
確認のポイント
-
[C] /regist
から戻るボタンを押したときの遷移先について -
[C] /regist
でページ再読み込みを行った後に戻るボタンを押したときの遷移先について -
[E] /other
から戻るボタンを押したときの遷移先について
[A] [B] [C] [E]
/index /entry /regist (->/result) /other
+-----------+ +-------------+ +--------------+ +-----------+
| Index | GET | Entry Form | POST | Registration | GET | Other |
| (Page) |------>| (Page) |------->| & Result |------------------------------>| (Page) |
| | | | | (Page) | +--->| |
+-----------+ +-------------+ +--------------+ | +-----------+
^ | | | |
| | | [D] | |
|<---[Browser back]<----+ +-----[F5]----> /result | |
| +-----------+ | |
| | Result |--+ |
| | (Page) | |
| | | |
| +-----------+ |
| | ^ |
| | | |
+--------------------[Browser back]<------------+ +---[Browser back]<--+
AddressバーのURL
[A]-----------------[B]--------------------[C]---------------------------------------------[E]
/index /entry /regist /other
↓ (History APIで書き換え)
/result
スタックされたHistory
[A]---------------->[B]------------------->[C]-------------------------------------------->[E]
/index /entry /result <---この履歴はHistory APIで書き換えたもの
/index /entry
/index
[D]<-----------------[E]
/entry
/index
動作確認の結果
-
[C] /regist
へ遷移すると、historyに[B] /entry
と、[A] /index
が記録されている。 -
[C] /regist
(アドレスバー上は/result)からブラウザの戻るボタンで戻ると[B] /entry
へ遷移する。([B] /entry
へのGETリクエストは発生しない) -
[C] /regist
(アドレスバー上は/result)でページ再読み込みをしてもhistoryは変わらない。 ([D] /result
へのGETリクエストが発生する) -
[C] /regist
を再読み込みした後の[D] /result
からブラウザの戻るボタンで戻ると[B] /entry
へ遷移する。([B] /entry
へのGETリクエストは発生しない) -
[E] /other
からブラウザの戻るボタンで戻ると[D] /result
へ遷移する。 ([D] /result
へのGETリクエストは発生しない)
この画面遷移の問題点
-
[C] /regist
または[D] /result
からブラウザの戻るボタンで[B] /entry
へ戻り、再度フォームをサブミットすることができてしまう。 - 対策として登録処理で2重登録のチェック処理の実装、登録時にワンタイムトークンを使用する。(PRGパターンの問題ではありません)
動作確認の結果 (Chromeのdevtoolsでキャッシュを無効化した状態)
-
[C] /regist
へ遷移すると、historyに[B] /entry
と、[A] /index
が記録されている。 -
[C] /regist
(アドレスバー上は/result)からブラウザの戻るボタンで戻ると[B] /entry
へ遷移する。([B] /entry
へのGETリクエストが発生する) -
[C] /regist
(アドレスバー上は/result)でページ再読み込みをしてもhistoryは変わらない。 ([D] /result
へのGETリクエストが発生する) -
[C] /regist
を再読み込みした後の[D] /result
からブラウザの戻るボタンで戻ると[B] /entry
へ遷移する。([B] /entry
へのGETリクエストが発生する) -
[E] /other
からブラウザの戻るボタンで戻ると[D] /result
へ遷移する。 ([D] /result
へのGETリクエストが発生する)
この画面遷移の問題点
-
[D] /result
からブラウザの戻るボタンで[B] /entry
へ戻り、再度フォームをサブミットすることができてしまう。 - 対策として登録処理で2重登録のチェック処理の実装する。(PRGパターンの問題ではありません)
-
[B] /entry
が再読み込みされるのでワンタイムトークンの実装は有効ではない。
検証用のコード
Form
PRGパターンを使う場合と同じ
Controller
@Controller
@RequestMapping(value = "/redirect3")
public class Redirect3Controller {
@RequestMapping(value = "/entry", method = RequestMethod.GET)
public String entry(Model model) {
Date current = new Date();
model.addAttribute("current", current);
return "redirect3/entry";
}
@RequestMapping(value = "/regist", method = RequestMethod.POST)
public String regist(RedirectForm form, Model model) {
// ここにformの内容をDBに登録する処理
// 登録IDを取得(みなし)
String id = UUID.randomUUID().toString();
model.addAttribute("id", id);
// 登録結果をmodelにセット
model.addAttribute("name", "rubytomato");
model.addAttribute("email", "rubytomato@example.com");
// 登録メッセージ
model.addAttribute("message", "登録完了");
Date current = new Date();
model.addAttribute("current", current);
return "redirect3/regist";
}
@RequestMapping(value = "/result/{id}", method = RequestMethod.GET)
public String result(@PathVariable("id") String id, Model model) {
model.addAttribute("id", id);
// ここにIDから登録情報を検索する処理
// 検索結果をmodelにセット
model.addAttribute("name", "rubytomato");
model.addAttribute("email", "rubytomato@example.com");
Date current = new Date();
model.addAttribute("current", current);
return "redirect3/regist";
}
@RequestMapping(value = "/other", method = RequestMethod.GET)
public String other(Model model) {
Date current = new Date();
model.addAttribute("current", current);
return "redirect3/other";
}
}
Template
index.html
PRGパターンを使う場合と同じ
entry.html
<body>
<div class="container-fluid">
<div class="row">
<div class="col-md-12 well">
<h2>[B] entry</h2>
<form action="/redirect3/regist" method="POST" class="form-horizontal">
<div class="form-group">
<label for="name" class="col-sm-2 control-label">name</label>
<div class="col-sm-10">
<input class="form-control" id="name" name="name" type="text" />
</div>
</div>
<div class="form-group">
<label for="email" class="col-sm-2 control-label">email</label>
<div class="col-sm-10">
<input class="form-control" id="email" name="email" type="email" />
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-default">送信</button>
</div>
</div>
</form>
</div>
<div class="col-md-12 well">
<div>
<p>現在日時:<span th:text="${current}">current</span></p>
</div>
<div>
<button class="back btn btn-default">戻る</button>
<button class="forward btn btn-default">進む</button>
</div>
</div>
</div>
</div>
</body>
regist.html
<body>
<div class="container-fluid">
<div class="row">
<div class="col-md-12 well">
<h2>[C] regist</h2>
<dl>
<dt>id</dt>
<dd th:text="${id}">id</dd>
<dt>name</dt>
<dd th:text="${name}">name</dd>
<dt>email</dt>
<dd th:text="${email}">email</dd>
</dl>
<div>
<a href="/redirect3/other">他のページ</a>
</div>
</div>
<div class="col-md-12 well">
<div>
<p>message</p>
<p th:text="${message}">message</p>
</div>
<div>
<p>現在日時:<span th:text="${current}">current</span></p>
</div>
<div>
<button class="back btn btn-default">戻る</button>
<button class="forward btn btn-default">進む</button>
</div>
</div>
</div>
</div>
<script type="text/javascript" th:inline="javascript">
/*<![CDATA[*/
$(function(){
if (window.location.pathname.indexOf("regist") != -1) {
console.log("regist");
var state = {a:1, b:2};
window.history.replaceState(state, "result", /*[[ '/redirect3/result/' + ${id} ]]*/ "/redirect3/result/123456");
}
});
/*]]>*/
</script>
</body>
other.html
PRGパターンを使う場合と同じ
History API
Methods
pushState
履歴をスタックするメソッド。
//履歴の追加
history.pushState(null, null, "/result");
replaceState
履歴の修正を行うメソッド。
通常はpushStateメソッドでスタックした履歴を修正するときに使用する。
//履歴の修正
history.replaceState(null, null, "/result");
Properties
length
スタックされているページ数を知る。
history.length;
state
現在の履歴エントリのstateを読み取る。
history.state;
イベント
popstate
pushStateメソッドでスタックした履歴へブラウザの戻る・進むボタンをクリックして移動したときに発生するイベント。
History APIを使わずに通常の画面遷移でスタックされた履歴ではイベントは発生しない。
$(window).on('popstate', function(event){
//ブラウザの戻る・進むボタンがクリックされたときに行いたい処理を実装
});