Chapter 04

PRGパターンの適用とバリデーション

この章で学ぶこと

入力した情報をPOSTすることに成功しました。

しかし、POSTしたあとの画面で、画面にリロードをかけると、もう一度POSTが行われることに気づいたでしょうか?
このままでは、いわゆる2重送信の問題が起きてしまいます。

この章では、数ある二重送信防止の仕組みの中から、比較的単純なPRGパターンの適用に関して説明します。この段階で実装イメージがつく方は、この章を読む必要はありません。

  • 2重送信防止の必要性について
  • PRGパターンの実装
  • バリデーションの実装

参考リンク

PRG(Post-Redirect-Get)パターンの図示がありますので、下記ページを参考にしてみてください。
TERASOLUNA Global Framework Guideline

2重送信防止の必要性について

2重送信は、例えば送信ボタンを連打したり、完了画面でリロードしたりすることで起きる、すでに送ったPOSTをもう一度送ってしまう問題です。

これの対策にはJavaScriptやCookieを利用する方法から、今回学ぶPRG(Post-Redirect-Get)パターンのような構造による対策まで、複数の方法があります。

どの方法をとるかは、その時の要件次第です。

たとえば、ショッピングサイトの会計ボタンで2重送信を起こしてはいけないのは、簡単に想像がつきますね。
今回のような掲示板ではさほどの問題がありませんが、2重送信が致命的になる要件も多くありますので、注意しておきましょう。

PRG(Post-Redirect-Get)パターンとは

PRGパターンは名前の通りで、POSTしたあとに強制的にリダイレクトをかけ、GETページを案内することを示しています。

2重送信が起きてしまうのは、現時点の実装のように、POSTで処理が終わってしまっていることによります。

  @PostMapping("/board")
  public String postComment(@ModelAttribute CommentForm comment) {
    return "board";
  }

ブラウザのリロードは、前回のリクエストの再現であるため、同じPOSTが飛んでしまうことにより2重送信が発生します。

なので、POSTしたあとに無害なページへリダイレクトしてしまうことにより、これを回避することが目的になります。

リダイレクトの実装

実は、リダイレクトの実装はとても簡単です。postCommentメソッドを以下のように書き直しましょう。

  @PostMapping("/board")
  public String postComment(@ModelAttribute CommentForm comment) {
    return "redirect:/board";
  }

これだけで完了です。

では、検証ツールのネットワークタブで見てみましょう。

Status列に着目してください。

HttpStatusが302である場合、それは一時的リダイレクトを意味しています。
Waterfall列のグラフは実行順を表しています。これを見て分かる通り、302のリダイレクトが起きたあとに、boardに再アクセスし、HttpStatus 200を受け取っています。200はアクセス成功の意味です。

200の詳細を見ると、GETメソッドになっていることがわかります。
GETは何度送信しても問題ありませんので、これでPRGパターンが実装できていることがわかります。

バリデーションの実装

さて、これでDBへユーザの入力を入れるにあたって後顧の憂いはなくなったでしょうか?
もう気づいているかもしれませんが、名前、メールアドレス、コメントのそれぞれのデータにはなんだって入ってしまいます。

名前の長さをあんまりにも長くとると、表示時にデザインの観点から問題が生じやすいです。
メールアドレスがそもそもメールアドレスじゃないのも困りものです。
コメントも、あんまりにも長すぎてはDBに保存する際に一工夫必要になってくるかもしれません。いたずらに巨大なPOSTを投げられるのも困ります。

ここで必要になってくる概念がバリデーションです。
バリデーションは、サービスの仕様に従ったデータしか受け取らないようにデータをチェックし、誤っていれば入力フォームに誤っている内容を出力する、というものです。

クライアントサイド、サーバーサイド

近年、JavaScriptを使ったライブラリの発展には目覚ましいものがあります。SPAのように、サーバサイドがほぼ関わることなくデータ送信できる画面も増えています。

では、モダンなウェブサイトではサーバサイドでバリデーションする必要はまったくなく、クライアントサイドでJavaScriptを使いチェックすれば事足りるのでしょうか。

答えは、サーバサイドにデータを渡すなら、そのバリデーションはサーバサイドにおいて必須であるということです。

ユーザのブラウザにロードして動作するJavaScriptはそもそも改ざん可能です。また、データを送信するだけなら、サーバサイドのエンドポイント(URL)がわかりさえすれば、画面を介さずデータを投げつけることさえできてしまいます。

クライアントサイドのバリデーションは、ユーザの利便性のみのために存在しますので、セキュリティ向上の役には全く立たないことを覚えておいてください。

SpringBootにおける、POSTデータのバリデーション

postされたデータのバリデーション方法として、spring-boot-starter-validationを使う方法が一般的です。

pom.xmlに依存関係を追加しましょう。

<!-- validationしたいので -->
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

ではどのようなバリデーションができるのでしょうか。
それを記載するととんでもなく長くするので、こちらの記事(英語)を参照してください。

CommentFormクラスを下記のように書き換えましょう。

package chalkboard.me.bulletinboard.presentation.form;

import lombok.Data;
import org.hibernate.validator.constraints.Length;
import org.springframework.lang.Nullable;

import javax.validation.constraints.Email;
import javax.validation.constraints.NotNull;

@Data
public class CommentForm {
  @Nullable
  @Length(max=20)
  private String name;
  @Nullable
  @Email
  @Length(max=100)
  private String mailAddress;
  @NotNull
  @Length(min=1, max=400)
  private String comment;
}

これだけで、バリデーションの設定は終了です。
NullableはNullかもしれないことを伝えるアノテーションで、バリデーションとは関係ありません.

メールアドレス検証に関する補足

今回はメールアドレス検証に@Emailを使用しますが、この選択には慎重であるべきです。
@Emailドキュメントによると、

有効なメールアドレスを構成する正確なセマンティクスは、Jakarta Bean Validation プロバイダーに任されています

となっています。
では、そのJakarta Bean Validationとは何かといいますと、Hibernate Validatorが利用されています。

Hibernate Validatorのメールアドレス検証は十分ではなく、このような問題の要因になります。

メールアドレス検証の手段は他にもあります。mailテストのソリューションを提供するmailtrap社の記事が有用です。

バリデーションの実行

それでは、バリデーションを動作させましょう。

バリデーションをするのは、POSTでデータを受け取ったときなので、postCommentメソッドに手を加えたら良さそうですね。
実際には、@Validatedというアノテーションを一つ追加すれば、自動的にバリデーションが行われます。

  @PostMapping("/board")
  public String postComment(@Validated @ModelAttribute CommentForm comment) {
    return "redirect:/board";
  }

しかし、これだけではバリデーション結果を受け取ることができません。ですので、BindingResultというデータを受け取れるよう、引数を拡張します。

  @PostMapping("/board")
  public String postComment(
      @Validated @ModelAttribute CommentForm comment,
      BindingResult bindingResult) {
    return "redirect:/board";
  }

結果はこのようになります。

エラーを検証する

では、実際にエラーを起こしてみましょう。bindingResultにエラー情報が入っているかをみたいので、デバッグを行います。

nameでエラーを出すのは面倒くさいので、メールアドレスのフォーマット違反と、コメントの必須エラーを起こしてみます。

これで送信を押すと、ブレークポイントに止まります。

bindingResultはたくさんのデータを持っていますが、errorsに注目しましょう。


このように、エラーが起きていることがわかります。

さて、DBに許容できない値を入れるわけには行きませんので、ユーザには訂正を促すか、諦めて貰う必要があります。
よくあるパターンは入力画面に戻します。その際、保持した入力情報を表示し、同時にエラー情報を画面に出してユーザに訂正してもらうことを期待します。

エラーを表示する

まずは入力フォームを表示した上で、エラー内容を表示しましょう。

postCommentメソッドを次のように書き換えましょう。

  @PostMapping("/board")
  public ModelAndView postComment(
      @Validated @ModelAttribute CommentForm comment,
      BindingResult bindingResult) {
    if(bindingResult.hasErrors()) {
      ModelAndView modelAndView = new ModelAndView("/board");
      modelAndView.addObject("commentForm", comment);
      return modelAndView;
    }
    return new ModelAndView("redirect:/board");
  }

bindingResult.hasErrors()は、フォームクラスの検証で一つでもエラーがあれば真を返します。
これにより、エラーが有るときはユーザの入力を保持したままもとの入力フォームを表示することができます。

fieldsでエラーメッセージを表示する

board.htmlのformタグを下記のように書き換えましょう。

<form method="POST" action="/board" th:object="${commentForm}">
<div>
    <input placeholder="名前" th:field="*{name}">
    <span class="error-message" th:if="${#fields.hasErrors('name')}" th:errors="*{name}">Name Error</span>
</div>
<div>
    <input placeholder="メールアドレス" th:field="*{mailAddress}">
    <span class="error-message" th:if="${#fields.hasErrors('mailAddress')}" th:errors="*{mailAddress}">mailAddress Error</span>
</div>
<div>
    <textarea placeholder="コメント内容" rows="5" cols="80" wrap="off" th:field="*{comment}">
    </textarea>
    <span class="error-message" th:if="${#fields.hasErrors('comment')}" th:errors="*{comment}">comment Error</span>
</div>
<button>送信</button>
</form>

board.cssにエラーメッセージ用のスタイルを追加します。

form {
    display: flex;
    flex-direction: column;
}

.error-message {
    color: #e02525;
}

さて、今回の理解の鍵は、下記です。
<span class="error-message" th:if="${#fields.hasErrors('name')}" th:errors="*{name}">Name Error</span>

Thymeleafの提供する#fieldsは、BindingResultと同等の内容を保持しています。
#fields.hasErrors('name')は、nameというフィールドでエラーが有ったかを検証するものです。
th:ifは真のときのみそのHTMLを描画する、の意味です。
組み合わせると、nameフィールドでエラーが有ったときのみ表示する、という効果を持っています。

th:errors="*{name}"は、先程bindingResultをデバッグしたときに見かけたエラーメッセージを取得してくるものになります。

結果、フォームでエラーがあると、このような画面を表示することができるようになります。

なお、バリデーション用のアノテーションは自作することもできます。
@Validatedは通常Controllerの実行時しか動作しませんが、手動発火する方法もあります。

今回のPR

https://github.com/angelica-keiskei/spring-sample/pull/4