かんがるーさんの日記

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

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

概要

Spring Boot でログイン画面 + 一覧画面 + 登録画面の Webアプリケーションを作る ( その5 )( Controllerクラス+Thymeleafテンプレートファイル作成 ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。
    • 検索/一覧画面 ( MyBatis-Spring版 ) の作成、確認
    • まずは検索条件の入力→一覧表示を単に実装します。ページネーションは実装しません。
  • これまでは Git/GitHubIntelliJ IDEA の使い方についても詳細を記載していましたが、今回から省略していきます。

ソフトウェア一覧

参考にしたサイト

  1. 5.5. 入力チェック — TERASOLUNA Global Framework Development Guideline 1.0.1.RELEASE documentation
    http://terasolunaorg.github.io/guideline/1.0.1.RELEASE/ja/ArchitectureInDetail/Validation.html

  2. MyBatis-Spring
    http://mybatis.github.io/spring/ja/

  3. spring boot '1.1.6' project. to '1.2.0.RC1' : Cannot subclass final class class com.sun.proxy.$Proxy88
    https://github.com/spring-projects/spring-boot/issues/1929

    • 別に作成していた GitHub 内の Repository でこの Issue へのリンクを自分の Isuue に書いていたら、相手の Issue に自分の Issue 名 ( 日本語 ) が表示されていました。うかつにリンクは貼れないですね。知りませんでした。Issue は削除できないので、あわてて Repository を削除しましたよ。。。

手順

1.0.x-makecountrylist ブランチの作成

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

Form クラスの作成、Controller クラスの修正

画面からの入力項目を格納する Form クラスを作成し、画面からの値を受け取れるよう Controller クラスのメソッドの引数に設定します。Bean Validation も試しています ( 単に試してみたかっただけであまり意味はありませんが )。

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

  2. src/main/java/ksbysample/webapp/basic/web の下の CountryListController.java の内容を リンク先のその1の内容に変更します。

  3. src/main/resources/templates の下の countryList.html の内容を リンク先のその1の内容に変更します。

  4. Bean Validation のエラーメッセージを定義するファイルを作成します。IntelliJ IDEA ではデフォルトで native2ascii が無効になっていますので有効にします。メイン画面のメニューから「File」->「Settings...」を選択します。

  5. 「Settings」ダイアログが表示されます。画面左側で「Editor」->「File Encodings」を選択後、画面右側の下部に表示される「Transparent native-to-ascii conversion」をチェックして「OK」ボタンをクリックします。

    f:id:ksby:20150117182734p:plain

  6. src/main/resources の下に ValidationMessages_ja_JP.properties を作成します。作成後、リンク先の内容に変更します。

  7. Gradle tasks View から bootRun を実行してから、検索条件入力→検索ボタンクリックしてみると、以下の画像のようにエラーメッセージが表示されます。Ctrl+F2 を押して Tomcat を停止します。

    f:id:ksby:20150117192642p:plain

    • ここではエラーメッセージが出ることを確認するために input タグの maxlength 属性を一旦削除して動作確認しています。
  8. Gradle tasks View から build タスクを実行し、BUILD SUCCESSFUL が表示されることを確認します。

  9. commit します。

    f:id:ksby:20150117193952p:plain

  10. 「Code Analysis」ダイアログが表示されますので、「Review」ボタンをクリックします。

  11. ValidationMessages_ja_JP.properties で Unused property という Warning が出力されています。使用していることを正しく検知できていないようなので、設定を無効にします。メイン画面のメニューから「File」->「Settings...」を選択します。

  12. 「Settings」ダイアログが表示されます。画面左側で「Editor」->「Inspections」を選択後、画面右側の検索文字列の入力フィールドに "Unused property" と入力した後、リストに表示される「Unused Property」のチェックを外して「OK」ボタンをクリックします。

    f:id:ksby:20150117195307p:plain

  13. 「Code Analysis」にはあと "Attribute th:... is not allowed here" の Warning が表示されていますので、エディタ上で 「Add th:... to custom html attributes」 を選択して追加して解消します。

  14. 再度 commit します。今度は「Code Analysis」ダイアログが出ずに commit されます。

ApplicationConfig.java への Bean の追加

  1. MyBatsi3 のための SqlSessionFactory を返す Bean を定義します。src/main/java/ksbysample/webapp/basic/config の下の ApplicationConfig.java をエディタで開き、リンク先の内容に変更します。

Entity クラスの作成

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

    • できれば自動生成させたかったのですが、方法が分かりませんでした。。。

MyBatis3 のマッピングファイル、Mapper クラスの作成

  1. src/main/resources の下に ksbysample/webapp/basic/service ディレクトリを作成し、その下に CountryMapper.xml を作成します。

    ※以前 Eclipse で Spring Boot を試していた時は src/main/java/ksbysample/webapp/basic/service の下に CountryMapper.xml と CountryMapper.java を一緒に置いておけばきちんと認識してくればのですが、今回試したら org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): ksbysample.webapp.basic.service.CountryMapper.selectCountry のエラーが出て認識してくれませんでした。jar ファイルを作成した時も CountryMapper.xml が jar ファイル内に出力されませんでした。resources ディレクトリの下に配置すると正常に動作する&jar ファイルにも出力してくれたので、こうしています。

  2. Project View で src/main/resources を選択した後コンテキストメニューを表示し、「New」->「Directory」を選択します。「New Directory」ダイアログが表示されたら ksbysample/webapp/basic/service と入力した後「OK」ボタンをクリックします。"/" 区切りで入力すると複数階層のディレクトリを一気に作成してくれます。

  3. src/main/resources/ksby/sample/webapp/basic/service の下にCountryMapper.xml を作成後、リンク先の内容に変更します。

  4. src/main/java/ksbysample/webapp/basic/service の下に CountryMapper.java を作成します ( CountryMapper.java はクラスではなくインターフェースです )。作成後、リンク先の内容に変更します。

Service クラスの作成

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

  2. Gradle tasks View から bootRun タスクを実行し、Started Application のログが表示されることを確認します。確認後、Ctrl+F2 を押して Tomcat を停止します。

  3. Gradle tasks View から build タスクを実行し、BUILD SUCCESSFUL が表示されることを確認します。

    • 試していた時は、実は build タスクを先に実施したのですが、test が通りませんでした。以下の2点が原因でした。

      • Java Configuraion クラスへの @MapperScan 及び sqlSessionFactory Bean の定義忘れ。
      • MyBatis3 の Mapper クラスには @Repository アノテーションを付けるものと思っていましたが、Spring Boot 1.2 からエラーとなり、@Service アノテーションにする必要がありました ( Spring Boot 1.1.x の時は @Repository アノテーションで動いていたんですが変わったようです )。
    • build タスクでエラーが出てもエラーのログは出ないため、原因が分かりませんでした。bootRun タスクで Tomcat を起動するとログが出力されます。簡単なものを除き、 bootRun タスクで Tomcat が起動することを確認 → build タスクが成功することを確認、の手順にした方がよいかもしれません。

  4. ここで一旦 commit します。commit時に「Code Analysis」ダイアログが表示されますが、「Review」ボタンをクリックして内容を確認したところ全て問題ない内容でしたので「Commit」ボタンをクリックして commit します。

Controller クラス及び Thymeleaf テンプレートファイルの修正

  1. src/main/java/ksbysample/webapp/basic/web の下の CountryListController.java をエディタで開き、リンク先のその2の内容に変更します。

  2. src/main/resources/templates の下の countryList.html をエディタで開き、リンク先のその2の内容に変更します。

動作確認

  1. Gradle tasks View から bootRun タスクを実行します。

  2. ブラウザで http://localhost:8080/countryList にアクセスして検索/一覧画面を表示した後、検索条件を入力して「検索」ボタンを押したらマッチする country テーブルのデータが表示されることを確認します。確認後、Ctrl+F2 を押して Tomcat を停止します。

    f:id:ksby:20150118183434p:plain

    • 実はここで、org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): ksbysample.webapp.basic.service.CountryMapper.selectCountry のエラーが出て動作せず、解決策もわからず結構悩みました。原因は CountryMapper.xml と CountryMapper.java を同じ src/main/java/ksbysample/webapp/basic/service の下に置いていたためでした。 詳細は上に記載しています。
  3. Gradle tasks View から build タスクを実行し BUILD SUCCESSFUL が表示されることを確認しますが、BUILD SUCCESSFUL が出るまで 5分程度かかったため原因を調査します。

  4. Project View の一番上の階層の ksbysample-webapp-basic を選択した後コンテキストメニューを表示し「Run 'Tests in 'ksbysample...' with Coverage」を選択してテストを実行すると、ログに Access to DialectResolutionInfo cannot be null when 'hibernate.dialect' not set と出力されたので、application-develop.properties, application-product.properties をエディタで開き、リンク先の内容を追加します。

  5. 再度「Run 'Tests in 'ksbysample...' with Coverage」を実行しますが、ログに Please provide a logging library and configure a valid spyLogDelegator name in the properties file. と出力されました。メイン画面のメニューから「Run」->「Edit Configurations...」を選択して「Run/Debug Configurations」ダイアログを表示した後、画面左側で「Defaults」->「JUnit」を選択し、画面右側の「VM options」に -Dlog4jdbc.spylogdelegator.name=net.sf.log4jdbc.log.slf4j.Slf4jSpyLogDelegator を追加して「OK」ボタンをクリックします。

  6. 再度「Run 'Tests in 'ksbysample...' with Coverage」を実行すると、Run View に All Tests Passed の文字が表示されます。ただし時間がかかる点が変わらないので引き続き調査します。

  7. いろいろ試してとりあえず分かったことは以下のことでした。解決策がわからないので一旦後に回します。

    • テストクラスで andExpect(status().isOk()) だけであれば時間はかからない。
    • その後の .andExpect(content().contentType("text/html;charset=UTF-8")) 以降を書くと時間がかかるようになる。
    • CountryControllerTest クラスのテストメソッドが testInput, testConfirm, testUpdate, testComplete の4つに分かれているのを1つにまとめてみても、時間がかかる点は変わらない。
    • setUp メソッド内で MockMvcBuilders.webAppContextSetup(this.context).build(); ではなく MockMvcBuilders.standaloneSetup(new CountryController()).build(); と書けば早くなると書いている Webサイトを見かけたが、試してみたらエラーになる点は変わらず ( java.lang.AssertionError: Content type not setorg.xml.sax.SAXParseException; lineNumber: 1; columnNumber: 1; 途中でファイルの末尾に達しました。(.andExpect(content().contentType("text/html;charset=UTF-8"))コメントアウトした場合 ) のエラーメッセージが出る )。
    • Spring Boot で Thymeleaf を使用する Controller のテストをする場合、HTML の内容確認はせずに Model にセットされた値のチェックだけした方がよいのではないかと思いましたが、調査に時間を取られ過ぎたので一旦保留にします。
  8. commit します。commit時に「Code Analysis」ダイアログが表示されますので、「Review」ボタンをクリックします。

  9. 「Code Analysis」に "Attribute th:... is not allowed here" の Warning が表示されていますので、エディタ上で 「Add th:... to custom html attributes」 を選択して追加して解消します。ApplicationConfig.java に Warning が表示されますが、そちらは無視します。

  10. 再度 commit します。「Code Analysis」ダイアログが出たら「Commit」ボタンをクリックします。

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

  1. git rebase -i HEAD~4 で commit を1つにします。

  2. git commit --amend でコミットメッセージを変更します。

  3. IntelliJ IDEA から GitHub へ Push します。

  4. GitHub で Pull Request を作成します。

  5. IntelliJ IDEA で 1.0.x へ切り替えた後、1.0.x-makecountrylist を merge します。

  6. GitHub へ Push します。

  7. ローカル及び GitHub の 1.0.x-makecountrylist を削除します。

ソースコード

CountryListForm.java

package ksbysample.webapp.basic.web;

import lombok.Data;

import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;

@Data
public class CountryListForm {

    @Size(max = 3, message = "{error.size.max}")
    private String code;

    @Size(max = 52, message = "{error.size.max}")
    private String name;

    @Pattern(regexp = "^(|Asia|Europe|North America|Africa|Oceania|Antarctica|South America)$", message = "{countryListForm.continent.pattern}")
    private String continent;

    @Size(max = 45, message = "{error.size.max}")
    private String localName;

}

CountryListController.java

■その1

    @RequestMapping
    public String index(@Validated CountryListForm countryListForm
            , BindingResult bindingResult
            , Model model) {

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

        return "countryList";
    }
  • index メソッドの引数に countryListForm, bindingResult, model を追加します。
  • countryListForm の Bean Validation でエラーが発生した場合には検索/一覧画面を表示する処理を追加します。

■その2

@Controller
@RequestMapping("/countryList")
public class CountryListController {

    @Autowired
    private CountryService countryService;

    @RequestMapping
    public String index(@Validated CountryListForm countryListForm
            , BindingResult bindingResult
            , Model model) {

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

        List<Country> countryList = countryService.findCountry(countryListForm);
        model.addAttribute("countryList", countryList);

        return "countryList";
    }

}
  • countryService を @Autowired する部分と、countryService を使用して country テーブルを検索する return の前の2行を追加します。

countryList.html

■その1

                <form method="post" action="/countryList" th:action="@{/countryList}" 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">クリア</button>
                    </div>
                </form>
  • 検索条件の入力フォームを上記のように変更します。
  • formタグに th:action, th:object 属性を追加します。
  • <div class="form-group" に th:classappend を追加します。
  • エラー発生時は各入力フィールドの下にエラーメッセージを表示させるようにするために、input タグ周りを大きく変更しています。また input タグに maxlength 属性を追加しています(Bean Validation によるエラーチェック発生時の動作を見たい場合には maxlength を削除して下さい)。

■その2

        <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 : ${countryList}">
                <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>
  • th:each="country : ${countryList}" とその下の th:text の部分を追加します。

ValidationMessages_ja_JP.properties

error.size.max = {max}文字以内で入力して下さい。

countryListForm.continent.pattern = Asia, Europe, North America, Africa, Oceania, Antarctica, South America のいずれかの文字列を入力して下さい。

ApplicationConfig.java

package ksbysample.webapp.basic.config;

import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.autoconfigure.jdbc.DataSourceBuilder;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.sql.DataSource;

@Configuration
@MapperScan("ksbysample.webapp")
public class ApplicationConfig {

    @Bean
    @ConfigurationProperties("spring.datasource")
    public DataSource dataSource() {
        return DataSourceBuilder.create().build();
    }

    @Bean
    public SqlSessionFactory sqlSessionFactory() throws Exception {
        SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
        sqlSessionFactoryBean.setDataSource(dataSource());
        sqlSessionFactoryBean.setTypeAliasesPackage("ksbysample.webapp");
        return sqlSessionFactoryBean.getObject();
    }

}
  • @Configuration の下に @MapperScan("ksbysample.webapp") を追加します。@MapperScan で定義したパッケージの下にあるマッピングファイルが自動的にスキャンされるようになります。
  • sqlSessionFactory Bean を追加します。

Country.java

package ksbysample.webapp.basic.domain;

import lombok.Data;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import java.math.BigDecimal;

@Entity
@Data
public class Country {

    @Id
    @Column(nullable = false)
    private String code;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private String continent;

    @Column(nullable = false)
    private String region;

    @Column(nullable = false, precision = 8, scale = 2)
    private BigDecimal surfaceArea;

    private Long indepYear;

    @Column(nullable = false)
    private Long population;

    @Column(precision = 2, scale = 1)
    private BigDecimal lifeExpectancy;

    @Column(precision = 8, scale = 2)
    private BigDecimal gNP;

    @Column(precision = 8, scale = 2)
    private BigDecimal gNPOld;

    @Column(nullable = false)
    private String localName;

    @Column(nullable = false)
    private String governmentForm;

    @Column(nullable = false)
    private String headOfState;

    private Long capital;

    @Column(nullable = false)
    private String code2;

}

CountryMapper.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="ksbysample.webapp.basic.service.CountryMapper">

    <select id="selectCountry" parameterType="countryListForm" resultType="Country">
        select  co.Code
                , co.Name
                , co.Continent
                , co.Region
                , co.SurfaceArea
                , co.IndepYear
                , co.Population
                , co.LifeExpectancy
                , co.GNP
                , co.GNPOld
                , co.LocalName
                , co.GovernmentForm
                , co.HeadOfState
                , co.Capital
                , co.Code2
        from    country co
        <where>
            <if test="countryListForm.code != null and countryListForm.code != ''">
                co.Code like '%${countryListForm.code}%'
            </if>
            <if test="countryListForm.name != null and countryListForm.name != ''">
                and co.Name like '%${countryListForm.name}%'
            </if>
            <if test="countryListForm.continent != null and countryListForm.continent != ''">
                and co.Continent like '%${countryListForm.continent}%'
            </if>
            <if test="countryListForm.localName != null and countryListForm.localName != ''">
                and co.LocalName like '%${countryListForm.localName}%'
            </if>
        </where>
        order by co.Code
    </select>

</mapper>

CountryMapper.java

package ksbysample.webapp.basic.service;

import ksbysample.webapp.basic.domain.Country;
import ksbysample.webapp.basic.web.CountryListForm;
import org.apache.ibatis.annotations.Param;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public interface CountryMapper {

    public List<Country> selectCountry(@Param("countryListForm") CountryListForm countryListForm);

}

CountryService.java

package ksbysample.webapp.basic.service;

import ksbysample.webapp.basic.domain.Country;
import ksbysample.webapp.basic.web.CountryListForm;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class CountryService {

    @Autowired
    private CountryMapper countryMapper;

    public List<Country> findCountry(CountryListForm countryListForm) {
        return countryMapper.selectCountry(countryListForm);
    }

}

application-develop.properties, application-product.properties

hibernate.dialect = org.hibernate.dialect.MySQLDialect

履歴

2015/01/19
初版発行。