Spring Boot で書籍の貸出状況確認・貸出申請する Web アプリケーションを作る ( その43 )( 貸出承認画面の作成3 )
概要
Spring Boot で書籍の貸出状況確認・貸出申請する Web アプリケーションを作る ( その42 )( 貸出承認画面の作成2 ) の続きです。
- 今回の手順で確認できるのは以下の内容です。
- 貸出承認画面の作成
- 「確定」ボタンをクリックした時の処理の実装。
- 2回に分けて書きます。1回目は入力チェック ( Bean Validation の追加及び Validator クラスの作成 ) の実装、2回目は更新処理&メール送信処理を実装します。
- カスタム Bean Validation も作成してみます。
- 貸出承認画面の作成
参照したサイト・書籍
TERASOLUNA Server Framework for Java (5.x) Development Guideline 5.0.1.RELEASE documentation - 5.5.3.2. 新規ルールを実装したBean Validationアノテーションの作成
http://terasolunaorg.github.io/guideline/5.0.1.RELEASE/ja/ArchitectureInDetail/Validation.html#id13- カスタム Bean Validation を作成する時に参照しました。
つかびーの技術日記 - Bean Validationで独自のアノーテションを作成し、検証を行う
http://tech-blog.tsukaby.com/archives/605- カスタム Bean Validation を作成する時に参照しました。
How to check if an object implements an interface?
http://stackoverflow.com/questions/10165887/how-to-check-if-an-object-implements-an-interface- クラスが特定のインターフェースを実装しているかをチェックする方法を調査した時に参照しました。
目次
- Lombok Plugin を update したらなぜかエラーが。。。
- 指定された Values 列挙型に定義されていない値が渡された場合にはエラーにするカスタム Bean Validation を作成する
- LendingapprovalForm, ApplyingBookForm クラスに Bean Validation のアノテーションを記述する
- LendingapprovalFormValidator クラスの変更
- LendingapprovalController クラスの変更
- lendingapproval.html クラスの変更
- 動作確認
手順
Lombok Plugin を update したらなぜかエラーが。。。
IntelliJ IDEA を再起動したら Lombok Plugin の update のダイアログが表示されたので update したのですが、カスタム Bean Validation を作成するために以前作成した LendingAppStatusValues 列挙型を開いたところ、なぜか以下の画像のように赤波線が表示されました。。。
clean タスク実行 → Rebuild Project 実行をしても正常に終了し、build タスクを実行しても "BUILD SUCCESSFUL" が出力されますので、コード的には問題ないようです。どうも update した Plugin が原因のようなのでひとつ前のバージョンへ戻します。
IntelliJ IDEA の Settings ダイアログから Plugin のバージョンを指定してダウンロード&インストールすることはできませんので、JetBrains の Plugin のホームページから以前のバージョンの Lombok Plugin のファイルをダウンロードしてインストールします。
JetBrains の Plugin のホームページにアクセスし、Lombok Plugin のページ ( https://plugins.jetbrains.com/plugin/6317 ) を開きます。ページ内のバージョン+Download リンクの一覧から 0.9.6.14 の Download リンクをクリックして lombok-plugin-0.9.6-14.jar をダウンロードします。
IntelliJ IDEA のメインメニューから「File」-「Settings」を選択します。
「Settings」ダイアログが表示されます。画面左側から「Plugins」を選択し、画面右側の「Install plugin from disk...」ボタンをクリックします。
「Choose Plugin File」ダイアログが表示されます。ダウンロードした lombok-plugin-0.9.6-14.jar を選択し、「OK」ボタンをクリックします。
「Settings」ダイアログの画面に戻ると「Restart IntelliJ IDEA」ボタンが表示されますのでクリックして IntelliJ IDEA を再起動します。
再起動後に再度 LendingAppStatusValues 列挙型を開くと今度は赤波線は表示されませんでした。
問題があったバージョンは 0.9.7.15 でしたので、次のバージョンが出るまで update は止めておきます。
指定された Values 列挙型に定義されていない値が渡された場合にはエラーにするカスタム Bean Validation を作成する
指定された Values 列挙型に定義されていない値が渡された場合にはエラーにするカスタム Bean Validation が欲しいと思ったので作成します。以下のように使用します。
@ValuesEnum(enumClass = LendingBookApprovalResultValues.class, allowEmpty = true) private String param;
src/main/java/ksbysample/webapp/lending/values の下に validation パッケージを作成します。
最初に Bean Validation のアノテーションを定義します。src/main/java/ksbysample/webapp/lending/values/validation の下に ValuesEnum.java を作成後、リンク先の内容 に変更します。
次に Validator を実装します。src/main/java/ksbysample/webapp/lending/values/validation の下に ValuesEnumValidator.java を作成後、リンク先の内容 に変更します。
一旦 commit します。
LendingapprovalForm, ApplyingBookForm クラスに Bean Validation のアノテーションを記述する
src/main/java/ksbysample/webapp/lending/web/lendingapproval の下の LendingapprovalForm.java を リンク先の内容 に変更します。
src/main/java/ksbysample/webapp/lending/web/lendingapproval の下の ApplyingBookForm.java を リンク先の内容 に変更します。
LendingapprovalFormValidator クラスの変更
FormValidator クラスを作成して以下の入力チェックを行います。
- 承認/却下が選択されていない書籍がある場合にはエラーにします。
- 却下が選択されているが却下理由が入力されていない書籍がある場合にはエラーにします。
1. エラーメッセージを追加します。src/main/resources の下の messages_ja_JP.properties を リンク先の内容 に変更します。
- src/main/java/ksbysample/webapp/lending/web/lendingapproval の下に LendingapprovalFormValidator.java を作成します。作成後、リンク先の内容 に変更します。
LendingapprovalController クラスの変更
- src/main/java/ksbysample/webapp/lending/web/lendingapproval の下の LendingapprovalController.java を リンク先の内容 に変更します。
lendingapproval.html クラスの変更
- src/main/resources/templates/lendingapproval の下の lendingapproval.html を リンク先の内容 に変更します。
動作確認
Gradle projects View から bootRun タスクを実行して Tomcat を起動します。
ブラウザを起動し http://localhost:8080/lendingapproval?lendingAppId=105 へアクセスします。最初はログイン画面が表示されますので ID に "tanaka.taro@sample.com"、Password に "taro" を入力して、「次回から自動的にログインする」をチェックせずに「ログイン」ボタンをクリックします。
貸出承認画面が表示されます。
何も入力せずに「確定」ボタンをクリックします。画面上部に「全ての書籍で承認か却下を選択してください。」のエラーメッセージが表示されることが確認できます。
1件目の書籍は「承認」ボタンを、3件目の書籍は「却下」ボタンを押してから「確定」ボタンをクリックします。
画面上部に「全ての書籍で承認か却下を選択してください。」のエラーメッセージが表示され、3件目は「却下理由」の入力フィールドが赤くなることが確認できます。「承認」「却下」ボタンも押されたままの状態で表示されています。
今度は全ての書籍で「却下」ボタンを押して、3件目の書籍だけ「却下理由」を入力してから「確定」ボタンをクリックします。
画面上部にエラーメッセージが表示されなくなり、1件目と2件目の「却下理由」の入力フィールドが赤くなることが確認できます。
今度は全ての書籍で「承認」ボタンを押して、3件目の書籍の「却下理由」の入力フィールドはクリアした後に「確定」ボタンをクリックします。
画面上部にエラーメッセージは表示されず、「却下理由」の入力フィールドは1つも赤くならないことが確認できます。
最後に全ての書籍で「却下」ボタンを押し「却下理由」も入力してから「確定」ボタンをクリックします。
画面上部にエラーメッセージは表示されず、「却下理由」の入力フィールドは1つも赤くならないことが確認できます。
Ctrl+F2 を押して Tomcat を停止します。
一旦 commit します。
ソースコード
ValuesEnum.java
package ksbysample.webapp.lending.values.validation; import javax.validation.Constraint; import javax.validation.Payload; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.Target; import static java.lang.annotation.ElementType.*; import static java.lang.annotation.RetentionPolicy.RUNTIME; @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) @Retention(RUNTIME) @Documented @Constraint(validatedBy = { ValuesEnumValidator.class }) public @interface ValuesEnum { String message() default "{ksbysample.webapp.lending.values.validation.ValuesEnum.message}"; Class<?>[] groups() default { }; Class<? extends Payload>[] payload() default { }; Class<? extends Enum<?>> enumClass(); boolean allowEmpty() default false; @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER }) @Retention(RUNTIME) @Documented @interface List { ValuesEnum[] value(); } }
- @Constraint アノテーションの validatedBy 属性に Validator のクラスを指定します。
String message() default
の後に Validation エラー時のエラーメッセージを定義するプロパティを記述します。- 属性の enumClass, allowEmpty に対応する
Class<? extends Enum<?>> enumClass();
,boolean allowEmpty() default false;
を追加します。 - @interface List の value メソッドの戻り値の型をアノテーション名と同じ
ValuesEnum[]
にします。 - 上記以外は Bean Validation のアノテーションを定義する時のテンプレート的な部分です。
ValuesEnumValidator.java
package ksbysample.webapp.lending.values.validation; import ksbysample.webapp.lending.values.Values; import org.apache.commons.lang3.StringUtils; import javax.validation.ConstraintValidator; import javax.validation.ConstraintValidatorContext; import java.text.MessageFormat; public class ValuesEnumValidator implements ConstraintValidator<ValuesEnum, String> { private Class<? extends Enum<?>> enumClass; private boolean allowEmpty; @Override public void initialize(ValuesEnum constraintAnnotation) { this.enumClass = constraintAnnotation.enumClass(); this.allowEmpty = constraintAnnotation.allowEmpty(); // enumClass 属性に Values インターフェースを実装していない列挙型が指定されている場合にはエラーにする try { if (!Values.class.isAssignableFrom(Class.forName(this.enumClass.getName()))) { throw new RuntimeException( MessageFormat.format("enumClass 属性に Values インターフェースを実装した列挙型が指定されていません ( {0} )", this.enumClass.getClass())); } } catch (ClassNotFoundException e) { throw new RuntimeException(e); } } @Override public boolean isValid(String value, ConstraintValidatorContext context) { if (value == null) { return true; } return isValueCheck(value); } private boolean isValueCheck(String value) { boolean result = false; if (StringUtils.isBlank(value)) { if (this.allowEmpty) { result = true; } } else { Values[] valuesList = (Values[]) this.enumClass.getEnumConstants(); for (Values values : valuesList) { if (StringUtils.equals(value, values.getValue())) { result = true; } } } return result; } }
LendingapprovalForm.java
package ksbysample.webapp.lending.web.lendingapproval; import ksbysample.webapp.lending.entity.LendingApp; import ksbysample.webapp.lending.entity.LendingBook; import lombok.Data; import lombok.NoArgsConstructor; import javax.validation.Valid; import java.util.List; import java.util.stream.Collectors; @Data @NoArgsConstructor public class LendingapprovalForm { private LendingApp lendingApp; private String username; @Valid private List<ApplyingBookForm> applyingBookFormList; public void setApplyingBookFormList(List<LendingBook> lendingBookList) { this.applyingBookFormList = null; if (lendingBookList != null) { this.applyingBookFormList = lendingBookList.stream() .map(ApplyingBookForm::new) .collect(Collectors.toList()); } } }
private List<ApplyingBookForm> applyingBookFormList;
に @Valid アノテーションを付加して、Bean Validation による入力チェックが実行されるようにします。- setApplyingBookFormList メソッド内の処理が少し冗長でしたので修正します。
ApplyingBookForm.java
package ksbysample.webapp.lending.web.lendingapproval; import ksbysample.webapp.lending.entity.LendingBook; import ksbysample.webapp.lending.values.LendingBookApprovalResultValues; import ksbysample.webapp.lending.values.validation.ValuesEnum; import lombok.Data; import lombok.NoArgsConstructor; import org.springframework.beans.BeanUtils; import javax.validation.constraints.Size; @Data @NoArgsConstructor public class ApplyingBookForm { private Long lendingBookId; private String isbn; private String bookName; private String lendingAppReason; @ValuesEnum(enumClass = LendingBookApprovalResultValues.class, allowEmpty = true) private String approvalResult; @Size(max = 128) private String approvalReason; private Long version; public ApplyingBookForm(LendingBook lendingBook) { BeanUtils.copyProperties(lendingBook, this); } }
private String approvalResult;
に @ValuesEnum アノテーションを付加します。全ての書籍で承認か却下のいずれかが選択されているかは FormValidator クラスでチェックするので、空を許容するためにallowEmpty = true
で定義します。private String approvalReason;
に最大文字数をチェックするための @Size アノテーションを付加します。
messages_ja_JP.properties
LendingapprovalParamForm.lendingAppId.emptyerr=貸出申請IDが指定されていません。 LendingapprovalForm.lendingApp.nodataerr=指定された貸出申請IDでは貸出申請されておりません。 LendingapprovalForm.applyingBookFormList.approvalResult.notAllCheckedErr=全ての書籍で承認か却下を選択してください。
- LendingapprovalForm.applyingBookFormList.approvalResult.notAllCheckedErr を追加します。
LendingapprovalFormValidator.java
package ksbysample.webapp.lending.web.lendingapproval; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component; import org.springframework.validation.Errors; import org.springframework.validation.Validator; import static ksbysample.webapp.lending.values.LendingBookApprovalResultValues.REJECT; @Component public class LendingapprovalFormValidator implements Validator { @Override public boolean supports(Class<?> clazz) { return clazz.equals(LendingapprovalForm.class); } @Override public void validate(Object target, Errors errors) { LendingapprovalForm lendingapprovalForm = (LendingapprovalForm) target; // 以下の点をチェックする // ・全ての書籍で承認か却下が選択されているか // ・却下が選択された書籍で却下理由が入力されているか boolean approvalResultAllChecked = true; int i = 0; for (ApplyingBookForm applyingBookForm : lendingapprovalForm.getApplyingBookFormList()) { if (StringUtils.isBlank(applyingBookForm.getApprovalResult())) { approvalResultAllChecked = false; } if (StringUtils.equals(applyingBookForm.getApprovalResult(), REJECT.getValue()) && StringUtils.isBlank(applyingBookForm.getApprovalReason())) { errors.rejectValue(String.format("applyingBookFormList[%d].approvalReason", i), null); } i++; } if (!approvalResultAllChecked) { errors.reject("LendingapprovalForm.applyingBookFormList.approvalResult.notAllCheckedErr"); } } }
LendingapprovalController.java
package ksbysample.webapp.lending.web.lendingapproval; import ksbysample.webapp.lending.exception.WebApplicationRuntimeException; import ksbysample.webapp.lending.helper.message.MessagesPropertiesHelper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; 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.bind.annotation.RequestMethod; @Controller @RequestMapping("/lendingapproval") public class LendingapprovalController { @Autowired private LendingapprovalService lendingapprovalService; @Autowired private MessagesPropertiesHelper messagesPropertiesHelper; @Autowired private LendingapprovalFormValidator lendingapprovalFormValidator; @InitBinder(value = "lendingapprovalForm") public void initBinder(WebDataBinder binder) { binder.addValidators(lendingapprovalFormValidator); } @RequestMapping public String index(@Validated LendingapprovalParamForm lendingapprovalParamForm , BindingResult bindingResult , LendingapprovalForm lendingapprovalForm , BindingResult bindingResultOfLendingapprovalForm) { if (bindingResult.hasErrors()) { throw new WebApplicationRuntimeException( messagesPropertiesHelper.getMessage("LendingapprovalParamForm.lendingAppId.emptyerr", null)); } // 画面に表示するデータを取得する lendingapprovalService.setDispData(lendingapprovalParamForm.getLendingAppId(), lendingapprovalForm); // 指定された貸出申請IDで申請中、承認済のデータがない場合には、貸出承認画面上にエラーメッセージを表示する if (lendingapprovalForm.getLendingApp() == null) { bindingResultOfLendingapprovalForm.reject("LendingapprovalForm.lendingApp.nodataerr"); } return "lendingapproval/lendingapproval"; } @RequestMapping(value = "/complete", method = RequestMethod.POST) public String complete(@Validated LendingapprovalForm lendingapprovalForm , BindingResult bindingResult) { if (bindingResult.hasErrors()) { return "lendingapproval/lendingapproval"; } return "lendingapproval/lendingapproval"; } }
@Autowired private LendingapprovalFormValidator lendingapprovalFormValidator;
を追加します。- initBinder メソッドを追加します。
- complete メソッドの以下の点を変更します。
- 引数に
@Validated LendingapprovalForm lendingapprovalForm
,BindingResult bindingResult
を追加します。 if (bindingResult.hasErrors()) { ... }
の処理を追加します。
- 引数に
lendingapproval.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>貸出承認</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"/> <style> .content-wrapper { background-color: #fffafa; } .selected-library { color: #ffffff !important; font-size: 100%; font-weight: 700; } .box-body.no-padding { padding-bottom: 10px !important; } .table>tbody>tr>td , .table>tbody>tr>th , .table>tfoot>tr>td , .table>tfoot>tr>th , .table>thead>tr>td , .table>thead>tr>th { padding: 5px; font-size: 90%; } .jp-gothic { font-family: Verdana, "游ゴシック", YuGothic, "Hiragino Kaku Gothic ProN", Meiryo, sans-serif; } .btn-approval.active { background-color: #00a65a; } .btn-reject.active { background-color: #dd4b39; } .has-error { background-color: #ffcccc; } </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>貸出承認</h1> </section> <!-- Main content --> <section class="content"> <div class="row"> <div class="col-xs-12"> <form id="lendingapprovalForm" method="post" action="/lendingapproval/complete" th:action="@{/lendingapproval/complete}" th:object="${lendingapprovalForm}"> <div class="alert alert-danger" th:if="${#fields.hasGlobalErrors()}"> <p th:each="err : ${#fields.globalErrors()}" th:text="${err}">共通エラーメッセージ表示エリア</p> </div> <div class="box" th:if="*{lendingApp != null}"> <div class="box-body no-padding"> <div class="col-xs-6 no-padding"> <table class="table table-bordered"> <colgroup> <col width="30%"/> <col width="70%"/> </colgroup> <tr> <th class="bg-purple">貸出申請ID</th> <td th:text="*{lendingApp.lendingAppId}">1</td> </tr> <tr> <th class="bg-purple">ステータス</th> <td th:text="${@vh.getText('LendingAppStatusValues', lendingapprovalForm.lendingApp.status)}">申請中</td> </tr> <tr> <th class="bg-purple">申請者</th> <td th:text="*{username}">田中 太郎</td> </tr> <input type="hidden" th:field="*{lendingApp.lendingAppId}"/> <input type="hidden" th:field="*{lendingApp.status}"/> <input type="hidden" th:field="*{username}"/> </table> </div> <br/> <table class="table"> <colgroup> <col width="5%"/> <col width="15%"/> <col width="15%"/> <col width="20%"/> <col width="20%"/> <col width="25%"/> </colgroup> <thead class="bg-purple"> <tr> <th>No.</th> <th>ISBN</th> <th>書名</th> <th>申請理由</th> <th>承認/却下</th> <th>却下理由</th> </tr> </thead> <tbody class="jp-gothic"> <tr th:each="applyingBookForm, iterStat : *{applyingBookFormList}"> <td th:text="${iterStat.count}">1</td> <td th:text="${applyingBookForm.isbn}">978-4-7741-6366-6</td> <td th:text="${applyingBookForm.bookName}">GitHub実践入門</td> <td th:text="${applyingBookForm.lendingAppReason}">開発で使用する為</td> <td> <div class="btn-group-sm" data-toggle="buttons"> <label class="btn btn-default btn-approval" th:classappend="*{applyingBookFormList[__${iterStat.index}__].approvalResult} == ${@vh.getValue('LendingBookApprovalResultValues', 'APPROVAL')} ? 'active' : ''"> <input type="radio" th:field="*{applyingBookFormList[__${iterStat.index}__].approvalResult}" th:value="${@vh.getValue('LendingBookApprovalResultValues', 'APPROVAL')}"/> 承認 </label> <label class="btn btn-default btn-reject" th:classappend="*{applyingBookFormList[__${iterStat.index}__].approvalResult} == ${@vh.getValue('LendingBookApprovalResultValues', 'REJECT')} ? 'active' : ''"> <input type="radio" th:field="*{applyingBookFormList[__${iterStat.index}__].approvalResult}" th:value="${@vh.getValue('LendingBookApprovalResultValues', 'REJECT')}"/> 却下 </label> </div> </td> <td> <input type="text" class="form-control input-sm" th:classappend="${#fields.hasErrors('*{applyingBookFormList[__${iterStat.index}__].approvalReason}')} ? 'has-error' : ''" th:field="*{applyingBookFormList[__${iterStat.index}__].approvalReason}"/> </td> <input type="hidden" th:field="*{applyingBookFormList[__${iterStat.index}__].lendingBookId}"/> <input type="hidden" th:field="*{applyingBookFormList[__${iterStat.index}__].isbn}"/> <input type="hidden" th:field="*{applyingBookFormList[__${iterStat.index}__].bookName}"/> <input type="hidden" th:field="*{applyingBookFormList[__${iterStat.index}__].lendingAppReason}"/> <input type="hidden" th:field="*{applyingBookFormList[__${iterStat.index}__].version}"/> </tr> </tbody> </table> <div class="text-center"> <button class="btn bg-blue js-btn-complete"><i class="fa fa-check-square-o"></i> 確定</button> </div> </div> </div> </form> </div> </div> </section> <!-- /.content --> </div> <!-- /.container --> </div> </div> <!-- ./wrapper --> <script th:replace="common/bottom-js"></script> <script type="text/javascript"> <!-- $(document).ready(function() { $(".js-btn-complete").click(function(){ $("#lendingapprovalForm").submit(); return false; }); }); --> </script> </body> </html>
- エラーの発生した入力フィールドを赤くするための
.has-error { background-color: #ffcccc; }
を追加します。 <input type="hidden" th:field="*{lendingApp.lendingAppId}"/>
~<input type="hidden" th:field="*{username}"/>
の3行を追加します。表示のみの項目の値を hidden で出力しておくことで、入力チェックエラー時に DB からデータを読み込み直さなくても画面にデータが表示されるようにします。<label class="btn btn-default btn-approval">
にth:classappend="*{applyingBookFormList[__${iterStat.index}__].approvalResult} == ${@vh.getValue('LendingBookApprovalResultValues', 'APPROVAL')} ? 'active' : ''"
を追加します。入力エラー時にチェックされていたボタンを再度チェック状態で表示するための対応です。<label class="btn btn-default btn-reject">
にもth:classappend="*{applyingBookFormList[__${iterStat.index}__].approvalResult} == ${@vh.getValue('LendingBookApprovalResultValues', 'REJECT')} ? 'active' : ''"
を追加します。th:classappend="${#fields.hasErrors('*{applyingBookFormList[__${iterStat.index}__].approvalReason}')} ? 'has-error' : ''"
を追加します。却下が選択されて却下理由が未入力の時に入力フィールドを赤くするための対応です。<input type="hidden" th:field="*{applyingBookFormList[__${iterStat.index}__].isbn}"/>
~<input type="hidden" th:field="*{applyingBookFormList[__${iterStat.index}__].lendingAppReason}"/>
の3行を追加します。
履歴
2016/01/15
初版発行。