Springboot-MVCのデモ作成

SpringBootで、MVCモデルでアプリケーションを作成する必要が出てきたので、
練習、忘備録として記録。

formに検索条件を入力したら、DBの該当レコードを右側に表示する簡単なアプリを作成。
MybatisでのDB接続、Validationの実装など、仕事で使いそうなところを軽く確認。

### Github Repository
https://github.com/ryoheiY/Spring-mvc-demo

インメモリDBを使っているので、cloneすればすぐ動作します。
一応テストコードも用意。
## Controller
以下コントローラ、
詰まった点
・executeSearchメソッドにて、＠Validatedアノテーションの引数の直後にBindingResultを
　セットしなければならなかった。
・executeSearchメソッドにて、BindingResultと紐づけるにはModelの属性名を
　クラス名の頭文字lowercaseで指定しなければならず、解決に時間がかかった。
~~~java
package com.example.demo.search.controller;

import com.example.demo.search.model.SearchCondition;
import com.example.demo.search.model.SearchResult;
import com.example.demo.search.service.SearchService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.servlet.ModelAndView;

import java.util.List;

@Controller
public class SearchController {

    Logger logger = LoggerFactory.getLogger(SearchController.class);

    SearchService searchService;

    @Autowired
    public SearchController(SearchService searchService) {
        this.searchService = searchService;
    }

    @GetMapping("/search")
    public ModelAndView initPage(ModelAndView modelAndView) {
        SearchCondition condition = new SearchCondition();
        searchService.init(condition);
        //view, model
        modelAndView.setViewName("index");
        modelAndView.addObject("searchCondition", condition);
        return modelAndView;
    }

    @PostMapping("/search")
    public ModelAndView executeSearch(
    @ModelAttribute @Validated SearchCondition condition, //form要素を受け取る
    BindingResult bindingResult, //@Validatedの直後に書かないとダメ
    ModelAndView modelAndView) {
        modelAndView.setViewName("index");
        //SearchConditionの頭文字lowercaseでmodel名指定する。
        modelAndView.addObject("searchCondition", condition);

        if(bindingResult.hasErrors()){
            logger.info("Error!");
            logger.info(bindingResult.getAllErrors().toString());
            modelAndView.addObject("errorMsg", "error!");
            return modelAndView;
        }

        List<SearchResult> results = searchService.search(condition);
        modelAndView.addObject("results", results);
        return modelAndView;
    }
}

~~~

## Service

特に詰まった点はなし。

~~~java
package com.example.demo.search.service;

import com.example.demo.search.entity.ResultEntity;
import com.example.demo.search.model.SearchCondition;
import com.example.demo.search.model.SearchResult;
import com.example.demo.search.repository.SearchRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

@Service
public class SearchServiceImpl implements SearchService {

    SearchRepository searchRepository;

    @Autowired
    public SearchServiceImpl(SearchRepository repository) {
        this.searchRepository = repository;
    }

    /**
     * 初期表示時の値をセットアップする。
     *
     * @param condition condition
     */
    public void init(SearchCondition condition) {
        condition.setNameField("Yamashita");
        condition.setAgeFrom(10);
        condition.setAgeTo(20);
    }

    /**
     * 条件をもとにResultテーブルを検索する。
     *
     * @param condition condition
     * @return 検索結果のリスト
     */
    public List<SearchResult> search(SearchCondition condition) {
        List<ResultEntity> entities = searchRepository.findByDynamicCondition(condition);
        List<SearchResult> searchResults = new ArrayList<>();
        for (ResultEntity entity : entities) {
            SearchResult searchResult = new SearchResult();
            dataTransfer(searchResult, entity);
            searchResults.add(searchResult);
        }
        return searchResults;
    }

    /**
     * searchResult　Entityから必要データだけを取り出す。
     *
     * @param searchResult 検索結果　画面データクラス
     * @param resultEntity 　Entity
     */
    private void dataTransfer(SearchResult searchResult, ResultEntity resultEntity) {
        searchResult.setName(resultEntity.getName());
        searchResult.setAge(resultEntity.getAge());
        searchResult.setEmail(resultEntity.getEmail());
    }
}

~~~

## Repository(Mybatis)
ORMは今までHibernate（JPA）しか経験がなかったが、
Hibernateよりも柔軟性があるし、何より実装がわかりやすくていいと思った。
~~~java
package com.example.demo.search.repository;

import com.example.demo.search.entity.ResultEntity;
import com.example.demo.search.model.SearchCondition;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.SelectProvider;
import org.apache.ibatis.jdbc.SQL;
import org.thymeleaf.util.StringUtils;

import java.util.List;

@Mapper
public interface SearchRepository {
    /**
     * 検索条件を元にresultテーブルからデータを取得。
     *
     * @param condition 動的検索条件
     * @return 検索結果レコードのリスト
     */
    @SelectProvider(type = SearchRepositoryProvider.class, method = "find")
    List<ResultEntity> findByDynamicCondition(SearchCondition condition);

    /**
     * 動的にSQLを生成するためのProvider
     */
    class SearchRepositoryProvider {
        /**
         * findByDynamicConditionの動的SQL生成Ï
         *
         * @param condition 検索条件クラス
         * @return sql
         */
        public String find(SearchCondition condition) {
            return new SQL() {{
                SELECT("name", "email", "age");
                FROM("result");
                if (!StringUtils.isEmpty(condition.getNameField())) {
                    WHERE("name LIKE CONCAT('%',#{nameField},'%')");
                }
                if (condition.getAgeFrom() != null) {
                    AND();
                    WHERE("age >= #{ageFrom}");
                }
                if (condition.getAgeTo() != null) {
                    AND();
                    WHERE("age <= #{ageTo}");
                }
            }}.toString();
        }
    }


}

~~~

## 画面Bean
Controllerでも書いたが、Validation周りが一番手間取った。
カスタムアノテーションなど使いこなせれば良さそうなので、要練習。
~~~java
package com.example.demo.search.model;

import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotNull;
import lombok.Data;

@Data
public class SearchCondition {
    String nameField;

    Integer ageFrom;

    Integer ageTo;

    @AssertTrue(message = "From, Toの大小関係が不正です。")
    public boolean isFromToValidate(){
        if(ageFrom != null && ageTo != null){
            return this.ageFrom <= this.ageTo;
        }
        return true;
    }

    @AssertTrue(message = "検索条件を入力してください。")
    public boolean isAllInputCheck(){
        return (this.nameField != null && !this.nameField.isEmpty())
                || this.ageFrom != null || this.ageTo != null;
    }
}

~~~

## 画面HTML
特にないが、強いていうならエラー周りの処理はもう少し勉強したい。
~~~html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="jp">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <link th:href="@{/css/index.css}" rel="stylesheet">
    <script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
<p>Hello!</p>
<div class="article">
    <div class="side">

        <div class="condition">
            <form class="w-full max-w-sm bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4" th:action="@{/search}" method="post" th:object="${searchCondition}">
                <div class="md:flex md:items-center mb-6">
                    <div class="md:w-1/3">
                        <label class="block text-gray-500 font-bold md:text-right mb-1 md:mb-0 pr-4" >
                            name
                        </label>
                    </div>
                    <div class="md:w-2/3">
                        <input class="bg-gray-200 appearance-none border-2 border-gray-200 rounded w-full py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-purple-500" th:value="*{nameField}" name="nameField" type="text">
                    </div>
                </div>
                <div class="md:flex md:items-center mb-6">
                    <div class="md:w-1/3">
                        <label class="block text-gray-500 font-bold md:text-right mb-1 md:mb-0 pr-4">
                            age from
                        </label>
                    </div>
                    <div class="md:w-2/3">
                        <input class="bg-gray-200 appearance-none border-2 border-gray-200 rounded w-full py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-purple-500" th:value="*{ageFrom}" name="ageFrom" type="number">
                    </div>
                </div>
                <div class="md:flex md:items-center mb-6">
                    <div class="md:w-1/3">
                        <label class="block text-gray-500 font-bold md:text-right mb-1 md:mb-0 pr-4">
                            age to
                        </label>
                    </div>
                    <div class="md:w-2/3">
                        <input class="bg-gray-200 appearance-none border-2 border-gray-200 rounded w-full py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-purple-500" th:value="*{ageTo}" name="ageTo" type="number">
                    </div>
                </div>
                <div class="md:flex md:items-center">
                    <div class="md:w-1/3"></div>
                    <div class="md:w-2/3">
                        <button class="shadow bg-purple-500 hover:bg-purple-400 focus:shadow-outline focus:outline-none text-white font-bold py-2 px-4 rounded" type="submit">
                            Submit
                        </button>
                    </div>
                </div>

                <div th:if="${#arrays.length(#fields.detailedErrors())} > 0" class="row">
                    <div class="col-md-12 m-3">
                        <div class="p-3 mb-2 bg-danger">
                            <div th:each="error : ${#fields.detailedErrors()}">
                                <span th:text="${error.message}"/>
                            </div>
                        </div>
                    </div>
                </div>
            </form>
        </div>
    </div>
    <div class="content">


        <div class="result">

            <div class="relative overflow-x-auto shadow-md sm:rounded-lg">
                <table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">
                    <thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
                    <tr>
                        <th scope="col" class="px-6 py-3">
                            name
                        </th>
                        <th scope="col" class="px-6 py-3">
                            email
                        </th>
                        <th scope="col" class="px-6 py-3">
                            age
                        </th>

                    </tr>
                    </thead>
                    <tbody>
                    <tr class="text-xl odd:bg-white odd:dark:bg-gray-900 even:bg-gray-100 even:dark:bg-gray-800 border-b dark:border-gray-700" th:each="result : ${results}">
                        <td th:text="${result.name}"></td>
                        <td th:text="${result.email}"></td>
                        <td th:text="${result.age}"></td>
                    </tr>

                    </tbody>
                </table>
            </div>

        </div>
    </div>
    <div></div>

</div>

</body>
</html>
~~~