ScalikeJDBCのTxBoundaryの使い方


こんにちは、@s_tsukaです。

今日は自分の好きなライブラリの1つであるScalikeJDBCのTxBoundaryについて触れて行きます!

ScalikeJDBCのトランザクション

皆さん、ScalikeJDBCを使うときは併せてトランザクションを使用しているかと思います。

例えばこんな感じで。

val count = DB localTx { implicit session =>
  // トランザクション開始

  val updateMembers = SQL("update members set name = ? where id = ?")

  updateMembers.bind("Alice", 1).update.apply() 
  updateMembers.bind("Bob", 2).update.apply() 

  // トランザクション終了
}

 引用:scalikejdbc-cookbook https://github.com/scalikejdbc/scalikejdbc-cookbook/blob/master/ja/04_transaction.md#localtx

localTxで囲ったブロック内で例外が発生するとrollbackします。

これはこれで素晴らしい機能なのですが、あるシーンで少し問題が出てきます。

例外が発生せずに終了する場合

例外が発生せずに終了する場合はcommitされます。「別にcommitでいいじゃん!」とも思えるのですが、Scalaコードの場合、結果をEitherで返す場合があるかと思います。

例えば以下のコードのような感じで。

まずはbuild.sbtです。ライブラリを使います。

name := "scala-study"

version := "1.0"

scalaVersion := "2.11.4"

libraryDependencies ++= Seq(
  "org.scalikejdbc"  %% "scalikejdbc"  % "2.2.+",
  "org.slf4j"        %  "slf4j-simple" % "1.7.+",
  "com.h2database"   %  "h2"           % "1.4.+",
  "org.specs2"       %% "specs2-core"  % "2.4.9" % "test"
)

以下は実際のコードです。h2dbを使っているのでメモリ上にDBやテーブルが一時的にできます。

DBとテーブル作ってinsertして最後にselectしています。

import scalikejdbc._

case class Person(name: String)

object Main {

  def main(args: Array[String]): Unit = {
    // DB作成など色々 環境作成のためにやむなくやってます
    setupDB

    // トランザクション処理!
    DB localTx { implicit session =>
      callSomething
    }

    // トランザクションがとうなったか確認用
    printTableData
  }

  /**
   * テーブル作成など。
   * 本質ではないコードです。
   */
  def setupDB: Unit = {
    Class.forName("org.h2.Driver")
    ConnectionPool.singleton("jdbc:h2:mem:scalikejdbc","user","pass")

    DB autoCommit { implicit session =>
      SQL("""
             create table person (
             name varchar(30) not null
             )
             """).execute.apply()
    }
  }

  /**
   * トランザクション処理
   */
  def callSomething(implicit s: DBSession = AutoSession): Either[Throwable, String] = {

    SQL("insert into person values('tsukaby')").execute().apply()

    // 何かバリデーションエラーなど処理に問題があった場合、例外throwではなくLeftで返す
    // Left(new IllegalStateException())

    // 処理に問題が無ければRightで返す
    Right("ok!")
  }

  /**
   * テーブル確認
   */
  def printTableData: Unit = {
    println("================")
    DB readOnly { implicit session =>
      val tmp = sql"""
            select * from person
            """.map(rs => Person(rs.string(1))).single().apply()
      println(tmp)
    }
    println("================")
  }
}

callSomethingは結果をEitherで返しています。

以下は実行結果です。

================
Some(Person(tsukaby))
================

処理は成功するので、当然値が取れます。

ここでcallSomethingメソッドを少し考えます。ここがものすごく複雑なロジックで途中で例外も発生すれば何らかのチェック処理でエラーだと判断することもあるとします。

そういう場合、ScalaではEitherというものを使うことがあります。EitherはRightまたはLeftという値を取ることができて、Rightなら成功、Leftなら失敗という意味になります。

上記のケースだと必ずRightを返していますが、コメントアウトを逆転してLeftを返すようにしてみます。つまり処理失敗です。

    // 何かバリデーションエラーなど処理に問題があった場合、例外throwではなくLeftで返す
    Left(new IllegalStateException())

    // 処理に問題が無ければRightで返す
    // Right("ok!")

こんな風にLeftを返すようにしてみました。

この状態で実行するとLeft、つまり失敗を返している(さらにLeftの中は例外!)にも関わらずPersonが1件取れてしまいます。

これはなぜかと言うとlocalTxはあくまで例外が投げられたときにrollbackするからです。localTxの気持ち的には「Leftとか例外じゃないし、別に正常終了じゃん?commitだよね」という感じです。

例外以外のケースでもrollbackさせたいときはTxBoundary

そこで今回の主役であるTxBoundaryの出番です。

結論から言うと、こう書きます。するとRightを返したときはcommitしますが、Leftのときはrollbackします。

    // トランザクション処理!
    import scalikejdbc.TxBoundary.Either._
    DB localTx { implicit session =>
      callSomething
    }

以下が実行結果です。

================
None
================

import文1行なのでお手軽ですね。

どうしてTxBoundaryでrollbackするのか

ソースを見ると割とすぐに分かります。

https://github.com/scalikejdbc/scalikejdbc/blob/develop/scalikejdbc-core/src/main/scala/scalikejdbc/TxBoundary.scala

matchしてLeftのときはtx.rollback()しています。これのおかげですね。

独自クラスでTxBoundaryしたい

例えばscalazのValidationなんかはTxBoundaryに対応していません。また、独自にResultTypeみたいな型を作って、それを戻り値型にしているケースなども対応していません。

けれど、自分で作ってしまえば大丈夫です。

例えば以下のような型があるとします。

sealed trait ResultType

object ResultType {
  object SuccessType extends ResultType
  object WarnType extends ResultType
  object ErrorType extends ResultType
  object FatalType extends ResultType
}

callSomethingの戻り値型がResultTypeで、SuccessかWarnのときはcommit, その他の場合はrollbackしたいとします。

こんな感じで独自トランザクション制御処理を作ります。

object TxBoundaryExtension {
    implicit def resultTypeTxBoundary = new TxBoundary[ResultType] {
      def finishTx(result: ResultType, tx: Tx): ResultType = {
        result match {
          case SuccessType => tx.commit()
          case WarnType => tx.commit()
          case ErrorType => tx.rollback()
          case FatalType => tx.rollback()
        }
        result
      }
    }
}

後は以下のようにimportするものを変えて、

    // トランザクション処理!
    import TxBoundaryExtension._
    DB localTx { implicit session =>
      callSomething
    }

callSomethingの戻り値型もResultTypeに変えれば上手く動きます。

  /**
   * トランザクション処理
   */
  def callSomething(implicit s: DBSession = AutoSession): ResultType = {

    SQL("insert into person values('tsukaby')").execute().apply()

    ErrorType
  }

この場合はErrorTypeなので、rollbackされます。

以上で終わりです。TxBoundary便利なので使いましょう!