始めに
「Extensible Effects(以下Eff)」にすっかりハマったので自作Effectを利用して環境依存(DBドライバ等)を追い出すDSLを書いてみる。
Scalaに関わらず普通にDBドライバ等の環境に依存したプログラミングしていた場合、ある日突然「Aドライバではなくて非互換のBドライバを使え」とか「DBじゃなくTextに書き出せ」とか言われたら大幅な改修が出てきてしまうというのはMVCやらクリーンアーキテクチャを説明する際によく出てくる話だったりします。
Scalaの場合、そういった環境依存を排除するにはインターフェースを切り出してモナドを利用するとか、フリーモナドでDSLを構築するとか、そういった手法がよく取り上げられていると思います。
今回はそれを「 モナドをスタックとしてシンプルに積むことができるEff 」を使ったDSLを書いて解決してみようという話です。書き味はフリーモナドでDSLを構築するのと似ています。
* Eff自体の細かな解説はしません。詳細はドキュメント等を確認ください。
主な環境
- scala 2.12.8
- eff 5.5.0
- eff-cats-effect 5.5.0
- eff-doobie 5.5.0
- cats-core 1.1.0
- cats-effect 1.0.0-RC2
- doobie 0.6.0-M2
- h2 1.4.192
例えば
こんなモデルがあったとして
final case class User(id: Int, name: String)
上記Userの検索と保存を期待するリポジトリと、それを利用して「Userをidで検索し、存在しなければ左に例外、存在したら右にnameを上書きしたUserを持つEitherとして返す」メソッドdef update(id: Int, name: String): Either[Throwable, User]
を求めたいとします。
普通に実装してみる
DBライブラリ(今回はdoobie)を使って環境依存した実装の場合、
import doobie._
import doobie.implicits._
class NormalUserRepository {
def find(id: Int): ConnectionIO[Option[User]] =
sql"""|SELECT
| id
| , name
|FROM
| users
|WHERE
| id = $id
|""".stripMargin.query[User].option
def save(user: User): ConnectionIO[Unit] =
sql"""|INSERT INTO
| users
| (
| id
| , name
| )
|VALUES
| (
| ${user.id}
| , ${user.name}
| )
|ON DUPLICATE KEY UPDATE
| id = ${user.id}
| , name = ${user.name}
|""".stripMargin.update.run.map(_ => ())
}
こんな感じになって、最終的なメソッドは
import cats.data._
import cats.effect._
import cats.free.Free
import cats.implicits._
import doobie._
import doobie.free.connection.ConnectionOp
import doobie.implicits._
object NormalUserManager {
val repository = new NormalUserRepository()
val tx: Transactor[IO] = Transactor.fromDriverManager[IO](
"org.h2.Driver",
"jdbc:h2:mem:test;DATABASE_TO_UPPER=false;MODE=MYSQL;DB_CLOSE_DELAY=-1",
"sa",
""
)
def update(id: Int, name: String): Either[Throwable, User] =
(for {
user <- EitherT[ConnectionIO, Throwable, User](repository.find(id).map {
case Some(v) =>
v.asRight[Throwable]
case None =>
new Exception("Not found").asLeft[User]
})
updated = user.copy(name = name)
_ <- EitherT[ConnectionIO, Throwable, Unit](
repository.save(updated).map(_.asRight[Throwable])
)
} yield updated).value.transact(tx).unsafeRunSync()
}
こんな感じになるのではないかと思います。
見ての通り、doobieのConnectionIO[_]
(Free[ConnectionOp, A]
のエイリアス)にべったり依存しています。
「doobieやめてslickにしろ」なんて話になった場合、update
メソッドだけの改修であれば大した工数ではありませんが、こういったメソッドが何個もあった場合は面倒なことになりそうです。
Effを使って
ではEffを使ってDSL化してみましょう。
まずはADT(代数的データ型)から定義します。
sealed trait UserRepositoryOp[A]
final case class Find(id: Int) extends UserRepositoryOp[Option[User]]
final case class Save(user: User) extends UserRepositoryOp[Unit]
次にEffectを起こすクリエーターを用意します。
import org.atnos.eff._
trait UserRepositoryCreator {
type _userRepositoryOp[R] = UserRepositoryOp |= R
def find[R: _userRepositoryOp](id: Int): Eff[R, Option[User]] =
EffCreation.send(Find(id))
def save[R: _userRepositoryOp](user: User): Eff[R, Unit] =
EffCreation.send(Save(user))
}
object UserRepositoryCreator extends UserRepositoryCreator
この時点でDBドライバ等の環境は[R: _userRepositoryOp]
によって最終的に実装されるまで隠匿されます。
続いて実装時に用いるdoobie用のインターラプターを用意します。
import doobie.implicits._
import org.atnos.eff._
import org.atnos.eff.addon.doobie.DoobieConnectionIOEffect._
import org.atnos.eff.interpret._
trait UserRepositoryDoobieInterpretation {
private def findEffect[R: _connectionIO](id: Int) =
for {
queried <- fromConnectionIO(sql"""|SELECT
| id
| , name
|FROM
| users
|WHERE
| id = $id
|""".stripMargin.query[User].option)
} yield queried
private def saveEffect[R: _connectionIO](user: User) =
for {
queried <- fromConnectionIO(
sql"""|INSERT INTO
| users
| (
| id
| , name
| )
|VALUES
| (
| ${user.id}
| , ${user.name}
| )
|ON DUPLICATE KEY UPDATE
| id = ${user.id}
| , name = ${user.name}
|""".stripMargin.update.run.map(_ => ())
)
} yield queried
def runUserRepository[R, U, A](effects: Eff[R, A])(
implicit member: Member.Aux[UserRepositoryOp, R, U],
connectionIO: _connectionIO[U]
): Eff[U, A] =
translate(effects)(new Translate[UserRepositoryOp, U] {
override def apply[X](kv: UserRepositoryOp[X]): Eff[U, X] = kv match {
case Find(id) =>
findEffect[U](id)
case Save(user) =>
saveEffect[U](user)
}
})
}
object UserRepositoryDoobieInterpretation
extends UserRepositoryDoobieInterpretation
この段階ではピンと来ないとは思いますがrunUserRepository
実行後に環境であるConnectionIO
がスタックに積まれるイメージです。他にも依存がある場合もここで積むことが可能です。
また、残念なことですがインターラプターにおいては 型安全は守られなれないことに注意してください。 Effectは全てasInstanceOf[X]
で返す必要があります。
!!2019/10/26訂正!! Twitterで型安全でいけると指摘いただきました! ごめんなさい! IntelliJが解釈してくれないだけでした😢
おまけでrunUserRepository
を既存のrunXX
同様にチェーンで呼び出せるようにしておきます。
import org.atnos.eff._
import org.atnos.eff.addon.doobie.DoobieConnectionIOEffect._
trait Syntax {
implicit final def toUserRepositoryOps[R, A](
effects: Eff[R, A]
): UserRepositoryOps[R, A] = new UserRepositoryOps[R, A](effects)
}
object Syntax extends Syntax
final class UserRepositoryOps[R, A](private val effects: Eff[R, A])
extends AnyVal {
def runUserRepository[U](
implicit member: Member.Aux[UserRepositoryOp, R, U],
connectionIO: _connectionIO[U]
): Eff[U, A] = UserRepositoryDoobieInterpretation.runUserRepository(effects)
}
割と手間がかかりましたが、以上でDSLの準備は完了です。
最終的にDSLを利用するupdate
メソッドを実装します。
import cats.effect._
import cats.implicits._
import doobie._
import org.atnos.eff._
import org.atnos.eff.all._
import org.atnos.eff.syntax.addon.cats.effect._
import org.atnos.eff.syntax.addon.doobie._
import org.atnos.eff.syntax.all._
import com.example.Syntax._
object EffUserManager {
private val tx = Transactor.fromDriverManager[IO](
"com.mysql.cj.jdbc.Driver",
"jdbc:mysql://localhost:3306/db?useSSL=false",
"root",
"password"
)
def update(id: Int, name: String): Either[Throwable, User] = {
type Stack = Fx.fx4[UserRepositoryOp, ThrowableEither, ConnectionIO, IO]
for {
optionalUser <- UserRepositoryCreator.find[Stack](id)
user <- fromEither[Stack, Throwable, User](optionalUser match {
case Some(v) =>
v.asRight[Throwable]
case None =>
new Exception("Not found").asLeft[User]
})
updated = user.copy(name = name)
_ <- UserRepositoryCreator.save[Stack](updated)
} yield updated).runUserRepository
.runConnectionIO(tx)
.runEither[Throwable]
.unsafeRunSync
}
}
Stack
にIO
が含まれていますが、これはrunConnectionIO(_)
がIO
を積むためです。
本筋とは異なりますがEffの特徴であるfor-comprehension
の綺麗さが出ているのが何となく分かってもらえるでしょうか?
ともあれ後は「UserRepositorySlickInterpretation
」とか「UserRepositoryTextInterpretation
」とか必要となった環境のインターラプターを用意してやるだけで、update
メソッド側ではStack
とrunXX
の差し替えのみで環境を切り替えることが可能となります。
終わりに
以上、EffでDSLを書いてみるでした。
- ボイラープレートが多い?
- importが多い?
-
runXX
が煩わしい? - 型を明示しないとIDEが重い(IntelliJお前だよ)?
そこはトレードオフというやつです。Effは戻り値の方がEff[R, *]
となるため、モナドトランスフォーマー等による型合わせからは解放されます。これは呼び出しが深くなればなるほど効果を発揮します(それに伴ってスタックやcontext bound
が増えてしまうというこれまたトレードオフなデメリットを持っていますが・・・)。
自分のようにモナドトランスフォーマーがしんどいと思われる方は使ってみてはいかがでしょうか?