かんがるーさんの日記

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

Spring Boot で書籍の貸出状況確認・貸出申請する Web アプリケーションを作る ( 番外編 )( Spring MVC メモ書き )

最近 Spring Boot で実装していて使うようになった、あるいは調べた Spring MVC 関連のメモ書きです。

目次

  1. Spring MVC でボタンによって入力チェックの内容を変えるには? ( groups 属性を使う場合 )
  2. Spring MVC でボタンによって入力チェックの内容を変えるには? ( groups 属性を使わない場合 )
  3. IntelliJ IDEA Ultimate Edition で Thymeleaf テンプレートファイル内の ${...} や *{...} で補完を有効にするには?
  4. Spring MVC で入力エラー時に表示しているデータを消さないようにするには?

メモ書き

Spring MVC でボタンによって入力チェックの内容を変えるには? ( groups 属性を使う場合 )

Bean Validation のアノテーションには groups 属性が用意されており、例えばボタンAとボタンBで実行される Bean Validation を変えたい場合には groups 属性を指定することで実現できます。

説明のために、最初に以下のソースを新規作成、変更しておきます。

beanValidationGroup.html は以下の画面です。

f:id:ksby:20160221120221p:plain

例えばこの画面で、以下のように入力チェックを切り替えたいとします。

  • 「ファイルアップロード」ボタンが押された場合
    • ID のみ必須にする。
  • 「データ更新」ボタンが押された場合
    • ID、名前、住所を必須にする。
  • 「メール送信」ボタンが押された場合
    • ID、名前、メールアドレスを必須にする。

以下のようにソースを変更します。

■BeanValidationGroupForm.java

package ksbysample.webapp.lending.web.springmvcmemo;

import lombok.Data;
import org.hibernate.validator.constraints.NotBlank;
import org.springframework.web.multipart.MultipartFile;

@Data
public class BeanValidationGroupForm {

    public static interface FileUploadGroup {}
    public static interface EditGroup {}
    public static interface SendmailGroup {}

    @NotBlank(groups = {FileUploadGroup.class, EditGroup.class, SendmailGroup.class})
    private Long id;

    private MultipartFile fileupload;

    @NotBlank(groups = {EditGroup.class, SendmailGroup.class})
    private String name;

    @NotBlank(groups = {EditGroup.class})
    private String address;

    @NotBlank(groups = {SendmailGroup.class})
    private String mailAddress;

}
  • public static interface FileUploadGroup {}, public static interface EditGroup {}, public static interface SendmailGroup {} を追加します。
  • Bean Validation のアノテーションに groups 属性を設定します。

■BeanValidationGroupController.java

    @RequestMapping("/fileupload")
    public String fileupload(@Validated(FileUploadGroup.class) BeanValidationGroupForm beanValidationGroupForm
            , BindingResult bindingResult) {
        ..........
    }

    @RequestMapping("/edit")
    public String edit(@Validated(EditGroup.class) BeanValidationGroupForm beanValidationGroupForm
            , BindingResult bindingResult) {
        ..........
    }

    @RequestMapping("/sendmail")
    public String sendmail(@Validated(SendmailGroup.class) BeanValidationGroupForm beanValidationGroupForm
            , BindingResult bindingResult) {
        ..........
    }
  • @Validated@Validated(FileUploadGroup.class) のように各メソッドの引数に付加した @Validated アノテーションにグルーピング用のインターフェースを指定します。

これで入力チェックの切り替えが実現できます。何も入力せずに各ボタンを押すと以下のようにエラーメッセージが表示されます。

■「ファイルアップロード」ボタンが押された場合 f:id:ksby:20160221132220p:plain

■「データ更新」ボタンが押された場合 f:id:ksby:20160221132308p:plain

■「メール送信」ボタンが押された場合 f:id:ksby:20160221132357p:plain

1点注意があり、groups 属性を指定する場合には全ての Bean Validation に groups 属性を指定しなければなりません。常に Bean Validation が実行されて欲しいからといって、以下のように @NotBlank しか書いていないと Bean Validation は実行されません。

    @NotBlank
    private Long id;

Spring MVC でボタンによって入力チェックの内容を変えるには? ( groups 属性を使わない場合 )

groups 属性を使わずに Bean Validation を切り分ける方法があります。Spring MVC では Controller に複数の Form クラスが渡されていて、その中に同じ名前のフィールドが定義されている場合、同じ値がセットされる動作を利用する方法です。

src/main/java/ksbysample/webapp/lending/config/ApplicationConfig.java に validator Bean を記述します。

package ksbysample.webapp.lending.config;

import org.springframework.amqp.core.Queue;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.validation.Validator;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import org.thymeleaf.extras.java8time.dialect.Java8TimeDialect;

@Configuration
public class ApplicationConfig {

    ..........

    @Bean
    public Validator validator() {
        return new LocalValidatorFactoryBean();
    }

}

src/main/java/ksbysample/webapp/lending/web/springmvcmemo の下に EditFormChecker.java, SendmailFormChecker.java を作成します。ここでチェックしたいフィールドに Bean Validation を付加します。

■EditFormChecker.java

package ksbysample.webapp.lending.web.springmvcmemo;

import lombok.Data;
import org.hibernate.validator.constraints.NotBlank;
import org.springframework.web.multipart.MultipartFile;

@Data
public class EditFormChecker {

    @NotBlank
    private Long id;

    private MultipartFile fileupload;

    @NotBlank
    private String name;

    @NotBlank
    private String address;

    private String mailAddress;

}

■SendmailFormChecker.java

package ksbysample.webapp.lending.web.springmvcmemo;

import lombok.Data;
import org.hibernate.validator.constraints.NotBlank;
import org.springframework.web.multipart.MultipartFile;

@Data
public class SendmailFormChecker {

    @NotBlank
    private Long id;

    private MultipartFile fileupload;

    @NotBlank
    private String name;

    private String address;

    @NotBlank
    private String mailAddress;

}

src/main/java/ksbysample/webapp/lending/web/springmvcmemo/BeanValidationGroupController.java を以下のように変更します。

package ksbysample.webapp.lending.web.springmvcmemo;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.validation.Validator;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/springMvcMemo/beanValidationGroup")
public class BeanValidationGroupController {

    @Autowired
    private Validator validator;

    ..........

    @RequestMapping("/edit")
    public String edit(@Validated BeanValidationGroupForm beanValidationGroupForm
            , BindingResult bindingResult
            , EditFormChecker editFormChecker) {
        validator.validate(editFormChecker, bindingResult);
        if (bindingResult.hasErrors()) {
            return "springmvcmemo/beanValidationGroup";
        }

        return "springmvcmemo/beanValidationGroup";
    }

    @RequestMapping("/sendmail")
    public String sendmail(@Validated BeanValidationGroupForm beanValidationGroupForm
            , BindingResult bindingResult
            , SendmailFormChecker sendmailFormChecker) {
        validator.validate(sendmailFormChecker, bindingResult);
        if (bindingResult.hasErrors()) {
            return "springmvcmemo/beanValidationGroup";
        }

        return "springmvcmemo/beanValidationGroup";
    }

}
  • @Autowired private Validator validator; を追加します。
  • edit メソッドの @Validated アノテーションは何も付けないようにし、第3引数に EditFormChecker editFormChecker を追加します。
  • edit メソッドの最初で validator.validate(editFormChecker, bindingResult); を呼び出すようにします。この時 beanValidationGroupForm に関連付けている bindingResult を第2引数に指定します。
  • sendmail メソッドも @Validated アノテーションは何も付けないようにし、第3引数に SendmailFormChecker sendmailFormChecker を追加します。
  • edit メソッドの最初で validator.validate(sendmailFormChecker, bindingResult); を呼び出すようにします。

これで EditFormChecker, SendmailFormChecker に定義した Bean Validation の結果が BeanValidationGroupForm に関連付けた bindingResult にセットされるようになります。

実際に動かして試してみると groups 属性を使った場合と同様にエラーメッセージが表示されます。

■「データ更新」ボタンが押された場合 f:id:ksby:20160224070815p:plain

■「メール送信」ボタンが押された場合 f:id:ksby:20160224070927p:plain

Form クラス内に @Valid アノテーションを付加したサブ Form がある場合でも動作します。@Valid アノテーションには groups 属性がないため、groups 属性を使う場合には サブ Form 内のフィールドの各 Bean Validation に groups 属性を指定しなければなりませんが、この方法の場合チェック用の Form クラスから @Valid アノテーションとサブ Form の定義自体を削除してしまえばチェックしないようにすることができます。

尚、この方法は他に書いている人を見かけたことがありません。邪道なんですよね。。。 ( あるいは何か気付いていない問題があるのか ) フィールド数が多い場合には便利だと思うんですけど、使って良いのか悩みます。もしかすると使うことがあるかもしれないので、メモ書きとして残しておきます。

IntelliJ IDEA Ultimate Edition で Thymeleaf テンプレートファイル内の ${...} や *{...} で補完を有効にするには?

Thymeleaf テンプレートファイル内で <!-- @thymesVar id="beanValidationGroupForm" type="ksbysample.webapp.lending.web.springmvcmemo.beanValidationGroupForm" --> のように <!-- @thymesVar id="..." type="..." --> のフォーマットの記述を入れると補完が有効になります。

f:id:ksby:20160221161253p:plain

f:id:ksby:20160221161449p:plain

*{...} の場合でも補完されます。

f:id:ksby:20160221161611p:plain

ずっと欲しいと思っていた機能でした。これが出来るようになるとかなり便利です。

Spring MVC で入力エラー時に表示しているデータを消さないようにするには?

  • 表示用フィールド、入力用フィールドを1つの Form クラスに入れておきます。
  • input タグのような入力用フィールドは特に何もしません。
  • th:text で表示しているような表示用フィールドは、同じデータを <input type="hidden" ... /> にも出力しておきます。これにより Controller クラスのメソッドが呼び出された時に Form クラスに値がセットされます。
  • あとは入力チェックエラーの時に、単に return するだけです。以前は入力チェックエラーの時には return の前に表示用データを取得していたのですが、最近はこの方法を使っています。
    @RequestMapping("/edit")
    public String edit(@Validated(EditGroup.class) BeanValidationGroupForm beanValidationGroupForm
            , BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            return "springmvcmemo/beanValidationGroup";
        }

        return "springmvcmemo/beanValidationGroup";
    }

ソースコード

WebSecurityConfig.java

package ksbysample.webapp.lending.config;

import ksbysample.webapp.lending.security.RoleAwareAuthenticationSuccessHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.security.SecurityProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
@Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    public static final String DEFAULT_SUCCESS_URL = "/loginsuccess";
    public static final String REMEMBERME_KEY = "ksbysample-webapp-lending";

    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                // 認証の対象外にしたいURLがある場合には、以下のような記述を追加します
                // 複数URLがある場合はantMatchersメソッドにカンマ区切りで対象URLを複数列挙します
                // .antMatchers("/country/**").permitAll()
                .antMatchers("/fonts/**").permitAll()
                .antMatchers("/html/**").permitAll()
                .antMatchers("/encode").permitAll()
                .antMatchers("/urllogin").permitAll()
                .antMatchers("/webapi/**").permitAll()
                .antMatchers("/springMvcMemo/**").permitAll()
                .anyRequest().authenticated();
        http.formLogin()
                .loginPage("/")
                .loginProcessingUrl("/login")
                .defaultSuccessUrl(WebSecurityConfig.DEFAULT_SUCCESS_URL)
                .failureUrl("/")
                .usernameParameter("id")
                .passwordParameter("password")
                .successHandler(new RoleAwareAuthenticationSuccessHandler())
                .permitAll()
                .and()
                .logout()
                .logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
                .logoutSuccessUrl("/")
                .deleteCookies("JSESSIONID")
                .deleteCookies("remember-me")
                .invalidateHttpSession(true)
                .permitAll()
                .and()
                .rememberMe()
                .key(REMEMBERME_KEY)
                .tokenValiditySeconds(60 * 60 * 24 * 30);
    }

    @Bean
    public AuthenticationProvider daoAuhthenticationProvider() {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(userDetailsService);
        daoAuthenticationProvider.setHideUserNotFoundExceptions(false);
        daoAuthenticationProvider.setPasswordEncoder(new BCryptPasswordEncoder());
        return daoAuthenticationProvider;
    }

    @Autowired
    public void configAuthentication(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(daoAuhthenticationProvider())
                .userDetailsService(userDetailsService);
    }

}
  • .antMatchers("/springMvcMemo/**").permitAll() を追加します。/springMvcMemo から始まる URL の場合には認証不要にします。

beanValidationGroup.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"/>
    <title>Spring MVC メモ書き</title>
    <!-- Tell the browser to be responsive to screen width -->
    <meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport"/>
    <link th:replace="common/head-cssjs"/>
    <!-- Bootstrap File Input -->
    <link href="/css/fileinput.min.css" rel="stylesheet" type="text/css"/>

    <style type="text/css">
        <!--
        .table {
            background-color: #ffccff;
            margin-bottom: 10px;
        }
        .has-error .form-control {
            background-color: #fff5ee;
        }
        .form-group {
            margin-bottom: 0px;
        }
        -->
    </style>
</head>

<!-- ADD THE CLASS layout-top-nav TO REMOVE THE SIDEBAR. -->
<body class="skin-blue layout-top-nav">
<div class="wrapper">

    <!-- Main Header -->
    <div th:replace="common/mainparts :: main-header"></div>

    <!-- Full Width Column -->
    <div class="content-wrapper">
        <div class="container">
            <!-- Content Header (Page header) -->
            <section class="content-header">
                <h1>Spring MVC メモ書き</h1>
            </section>

            <!-- Main content -->
            <section class="content">
                <div class="row">
                    <div class="col-xs-12">
                        <!-- @thymesVar id="beanValidationGroupForm" type="ksbysample.webapp.lending.web.springmvcmemo.BeanValidationGroupForm" -->
                        <form id="beanValidationGroupForm" enctype="multipart/form-data" method="post"
                              action="/springMvcMemo/beanValidationGroup/fileupload" th:action="@{/springMvcMemo/beanValidationGroup/fileupload}"
                              th:object="${beanValidationGroupForm}">
                            <table class="table table-bordered">
                                <colgroup>
                                    <col width="20%"/>
                                    <col width="80%"/>
                                </colgroup>
                                <tbody>
                                    <tr>
                                        <th>ID</th>
                                        <td>
                                            <div class="row"><div class="col-xs-12">
                                                <span th:text="*{id}">1</span>
                                            </div></div>
                                            <div class="row" th:if="${#fields.hasErrors('*{id}')}"><div class="col-xs-12">
                                                <p class="form-control-static text-danger" th:errors="*{id}">ここにエラーメッセージを表示します</p>
                                            </div></div>
                                        </td>
                                    </tr>
                                    <tr>
                                        <th>アップロードファイル</th>
                                        <td>
                                            <div class="col-xs-12">
                                                <div class="form-group">
                                                    <input type="file" name="fileupload" class="js-fileupload"/>
                                                </div>
                                                <div class="callout callout-danger" th:if="${#fields.hasGlobalErrors()}">
                                                    <h4><i class="fa fa-warning"></i> アップロードされたCSVファイルでエラーが発生しました。</h4>
                                                    <ul th:each="err : ${#fields.globalErrors()}">
                                                        <li th:text="${err}">エラーメッセージ</li>
                                                    </ul>
                                                </div>
                                            </div>
                                        </td>
                                    </tr>
                                    <tr>
                                        <th>名前</th>
                                        <td>
                                            <div class="col-xs-12">
                                                <div class="form-group" th:classappend="${#fields.hasErrors('*{name}')} ? 'has-error' : ''">
                                                    <div class="row"><div class="col-xs-8">
                                                        <input type="text" name="name" id="name" class="form-control input-sm" value=""
                                                               placeholder="(例) 田中 太郎" th:field="*{name}"/>
                                                    </div></div>
                                                    <div class="row" th:if="${#fields.hasErrors('*{name}')}"><div class="col-xs-12">
                                                        <p class="form-control-static text-danger" th:errors="*{name}">ここにエラーメッセージを表示します</p>
                                                    </div></div>
                                                </div>
                                            </div>
                                        </td>
                                    </tr>
                                    <tr>
                                        <th>住所</th>
                                        <td>
                                            <div class="col-xs-12">
                                                <div class="form-group" th:classappend="${#fields.hasErrors('*{address}')} ? 'has-error' : ''">
                                                    <div class="row"><div class="col-xs-12">
                                                        <input type="text" name="address" id="address" class="form-control input-sm" value=""
                                                               placeholder="住所を都道府県から入力してください" th:field="*{address}"/>
                                                    </div></div>
                                                    <div class="row" th:if="${#fields.hasErrors('*{address}')}"><div class="col-xs-12">
                                                        <p class="form-control-static text-danger" th:errors="*{address}">ここにエラーメッセージを表示します</p>
                                                    </div></div>
                                                </div>
                                            </div>
                                        </td>
                                    </tr>
                                    <tr>
                                        <th>メールアドレス</th>
                                        <td>
                                            <div class="col-xs-12">
                                                <div class="form-group" th:classappend="${#fields.hasErrors('*{mailAddress}')} ? 'has-error' : ''">
                                                    <div class="row"><div class="col-xs-12">
                                                        <input type="text" name="mailAddress" id="mailAddress" class="form-control input-sm" value=""
                                                               placeholder="(例) test@sample.com" th:field="*{mailAddress}"/>
                                                    </div></div>
                                                    <div class="row" th:if="${#fields.hasErrors('*{mailAddress}')}"><div class="col-xs-12">
                                                        <p class="form-control-static text-danger" th:errors="*{mailAddress}">ここにエラーメッセージを表示します</p>
                                                    </div></div>
                                                </div>
                                            </div>
                                        </td>
                                    </tr>
                                </tbody>
                            </table>
                            <input type="hidden" th:field="*{id}"/>
                            <div class="text-center">
                                <button class="btn bg-blue js-btn-edit"><i class="fa fa-edit"></i> データ更新</button>
                                <button class="btn bg-orange js-btn-sendmail"><i class="fa fa-envelope-o"></i> メール送信</button>
                            </div>
                        </form>
                    </div>
                </div>
            </section>
            <!-- /.content -->
        </div>
        <!-- /.container -->
    </div>

</div>
<!-- ./wrapper -->

<script th:replace="common/bottom-js"></script>
<!-- Bootstrap File Input -->
<script src="/js/fileinput.min.js" type="text/javascript"></script>
<script src="/js/fileinput_locale_ja.js" type="text/javascript"></script>
<script type="text/javascript">
    <!--
    $(document).ready(function() {
        $('.js-fileupload').fileinput({
            language: 'ja',
            showPreview: false,
            maxFileCount: 1,
            browseClass: 'btn btn-info fileinput-browse-button',
            browseIcon: '',
            browseLabel: ' ファイル選択',
            removeClass: 'btn btn-warning',
            removeIcon: '',
            removeLabel: ' 削除',
            uploadClass: 'btn btn-success fileinput-upload-button',
            uploadIcon: '<i class="fa fa-upload"></i>',
            uploadLabel: ' アップロード',
            allowedFileExtensions: ['jpg', 'gif', 'png'],
            msgValidationError: '<span class="text-danger"><i class="fa fa-warning"></i> JPG,GIF,PNG ファイルのみ有効です。'
        });

        $('.js-btn-edit').bind('click', function(){
            $('#beanValidationGroupForm').attr('action', '/springMvcMemo/beanValidationGroup/edit');
            $('#beanValidationGroupForm').submit();
            return false;
        });

        $('.js-btn-sendmail').bind('click', function(){
            $('#beanValidationGroupForm').attr('action', '/springMvcMemo/beanValidationGroup/sendmail');
            $('#beanValidationGroupForm').submit();
            return false;
        });
    });
    -->
</script>
</body>
</html>

BeanValidationGroupForm.java

package ksbysample.webapp.lending.web.springmvcmemo;

import lombok.Data;
import org.springframework.web.multipart.MultipartFile;

@Data
public class BeanValidationGroupForm {

    private Long id;

    private MultipartFile fileupload;

    private String name;

    private String address;

    private String mailAddress;

}

BeanValidationGroupController.java

package ksbysample.webapp.lending.web.springmvcmemo;

import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/springMvcMemo/beanValidationGroup")
public class BeanValidationGroupController {

    @RequestMapping
    public String beanValidationGroup(BeanValidationGroupForm beanValidationGroupForm) {
        return "springmvcmemo/beanValidationGroup";
    }

    @RequestMapping("/fileupload")
    public String fileupload(@Validated BeanValidationGroupForm beanValidationGroupForm
            , BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            return "springmvcmemo/beanValidationGroup";
        }

        return "springmvcmemo/beanValidationGroup";
    }

    @RequestMapping("/edit")
    public String edit(@Validated BeanValidationGroupForm beanValidationGroupForm
            , BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            return "springmvcmemo/beanValidationGroup";
        }

        return "springmvcmemo/beanValidationGroup";
    }

    @RequestMapping("/sendmail")
    public String sendmail(@Validated BeanValidationGroupForm beanValidationGroupForm
            , BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            return "springmvcmemo/beanValidationGroup";
        }

        return "springmvcmemo/beanValidationGroup";
    }

}