🔥

Javaで実装したRESTControllerのメソッドをjavascriptのFetchAPIを使って非同期処理でPOSTする

2024/03/20に公開

はじめに

ホットペッパーぐるめのAPIを使って店舗検索アプリを作成したのだが、お気に入りの店舗を保存するためにお気に入り機能を作ってみた。
ただ、登録する度に画面遷移など画面に動きが出た場合、複数の店舗をお気に入り登録するのがめんどくさくなる。そこでCRUD処理を非同期で行い画面に動きを出さないことで連続したお気に入り登録ができるようにしたので、備忘録として残しておく。

この記事でわかること

  1. javascriptのFETCH APIを使って非同期処理を実現する

実装前の状況

お気に入り機能を実装するためにはCrudRepositoryの継承インターフェースを使ってCRUD機能を用意し、
それをRestControllerで実行するという簡単なものだ。
ただ、ここで問題が発生。
RestControllerでINSERTした場合はINSERTした結果のResponseBodyを返すことになり、それを画面表示するようになってしまう。
RestControllerとControllerの戻り値についてはこちらの記事がわかりやすかったです。
https://note.com/yota7/n/n2ee5c62957f5

htmlとRestControllerは以下の通り。
list.html

<div layout:fragment="main-content" class="main-content">
    <div style="width: 75%; padding: 20px;">
        <img src="https://imgfp.hotp.jp/SYS/fw_party/images/common/img_list_kv_720x99.png" alt="歓送迎会パーフェクトガイド" width="720" height="99">
        <h2>店舗一覧</h2>
        <h3 th:if="${zero}" th:text="${zero}">取得結果</h3>
        <div th:if="${shopMap != null}">
        <div th:each="shop, iterStat : ${shopMap.entrySet()}">
            <form th:id="'form' + ${iterStat.index}" th:action="@{/saveStore}" method="post">
                <input type="hidden" name="shopName" th:value="${shop.key}" />
                <div>
                    <h2 th:text="${iterStat.count} + '.' + ${shop.key}"></h2>
                </div>
                <div th:each="detail : ${shop.value.entrySet()}">
                    <input type="hidden" th:name="${detail.key}" th:value="${detail.value}" />
                    <div th:if="${detail.key == '店舗画像'}">
                        <img th:src="${detail.value}" alt="店舗画像" />
                    </div>
                    <div th:if="${detail.key != '店舗画像' 
                            && detail.key != '店舗URL' 
                            && detail.key != '店舗名かな' 
                            && detail.key != '中エリアコード' 
                            && detail.key != '中エリア名' }" 
                        th:text="${detail.key} + ': ' + ${detail.value}"></div>
                    <div th:if="${detail.key == '店舗URL'}">
                        <a th:href="${detail.value}" target="_blank" rel="noopener noreferrer">店舗URL</a>
                    </div>
                </div>
                <button type="submit" th:attr="data-form-id='form' + ${iterStat.index}" class="saveShopButton">お気に入り</button>
            </form>
        </div>
    </div>
</div>
</div>

StoreController.java

import java.io.IOException;
import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.example.demo.entity.Store;
import com.example.demo.repository.StoreCrudRepository;

@RestController
public class StoreController {

	@Autowired
	private StoreCrudRepository storeCrudRepository;
	
	@PostMapping("/saveStore")
	public Store saveStore(@RequestParam Map<String, String> allParams) throws IOException {
		Store store = new Store();
		store.setStoreName(allParams.get("shopName"));
		store.setStoreNameKana(allParams.get("店舗名かな"));
		store.setAddress(allParams.get("店舗住所"));
		store.setBusinessHours(allParams.get("営業時間"));
		store.setCatchphrase(allParams.get("店舗紹介"));
		store.setImageURL(allParams.get("店舗画像"));
		store.setStoreHpURL(allParams.get("店舗URL"));
		store.setAreaCode(allParams.get("中エリアコード"));
		store.setAreaName(allParams.get("中エリア名"));
        // allParamsから店舗情報をセット
        storeCrudRepository.save(store);
        return store;
	}

}

これでは、お気に入りをする度にレスポンスボティが画面に表示されてしまうので邪魔くさい。
かといってRestControllerではなく、Controllerを使ってもhtmlを返してしまい、画面遷移させてしまう。
これでは、1回の検索で1件のお気に入りしかできないことになってしまう。
ではこれらを解決するのはどうすれば良いのだろうか。

問題点

・ RestController:レスポンスボティを表示させる
・ Controller:ページ遷移させる
・ 上記2つではお気に入りや店舗閲覧の操作性が損なわれる。

解決案

・ 非同期処理でページ遷移させない

完成イメージ


お気に入りボタンを押してもUI上は画面遷移はさせず、処理の成功・失敗はalertで通知。
ちなみにDBでは店舗名をuniqueとしInsertは失敗とする。

FETCH APIとは

https://www.webdesignleaves.com/pr/jquery/fetch-api-basic.html
こちらの記事では

Fetch API は、JavaScript を使ってサーバーに HTTP リクエストを送信(AJAX を実装)できるインターフェースで、指定した URL からリソース(データ)を取得することができます。
XMLHttpRequest と同様の機能を持ちますが、よりシンプルで柔軟な API で簡潔に記述することができます。IE や一部のブラウザは対応していませんが、主要なモダンブラウザで利用できます(caniuse)。
リソース(データ)を取得するためのリクエストの送信は、fetch() メソッドを使います。
fetch() メソッドにリソースの URL を指定して呼び出すとリクエストが発行され、Response オブジェクトを結果に持つ Promise オブジェクトが返されます。
Promise は非同期処理を扱うための仕組みで、Promise オブジェクトの then() メソッドに処理(コールバック関数)を記述することができます。
また、Promise を効率よく記述するための async と await キーワードを使うこともできます。
Promise と async/await の簡単な説明は最後の方のセクションにあります。

超簡単にいうと
非同期処理が簡単に記述できて、可読性も高くてよく使われているよってことだと思います。

手順

htmlでjsを読み込む

先程のlist.htmlに下記コードを追記

<head>
    ・・・
	<script src="/js/hello.js"></script>
</head>

htmlのお気に入りボタンに機能を持たせる

繰り返し処理で店舗を表示させているので、店舗を出力し終えた後に各お気に入りボタンに機能を持たせる

//htmlを読み込み終わった後に用意する関数を作成
document.addEventListener('DOMContentLoaded', function() {
    document.querySelectorAll('.saveShopButton').forEach(button => {
    //クリックした時の関数を作成
        button.addEventListener('click', function(e) {
        //e.preventDefaultメソッドを呼び出すことで、このデフォルトの動作をキャンセルしています。
        //つまりhtmlに書いているbutton本来の処理を止める記載。
            e.preventDefault();

ちなみにe.preventDefault()については下記サイトがわかりやすかった。
https://qiita.com/yokoto/items/27c56ebc4b818167ef9e

各フォームの入力値を取得する

// data-form-id属性からフォームのIDを取得
var formId = this.getAttribute('data-form-id');
// フォームのIdからフォームの値全てを取得する(例id="form15"でinput name="shopName"のvalue, input name="shopNameKana"のvalue など)
var form = document.getElementById(formId);
console.log('form:', form);
// https://developer.mozilla.org/ja/docs/Web/API/FormData/FormData
// form のinputのnameプロパティをキーとして値をセットする。今回の場合はth:valueが値となる。

この時formの値をconsole.log('form:', form);で確認すると、

<form id="form0" action="/saveStore" method="post">
    <input type="hidden" name="shopName" value="アーキテクチャcafe&amp;bar 棲家">
    <div>
        <h2>1.アーキテクチャcafe&amp;bar 棲家</h2>
    </div>
    <div>
        <input type="hidden" name="店舗画像" value="https://imgfp.hotp.jp/IMGH/12/50/P038111250/P038111250_238.jpg">
        <div>
            <img src="https://imgfp.hotp.jp/IMGH/12/50/P038111250/P038111250_238.jpg" alt="店舗画像">
        </div>
    </div>
    <div>
        <input type="hidden" name="店舗名かな" value="あーきてくちゃかふぇあんどばーすみか">
    </div>
    <div>
        <input type="hidden" name="中エリアコード" value="Y055">
    </div>
    <div>
        <input type="hidden" name="中エリア名" value="新宿">
    </div>
    <div>
        <input type="hidden" name="店舗住所" value="東京都新宿区新宿3-6-7 4階">
        <div>店舗住所: 東京都新宿区新宿3-6-7 4階</div>
    </div>
    <div>
        <input type="hidden" name="店舗紹介" value="最高の居心地を追求 完全プライベート個室完備">
        <div>店舗紹介: 最高の居心地を追求 完全プライベート個室完備</div>
    </div>
    <div>
        <input type="hidden" name="営業時間" value="月~木、日、祝日: 11:00~翌1:00 (料理L.O. 翌0:00 ドリンクL.O. 翌0:30)金、土、祝前日: 11:00~翌4:00 (料理L.O. 翌3:00 ドリンクL.O. 翌3:30)">
        <div>営業時間: 月~木、日、祝日: 11:00~翌1:00 (料理L.O. 翌0:00 ドリンクL.O. 翌0:30)金、土、祝前日: 11:00~翌4:00 (料理L.O. 翌3:00 ドリンクL.O. 翌3:30)</div>
    </div>
    <div>
        <input type="hidden" name="店舗URL" value="https://www.hotpepper.jp/strJ001274185/?vos=nhppalsa000016">
        <div>
            <a href="https://www.hotpepper.jp/strJ001274185/?vos=nhppalsa000016" target="_blank" rel="noopener noreferrer">店舗URL</a>
        </div>
    </div>
    <button type="submit" class="saveShopButton" data-form-id="form0">お気に入り</button>
</form>

であった。

フォーム入力値をMap<String,String> allParamsの形に変換

先程のコードの末尾

var form = document.getElementById(formId);

の下に

var formData = new FormData(form);

を記載する。
こうすることでリクエストの形式をKey:Valueとすることができる。
https://developer.mozilla.org/ja/docs/Web/API/FormData/FormData
より
formのinputの中身は

name="店舗名かな" value="あーきてくちゃかふぇあんどばーすみか"
name="店舗住所" value="東京都新宿区新宿3-6-7 4階"

となっているため、
keyが店舗名かな、valueがあーきてくちゃかふぇあんどばーすみか
といった形なる。

FetchAPIでリクエストを送る

https://apidog.com/jp/blog/javascript-fetch/
こちらの記事によると

Fetch APIでHTTPリクエストを送信するには、その実装コードを書かなくていけません。Fetchの実装コードの基本的な書き方は以下のようになります。

// Fetch APIでGETリクエストの例
fetch('https://example.com/api/data') 
 .then(response => {
  return response.json();  
})
.then(data => {
  // dataを処理
})
.catch(error => {
  console.error(error);  
});

上記のコードの中で、要点は次:
fetch() メソッドでリクエストを送信します
第一引数にエンドポイントのURL、第二引数にオプションを設定できます
レスポンスはPromiseを返します
then() メソッドでレスポンスを処理します
テキストやJSONなどに変換することができます
catch() でエラーハンドリングを行います

また使い方はこちらの記事がわかりやすかったです。
https://tcd-theme.com/2021/11/javascript-fetchapi.html

ではfetchメソッドでリクエストを送ってみる。

// formのaction属性にはth:action="@{/saveStore}"を設定している      
fetch(form.action, {
    method: 'POST',
    body: formData
})
.then(response => {
    if(response.ok) {
        return response.text();
    }
    throw new Error('何かしらのエラーが発生');
})
.then(data => {
    alert('登録完了');
    console.log('Success:', data);
})
.catch(error => {
    alert('登録失敗');
    console.error('Error:', error);
});

動作確認

お気に入り前

お気に入り後

非同期で登録できており
当初の問題点だった画面遷移もなく、お気に入り登録や店舗閲覧の操作性も損なっていなさそう。

コード一覧  ※作成中のコメントアウトなどはご容赦ください

StoreController

package com.example.demo.controller;

import java.io.IOException;
import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.example.demo.entity.Store;
import com.example.demo.repository.StoreCrudRepository;

@RestController
public class StoreController {

	@Autowired
	private StoreCrudRepository storeCrudRepository;
	
	@PostMapping("/saveStore")
    public Store saveStore(@RequestParam Map<String, String> allParams) throws IOException {
		Store store = new Store();
		store.setStoreName(allParams.get("shopName"));
		store.setStoreNameKana(allParams.get("店舗名かな"));
		store.setAddress(allParams.get("店舗住所"));
		store.setBusinessHours(allParams.get("営業時間"));
		store.setCatchphrase(allParams.get("店舗紹介"));
		store.setImageURL(allParams.get("店舗画像"));
		store.setStoreHpURL(allParams.get("店舗URL"));
		store.setAreaCode(allParams.get("中エリアコード"));
		store.setAreaName(allParams.get("中エリア名"));
        // allParamsから店舗情報をセット
        storeCrudRepository.save(store);
        return store;
	}
}

hello.js

//htmlを読み込み終わった後に用意する関数を作成
document.addEventListener('DOMContentLoaded', function() {
    document.querySelectorAll('.saveShopButton').forEach(button => {
		//クリックした時の関数を作成
        button.addEventListener('click', function(e) {
			//https://qiita.com/yokoto/items/27c56ebc4b818167ef9e
			//e.preventDefaultメソッドを呼び出すことで、このデフォルトの動作をキャンセルしています。
			//つまりhtmlに書いているbutton本来の処理を止める記載。
            e.preventDefault();
            
            // data-form-id属性からフォームのIDを取得
            var formId = this.getAttribute('data-form-id');
            // フォームのIdからフォームの値全てを取得する(例id="form15"でinput name="shopName"のvalue, input name="shopNameKana"のvalue など)
            var form = document.getElementById(formId);
            console.log('form:', form);
            // https://developer.mozilla.org/ja/docs/Web/API/FormData/FormData
            // form のinputのnameプロパティをキーとして値をセットする。今回の場合はth:valueが値となる。
            var formData = new FormData(form);
			console.log('formData:', formData);
            // Fetch APIを使用してフォームデータを非同期に送信
			// https://apidog.com/jp/blog/javascript-fetch/
			// formのaction属性にはth:action="@{/saveStore}"を設定している      
            fetch(form.action, {
                method: 'POST',
                body: formData
            })
            .then(response => {
                if(response.ok) {
                    return response.text();
                }
                throw new Error('何かしらのエラーが発生');
            })
            .then(data => {
				alert('登録完了');
                console.log('Success:', data);
            })
            .catch(error => {
				alert('登録失敗');
                console.error('Error:', error);
            });
        });
    });
});

list.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layout/layout}">
<head>
	<meta charset="UTF-8">
	<title>一覧表示</title>
	<script src="/js/hello.js"></script>
</head>
<body>
<div layout:fragment="main-content" class="main-content">
    <div style="width: 75%; padding: 20px;">
        <img src="https://imgfp.hotp.jp/SYS/fw_party/images/common/img_list_kv_720x99.png" alt="歓送迎会パーフェクトガイド" width="720" height="99">
        <h2>店舗一覧</h2>
        <h3 th:if="${zero}" th:text="${zero}">取得結果</h3>
        <div th:if="${shopMap != null}">
        <div th:each="shop, iterStat : ${shopMap.entrySet()}">
            <form th:id="'form' + ${iterStat.index}" th:action="@{/saveStore}" method="post">
                <input type="hidden" name="shopName" th:value="${shop.key}" />
                <div>
                    <h2 th:text="${iterStat.count} + '.' + ${shop.key}"></h2>
                </div>
                <div th:each="detail : ${shop.value.entrySet()}">
                    <input type="hidden" th:name="${detail.key}" th:value="${detail.value}" />
                    <div th:if="${detail.key == '店舗画像'}">
                        <img th:src="${detail.value}" alt="店舗画像" />
                    </div>
                    <div th:if="${detail.key != '店舗画像' 
                            && detail.key != '店舗URL' 
                            && detail.key != '店舗名かな' 
                            && detail.key != '中エリアコード' 
                            && detail.key != '中エリア名' }" 
                        th:text="${detail.key} + ': ' + ${detail.value}"></div>
                    <div th:if="${detail.key == '店舗URL'}">
                        <a th:href="${detail.value}" target="_blank" rel="noopener noreferrer">店舗URL</a>
                    </div>
                </div>
                <button type="submit" th:attr="data-form-id='form' + ${iterStat.index}" class="saveShopButton">お気に入り</button>
            </form>
        </div>
    </div>
    </div>
    </div>
    </body>
    </html>

Discussion