読者です 読者をやめる 読者になる 読者になる

かんがるーさんの日記

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

Spring Boot で書籍の貸出状況確認・貸出申請する Web アプリケーションを作る ( その37 )( 貸出申請画面の作成8 )

概要

Spring Boot で書籍の貸出状況確認・貸出申請する Web アプリケーションを作る ( その36 )( 貸出申請画面の作成7 ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。
    • 貸出申請画面の作成
      • 他のブラウザから既にデータが更新されていた場合に更新させないようにする処理の実装

参照したサイト・書籍

  1. Welcome to Doma - User Documentation - 更新 - SQL自動生成におけるバージョン番号と楽観的排他制御
    http://doma.readthedocs.org/ja/stable/query/update/#id4

  2. TERASOLUNA Server Framework for Java (5.x) Development Guideline - 5.4. 排他制御
    http://terasolunaorg.github.io/guideline/5.0.1.RELEASE/ja/ArchitectureInDetail/ExclusionControl.html

  3. Welcome to Doma-Gen - User Documentation - EntityConfig
    http://doma-gen.readthedocs.org/ja/stable/gen/#entityconfig

目次

  1. どうやって他のブラウザから既にデータが更新されていた場合に更新させないようにするか?
  2. lending_app, lending_book テーブルへ version カラムを追加する
  3. gen タスク → domaGen タスクへの変更、domaGen タスクの実行
  4. LendingBookDto クラス、lendingapp.html の変更
  5. messages_ja_JP.properties、LendingappController クラスの変更
  6. 動作確認

手順

どうやって他のブラウザから既にデータが更新されていた場合に更新させないようにするか?

Doma 2 に楽観的排他制御のための機能が提供されていますので、それを利用します。以下の内容で実装します。

  • 排他制御したいテーブル ( 今回は lending_app, lending_book ) にバージョン番号用のカラム version を追加します。
  • domaGen タスクを実行して Entity クラス、SQL ファイルに反映します。Dao インターフェースは domaGen タスクでは反映されないため ( Doma-Gen はメソッドを追加するのですが domaGen タスクに入れている処理が消してしまうためです )、エディタからメソッドを追加します。
  • バージョン番号用のカラム version を追加すると Doma-Gen により Dao インターフェースに selectByIdAndVersion というメソッドと 対応する SQL ファイルが追加されるのですが、更新処理時にはこのメソッドは使用せず update メソッドで楽観的排他制御が実行されるだけにします。

    selectByIdAndVersion メソッドで指定された version のデータがない場合には NoResultException が throw されるのですが、それだと他のブラウザからデータが更新されて version カラムの値が更新されたのか、それとも別の理由で本当にデータが削除されたのかが分からなくなるのではないかと思ったためです。

    他で更新されて version カラムの値が更新されている場合には OptimisticLockException が throw されますので、例外が throw されたら更新処理を中断して画面にエラーメッセージを表示します。

lending_app, lending_book テーブルへ version カラムを追加する

  1. /sql の下の create_table.sqlリンク先の内容 に変更します。

  2. テーブルへのカラム追加は IntelliJ IDEA の Database tool で行います。Database tool を表示し、lending_app テーブルを選択してコンテキストメニューを表示した後、「Modify Table...」を選択します。

    f:id:ksby:20151223084206p:plain

  3. 「Modify Table」ダイアログが表示されますので、画面右側の「+」ボタンをクリックします。カラム追加用の入力フィールドが表示されますので、Name, Type, Default を入力、Not null をチェックした後「Refactor」ボタンをクリックします。

    f:id:ksby:20151223174227p:plain

    編集すると alter table 文も表示してくれるとは、Database tool 結構便利ですね。

  4. lending_book テーブルにも同じ手順で version カラムを追加します。

  5. カラムの追加が完了すると Database tool に以下の画像のように表示されます。

    f:id:ksby:20151223090246p:plain

gen タスク → domaGen タスクへの変更、domaGen タスクの実行

  1. まず gen タスクを Spring Boot で Doma 2 を使用するには に記載した domaGen タスクに変更します。build.gradle を リンク先の内容 に変更します。

  2. Gradle projects View の左上にある「Refresh all Gradle projects」ボタンをクリックして更新します。

  3. Gradle projects view から domaGen タスクを実行し、Entity クラス、Dao インターフェースを作り直します。version カラムを追加した場合、以下の点が追加・変更されます。

    • Entity クラス ( LendingApp.java, LendingBook.java ) が リンク先の内容 に変更されます。
    • src/main/resources/META-INF/ksbysample/webapp/lending/dao の下に LendingAppDao/selectByIdAndVersion.sql, LendingBookDao/selectByIdAndVersion.sql が追加されます。リンク先の内容 です。
  4. SQL ファイルが新規で作成されますが、Doma-Gen が Dao インターフェースに追加したメソッドが domaGen タスクにより消されてしまうため、エディタからメソッドを追加します。src/main/java/ksbysample/webapp/lending/dao の下の LendingAppDao.java, LendingBookDao.javaリンク先の内容 に変更します。

LendingBookDto クラス、lendingapp.html の変更

貸出申請画面の HTML に version カラムの値を出力されるようにします。

  1. src/main/java/ksbysample/webapp/lending/web/lendingapp の下の LendingBookDto.javaリンク先の内容 に変更します。

  2. src/main/resources/templates/lendingapp の下の lendingapp.html を リンク先の内容 に変更します。

messages_ja_JP.properties、LendingappController クラスの変更

「更新」「一時保存」ボタン押下時に楽観的排他制御でエラーが発生した場合には、画面上にエラーメッセージを表示します。

  1. src/main/resources の下の messages_ja_JP.properties を リンク先の内容 に変更します。

  2. src/main/java/ksbysample/webapp/lending/web/lendingapp の下の LendingappController.javaリンク先の内容 に変更します。

動作確認

動作確認します。データは Spring Boot で書籍の貸出状況確認・貸出申請する Web アプリケーションを作る ( その31 )( 貸出申請画面の作成2 ) で作成した貸出申請ID = 105 のデータを使用します。lending_app.status の値が 3 になっている場合には 2 に変更しておきます。

  1. メールを受信するので smtp4dev が起動していない場合には起動します。

  2. Gradle projects View から bootRun タスクを実行して Tomcat を起動します。

  3. 最初に1つのブラウザからデータを更新しただけの場合には正常に処理が実行されることを確認します。ブラウザを起動し http://localhost:8080/lendingapp?lendingAppId=105 へアクセスします。最初はログイン画面が表示されますので ID に "tanaka.taro@sample.com"、Password に "taro" を入力して、「次回から自動的にログインする」をチェックせずに「ログイン」ボタンをクリックします。

  4. 貸出申請画面が表示されたら何件かの書籍で「申請する」を選択して申請理由を記入した後、「申請」ボタンをクリックします。

    f:id:ksby:20151223232221p:plain

  5. 正常に処理が進み、更新後の状態の画面が表示されることが確認できます。

    f:id:ksby:20151223232342p:plain

  6. 次に2つのブラウザから更新を試み、後から更新したブラウザでは更新できないことを確認します。

  7. lending_app.status の値を 2 に戻し、version カラムを全て 1 に変更します。データは以下の状態です。

    f:id:ksby:20151223234020p:plain

  8. まず片方のブラウザ ( IE ) で http://localhost:8080/lendingapp?lendingAppId=105 へアクセスします。

    f:id:ksby:20151223232816p:plain

  9. 次にもう1つのブラウザ ( Chrome ) で http://localhost:8080/lendingapp?lendingAppId=105 へアクセスします。ログイン画面が表示されたら ID に "tanaka.taro@sample.com"、Password に "taro" を入力して、「次回から自動的にログインする」をチェックせずに「ログイン」ボタンをクリックします。

    f:id:ksby:20151223233009p:plain

  10. Chrome の方で何件かの書籍で「申請する」を選択して申請理由を記入した後、「一時保存」ボタンをクリックします。

    f:id:ksby:20151223233309p:plain

    lending_book.version の値が 2 に更新されています。

    f:id:ksby:20151223235242p:plain

  11. IE の方で何件かの書籍で「申請する」を選択して申請理由を記入した後、「申請」ボタンをクリックします。

    画面上部にエラーメッセージが表示されることが確認できます。

    f:id:ksby:20151223233532p:plain

    lending_app.status, lending_app.version, lending_book.version のデータが更新されていないことも確認できます。

    f:id:ksby:20151223235512p:plain

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

  13. 一旦 commit します。

ソースコード

create_table.sql

create table lending_app (
      lending_app_id          bigserial primary key
    , status                varchar(1) not null
    , lending_user_id       bigint not null references user_info(user_id)
    , approval_user_id      bigint references user_info(user_id)
    , version               bigint not null default 1
);

create table lending_book (
      lending_book_id         bigserial primary key
    , lending_app_id        bigint not null references lending_app(lending_app_id) on delete cascade
    , isbn                  varchar(17) not null
    , book_name             varchar(128) not null
    , lending_state         varchar(16)
    , lending_app_flg       varchar(1)
    , lending_app_reason    varchar(128)
    , approval_result       varchar(1)
    , approval_reason       varchar(128)
    , version               bigint not null default 1
);
  • lending_app, lending_book テーブルに version カラムを追加します。

build.gradle

// for Doma-Gen
task domaGen << {
    // まず変更が必要なもの
    def rootPackageName  = 'ksbysample.webapp.lending'
    def daoPackagePath   = 'src/main/java/ksbysample/webapp/lending/dao'
    def dbUrl            = 'jdbc:postgresql://localhost/ksbylending'
    def dbUser           = 'ksbylending_user'
    def dbPassword       = 'xxxxxxxx'
    def tableNamePattern = '.*'
    // おそらく変更不要なもの
    def importOfComponentAndAutowiredDomaConfig = "${rootPackageName}.util.doma.ComponentAndAutowiredDomaConfig"
    def workDirPath      = 'work'
    def workDaoDirPath   = "${workDirPath}/dao"

    // 作業用ディレクトリを削除する
    clearDir("${workDirPath}")

    // 現在の Dao インターフェースのバックアップを取得する
    copy() {
        from "${daoPackagePath}"
        into "${workDaoDirPath}/org"
    }

    // Dao インターフェース、Entity クラスを生成する
    ant.taskdef(resource: 'domagentask.properties',
            classpath: configurations.domaGenRuntime.asPath)
    ant.gen(url: "${dbUrl}", user: "${dbUser}", password: "${dbPassword}", tableNamePattern: "${tableNamePattern}") {
        entityConfig(packageName: "${rootPackageName}.entity", useListener: false)
        daoConfig(packageName: "${rootPackageName}.dao")
        sqlConfig()
    }

    // 生成された Dao インターフェースを作業用ディレクトリにコピーし、
    // @ComponentAndAutowiredDomaConfig アノテーションを付加する
    copy() {
        from "${daoPackagePath}"
        into "${workDaoDirPath}/replace"
        filter {
            line -> line.replaceAll('import org.seasar.doma.Dao;', "import ${importOfComponentAndAutowiredDomaConfig};\nimport org.seasar.doma.Dao;")
                    .replaceAll('@Dao', '@Dao\n@ComponentAndAutowiredDomaConfig')
        }
    }

    // @ComponentAndAutowiredDomaConfig アノテーションを付加した Dao インターフェースを
    // dao パッケージへ戻す
    copy() {
        from "${workDaoDirPath}/replace"
        into "${daoPackagePath}"
    }

    // 元々 dao パッケージ内にあったファイルを元に戻す
    copy() {
        from "${workDaoDirPath}/org"
        into "${daoPackagePath}"
    }

    // 作業用ディレクトリを削除する
    clearDir("${workDirPath}")

    // 自動生成したファイルを git add する
    addGit()
}
  • gen タスクを上記の domaGen タスクで置き換えます。

LendingApp.java, LendingBook.java

■LendingApp.java

package ksbysample.webapp.lending.entity;

import org.seasar.doma.Column;
import org.seasar.doma.Entity;
import org.seasar.doma.GeneratedValue;
import org.seasar.doma.GenerationType;
import org.seasar.doma.Id;
import org.seasar.doma.Table;
import org.seasar.doma.Version;

/**
 */
@Entity
@Table(name = "lending_app")
public class LendingApp {

    /** */
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "lending_app_id")
    Long lendingAppId;

    /** */
    @Column(name = "status")
    String status;

    /** */
    @Column(name = "lending_user_id")
    Long lendingUserId;

    /** */
    @Column(name = "approval_user_id")
    Long approvalUserId;

    /** */
    @Version
    @Column(name = "version")
    Long version;

    /** 
     * Returns the lendingAppId.
     * 
     * @return the lendingAppId
     */
    public Long getLendingAppId() {
        return lendingAppId;
    }

    /** 
     * Sets the lendingAppId.
     * 
     * @param lendingAppId the lendingAppId
     */
    public void setLendingAppId(Long lendingAppId) {
        this.lendingAppId = lendingAppId;
    }

    /** 
     * Returns the status.
     * 
     * @return the status
     */
    public String getStatus() {
        return status;
    }

    /** 
     * Sets the status.
     * 
     * @param status the status
     */
    public void setStatus(String status) {
        this.status = status;
    }

    /** 
     * Returns the lendingUserId.
     * 
     * @return the lendingUserId
     */
    public Long getLendingUserId() {
        return lendingUserId;
    }

    /** 
     * Sets the lendingUserId.
     * 
     * @param lendingUserId the lendingUserId
     */
    public void setLendingUserId(Long lendingUserId) {
        this.lendingUserId = lendingUserId;
    }

    /** 
     * Returns the approvalUserId.
     * 
     * @return the approvalUserId
     */
    public Long getApprovalUserId() {
        return approvalUserId;
    }

    /** 
     * Sets the approvalUserId.
     * 
     * @param approvalUserId the approvalUserId
     */
    public void setApprovalUserId(Long approvalUserId) {
        this.approvalUserId = approvalUserId;
    }

    /** 
     * Returns the version.
     * 
     * @return the version
     */
    public Long getVersion() {
        return version;
    }

    /** 
     * Sets the version.
     * 
     * @param version the version
     */
    public void setVersion(Long version) {
        this.version = version;
    }
}
  • version カラムが反映されて @Version @Column(name = "version") Long version; が追加されます。特に domaGen タスクは修正しなくても楽観的排他制御に必要な @Version アノテーションが付加されます。

■LendingBook.java

package ksbysample.webapp.lending.entity;

import org.seasar.doma.Column;
import org.seasar.doma.Entity;
import org.seasar.doma.GeneratedValue;
import org.seasar.doma.GenerationType;
import org.seasar.doma.Id;
import org.seasar.doma.Table;
import org.seasar.doma.Version;

/**
 */
@Entity
@Table(name = "lending_book")
public class LendingBook {

    /** */
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "lending_book_id")
    Long lendingBookId;

    /** */
    @Column(name = "lending_app_id")
    Long lendingAppId;

    /** */
    @Column(name = "isbn")
    String isbn;

    /** */
    @Column(name = "book_name")
    String bookName;

    /** */
    @Column(name = "lending_state")
    String lendingState;

    /** */
    @Column(name = "lending_app_flg")
    String lendingAppFlg;

    /** */
    @Column(name = "lending_app_reason")
    String lendingAppReason;

    /** */
    @Column(name = "approval_result")
    String approvalResult;

    /** */
    @Column(name = "approval_reason")
    String approvalReason;

    /** */
    @Version
    @Column(name = "version")
    Long version;

    /** 
     * Returns the lendingBookId.
     * 
     * @return the lendingBookId
     */
    public Long getLendingBookId() {
        return lendingBookId;
    }

    /** 
     * Sets the lendingBookId.
     * 
     * @param lendingBookId the lendingBookId
     */
    public void setLendingBookId(Long lendingBookId) {
        this.lendingBookId = lendingBookId;
    }

    /** 
     * Returns the lendingAppId.
     * 
     * @return the lendingAppId
     */
    public Long getLendingAppId() {
        return lendingAppId;
    }

    /** 
     * Sets the lendingAppId.
     * 
     * @param lendingAppId the lendingAppId
     */
    public void setLendingAppId(Long lendingAppId) {
        this.lendingAppId = lendingAppId;
    }

    /** 
     * Returns the isbn.
     * 
     * @return the isbn
     */
    public String getIsbn() {
        return isbn;
    }

    /** 
     * Sets the isbn.
     * 
     * @param isbn the isbn
     */
    public void setIsbn(String isbn) {
        this.isbn = isbn;
    }

    /** 
     * Returns the bookName.
     * 
     * @return the bookName
     */
    public String getBookName() {
        return bookName;
    }

    /** 
     * Sets the bookName.
     * 
     * @param bookName the bookName
     */
    public void setBookName(String bookName) {
        this.bookName = bookName;
    }

    /** 
     * Returns the lendingState.
     * 
     * @return the lendingState
     */
    public String getLendingState() {
        return lendingState;
    }

    /** 
     * Sets the lendingState.
     * 
     * @param lendingState the lendingState
     */
    public void setLendingState(String lendingState) {
        this.lendingState = lendingState;
    }

    /** 
     * Returns the lendingAppFlg.
     * 
     * @return the lendingAppFlg
     */
    public String getLendingAppFlg() {
        return lendingAppFlg;
    }

    /** 
     * Sets the lendingAppFlg.
     * 
     * @param lendingAppFlg the lendingAppFlg
     */
    public void setLendingAppFlg(String lendingAppFlg) {
        this.lendingAppFlg = lendingAppFlg;
    }

    /** 
     * Returns the lendingAppReason.
     * 
     * @return the lendingAppReason
     */
    public String getLendingAppReason() {
        return lendingAppReason;
    }

    /** 
     * Sets the lendingAppReason.
     * 
     * @param lendingAppReason the lendingAppReason
     */
    public void setLendingAppReason(String lendingAppReason) {
        this.lendingAppReason = lendingAppReason;
    }

    /** 
     * Returns the approvalResult.
     * 
     * @return the approvalResult
     */
    public String getApprovalResult() {
        return approvalResult;
    }

    /** 
     * Sets the approvalResult.
     * 
     * @param approvalResult the approvalResult
     */
    public void setApprovalResult(String approvalResult) {
        this.approvalResult = approvalResult;
    }

    /** 
     * Returns the approvalReason.
     * 
     * @return the approvalReason
     */
    public String getApprovalReason() {
        return approvalReason;
    }

    /** 
     * Sets the approvalReason.
     * 
     * @param approvalReason the approvalReason
     */
    public void setApprovalReason(String approvalReason) {
        this.approvalReason = approvalReason;
    }

    /** 
     * Returns the version.
     * 
     * @return the version
     */
    public Long getVersion() {
        return version;
    }

    /** 
     * Sets the version.
     * 
     * @param version the version
     */
    public void setVersion(Long version) {
        this.version = version;
    }
}
  • version カラムが反映されて @Version @Column(name = "version") Long version; が追加されます。

LendingAppDao/selectByIdAndVersion.sql, LendingBookDao/selectByIdAndVersion.sql

■LendingAppDao/selectByIdAndVersion.sql

select
  /*%expand*/*
from
  lending_app
where
  lending_app_id = /* lendingAppId */1
  and
  version = /* version */1

■LendingBookDao/selectByIdAndVersion.sql

select
  /*%expand*/*
from
  lending_book
where
  lending_book_id = /* lendingBookId */1
  and
  version = /* version */1

LendingAppDao.java, LendingBookDao.java

■LendingAppDao.java

package ksbysample.webapp.lending.dao;

import ksbysample.webapp.lending.entity.LendingApp;
import ksbysample.webapp.lending.util.doma.ComponentAndAutowiredDomaConfig;
import org.seasar.doma.Dao;
import org.seasar.doma.Delete;
import org.seasar.doma.Insert;
import org.seasar.doma.Select;
import org.seasar.doma.Update;
import org.seasar.doma.jdbc.SelectOptions;

/**
 */
@Dao
@ComponentAndAutowiredDomaConfig
public interface LendingAppDao {

    /**
     * @param lendingAppId
     * @return the LendingApp entity
     */
    @Select
    LendingApp selectById(Long lendingAppId);
    @Select(ensureResult = true)
    LendingApp selectByIdAndVersion(Long lendingAppId, Long version);
    @Select
    LendingApp selectById(Long lendingAppId, SelectOptions options);
    @Select(ensureResult = true)
    LendingApp selectByIdAndVersion(Long lendingAppId, Long version, SelectOptions options);

    /**
     * @param entity
     * @return affected rows
     */
    @Insert
    int insert(LendingApp entity);

    /**
     * @param entity
     * @return affected rows
     */
    @Update
    int update(LendingApp entity);

    /**
     * @param entity
     * @return affected rows
     */
    @Delete
    int delete(LendingApp entity);
}
  • @Select(ensureResult = true) LendingApp selectByIdAndVersion(Long lendingAppId, Long version); を追加します。ensureResult = true は検索結果が1件以上存在することを保証したい場合に付ける要素です ( http://doma.readthedocs.org/ja/stable/query/select/?highlight=ensureresult#id14 に記載があります )。
  • @Select(ensureResult = true) LendingApp selectByIdAndVersion(Long lendingAppId, Long version, SelectOptions options); を追加します。

■LendingBookDao.java

package ksbysample.webapp.lending.dao;

import ksbysample.webapp.lending.entity.LendingBook;
import ksbysample.webapp.lending.util.doma.ComponentAndAutowiredDomaConfig;
import org.seasar.doma.Dao;
import org.seasar.doma.Delete;
import org.seasar.doma.Insert;
import org.seasar.doma.Select;
import org.seasar.doma.Update;
import org.seasar.doma.jdbc.SelectOptions;

import java.util.List;

/**
 */
@Dao
@ComponentAndAutowiredDomaConfig
public interface LendingBookDao {

    /**
     * @param lendingBookId
     * @return the LendingBook entity
     */
    @Select
    LendingBook selectById(Long lendingBookId);
    @Select(ensureResult = true)
    LendingBook selectByIdAndVersion(Long lendingBookId, Long version);
    @Select
    List<LendingBook> selectByLendingAppId(Long lendingAppId);
    @Select
    List<LendingBook> selectByLendingAppId(Long lendingAppId, SelectOptions options);
    
    /**
     * @param entity
     * @return affected rows
     */
    @Insert
    int insert(LendingBook entity);

    /**
     * @param entity
     * @return affected rows
     */
    @Update
    int update(LendingBook entity);
    @Update(include = {"lendingState"})
    int updateLendingState(LendingBook entity);
    @Update(include = {"lendingAppFlg", "lendingAppReason"})
    int updateLendingAppFlgAndReason(LendingBook entity);
    
    /**
     * @param entity
     * @return affected rows
     */
    @Delete
    int delete(LendingBook entity);
}
  • @Select(ensureResult = true) LendingBook selectByIdAndVersion(Long lendingBookId, Long version); を追加します。

LendingBookDto.java

package ksbysample.webapp.lending.web.lendingapp;

import ksbysample.webapp.lending.entity.LendingBook;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.beans.BeanUtils;

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

@Data
@NoArgsConstructor
public class LendingBookDto {

    private Long lendingBookId;

    private String isbn;

    private String bookName;

    private String lendingState;

    @Pattern(regexp = "^(|1)$")
    private String lendingAppFlg;

    @Size(max = 128)
    private String lendingAppReason;

    private Long version;
    
    LendingBookDto(LendingBook lendingBook) {
        BeanUtils.copyProperties(lendingBook, this);
    }

}
  • lendingBookId 以外にアクセス修飾子を付けていなかったので、各フィールドに private を追加します。
  • private Long version; を追加します。

lendingapp.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;
        }
        .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="lendingappForm" method="post" action="/lendingapp/apply" th:action="@{/lendingapp/apply}" th:object="${lendingappForm}">
                            <div class="alert alert-danger" th:if="${#fields.hasGlobalErrors()}">
                                <p th:each="err : ${#fields.globalErrors()}" th:text="${err}">共通エラーメッセージ表示エリア</p>
                            </div>
                            <div class="alert alert-success" th:if="${successMessages}">
                                <button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>
                                <p th:each="msg : ${successMessages}" th:text="${msg}">通常メッセージ表示エリア</p>
                            </div>
                            <div class="box">
                                <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>
                                                <th th:text="*{lendingApp.lendingAppId}">1</th>
                                            </tr>
                                            <tr>
                                                <th class="bg-purple">ステータス</th>
                                                <th th:text="${@vh.getText('LendingAppStatusValues', lendingappForm.lendingApp.status)}">申請中</th>
                                            </tr>
                                        </table>
                                        <input type="hidden" th:field="*{lendingApp.lendingAppId}"/>
                                        <input type="hidden" th:field="*{lendingApp.status}"/>
                                        <input type="hidden" th:field="*{lendingApp.version}"/>
                                    </div>
                                    <br/>
    
                                    <table class="table table-hover">
                                        <colgroup>
                                            <col width="5%"/>
                                            <col width="20%"/>
                                            <col width="20%"/>
                                            <col width="15%"/>
                                            <col width="15%"/>
                                            <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="lendingBookDto, iterStat : *{lendingBookDtoList}">
                                            <th th:text="${iterStat.count}">1</th>
                                            <th th:text="${lendingBookDto.isbn}">978-1-4302-5908-4</th>
                                            <th th:text="${lendingBookDto.bookName}">Spring Recipes</th>
                                            <th th:text="${lendingBookDto.lendingState}">蔵書なし</th>
                                            <th>
                                                <select class="form-control input-sm"
                                                        th:classappend="${#fields.hasErrors('*{lendingBookDtoList[__${iterStat.index}__].lendingAppFlg}')} ? 'has-error' : ''"
                                                        th:field="*{lendingBookDtoList[__${iterStat.index}__].lendingAppFlg}"
                                                        th:if="*{lendingApp.status} == ${@vh.getValue('LendingAppStatusValues', 'UNAPPLIED')} and ${lendingBookDto.lendingState} == '蔵書あり'">
                                                    <option th:each="lendingAppFlg : ${@vh.values('LendingBookLendingAppFlgValues')}"
                                                            th:value="${lendingAppFlg.getValue()}"
                                                            th:text="${lendingAppFlg.getText()}">しない</option>
                                                </select>
                                                <span th:text="${@vh.getText('LendingBookLendingAppFlgValues', lendingBookDto.lendingAppFlg)}"
                                                      th:if="*{lendingApp.status} != ${@vh.getValue('LendingAppStatusValues', 'UNAPPLIED')}">しない
                                                </span>
                                            </th>
                                            <th>
                                                <input type="text" class="form-control input-sm"
                                                       th:classappend="${#fields.hasErrors('*{lendingBookDtoList[__${iterStat.index}__].lendingAppReason}')} ? 'has-error' : ''"
                                                       th:field="*{lendingBookDtoList[__${iterStat.index}__].lendingAppReason}"
                                                       th:if="*{lendingApp.status} == ${@vh.getValue('LendingAppStatusValues', 'UNAPPLIED')} and ${lendingBookDto.lendingState} == '蔵書あり'"/>
                                                <span th:text="${lendingBookDto.lendingAppReason}"
                                                      th:if="*{lendingApp.status} != ${@vh.getValue('LendingAppStatusValues', 'UNAPPLIED')}">入力された申請理由を表示する
                                                </span>
                                            </th>
                                            <input type="hidden" th:field="*{lendingBookDtoList[__${iterStat.index}__].lendingBookId}"/>
                                            <input type="hidden" th:field="*{lendingBookDtoList[__${iterStat.index}__].isbn}"/>
                                            <input type="hidden" th:field="*{lendingBookDtoList[__${iterStat.index}__].bookName}"/>
                                            <input type="hidden" th:field="*{lendingBookDtoList[__${iterStat.index}__].lendingState}"/>
                                            <input type="hidden" th:field="*{lendingBookDtoList[__${iterStat.index}__].version}"/>
                                        </tr>
                                        </tbody>
                                    </table>
                                    <div class="text-center" th:if="*{lendingApp.status} == ${@vh.getValue('LendingAppStatusValues', 'UNAPPLIED')}">
                                        <button class="btn bg-blue js-btn-apply"><i class="fa fa-thumbs-up"></i> 申請</button>
                                        <button class="btn bg-orange js-btn-temporarySave"><i class="fa fa-tag"></i> 一時保存(まだ申請しない)</button>
                                        <input type="hidden" th:field="*{btn}"/>
                                    </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-apply").click(function(){
            $("#btn").val("apply");
            $("#lendingappForm").submit();
            return false;
        });

        $(".js-btn-temporarySave").click(function(){
            $("#btn").val("temporarySave");
            $("#lendingappForm").attr("action", "/lendingapp/temporarySave");
            $("#lendingappForm").submit();
            return false;
        });
    });
    -->
</script>
</body>
</html>
  • <input type="hidden" th:field="*{lendingApp.version}"/> を追加します。
  • <input type="hidden" th:field="*{lendingBookDtoList[__${iterStat.index}__].version}"/> を追加します。

messages_ja_JP.properties

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

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

Global.optimisticLockException=既にデータが更新されているため更新できませんでした。データを読み込み直してください。

UploadBooklistForm.fileupload.openerr=アップロードされたCSVファイルをオープンできませんでした。
UploadBooklistForm.fileupload.lengtherr={0}行目のレコードの項目数が 2個ではありません ( {1}個 )。
UploadBooklistForm.fileupload.isbn.patternerr={0}行目のISBNのデータに数字、ハイフン以外の文字が使用されています ( {1} )。
UploadBooklistForm.fileupload.isbn.lengtherr={0}行目のISBNのデータの文字数が17文字以内でありません ( {1} )。
UploadBooklistForm.fileupload.isbn.numlengtherr={0}行目のISBNのデータの数字のみの文字数が10、13のいずれでもありません ( {1} )。
UploadBooklistForm.fileupload.isbn.duplicateerr={0} のISBNのデータが {1}件重複しています。
UploadBooklistForm.fileupload.bookname.lengtherr={0}行目の書名のデータの文字数が128文字以内でありません ( {1} )。

LendingappForm.lendingAppId.emptyerr=貸出申請IDが指定されていません。
LendingappForm.lendingApp.nodataerr=指定された貸出申請IDのデータが登録されておりません。
LendingappForm.lendingBookDtoList.notExistApply=最低1冊「申請する」を選択してください。
LendingappForm.lendingBookDtoList.emptyReason=「申請する」を選択している書籍の申請理由を入力してください。
  • Global.optimisticLockException を追加します。

LendingappController.java

package ksbysample.webapp.lending.web.lendingapp;

import ksbysample.webapp.lending.cookie.CookieLastLendingAppId;
import ksbysample.webapp.lending.entity.LendingApp;
import ksbysample.webapp.lending.entity.LendingBook;
import ksbysample.webapp.lending.exception.WebApplicationRuntimeException;
import ksbysample.webapp.lending.helper.message.MessagesPropertiesHelper;
import ksbysample.webapp.lending.helper.thymeleaf.SuccessMessagesHelper;
import ksbysample.webapp.lending.util.cookie.CookieUtils;
import org.apache.commons.lang.StringUtils;
import org.seasar.doma.jdbc.OptimisticLockException;
import org.springframework.beans.factory.annotation.Autowired;
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.bind.annotation.RequestMethod;

import javax.mail.MessagingException;
import javax.servlet.http.HttpServletResponse;
import java.util.List;

import static ksbysample.webapp.lending.values.LendingAppStatusValues.UNAPPLIED;

@Controller
@RequestMapping("/lendingapp")
public class LendingappController {

    @Autowired
    private LendingappService lendingappService;

    @Autowired
    private MessagesPropertiesHelper messagesPropertiesHelper;

    @Autowired
    private LendingappFormValidator lendingappFormValidator;

    @InitBinder(value = "lendingappForm")
    public void initBinder(WebDataBinder binder) {
        binder.addValidators(lendingappFormValidator);
    }

    @RequestMapping
    public String index(@Validated LendingappParamForm lendingappParamForm
            , BindingResult bindingResultForLendingappParamForm
            , LendingappForm lendingappForm
            , HttpServletResponse response) {
        if (bindingResultForLendingappParamForm.hasErrors()) {
            throw new WebApplicationRuntimeException(
                    messagesPropertiesHelper.getMessage("LendingappForm.lendingAppId.emptyerr", null));
        }

        // 画面に表示するデータを取得する
        setDispData(lendingappParamForm.getLendingAppId(), lendingappForm);

        // 未申請の場合には LastLendingAppId Cookie に貸出申請ID をセットする
        if (StringUtils.equals(lendingappForm.getLendingApp().getStatus(), UNAPPLIED.getValue())) {
            CookieUtils.addCookie(CookieLastLendingAppId.class
                    , response, String.valueOf(lendingappParamForm.getLendingAppId()));
        }

        return "lendingapp/lendingapp";
    }

    @RequestMapping(value = "/apply", method = RequestMethod.POST)
    public String apply(@Validated LendingappForm lendingappForm
            , BindingResult bindingResult
            , HttpServletResponse response) throws MessagingException {
        if (bindingResult.hasErrors()) {
            return "lendingapp/lendingapp";
        }

        try {
            // 入力された内容で申請する
            lendingappService.apply(lendingappForm);

            // 画面に表示するデータを取得する
            setDispData(lendingappForm.getLendingApp().getLendingAppId(), lendingappForm);

            // LastLendingAppId Cookie を削除する
            CookieUtils.removeCookie(CookieLastLendingAppId.class, response);
        } catch (OptimisticLockException e) {
            bindingResult.reject("Global.optimisticLockException");
        }

        return "lendingapp/lendingapp";
    }

    @RequestMapping(value = "/temporarySave", method = RequestMethod.POST)
    public String temporarySave(@Validated LendingappForm lendingappForm
            , BindingResult bindingResult
            , Model model) {
        if (bindingResult.hasErrors()) {
            return "lendingapp/lendingapp";
        }

        try {
            // 入力された内容を一時保存する
            lendingappService.temporarySave(lendingappForm);
    
            // 画面に表示する通常メッセージをセットする
            SuccessMessagesHelper successMessagesHelper = new SuccessMessagesHelper("一時保存しました");
            successMessagesHelper.setToModel(model);
        } catch (OptimisticLockException e) {
            bindingResult.reject("Global.optimisticLockException");
        }
        
        return "lendingapp/lendingapp";
    }
    
    private void setDispData(Long lendingAppId, LendingappForm lendingappForm) {
        LendingApp lendingApp = lendingappService.getLendingApp(lendingAppId);
        List<LendingBook> lendingBookList = lendingappService.getLendingBookList(lendingAppId);
        lendingappForm.setLendingApp(lendingApp);
        lendingappForm.setLendingBookList(lendingBookList);
    }

}
  • apply メソッド、temporarySave メソッドの以下の点を変更します。
    • if (bindingResult.hasErrors()) { ... } の後から最後の return "lendingapp/lendingapp"; までの処理を try { ... } catch (OptimisticLockException e) { bindingResult.reject("Global.optimisticLockException"); } で囲みます。
    • 最初はクラス内に @ExceptionHandler(OptimisticLockException.class) を付加したメソッドを追加してそこにエラー処理を実装しようとしたのですが、エラー発生時に入力されたデータを消さずに、かつ画面上部にエラーメッセージを表示させる方法が分からなかったので、各メソッド内で OptimisticLockException を catch して処理するようにしました。

履歴

2015/12/24
初版発行。