SpringBootで、MVCモデルでアプリケーションを作成する必要が出てきたので、
練習、忘備録として記録。
formに検索条件を入力したら、DBの該当レコードを右側に表示する簡単なアプリを作成。
MybatisでのDB接続、Validationの実装など、仕事で使いそうなところを軽く確認。
Github Repository
インメモリDBを使っているので、cloneすればすぐ動作します。
一応テストコードも用意。
Controller
以下コントローラ、
詰まった点
・executeSearchメソッドにて、@Validatedアノテーションの引数の直後にBindingResultを
セットしなければならなかった。
・executeSearchメソッドにて、BindingResultと紐づけるにはModelの属性名を
クラス名の頭文字lowercaseで指定しなければならず、解決に時間がかかった。
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
特に詰まった点はなし。
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よりも柔軟性があるし、何より実装がわかりやすくていいと思った。
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周りが一番手間取った。
カスタムアノテーションなど使いこなせれば良さそうなので、要練習。
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
特にないが、強いていうならエラー周りの処理はもう少し勉強したい。
<!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>