かんがるーさんの日記

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

Spring Boot でログイン画面 + 一覧画面 + 登録画面の Webアプリケーションを作る ( その13 )( 登録画面作成3 )

概要

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

  • 今回の手順で確認できるのは以下の内容です。
    • 登録画面 ( 入力→確認→完了 ) の作成、確認。今回は登録処理と完了画面を実装します。
    • properties ファイルのキャッシュ?を無効化できないか調べます。

ソフトウェア一覧

参考にしたサイト

  1. Spring Boot Reference Guide - Appendix A. Common application properties
    http://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html

  2. Spring Data JPA
    http://projects.spring.io/spring-data-jpa/

  3. Spring Data JPA Examples
    https://github.com/spring-projects/spring-data-jpa-examples

  4. What is difference between CrudRepository and JpaRepository interfaces in Spring Data JPA
    http://stackoverflow.com/questions/14014086/what-is-difference-between-crudrepository-and-jparepository-interfaces-in-spring

  5. Spring Boot + JPA : Column name annotation ignored
    http://stackoverflow.com/questions/25283198/spring-boot-jpa-column-name-annotation-ignored

  6. Failed to convert property value of type java.lang.String to required type double
    http://stackoverflow.com/questions/10778822/failed-to-convert-property-value-of-type-java-lang-string-to-required-type-doubl

手順

Tomcat を起動する

Spring Loaded を生かすために今回も最初に Tomcat を起動して必要な時だけ再起動します。

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

properties ファイルのキャッシュ?を無効化する。

  1. Spring Boot Reference Guide - Appendix A. Common application properties を見ると、以下の設定がありました。cache-seconds = -1 とあるので、この設定で永久にキャッシュされているようです。また basename=messages という設定もあり、この設定ならばわざわざ messageSource Bean を定義する必要がないことが分かりました ( いろいろな Spring Boot のサンプルを見ていた時に messages.properties を使用したい時は messageSource Bean は必ず定義するものと思い込んでいたようです )。

    spring.messages.basename=messages
    spring.messages.cache-seconds=-1
    spring.messages.encoding=UTF-8

  2. develop 環境の時だけ properties ファイルのキャッシュを無効にします。src/main/resources の下の application-develop.properties を リンク先のその1の内容 に変更します。

  3. messageSource Bean を削除します。src/main/java/ksbysample/webapp/basic/config の下の ApplicationConfig.javaリンク先の内容 に変更します。

  4. 確認します。まずは Ctrl+F5 を押して Tomcat を再起動します ( application-develop.properties の変更を反映するためです )。

  5. ブラウザで http://localhost:8080/ にアクセスしてログイン画面を表示し、ID に "test" を入力して「ログイン」ボタンをクリックします。ログインエラーとなり、エラーメッセージとして「入力された ID あるいはパスワードが正しくありません」が表示されます。

    f:id:ksby:20150216020756p:plain

  6. src/main/resources の下の messages_ja_JP.properties の AbstractUserDetailsAuthenticationProvider.badCredentials の文字列の末尾に "xxx" を付けます。

  7. 再度ログイン画面の「ログイン」ボタンをクリックします。ログインエラーとなり、今度はエラーメッセージとして「入力された ID あるいはパスワードが正しくありませんxxx」が表示されます。Tomcat を再起動しなくても properties ファイルの変更が反映されています。

    f:id:ksby:20150216021116p:plain

  8. src/main/resources の下の messages_ja_JP.properties の AbstractUserDetailsAuthenticationProvider.badCredentials の文字列の末尾に付けた "xxx" を削除します。

登録処理の作成

確認画面で「登録」ボタンが押された時の処理を作成します。

  1. country テーブルの Entity クラスは既に作成済なので、Repository インターフェースを作成します。 src/main/java/ksbysample/webapp/basic/service の下に CountryRepository.java を作成します。作成後、リンク先の内容 に変更します。

  2. CountryService クラスに登録用のメソッドを追加します。src/main/java/ksbysample/webapp/basic/service の下の CountryService.javaリンク先の内容 に変更します。

  3. CountryController クラスの update メソッドを変更します。src/main/java/ksbysample/webapp/basic/web の下の CountryController.javaリンク先の内容 に変更します。

  4. 確認します。まずは Ctrl+F5 を押して Tomcat を再起動します。

  5. ブラウザで http://localhost:8080/country/input にアクセスして登録画面を表示し、値を入力して「確認」ボタンをクリックします。

    f:id:ksby:20150218020004p:plain

  6. 確認画面が表示されますので「登録」ボタンをクリックします。

    f:id:ksby:20150218020150p:plain

  7. HTTP 500 が返ってきました。ログを見ると com.mysql.jdbc.exceptions.jdbc4.MySQLSyntaxErrorException: Unknown column 'country0_.government_form' in 'field list' というエラーが出力されていました。原因を調査します。

    f:id:ksby:20150218020351p:plain

  8. 調査して分かったことを以下に記載します。

    • CrudRepository の save メソッドの実装は、いきなり update するのではなくまずは select していました。Exception が出力される直前のログに select 文が出力されていました。

    • DB のカラム名は GovernmentForm、Entity クラスのフィールド名は governmentForm で定義しているので、select 文では GovernmentForm になると思ったのですが、実際には government_form になっていました。Entity クラスではキャメルケースで定義していたものが、select 文の時にスネークケースに変更されたようです。

    • 更にいろいろ調べてみると、どうもデフォルトの設定ではキャメルケースがスネークケースに変換されるようになっているようです。また @Column アノテーションカラム名を明示しても効かないようです。spring.jpa.hibernate.naming_strategy=org.hibernate.cfg.EJB3NamingStrategy を定義すれば解決するとのことでした。

  9. src/main/resources の下の application-develop.properties を リンク先のその2の内容 に変更します。

  10. src/main/resources の下の application-product.properties を リンク先の内容 に変更します。

  11. 再度 Ctrl+F5 を押して Tomcat を再起動した後、確認画面まで進めて「登録」ボタンをクリックしますが、また HTTP 500 が返ってきました。ログを見ると今度は com.mysql.jdbc.exceptions.jdbc4.MySQLIntegrityConstraintViolationException: Column 'Population' cannot be null というエラーが出力されていました。

  12. 調査すると、原因は CountryForm クラスのフィールド population の型が BigDecimal なのに対し、Country クラスのフィールド population の型が Long のためでした。country テーブルの Poplation は INT(11) なので、どちらも Long型に変更します。src/main/java/ksbysample/webapp/basic/web の下の CountryForm.javaリンク先の内容 に変更します。

  13. 再度 Ctrl+F5 を押して Tomcat を再起動した後、確認画面まで進めて「登録」ボタンをクリックします。今度は完了画面まで進みました。

    f:id:ksby:20150218091605p:plain

    データも入力した通り登録されています。

    f:id:ksby:20150218091926p:plain

    また、完了画面の「次のデータを登録する」「検索/一覧画面へ」ボタンをクリックするとそれぞれ登録画面、検索/一覧画面へ遷移されましたので、完了画面は何も変更しません。

  14. Population の型を Long に変更したので、数値以外の値を入力した時のエラーメッセージが今のままだと英語のものが表示されます。

    f:id:ksby:20150218092813p:plain

  15. src/main/resources の下の messages_ja_JP.properties を リンク先の内容 に変更します。今度は日本語のエラーメッセージが表示されます。

    f:id:ksby:20150218093144p:plain

Tomcat を終了する

  1. Ctrl+F2 を押して Tomcat を停止します。相変わらず Tomcat を再起動していて Spring Loaded を有効に活用できていませんね。。。

commit する

本来テストクラスの作成が残っていますが、かなり時間がかかったので一旦 commit します。

  1. commit の前に build タスクが成功するか確認します。Gradle tasks View から build を実行しますが、BUILD FAILED が出力されました。test タスクでエラーが出ているようです。

  2. 「Run 'Tests in 'ksbysample...' with Coverage」を実行してエラーが出ているテストを確認すると、CountryControllerTest クラスの testConfirm, testUpdate でした。一旦この2つのテストは実行されないようにします。src/main/test/java/ksbysample/webapp/basic/web の下の CountryControllerTest.javaリンク先の内容 に変更します。

    f:id:ksby:20150220004006p:plain

  3. 再度 Gradle tasks View から build を実行します。今度は BUILD SUCCESSFUL が出力されました。

  4. commit します。

    f:id:ksby:20150220005331p:plain

  5. 「Code Analysis」ダイアログが表示されますので「Review」ボタンをクリックして内容を確認します。以下の対応だけ行います。

    • Method 'initBinder(...)' is never used の Warning は Suppress for methods ... を選択して無視されるようにします。

    f:id:ksby:20150220010009p:plain

    • Local variable 'page' is redundant の Warning は Inline variable を選択して修正します。

    f:id:ksby:20150220010216p:plain

    • あと2つ Class 'ApplicationTest' is never usedPrivate field 'CONTINENT_LIST' is never assinged が出ますが、これらは何も対応しません。
  6. 再度 commit し、「Code Analysis」ダイアログが表示されたら「Commit」ボタンをクリックします。

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

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

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

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

  4. GitHub へ Push します。

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

次回は。。。

  • いくつか気になっている点があるので、その辺を修正します。
  • 修正後に登録画面 ( 入力→確認→完了 ) のテストクラスを作成します。

ソースコード

application-develop.properties

■その1

spring.thymeleaf.cache = false

spring.datasource.url = jdbc:log4jdbc:mysql://localhost/world
spring.datasource.username = root
spring.datasource.password = xxxxxxxx
spring.datasource.driverClassName = net.sf.log4jdbc.sql.jdbcapi.DriverSpy

spring.jpa.hibernate.ddl-auto = none

hibernate.dialect = org.hibernate.dialect.MySQLDialect

spring.messages.cache-seconds = 0
  • spring.messages.cache-seconds = 0 を追加します。値を 0 で設定することでキャッシュを無効にします。

■その2

spring.thymeleaf.cache = false

spring.datasource.url = jdbc:log4jdbc:mysql://localhost/world
spring.datasource.username = root
spring.datasource.password = xxxxxxxx
spring.datasource.driverClassName = net.sf.log4jdbc.sql.jdbcapi.DriverSpy

spring.jpa.hibernate.ddl-auto = none
spring.jpa.hibernate.naming_strategy = org.hibernate.cfg.EJB3NamingStrategy

hibernate.dialect = org.hibernate.dialect.MySQLDialect

spring.messages.cache-seconds = 0
  • spring.jpa.hibernate.naming_strategy = org.hibernate.cfg.EJB3NamingStrategy を追加します。

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.boot.env.YamlPropertySourceLoader;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;

import javax.sql.DataSource;
import java.io.IOException;

@Configuration
@MapperScan("ksbysample.webapp.basic")
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.basic");
        return sqlSessionFactoryBean.getObject();
    }

}
  • messageSource Bean を削除します。

CountryRepository.java

package ksbysample.webapp.basic.service;

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

@Repository
public interface CountryRepository extends CrudRepository<Country, String> {
}
  • CRUD の機能だけ必要なので JpaRepository インターフェースではなく CrudRepository インターフェースを継承しています。ちなみに JpaRepository が一番機能があり以下の画像の全てのメソッドが使用できます。CrudRepository が一番機能が少なく下半分のメソッドしか使用できません。

    f:id:ksby:20150217012536p:plain

    ※この画像のダイアログは public interface CountryRepositorypublic class CountryRepository に変更した後、Alter+Enter を押してコンテキストメニューを表示して「Implement Methods」を選択すると表示されます。

    f:id:ksby:20150217013536p:plain

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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

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

@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);
        }

        Page<Country> page = new PageImpl<Country>(countryList, pageable, count);
        return page;
    }

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

}

CountryController.java

package ksbysample.webapp.basic.web;

import ksbysample.webapp.basic.config.Constant;
import ksbysample.webapp.basic.service.CountryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
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.WebDataBinder;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Controller
@RequestMapping("/country")
public class CountryController {

    @Autowired
    private Constant constant;

    @Autowired
    private CountryFormValidator countryFormValidator;

    @Autowired
    private CountryService countryService;

    @InitBinder
    public void initBinder(WebDataBinder binder) {
        binder.addValidators(countryFormValidator);
    }

    @RequestMapping("/input")
    public String input(CountryForm countryForm
            , Model model) {

        model.addAttribute("continentList", constant.getCONTINENT_LIST());
        return "country/input";
    }

    @RequestMapping("/input/back")
    public String inputBack(CountryForm countryForm
            , RedirectAttributes redirectAttributes) {

        redirectAttributes.addFlashAttribute("countryForm", countryForm);
        return "redirect:/country/input";
    }

    @RequestMapping("/confirm")
    public String confirm(@Validated CountryForm countryForm
            , BindingResult bindingResult
            , Model model) {

        if (bindingResult.hasErrors()) {
            model.addAttribute("continentList", constant.getCONTINENT_LIST());
            return "country/input";
        }

        return "country/confirm";
    }

    @RequestMapping("/update")
    public String update(@Validated CountryForm countryForm
            , BindingResult bindingResult
            , Model model
            , HttpServletResponse response) throws IOException {

        if (bindingResult.hasErrors()) {
            response.sendError(HttpStatus.BAD_REQUEST.value());
            return null;
        }

        countryService.save(countryForm);
        return "redirect:/country/complete";
    }

    @RequestMapping("/complete")
    public String complete() {
        return "country/complete";
    }

}
  • private CountryService countryService; を追加します。
  • update メソッドの引数に @Validated CountryForm countryForm, BindingResult bindingResult, Model model, HttpServletResponse response を追加します。またメソッド内の処理で、チェックエラーが発生した場合には HTTPステータスコード 400 を返す処理を追加し、問題なければ countryService.save を呼び出して入力された値を DB に登録します。

application-product.properties

spring.thymeleaf.cache = true

spring.datasource.url = jdbc:mysql://localhost/world
spring.datasource.username = root
spring.datasource.password = xxxxxxxx
spring.datasource.driverClassName = com.mysql.jdbc.Driver

spring.jpa.hibernate.ddl-auto = none
spring.jpa.hibernate.naming_strategy = org.hibernate.cfg.EJB3NamingStrategy

hibernate.dialect = org.hibernate.dialect.MySQLDialect
  • spring.jpa.hibernate.naming_strategy = org.hibernate.cfg.EJB3NamingStrategy を追加します。

CountryForm.java

    @NotNull
    @Digits(integer=11, fraction=0, message = "{error.digits.integeronly}")
    private Long population;
  • population の型を BigDecimail → Long に変更します。

messages_ja_JP.properties

AbstractUserDetailsAuthenticationProvider.locked=入力された ID はロックされています
AbstractUserDetailsAuthenticationProvider.disabled=入力された ID は使用できません
AbstractUserDetailsAuthenticationProvider.expired=入力された ID の有効期限が切れています
AbstractUserDetailsAuthenticationProvider.credentialsExpired=入力された ID のパスワードの有効期限が切れています
AbstractUserDetailsAuthenticationProvider.badCredentials=入力された ID あるいはパスワードが正しくありません

typeMismatch.java.math.BigDecimal=数値を入力して下さい。
typeMismatch.java.lang.Long=数値を入力して下さい。

countryForm.code2.equalCode = Code2 には Code と異なる文字列を入力して下さい。
countryForm.continent.notAsia = Name に "Japan" あるいは "日本" を入力している場合、Continent は "Asia" を選択して下さい。
countryForm.region.notAsiaPattern = Continent に "Asia" を選択している場合、Region には "Eastern Asia", "Middle East", "Southeast Asia", "Southern and Central Asia" のいずれかの文字列を入力して下さい。
  • typeMismatch.java.lang.Long を追加します。

CountryControllerTest.java

    @Ignore
    @Test
    public void testConfirm() throws Exception {
        this.mvc.perform(get("/country/confirm")).andExpect(status().isOk())
                .andExpect(content().contentType("text/html;charset=UTF-8"))
                .andExpect(view().name("country/confirm"))
                .andExpect(xpath("/html/head/title").string("Countryデータ登録画面(確認)"));
    }

    @Ignore
    @Test
    public void testUpdate() throws Exception {
        this.mvc.perform(get("/country/update")).andExpect(status().isFound())
                .andExpect(redirectedUrl("/country/complete"));
    }

履歴

2015/02/20
初版発行。