かんがるーさんの日記

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

Spring Boot でログイン画面 + 一覧画面 + 登録画面の Webアプリケーションを作る ( その14 )( 気になった点を修正 )

概要

Spring Boot でログイン画面 + 一覧画面 + 登録画面の Webアプリケーションを作る ( その13 )( 登録画面作成3 ) の続きです。今回は気になっていた点を修正します。

  • 今回の手順で確認できるのは以下の内容です。
    • PagenationHelper クラスのコンストラクタの引数を Page<?> page だけにする。
    • Constant クラスのフィールドを public final にして、getter メソッドを削除する。
    • application-develop.properties、application-product.properties の共通設定は application.properties に記述する。
    • Controller クラスのメソッド内から直接 response.sendError を呼び出さずに、例外を throw して @ControllerAdvice を付加したクラス内で response.sendError を呼び出すようにする。

ソフトウェア一覧

参考にしたサイト

  1. Spring-Bootの設定プロパティと環境変数
    http://qiita.com/NewGyu/items/d51f527c7199b746c6b6

    • 設定の反映順序が書いてあり、参考にしました。
  2. Spring Boot Reference Guide - 23. Externalized Configuration
    http://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#boot-features-external-config

  3. Spring Boot Error Responses
    http://www.jayway.com/2014/10/19/spring-boot-error-responses/

  4. Spring Framework Reference Documentation - 17. Web MVC framework - Advising controllers with the @ControllerAdvice annotation
    http://docs.spring.io/spring-framework/docs/4.1.x/spring-framework-reference/html/mvc.html#mvc-ann-controller-advice

  5. Java言語の例外(Exception)とカスタム例外の定義方法
    http://www.syboos.jp/java/doc/exception-and-custom-exception.html

  6. IntelliJ Idea generating serialVersionUID
    http://stackoverflow.com/questions/12912287/intellij-idea-generating-serialversionuid

  7. 多言語対応化メッセージリソースをロケールを使って読込む方法
    http://javatechnology.net/spring/read-message-properties/

  8. Spring Resource Bundle With ResourceBundleMessageSource Example
    http://www.mkyong.com/spring/spring-resource-bundle-with-resourcebundlemessagesource-example/

  9. @CONTROLLERADVICE IMPROVEMENTS IN SPRING 4
    http://blog.codeleak.pl/2013/11/controlleradvice-improvements-in-spring.html

手順

PagenationHelper のコンストラクタの引数を Page<?> page だけにする

メソッドの引数にジェネリクスの変数を渡す方法が分かったので、変更します。

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

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

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

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

  5. bootRun タスクを実行して Tomcat を起動後、検索/一覧画面でページネーションが正常に動作することを確認します。

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

Constant クラスのフィールドを public final にして、getter メソッドを削除する

Constant クラスのフィールドは外部からの変更を禁止するために本当は public final にしたかったのですが ( public final で宣言すれば constant.CONTINENT_LIST の形式でアクセスできるため )、final が付いたフィールドには @Value アノテーションで値をセットできないため getter メソッドのみ提供する形で実現していました。

final が付いたフィールドに @Value アノテーションで取得した値をセットする方法が分かりましたので変更します。

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

  2. final を付ける件とは直接関係はありませんが、設定ファイル内の定数名をフィールドに合わせたいので修正します。src/main/resources の下の constant.properties を リンク先の内容 に変更します。

  3. src/main/java/ksbysample/webapp/basic/config の下の Constant.javaリンク先の内容 に変更します。

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

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

  6. bootRun タスクを実行して Tomcat を起動後、登録画面で Continent の項目に constant.properties の CONTINENT_LIST に設定した値が表示されることを確認します。

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

application-develop.properties、application-product.properties の共通設定は application.properties に記述する

spring.profiles.active で指定した文字列が付いた設定ファイル ( -Dspring.profiles.active=develop ならば application-develop.properties ) だけが読み込まれるものと思っていたのですが、最初に application.properties が読み込まれて、その後 application-develop.properties の設定が読み込まれて上書きされるようです。

application.properties に spring.messages.cache-seconds = -1 を、application-develop.properties に spring.messages.cache-seconds = 0 を設定したら application-develop.properties の spring.messages.cache-seconds = 0 の設定の方が有効でした。

共通の設定は application.properties に記述するようにします。ついでに設定項目がアルファベット順で並ぶよう変更します。

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

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

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

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

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

  6. bootRun タスクを実行して Tomcat を起動後、検索/一覧画面、登録画面 ( 入力→確認→完了 ) でエラーが出ずに正常に動作することを確認します。

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

Controller クラスのメソッド内から直接 response.sendError を呼び出さずに、例外を throw して @ControllerAdvice を付加したクラス内で response.sendError を呼び出すようにする

Spring Framework では Controller クラスの例外処理を @ControllerAdvice アノテーションを付加したクラスに集約できるので、Controller クラスは例外を throw して、@ControllerAdvice アノテーションを付加したクラス内で response.sendError をするよう変更します。

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

  2. src/main/java/ksbysample/webapp/basic の下に exception パッケージを作成します。

  3. 独自 Exception クラスを作成します。まずは Exception クラスに付加する serialVersionUID フィールドを IntelliJ IDEA に自動生成してもらうために設定を変更します。

  4. IntelliJ IDEA のメインメニューから「File」-「Settings...」を選択します。

  5. 「Settings」ダイアログが表示されたら、画面左側で「Editor」-「Inspections」を選択し、画面中央に表示されるリストから「Serialization issues」-「Serializable class without 'serialVersionUID'」をチェックします。チェック後、「OK」ボタンをクリックしてダイアログを閉じます。

    f:id:ksby:20150224230555p:plain

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

  7. 例外発生時のエラーメッセージを作成します。src/main/resources の下の messages_ja_JP.properties を リンク先の内容 に変更します。

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

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

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

  11. bootRun タスクを実行して Tomcat を起動します。

  12. ブラウザで http://localhost:8080/country/update にアクセスします。ログイン画面が表示されたら test / ptest を入力後「ログイン」ボタンをクリックします。

  13. Whitelabel Error Page が表示され、HTTPステータスコードが 400(BAD REQUEST) になっていることが確認できます。尚、この時ログを見てみたのですが、例外が発生したソースと行数が出るログは出力されていませんでした。必要な場合にはログ出力する処理を追加する必要があります。

    f:id:ksby:20150225154320p:plain

  14. Ctrl+F2 を押して Tomcat を停止します。

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

    • commit 時に「Code Analysis」ダイアログが表示されますが、Class 'ExceptionHandlerAdvice' is never usedSuppress for classes annotated by ... を選択して無視されるようにし、それ以外の '... is never used' の Warning は何もしません。

    f:id:ksby:20150225155819p:plain

ソースコード

PagenationHelper.java

    public PagenationHelper(Page<?> page) {
        int number = page.getNumber();
        int totalPages = page.getTotalPages();

  • コンストランクタの引数を int number, int size, int totalPagesPage<?> page へ変更します。以前 Page<T> page で渡そうとしてエラーになりどうすればよいか分からなかったのですが、Page<T> ではなく Page<?> の形で渡せばよいことが分かりました。
  • number, totalPages の変数に展開する処理を追加します。コンストラクタ内で毎回 page.getNumber() 等と書くのは面倒なので、最初に変数に展開します。size はメソッド内で使用していなかったので変数に展開しませんでした。

CountryListController.java

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

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

        countryListForm.setSize(pageable.getPageSize());
        countryListForm.setPage(pageable.getPageNumber());
        Page<Country> page = countryService.findCountry(countryListForm, pageable);
        PagenationHelper ph = new PagenationHelper(page);

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

        return "countryList";
    }
  • index メソッド内の PagenationHelper のインスタンスを生成する時の引数を page.getNumber(), page.getSize(), page.getTotalPages()page へ変更します。

constant.properties

CONTINENT_LIST=Asia,Europe,North America,Africa,Oceania,Antarctica,South America
  • 定数名を continent.listCONTINENT_LIST へ変更します。

Constant.java

package ksbysample.webapp.basic.config;


import lombok.Getter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.PropertySource;
import org.springframework.stereotype.Component;

import java.util.List;

@Component
@PropertySource("classpath:constant.properties")
public class Constant {

    public final List<String> CONTINENT_LIST;

    @Autowired
    public Constant(
            @Value("#{'${CONTINENT_LIST}'.split(',')}") List<String> CONTINENT_LIST
    ) {
        this.CONTINENT_LIST = CONTINENT_LIST;
    }

}

CountryController.java

■その1

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.CONTINENT_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.CONTINENT_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";
    }

}
  • constant.getCONTINENT_LIST()constant.CONTINENT_LIST へ変更します。

■その2

package ksbysample.webapp.basic.web;

import ksbysample.webapp.basic.config.Constant;
import ksbysample.webapp.basic.exception.InvalidRequestException;
import ksbysample.webapp.basic.service.CountryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
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;
import java.util.Locale;

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

    @Autowired
    private Constant constant;

    @Autowired
    private CountryFormValidator countryFormValidator;

    @Autowired
    private CountryService countryService;

    @Autowired
    private MessageSource messageSource;

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

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

        model.addAttribute("continentList", constant.CONTINENT_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.CONTINENT_LIST);
            return "country/input";
        }

        return "country/confirm";
    }

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

        if (bindingResult.hasErrors()) {
            throw new InvalidRequestException(messageSource.getMessage("common.invalidRequestException.message", new Object[]{bindingResult.toString()}, locale));
        }

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

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

}
  • private MessageSource messageSource; を追加します。
  • update メソッドの引数に Locale locale を追加します。また throws宣言 に InvalidRequestException を追加します。
  • if (bindingResult.hasErrors()) { ... } 内の処理を InvalidRequestException を throw するよう変更します。

application.properties

hibernate.dialect = org.hibernate.dialect.MySQLDialect

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

application-develop.properties

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.messages.cache-seconds = 0

spring.thymeleaf.cache = false
  • application.properties に記述した設定を削除します。
  • 設定項目をアルファベット順に並び替えます。

application-product.properties

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

spring.thymeleaf.cache = true
  • application.properties に記述した設定を削除します。
  • 設定項目をアルファベット順に並び替えます。

InvalidRequestException.java

package ksbysample.webapp.basic.exception;

public class InvalidRequestException extends Exception {

    private static final long serialVersionUID = -2148809788806855935L;

    public InvalidRequestException() {
        super();
    }

    public InvalidRequestException(String message) {
        super(message);
    }

    public InvalidRequestException(String message, Throwable cause) {
        super(message, cause);
    }

    public InvalidRequestException(Throwable cause) {
        super(cause);
    }

}
  • serialVersionUID フィールドは以下の手順で IntelliJ IDEA に自動生成してもらいます。

    1. クラス名の InvalidRequestException のところにカーソルを移動します。

    2. 電球アイコンが表示されるので、クリックしてメニューを表示した後「Add 'serialVersionUID' field」を選択します。

      f:id:ksby:20150224231219p:plain

  • InvalidRequestException は IOException のソースを参考に作成しました。

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=数値を入力して下さい。

common.invalidRequestException.message = 本来発生し得ない Validation エラーが発生しました。不正にアクセスされた可能性があります。{0}
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" のいずれかの文字列を入力して下さい。
  • common.invalidRequestException.message を追加します。

ExceptionHandlerAdvice.java

package ksbysample.webapp.basic.web;

import ksbysample.webapp.basic.exception.InvalidRequestException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

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

@ControllerAdvice
public class ExceptionHandlerAdvice {

    @ExceptionHandler
    public void handleInvalidRequestException(InvalidRequestException e, HttpServletResponse response)
            throws IOException {
        response.sendError(HttpStatus.BAD_REQUEST.value());
    }

}

履歴

2015/02/25
初版発行。