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

かんがるーさんの日記

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

Spring Boot でログイン画面 + 一覧画面 + 登録画面の Webアプリケーションを作る ( 番外編 )( トランザクション実行時の動作検証 )

概要

登録画面 ( 入力→確認→完了 ) のテストクラスを作成する前に、実装した登録処理で例外発生時に正常にロールバックするのか等を確認したいと思ったので、検証します。

ソフトウェア一覧

参考にしたサイト

  1. minokubaの日記 - [spring]10.5 Declarative transaction management
    http://d.hatena.ne.jp/minokuba/20110501/1304265347

  2. Spring-Boot: How do I reference application.properties in an @ImportResource
    http://stackoverflow.com/questions/21470409/spring-boot-how-do-i-reference-application-properties-in-an-importresource

  3. ぺーぺーSEの日記 - Spring AOP
    http://d.hatena.ne.jp/tanakakns/20121210/1355137807

  4. Spring Framework Reference Documentation - 12. Transaction Management - 12.5.2 Example of declarative transaction implementation
    http://docs.spring.io/spring/docs/current/spring-framework-reference/html/transaction.html#transaction-declarative-first-example

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

    • 2015/2/23 に新しい 5.0.0.RELEASE のドキュメントが出ていました。
  6. IBM developerWorks - トランザクション・ストラテジー: トランザクションの落とし穴を理解する
    http://www.ibm.com/developerworks/jp/java/library/j-ts1.html

手順

検証前にテーブルで使用されているストレージエンジンを確認したら MyISAM でした。。。

MySQL のデフォルトのストレージエンジンが Version 5.5 から InnoDB になったと聞いていたので、world DB のテーブルも InnoDB だとてっきり思っていたのですが、今回確認してみたら MyISAM でした。

mysql> use information_schema;
Database changed
mysql> select table_name, engine from tables where table_schema = 'world';
+-----------------+--------+
| table_name      | engine |
+-----------------+--------+
| city            | MyISAM |
| country         | MyISAM |
| countrylanguage | MyISAM |
| user            | InnoDB |
| user_role       | InnoDB |
+-----------------+--------+
5 rows in set (0.00 sec)

mysql>

MyISAM ではトランザクションの検証が出来ないので、InnoDB に変換します。

mysql> use world;
Database changed
mysql> alter table city engine = InnoDB;
Query OK, 4079 rows affected (0.20 sec)
Records: 4079  Duplicates: 0  Warnings: 0

mysql> alter table country engine = InnoDB;
Query OK, 240 rows affected (0.04 sec)
Records: 240  Duplicates: 0  Warnings: 0

mysql> alter table countrylanguage engine = InnoDB;
Query OK, 984 rows affected (0.05 sec)
Records: 984  Duplicates: 0  Warnings: 0

mysql> use information_schema;
Database changed
mysql> select table_name, engine from tables where table_schema = 'world';
+-----------------+--------+
| table_name      | engine |
+-----------------+--------+
| city            | InnoDB |
| country         | InnoDB |
| countrylanguage | InnoDB |
| user            | InnoDB |
| user_role       | InnoDB |
+-----------------+--------+
5 rows in set (0.01 sec)

mysql>

実装した登録処理はどのような処理をしているのか?

@Transactional アノテーションを付加した CountryService クラスの save メソッドが呼び出された時の処理をログから追ってみました。

f:id:ksby:20150226235031p:plain

上記のデータを入力し ( この後の検証では特に記載がなければこのデータを入力しています ) 、確認画面で「登録」ボタンをクリックすると以下の処理が実行されました ( 主要な処理だけ記載しています ) 。

  1. Connection.setAutoCommit(false)
  2. select country0.code as code1_0_0, … from Country country0 where country0.code=‘JP2’
  3. insert into Country (capital, … ) values (…)
  4. Connection.commit()
  5. Connection.setAutoCommit(true)

以前 Spring Boot でログイン画面 + 一覧画面 + 登録画面の Webアプリケーションを作る ( その13 )( 登録画面作成3 ) で CrudRepository インターフェースのメソッド一覧を記載しましたが、登録するためのメソッドは save メソッドしかありませんでした。

今回動作を確認したところ insert 前に select していることから、同じ主キーのデータが登録されている場合には update されるのかな?と思い、再度同じデータを登録してみます。

再度データを入力し、確認画面で「登録」ボタンをクリックすると以下の処理が実行されました。

  1. Connection.setAutoCommit(false)
  2. select country0.code as code1_0_0, … from Country country0 where country0.code=‘JP2’
  3. Connection.commit()
  4. Connection.setAutoCommit(true)

insert も update も実行されませんでした。。。

データが変更されていない場合には何も実行しないようです。今度はデータを一部変えてみます。

f:id:ksby:20150226235654p:plain

上記のデータを入力し ( SurfaceArea だけ 1 → 2 へ変えています ) 、確認画面で「登録」ボタンをクリックすると以下の処理が実行されました。

  1. Connection.setAutoCommit(false)
  2. select country0.code as code1_0_0, … from Country country0 where country0.code=‘JP2’
  3. update Country set capital=NULL, … where code=‘JP2’
  4. Connection.commit()
  5. Connection.setAutoCommit(true)

今度は update 文が実行されました。

ここまでの検証で分かったことは、以下の通りです。

  • CrudRepository インターフェースの save メソッドを呼び出すといきなり insert 文や update 文を実行せず、最初に select 文が実行されます。
  • 主キーが同じデータがない場合には insert 文が、ある場合には update 文が実行されます。
  • ただし主キーが同じデータがあり、かつ Entity クラスにセットしたデータと DB のデータが同じ場合には update 文は実行されません。

例外発生時にロールバックするのか?

例外発生時にロールバックするのかを検証してみます。

まずは、CountryService クラスの save メソッドで非チェック例外である RuntimeException を throw してみます。

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

country テーブルから Code = ‘JP2’ のデータを手動で削除した後、データを入力し、確認画面で「登録」ボタンをクリックすると以下の処理が実行されました。

  1. Connection.setAutoCommit(false)
  2. select country0.code as code1_0_0, … from Country country0 where country0.code=‘JP2’
  3. Connection.rollback()
  4. Connection.setAutoCommit(true)

ロールバックされました。また insert 文が実行されていませんでした。@Transaction アノテーションが付加されたメソッド内で例外が発生せずに正常終了した時だけ insert 文が実行されるようです。

今度はチェック例外である Exception を throw してみます。

最初に src/main/java/ksbysample/webapp/basic/service の下の CountryService.javaリンク先のその2の内容 に変更します。

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

データを入力し、確認画面で「登録」ボタンをクリックすると以下の処理が実行されました。

  1. Connection.setAutoCommit(false)
  2. select country0.code as code1_0_0, … from Country country0 where country0.code=‘JP2’
  3. insert into Country (capital, … ) values (…)
  4. Connection.commit()
  5. Connection.setAutoCommit(true)
  6. java.lang.Exception ログ出力

チェック例外発生時はロールバックされずにコミットされました。country テーブルにも確かに Code = ‘JP2’ のデータが登録されていました。

ここまでの検証で分かったことは、以下の通りです。

  • 非チェック例外発生時はロールバックされますが、チェック例外発生時はコミットされます。

チェック例外発生時にもロールバックさせることは可能か?

調べてみると、@Transactional(rollbackFor = Exception.class) のように @Transactional アノテーションに rollbackFor 属性で指定するか、xmlファイルを作成して、その中で <tx:advice> ... </tx:advice> でチェック例外でもロールバックするよう設定すれば、ロールバックされるようです。個別に指定することはないような気がするので、後者の方法を試してみます。

最初に src/main/resources の下に applicationContext.xml を新規作成します。作成後、リンク先のその1の内容 に変更します。

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

country テーブルから Code = ‘JP2’ のデータを手動で削除した後、データを入力し、確認画面で「登録」ボタンをクリックすると以下の処理が実行されました。

  1. Connection.setAutoCommit(false)
  2. select country0.code as code1_0_0, … from Country country0 where country0.code=‘JP2’
  3. Connection.rollback()
  4. Connection.setAutoCommit(true)
  5. java.lang.Exception ログ出力

今度はロールバックされました。country テーブルにも Code = ‘JP2’ のデータは登録されていませんでした。

全てのトランザクションを必ず read-only にすることは可能か?

チェック例外発生時にもロールバックさせる方法を調べている時に、全てのトランザクションを必ず read-only にする方法も見つけたので、記載しておきます。

※ただしこの方法は、トランザクション内に @Lock(LockModeType.PESSIMISTIC_WRITE) のような悲観ロックする @Lock アノテーションが付加されたメソッドが呼び出されていないことが条件になります。存在する場合、そのメソッドが呼び出されると java.sql.SQLException: Cannot execute statement in a READ ONLY transaction. の例外が発生します。

最初に src/main/resources の下の applicationContext.xmlリンク先のその2の内容 に変更します。

次に src/main/java/ksbysample/webapp/basic/service の下の CountryService.java の save メソッドを何も例外を throw しない検証前のソースファイルに戻します。

データを入力し、確認画面で「登録」ボタンをクリックすると以下の処理が実行されました。

  1. Connection.setReadOnly(true)
  2. Connection.setAutoCommit(false)
  3. select country0.code as code1_0_0, … from Country country0 where country0.code=‘JP2’
  4. Connection.commit()
  5. Connection.setAutoCommit(true)
  6. Connection.setReadOnly(false)

今回は最初に Connection.setReadOnly(true) が呼び出されました。またコミットは実行されますが、insert 文自体が実行されていませんでした。トランザクションが read-only に設定されると insert/update/delete の select 以外の SQL が実行されないようです。

insert/update 前に select … for update でロックするには?

CrudRepository インターフェースの save メソッドを呼び出すと insert/update 文の前に select 文が実行されますが、select … for update 文ではなく select 文でした。update 文がすぐに実行されないようなので、事前に select 文が実行されるのであれば select … for update 文にすることができないか検証してみます。

いろいろ調べてみると @Lock(LockModeType.PESSIMISTIC_WRITE) アノテーションを付加すると select … for update になるらしいので、付加してみます。

ロックの検証をするので、事前に Code = ‘JP2’ のデータを登録しておきます。

最初に src/main/resources の下の applicationContext.xmlリンク先のその3の内容 に変更し、今回は適用されないようにします。

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

f:id:ksby:20150226235654p:plain

上記のデータを入力し ( update 文が実行されるよう SurfaceArea だけ 1 → 2 へ変えています ) 、確認画面で「登録」ボタンをクリックすると以下の処理が実行されました。

  1. Connection.setAutoCommit(false)
  2. select country0.code as code1_0_0, … from Country country0 where country0.code=‘JP2’
  3. update Country set capital=NULL, … where code=‘JP2’
  4. Connection.commit()
  5. Connection.setAutoCommit(true)

ただの select 文でした。。。 save メソッドに @Lock(LockModeType.PESSIMISTIC_WRITE) アノテーションを付加しても select … for update 文にはならないようです。

CrudRepository にはデータを 1件だけ取得する findOne メソッドがありました。今度は findOne メソッドに @Lock(LockModeType.PESSIMISTIC_WRITE) アノテーションを付加し、save メソッドを呼び出す前に findOne メソッドを呼び出してみます。

最初に src/main/java/ksbysample/webapp/basic/service の下の CountryRepository.javaリンク先のその2の内容 に変更します。

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

データを入力し ( 今度は SurfaceArea = 1 です ) 、確認画面で「登録」ボタンをクリックすると以下の処理が実行されました。

  1. Connection.setAutoCommit(false)
  2. select country0.code as code1_0_0, … from Country country0 where country0.code=‘JP2’ for update
  3. update Country set capital=NULL, … where code=‘JP2’
  4. Connection.commit()
  5. Connection.setAutoCommit(true)

今度は select … for update 文になりました。また findOne メソッドと save メソッドで2回 select 文が実行されるのかと思いましたが、1回しか実行されませんでした。おそらく CrudRepository や JpaRepository のメソッドで取得した Entity クラスのインスタンスを使用して save メソッドを呼び出した場合には select 文が実行されないものと思われます。

トランザクションタイムアウト時間を一律設定するには?

applicationContext.xml を使用すれば、全てのトランザクションに同じタイムアウト時間を設定することが出来ると思われるので、試してみます。

src/main/resources の下の applicationContext.xmlリンク先のその4の内容 に変更します。

コマンドプロンプトmysql コマンドから以下のコマンドを実行し Code = ‘JP2’ のデータをロックします。

C:\>mysql -u root -p                                                            
mysql> use world;                                                               
Database changed                                                                
mysql> begin;                                                                   
Query OK, 0 rows affected (0.00 sec)                                            
                                                                                
mysql> select * from country where code = 'JP2' for update;                     
+------+-------+-----------+--------------+-------------+-----------+-----------
-+----------------+------+--------+-----------+----------------+-------------+--
-------+-------+                                                                
| Code | Name  | Continent | Region       | SurfaceArea | IndepYear | Population
 | LifeExpectancy | GNP  | GNPOld | LocalName | GovernmentForm | HeadOfState | C
apital | Code2 |                                                                
+------+-------+-----------+--------------+-------------+-----------+-----------
-+----------------+------+--------+-----------+----------------+-------------+--
-------+-------+                                                                
| JP2  | Japan | Asia      | Eastern Asia |        1.00 |      NULL |          2
 |           NULL | NULL |   NULL | Nippon    | test           | NULL        |  
  NULL | JP    |                                                                
+------+-------+-----------+--------------+-------------+-----------+-----------
-+----------------+------+--------+-----------+----------------+-------------+--
-------+-------+                                                                
1 row in set (0.00 sec)                                                         
                                                                                
mysql>                                                                          

データを入力し ( 今度は SurfaceArea = 2 です ) 、確認画面で「登録」ボタンをクリックすると以下の処理が実行されました。

  1. Connection.setAutoCommit(false)
  2. select country0.code as code1_0_0, … from Country country0 where country0.code=‘JP2’ for update (ここで 5 秒間待機)
  3. com.mysql.jdbc.exceptions.MySQLTimeoutException: Statement cancelled due to timeout or client request ログ出力
  4. Connection.rollback()
  5. Connection.setAutoCommit(true)

設定通り 5 秒待機した後、例外が発生しロールバックされました。ただし発生した例外が MySQLTimeoutException と MySQL 固有の例外クラスのようなのですが、DB に依存しない例外クラスにはならないのでしょうか?

考察

いろいろ検証してみて、自分で考えてみたことを記載してみます。

  1. クラスやメソッド個別に @Transactional アノテーションを付加するより、applicationContext.xml を使用して Service クラスのメソッド全てをトランザクション対象になるよう一律設定した方が実装は楽な気がします。Spring Boot の実装サンプルは @Transactional アノテーションを付加するものしか見たことがなかったのですが、何か理由があるのでしょうか。。。
  2. トランザクションが不要な Service クラスのメソッドに @Transactional(readOnly = true) を付加しているサンプルもよく見かけたのですが、メリットが感じられませんでした。付加する必要がない気がします。
  3. チェック例外をロールバック対象にする設定は入れておいた方がよい気がします。Exception が発生してトランザクション対象のメソッドを抜けたのにコミットされるというのは分かりにくい ( というよりこの設定は絶対問題を発生させる ) 気がします。タイムアウトの設定も、タイムアウトなしという設定は考えられないので入れるべきものと思われます。

最後に今回検証で追加・変更したソースファイルは反映しません。一旦全て元に戻します。

ソースコード

CountryService.java

■その1

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

        throw new RuntimeException("これはテストです");
    }
  • メソッドの最後に throw new RuntimeException("これはテストです"); を追加します。

■その2

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

        throw new Exception("これはテストです");
    }
  • save メソッドに throws Exception を追加します。
  • メソッドの最後に throw new Exception("これはテストです"); を追加します。

■その3

    @Transactional
    public void save(CountryForm countryForm) throws Exception {
        Country country = countryRepository.findOne(countryForm.getCode());
        if (country == null) {
            country = new Country();
        }
        BeanUtils.copyProperties(countryForm, country);
        countryRepository.save(country);
    }
  • save メソッドの前に findOne メソッドを呼び出すように変更します。

CountryController.java

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

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

        countryService.save(countryForm);
        return "redirect:/country/complete";
    }
  • update メソッドの throws 節を throws IOException, InvalidRequestExceptionthrows Exception へ変更します。

applicationContext.xml

■その1

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:aop="http://www.springframework.org/schema/aop"
       xmlns:tx="http://www.springframework.org/schema/tx"
       xsi:schemaLocation="
        http://www.springframework.org/schema/beans
        http://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/tx
        http://www.springframework.org/schema/tx/spring-tx.xsd
        http://www.springframework.org/schema/aop
        http://www.springframework.org/schema/aop/spring-aop.xsd">

    <tx:advice id="txAdvice" transaction-manager="transactionManager">
        <tx:attributes>
            <tx:method name="*" rollback-for="Exception"/>
        </tx:attributes>
    </tx:advice>

    <aop:config>
        <aop:pointcut id="pointcutService" expression="execution(* ksbysample.webapp.basic.service.*.*(..))"/>
        <aop:advisor advice-ref="txAdvice" pointcut-ref="pointcutService"/>
    </aop:config>

</beans>
  • <tx:method name="*" rollback-for="Exception"/> の記述で、メソッド名の制限なしで Exception を継承する例外クラスの例外発生時にロールバックするよう設定しています。
  • <aop:config> ... </aop:config> の記述で ksbysample.webapp.basic.service パッケージ配下のメソッドに <tx:advice id="txAdvice" ...> ... </tx:advice> の設定を関連付けています。

■その2

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:aop="http://www.springframework.org/schema/aop"
       xmlns:tx="http://www.springframework.org/schema/tx"
       xsi:schemaLocation="
        http://www.springframework.org/schema/beans
        http://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/tx
        http://www.springframework.org/schema/tx/spring-tx.xsd
        http://www.springframework.org/schema/aop
        http://www.springframework.org/schema/aop/spring-aop.xsd">

    <tx:advice id="txAdvice" transaction-manager="transactionManager">
        <tx:attributes>
            <tx:method name="*" read-only="true"/>
        </tx:attributes>
    </tx:advice>

    <aop:config>
        <aop:pointcut id="pointcutService" expression="execution(* ksbysample.webapp.basic.service.*.*(..))"/>
        <aop:advisor advice-ref="txAdvice" pointcut-ref="pointcutService"/>
    </aop:config>

</beans>
  • <tx:method name="*" rollback-for="Exception"/><tx:method name="*" read-only="true"/> へ変更します。

■その3

    <!--<aop:config>-->
        <!--<aop:pointcut id="pointcutService" expression="execution(* ksbysample.webapp.basic.service.*.*(..))"/>-->
        <!--<aop:advisor advice-ref="txAdvice" pointcut-ref="pointcutService"/>-->
    <!--</aop:config>-->

■その4

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:aop="http://www.springframework.org/schema/aop"
       xmlns:tx="http://www.springframework.org/schema/tx"
       xsi:schemaLocation="
        http://www.springframework.org/schema/beans
        http://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/tx
        http://www.springframework.org/schema/tx/spring-tx.xsd
        http://www.springframework.org/schema/aop
        http://www.springframework.org/schema/aop/spring-aop.xsd">

    <tx:advice id="txAdvice" transaction-manager="transactionManager">
        <tx:attributes>
            <tx:method name="*" rollback-for="Exception" timeout="5"/>
        </tx:attributes>
    </tx:advice>

    <aop:config>
        <aop:pointcut id="pointcutService" expression="execution(* ksbysample.webapp.basic..*Service.*(..))"/>
        <aop:advisor advice-ref="txAdvice" pointcut-ref="pointcutService"/>
    </aop:config>

</beans>
  • <tx:method name="*" ...> の設定に timeout="5" を追加しました。タイムアウト時間を 5秒に設定しています。
  • <aop:pointcut id="pointcutService" expression="execution(* ksbysample.webapp.basic.service.*.*(..))"/><aop:pointcut id="pointcutService" expression="execution(* ksbysample.webapp.basic..*Service.*(..))"/> に変更しました。ksbysample.webapp.basic パッケージの下の任意の場所にあるクラス名の末尾が Service のクラスのメソッドに tx:advice の設定が反映されるようにします。

Application.java

package ksbysample.webapp.basic;

import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ImportResource;

import java.text.MessageFormat;

@ImportResource("classpath:applicationContext.xml")
@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        String springProfilesActive = System.getProperty("spring.profiles.active");
        if (!StringUtils.equals(springProfilesActive, "product")
                && !StringUtils.equals(springProfilesActive, "develop")) {
            throw new UnsupportedOperationException(MessageFormat.format("JVMの起動時引数 -Dspring.profiles.active で develop か product を指定して下さい ( -Dspring.profiles.active={0} )。", springProfilesActive));
        }

        SpringApplication.run(Application.class, args);
    }

}
  • @SpringBootApplication の前に @ImportResource("classpath:applicationContext.xml") を追加します。

CountryRepository.java

■その1

package ksbysample.webapp.basic.service;

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

import javax.persistence.LockModeType;

@Repository
public interface CountryRepository extends CrudRepository<Country, String> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    public Country save(Country country);

}
  • public Country save(Country country); を追加し、@Lock(LockModeType.PESSIMISTIC_WRITE) アノテーションを付加します。

■その2

package ksbysample.webapp.basic.service;

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

import javax.persistence.LockModeType;

@Repository
public interface CountryRepository extends CrudRepository<Country, String> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    public Country findOne(String code);

}
  • public Country save(Country country);public Country findOne(String code); へ変更します。

履歴

2015/03/01
初版発行。