【Spring】二重送信防止の実装サンプル

アプリケーションで登録ボタンなどを二重に押下して二重にデータが送信されてしまうことを防ぐ方法を学んだのでサンプルを実装してみました。

ボタン押下時にボタンを非活性にするなど様々な方法がありますが、本記事ではSpringのアノテーションを使用した二重送信防止処理について記載します。

# 概要
登録画面のフォームに必要な情報を入力して登録ボタンを押下すると、テーブルにフォームの情報を登録して登録完了画面に遷移するという簡易的なサンプルを実装します。
登録画面遷移後にブラウザバックで登録画面に戻って再度登録ボタンを押下したときに再登録されることを防ぎます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/935552/e9a48336-1b6b-a74c-5f5d-3483b2ef3b76.png)

# 二重送信防止の概要
仕組みとしてはブラウザとサーバでトークン値を持つようにして。画面遷移のタイミングでそれぞれのトークン値を比較します。
トークン値が一致しなければ不正と判断しエラーを発生させることで二重送信を防止します。
以下、処理の流れです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/935552/6451abeb-5b70-6119-6aee-09aba2d004d5.png)

①登録画面で必要な情報を入力し、登録ボタンを押下します。
ここでトークン値チェックを行いますすが、ブラウザとサーバのトークン値が一致するため、登録処理が実行されます。
また、このタイミングでサーバのトークン値が破棄され、新しいトークン値に更新されます。
データ登録後、登録完了画面に遷移します。

②登録完了画面に遷移したらブラウザバックをして登録画面に戻ります。

③登録画面で再度登録ボタンを押下します。

④ブラウザとサーバのトークン値チェックを行います。①でサーバのトークン値が更新されたことにより。トークン値が一致しないので不正な操作と判断され登録処理が実行されずにエラー画面に遷移します。


# 実装サンプル

ここから実装サンプルを記載します。
使用技術は以下の通りです。

Javaバージョン:17
フレームワーク:Spring Boot
テンプレートエンジン:Thymeleaf
パッケージ管理:Gradle
ORマッパー:MyBatis
DB:h2db


MyBatisやh2dbなどに関する内容は知識がある前提で必要最低限の内容しか記載しません。

二重送信防止を実現するには@TransactionTokenCheckというアノテーションを使用するので依存関係を追加します。

``` build.gradle
implementation 'jp.fintan.keel:keel-spring-boot-starter-web:2.0.0'
```

登録画面です。フォームと登録ボタンがあるシンプルな作りにしています。
サンプルなのでバリデーションやPKなどの細かいところは設定していません。(PKはテーブル定義時に忘れていました、、)

``` index.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Index</title>
</head>
<body>
    <div class="main">
		<form th:action="@{/regist}" th:object="${sample}" method="post">
			<label for="id">ID : </label>
			<input id="id" type="text" th:field="*{id}"><br>
				
			<label for="name">名前 : </label>
			<input id="name" type="text" th:field="*{name}"><br>
				
			<label for="memo">メモ : </label>
			<input id="memo" type="text" th:field="*{memo}"><br>

			<button type="submit" value="登録">登録</button>
		</form>
	</div>
</body>
</html>
```

登録完了画面とエラー画面はメッセージが表示されるだけの画面にしています。

``` complete.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Complete</title>
</head>
<body>
    <div class="main">
		<p>登録完了しました。</p>
	</div>
</body>
</html>
```

``` error.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Error</title>
</head>
<body>
	<h2>エラーが発生しました！</h2>
</body>
</html>
```

フォームに紐づくクラスです。
``` Sample.java
package com.example.demo.entity;

public class Sample {
	private int id;
	private String name;
	private String memo;
	
	public Sample() {
	}
	
	public int getId() {
		return id;
	}
	public void setId(int id) {
		this.id = id;
	}
	public String getName() {
		return name;
	}
	public void setName(String name) {
		this.name = name;
	}
	public String getMemo() {
		return memo;
	}
	public void setMemo(String memo) {
		this.memo = memo;
	}
}

```

コントローラーです。ここで二重送信防止を実現します。
``` TransactionTokenController.java
package com.example.demo.Controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;

import com.example.demo.Service.TransactionTokenService;
import com.example.demo.entity.Sample;

import jp.fintan.keel.spring.web.token.transaction.TransactionTokenCheck;
import jp.fintan.keel.spring.web.token.transaction.TransactionTokenType;

@Controller
@TransactionTokenCheck("transaction")
public class TransactionTokenController {

	@Autowired
	TransactionTokenService transactionTokenService;
	
	@GetMapping("/")
	@TransactionTokenCheck(type = TransactionTokenType.BEGIN)
	public String index(Model model) {
		model.addAttribute("sample", new Sample());
		return "/index";
	}
	
	@PostMapping("/regist")
	@TransactionTokenCheck(type = TransactionTokenType.IN)
	public String regist(@ModelAttribute Sample sample) {
		transactionTokenService.registSamples(sample);
		return "/complete";
	}
	
	@ExceptionHandler({ Exception.class })
	public String handleError() {
		return "/error";
	}

}


```

クラスに付与する@TransactionTokenCheckのvalue属性の値をNameSpaceとして使用することが可能です。

メソッドに付与する@TransactionTokenCheckのtype属性の値「TransactionTokenType.BEGIN」が設定されているメソッドが実行されるとトークン値が作成されます。

「TransactionTokenType.IN」が設定されたメソッドが実行されるとトークンチェックが実行されます。
トークン値が一致しているとトークン値が更新され処理が実行されます。

transactionTokenServiceはテーブルへの登録処理を呼び出しているだけなので詳細は割愛します。


実際に動作を確認してみましょう。

まずは登録画面のフォームに入力して登録ボタンを押下します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/935552/c96a8e3c-8c60-1687-a08a-7bafc7890660.png)


登録完了画面に遷移しました。テーブルにもレコードが登録できていることが確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/935552/c899cfff-e0f0-6149-4d38-dd7402588062.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/935552/78368739-01a0-eb44-4c66-14282a24f2d2.png)

ここでブラウザバックで登録画面に戻って再度登録ボタンを押下します。
するとエラー画面に遷移しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/935552/936066cd-141f-3522-8c84-6b8a11fbc461.png)


トークン値チェックでブラウザとサーバのトークン値が一致しなかったことでExceptionがスローされています。

このように簡単に二重送信防止を実装することができました。

# 終わりに
今回現場で実装しているソースを読んだのでアウトプットで自分でも実装してみました。
前々からどうやって実装するか気になっていたので勉強になりました。