かんがるーさんの日記

最近自分が興味をもったものを調べた時の手順等を書いています。今は Spring Boot をいじっています。

Spring Boot でログイン画面 + 一覧画面 + 登録画面の Webアプリケーションを作る ( その21 )( 検索画面 ( Spring Data JPA 版 ) 作成 )

概要

Spring Boot でログイン画面 + 一覧画面 + 登録画面の Webアプリケーションを作る ( その20 )( 登録画面作成5 ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。
    • 検索/一覧画面 ( Spring Data JPA 版 ) の作成
  • Spring Data JPA の Specifications を使用して検索/一覧画面を作成します。ページネーションも実装します。
  • URL は 検索/一覧画面 ( MyBatis-Spring版 ) とは別の /countryListJpa にします。ただし機能は /countryList と全く一緒です。
  • テストは書きません。

ソフトウェア一覧

参考にしたサイト

  1. Spring Data JPA の Specificationでらくらく動的クエリー
    http://qiita.com/tag1216/items/3a408d2751a6310e2948

  2. Spring Data JPA でのクエリー実装方法まとめ
    http://qiita.com/tag1216/items/55742fdb442e5617f727

手順

ブランチの作成

  1. IntelliJ IDEA 上で 1.0.x-makecountrylistjpa ブランチを作成します。

テンプレートファイルの新規作成、ヘッダーの修正

  1. src/main/resources/templates の下の countryList.html をコピー&ペーストして countryListJpa.html を作成します。作成後、リンク先の内容 に変更します。

  2. src/main/resources/templates/common の下の header.html を リンク先の内容 に変更します。

  3. 画面上部に "検索/一覧(JPA)" のメニューが表示されているか確認します。bootRun タスクを実行して Tomcat を起動後、ログイン画面からログインして検索/一覧画面を表示します。

    f:id:ksby:20150321223606p:plain

CountryRepository クラスの変更

  1. src/main/java/ksbysample/webapp/basic/service の下の CountryRepository.javaリンク先の内容 に変更します。

CountrySpecifications クラスの新規作成、及び CountryService クラスの変更

  1. src/main/java/ksbysample/webapp/basic/service の下に CountrySpecifications.java を新規作成します。作成後、リンク先の内容 に変更します。

  2. src/main/java/ksbysample/webapp/basic/service の下の CountryService.javaリンク先の内容 に変更します。

Controller クラスの新規作成

  1. src/main/java/ksbysample/webapp/basic/web の下に CountryListJpaController.java を新規作成します。作成後、リンク先の内容 に変更します。

動作確認

  1. Tomcat は起動したままでしたのでそのまま行けるかと思いましたが、/countryListJpa にアクセスできませんでした。Run View で Ctrl+F5 を押して Tomcat を再起動します。

  2. ブラウザで http://localhost:8080/countryListJpa にアクセスして検索/一覧画面を表示して動作を確認します。

    f:id:ksby:20150322094022p:plain

    • 画面の表示は問題なし。
    • ページングも正常に動作します。ページャーの2ページ目のリンクをクリックしたら以下の SQL ( countで件数取得 → データ取得 の順で select を2回 ) が実行されていました。

      select count(country0.code) as col_0_0 from Country country0
      select country0
      .code as code1_0, ..... from Country country0 limit 5, 5

    • 検索条件を入力すると以下の SQL ( where 句だけ記載 ) が実行されていました。

      ■全ての検索条件を入力した場合
      where (country0.code like '%J%') and (country0.name like '%Japan%') and (country0.continent like '%Asia%') and (country0.localName like '%Nippon%')
      ■localName だけ入力した場合
      where country0_.localName like '%Ni%'

  3. Run View で Ctrl+F2 を押して Tomcat を停止します。

commit、GitHub へ Push、1.0.x-makecountrylistjpa -> 1.0.x へ Pull Request、1.0.x でマージ、1.0.x-makecountrylistjpa ブランチを削除

  1. commit の前に build タスクを実行し、BUILD SUCCESSFUL が表示されることを確認します。

  2. commit、GitHub へ Push、1.0.x-makecountrylistjpa -> 1.0.x へ Pull Request、1.0.x でマージ、1.0.x-makecountrylistjpa ブランチを削除、をします。

考察

次回は。。。

以下に記載した内容の内、出来るところまで進めます。

上記の対応後に、Windows で本番稼働させるためのディレクトリ作成、bat ファイル作成、サービス登録、jar ファイル配置、動作確認して完了にする予定です。

ソースコード

countryListJpa.html

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <meta name="viewport" content="width=device-width, initial-scale=1"/>

    <title>検索/一覧画面</title>

    <!-- Bootstrap core CSS -->
    <link href="/css/bootstrap.min.css" rel="stylesheet"/>

    <!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
    <!--[if lt IE 9]>
    <script src="/js/html5shiv.min.js"></script>
    <script src="/js/respond.min.js"></script>
    <![endif]-->

    <!-- Custom styles for this template -->
    <style>
    <!--
    body {
        padding-top: 70px;
    }
    .navbar-brand {
        font-size: 24px;
    }
    .panel-heading {
        padding: 5px 10px;
    }
    .form-group {
        margin-bottom: 5px;
    }
    .pagination {
        margin: 10px 0;
    }
    .panel {
        margin-bottom: 10px;
    }
    .table {
        margin-bottom: 10px;
    }
    -->
    </style>
</head>

<body>
    <div id="header" th:include="common/header :: header (active='countryListJpa')"></div>

    <div class="container">
        <div class="panel panel-default">
            <div class="panel-heading">
                <div class="panel-title">検索条件</div>
            </div>
            <div class="panel-body">
                <form id="searchForm" method="post" action="/countryListJpa" th:action="@{/countryListJpa}" th:object="${countryListForm}" class="form-horizontal">
                    <div class="form-group" th:classappend="${#fields.hasErrors('*{code}')} ? 'has-error' : ''">
                        <label for="code" class="control-label col-sm-2">Code</label>
                        <div class="col-sm-10">
                            <div class="row"><div class="col-sm-2"><input type="text" name="code" id="code" maxlength="3" class="form-control input-sm" th:field="*{code}"/></div></div>
                            <div class="row" th:if="${#fields.hasErrors('*{code}')}"><div class="col-sm-10"><p class="form-control-static text-danger"><small th:errors="*{code}"></small></p></div></div>
                        </div>
                    </div>
                    <div class="form-group" th:classappend="${#fields.hasErrors('*{name}')} ? 'has-error' : ''">
                        <label for="name" class="control-label col-sm-2">Name</label>
                        <div class="col-sm-10">
                            <div class="row"><div class="col-sm-6"><input type="text" name="name" id="name" maxlength="52" class="form-control input-sm" th:field="*{name}"/></div></div>
                            <div class="row" th:if="${#fields.hasErrors('*{name}')}"><div class="col-sm-10"><p class="form-control-static text-danger"><small th:errors="*{name}"></small></p></div></div>
                        </div>
                    </div>
                    <div class="form-group" th:classappend="${#fields.hasErrors('*{continent}')} ? 'has-error' : ''">
                        <label for="continent" class="control-label col-sm-2">Continent</label>
                        <div class="col-sm-10">
                            <div class="row"><div class="col-sm-3"><input type="text" name="continent" id="continent" class="form-control input-sm" th:field="*{continent}"/></div></div>
                            <div class="row" th:if="${#fields.hasErrors('*{continent}')}"><div class="col-sm-10"><p class="form-control-static text-danger"><small th:errors="*{continent}"></small></p></div></div>
                        </div>
                    </div>
                    <div class="form-group" th:classappend="${#fields.hasErrors('*{localName}')} ? 'has-error' : ''">
                        <label for="localName" class="control-label col-sm-2">LocalName</label>
                        <div class="col-sm-10">
                            <div class="row"><div class="col-sm-5"><input type="text" name="localName" id="localName" maxlength="45" class="form-control input-sm" th:field="*{localName}"/></div></div>
                            <div class="row" th:if="${#fields.hasErrors('*{localName}')}"><div class="col-sm-10"><p class="form-control-static text-danger"><small th:errors="*{localName}"></small></p></div></div>
                        </div>
                    </div>
                    <div class="text-center">
                        <button type="submit" value="検索" class="btn btn-primary">検索</button>
                        <button type="reset" value="クリア" class="btn btn-default js-searchForm-clear">クリア</button>
                    </div>
                </form>
            </div>
        </div>

        <div th:if="${page} != null">
            <div id="pagenation" th:include="common/pagenation :: pagenation (url='/countryListJpa', page=${page}, ph=${ph})"></div>

            <table class="table table-condensed table-bordered table-striped">
                <tr class="info">
                    <th>Code</th>
                    <th>Name</th>
                    <th>Continent</th>
                    <th>LocalName</th>
                </tr>
                <tr th:each="country : ${page.content}">
                    <td th:text="${country.code}">ABW</td>
                    <td th:text="${country.name}">Aruba</td>
                    <td th:text="${country.continent}">North America</td>
                    <td th:text="${country.localName}">Aruba</td>
                </tr>
            </table>

            <div id="pagenation" th:include="common/pagenation :: pagenation (url='/countryListJpa', page=${page}, ph=${ph})"></div>

            <form id="pagenationForm" method="post" action="#" th:action="@{#}" th:object="${countryListForm}">
                <input type="hidden" name="code" id="code" th:value="*{code}"/>
                <input type="hidden" name="name" id="name" th:value="*{name}"/>
                <input type="hidden" name="continent" id="continent" th:value="*{continent}"/>
                <input type="hidden" name="localName" id="localName" th:value="*{localName}"/>
            </form>
        </div>
    </div>

    <!-- Bootstrap core JavaScript
    ================================================== -->
    <!-- Placed at the end of the document so the pages load faster -->
    <script src="/js/jquery.min.js"></script>
    <script src="/js/bootstrap.min.js"></script>
    <!-- IE10 viewport hack for Surface/desktop Windows 8 bug -->
    <script src="/js/ie10-viewport-bug-workaround.js"></script>

    <script type="text/javascript">
    <!--
    $(document).ready(function() {
        $('#code').focus();

        $('.js-searchForm-clear').click(function(){
            $('#searchForm')
                .find('input')
                .val('');
            $('#code').focus();
        });

        $('.js-pagenation').each(function(){
            $(this).click(function(){
                $('#pagenationForm').attr('action', $(this).attr('href'));
                $(this).attr('href', '#');
                $('#pagenationForm').submit();
            });
        });
    });
    -->
    </script>
</body>
</html>
  • <div id="header" th:include="common/header :: header (active='countryList')"></div><div id="header" th:include="common/header :: header (active='countryListJpa')"></div> へ変更します。
  • <form id="searchForm" method="post" action="/countryList" th:action="@{/countryList}" th:object="${countryListForm}" class="form-horizontal"><form id="searchForm" method="post" action="/countryListJpa" th:action="@{/countryListJpa}" th:object="${countryListForm}" class="form-horizontal"> へ変更します。action の URL を countryListJpa へ変更しています。
  • <div id="pagenation" th:include="common/pagenation :: pagenation (url='/countryList', page=${page}, ph=${ph})"></div><div id="pagenation" th:include="common/pagenation :: pagenation (url='/countryListJpa', page=${page}, ph=${ph})"></div> へ変更します。2箇所あります。

header.html

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <meta name="viewport" content="width=device-width, initial-scale=1"/>

    <title>ヘッダー</title>

    <!-- Bootstrap core CSS -->
    <link href="/css/bootstrap.min.css" rel="stylesheet"/>

    <!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
    <!--[if lt IE 9]>
    <script src="/js/html5shiv.min.js"></script>
    <script src="/js/respond.min.js"></script>
    <![endif]-->

    <!-- Custom styles for this template -->
    <style>
    <!--
    body {
        padding-top: 50px;
    }
    .navbar-brand {
        font-size: 24px;
    }
    .form-group {
        margin-bottom: 5px;
    }
    -->
    </style>
</head>

<body>

    <div th:fragment="header (active)">
        <nav class="navvar navbar-inverse navbar-fixed-top" role="navigation">
            <div class="container">
                <div class="navbar-header">
                    <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar">
                        <span class="sr-only">ナビゲーションボタン</span>
                        <span class="icon-bar"></span>
                        <span class="icon-bar"></span>
                        <span class="icon-bar"></span>
                    </button>
                    <div class="navbar-brand">WebApp - Basic</div>
                </div>
                <div id="navbar" class="collapse navbar-collapse">
                    <ul class="nav navbar-nav  navbar-left">
                        <li th:class="${active == 'countryList'} ? 'active'"><a href="/countryList"><span class="glyphicon glyphicon-search"></span> 検索/一覧</a></li>
                        <li th:class="${active == 'countryInput'} ? 'active'"><a href="/country/input"><span class="glyphicon glyphicon-pencil"></span> 登録</a></li>
                        <li th:class="${active == 'countryListJpa'} ? 'active'"><a href="/countryListJpa"><span class="glyphicon glyphicon-search"></span> 検索/一覧(JPA)</a></li>
                    </ul>
                    <ul class="nav navbar-nav navbar-right">
                        <li><a href="/logout"><span class="glyphicon glyphicon-log-out"></span> ログアウト</a></li>
                    </ul>
                </div>
            </div>
        </nav>
    </div>

</body>
</html>
  • <li th:class="${active == 'countryListJpa'} ? 'active'"><a href="/countryListJpa"><span class="glyphicon glyphicon-search"></span> 検索/一覧(JPA)</a></li> を追加します。

CountryRepository.java

package ksbysample.webapp.basic.service;

import ksbysample.webapp.basic.domain.Country;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface CountryRepository extends CrudRepository<Country, String>, JpaSpecificationExecutor<Country> {
}
  • extends 節に , JpaSpecificationExecutor<Country> を追加します。今回は JpaSpecificationExecutor インターフェースの findAll メソッドを使用します ( 以下の画像を参照 )。

    f:id:ksby:20150321224940p:plain

CountrySpecifications.java

package ksbysample.webapp.basic.service;

import ksbysample.webapp.basic.domain.Country;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.jpa.domain.Specification;

public class CountrySpecifications {

    public static Specification<Country> codeContains(String code) {
        return StringUtils.isEmpty(code) ? null : (root, query, cb) ->
                cb.like(root.get("code"), "%" + code + "%");
    }

    public static Specification<Country> nameContains(String name) {
        return StringUtils.isEmpty(name) ? null : (root, query, cb) ->
                cb.like(root.get("name"), "%" + name + "%");
    }

    public static Specification<Country> continentContains(String continent) {
        return StringUtils.isEmpty(continent) ? null : (root, query, cb) ->
                cb.like(root.get("continent"), "%" + continent + "%");
    }

    public static Specification<Country> localNameContains(String localName) {
        return StringUtils.isEmpty(localName) ? null : (root, query, cb) ->
                cb.like(root.get("localName"), "%" + localName + "%");
    }

}
  • 「参考にしたサイト」の URL のページのサンプルと IntelliJ IDEA の Lambda 変換機能を利用して Lambda で書いてみましたが、自分1人で最初から上のように書ける自信がありません。簡潔だけど、分かりやすいかどうかと言えば自分にはまだまだ Lambda は分かりにくいですね。。。

CountryService.java

package ksbysample.webapp.basic.service;

import ksbysample.webapp.basic.domain.Country;
import ksbysample.webapp.basic.web.CountryForm;
import ksbysample.webapp.basic.web.CountryListForm;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specifications;
import org.springframework.stereotype.Service;

import java.util.Collections;
import java.util.List;

import static ksbysample.webapp.basic.service.CountrySpecifications.*;

@Service
public class CountryService {

    @Autowired
    private CountryMapper countryMapper;

    @Autowired
    private CountryRepository countryRepository;

    public Page<Country> findCountry(CountryListForm countryListForm, Pageable pageable) {
        long count = countryMapper.selectCountryCount(countryListForm);

        List<Country> countryList = Collections.emptyList();
        if (count > 0) {
            countryList = countryMapper.selectCountry(countryListForm);
        }

        return new PageImpl<>(countryList, pageable, count);
    }

    public Page<Country> findCountryJpa(CountryListForm countryListForm, Pageable pageable) {
        return countryRepository.findAll(
                Specifications
                        .where(codeContains(countryListForm.getCode()))
                        .and(nameContains(countryListForm.getName()))
                        .and(continentContains(countryListForm.getContinent()))
                        .and(localNameContains(countryListForm.getLocalName()))
                , pageable);
    }

    public void save(CountryForm countryForm) {
        Country country = new Country();
        BeanUtils.copyProperties(countryForm, country);
        countryRepository.save(country);
    }

}
  • import static ksbysample.webapp.basic.service.CountrySpecifications.*; を追加します。
  • findCountryJpa メソッドを追加します。
  • またコミット時に警告が出ていたので findCountry メソッド内の return new PageImpl<Country>(countryList, pageable, count);return new PageImpl<>(countryList, pageable, count); へ変更します。

CountryListJpaController.java

package ksbysample.webapp.basic.web;

import ksbysample.webapp.basic.domain.Country;
import ksbysample.webapp.basic.helper.PagenationHelper;
import ksbysample.webapp.basic.service.CountryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/countryListJpa")
public class CountryListJpaController {

    @Autowired
    private CountryService countryService;

    private static final int DEFAULT_PAGEABLE_SIZE = 5;

    @RequestMapping
    public String index(@Validated CountryListForm countryListForm
            , BindingResult bindingResult
            , @PageableDefault(size = DEFAULT_PAGEABLE_SIZE, page = 0) Pageable pageable
            , Model model) {

        if (bindingResult.hasErrors()) {
            return "countryListJpa";
        }

        Page<Country> page = countryService.findCountryJpa(countryListForm, pageable);
        PagenationHelper ph = new PagenationHelper(page);

        model.addAttribute("page", page);
        model.addAttribute("ph", ph);

        return "countryListJpa";
    }

}

履歴

2015/03/22
初版発行。