LoginSignup
5
4

More than 1 year has passed since last update.

Extensible Effects でDSLを書く 【Scala】

Last updated at Posted at 2019-09-06

始めに

「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

例えば

こんなモデルがあったとして

User.scala
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)を使って環境依存した実装の場合、

NormalUserRepository.scala
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(_ => ())
}

こんな感じになって、最終的なメソッドは

NormalUserManager.scala
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(代数的データ型)から定義します。

adt.scala
sealed trait UserRepositoryOp[A]

final case class Find(id: Int) extends UserRepositoryOp[Option[User]]

final case class Save(user: User) extends UserRepositoryOp[Unit]

次にEffectを起こすクリエーターを用意します。

UserRepositoryCreator.scala
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用のインターラプターを用意します。

UserRepositoryDoobieInterpretation
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同様にチェーンで呼び出せるようにしておきます。

syntax.scala
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メソッドを実装します。

EffUserManager.scala
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
  }
}

StackIOが含まれていますが、これはrunConnectionIO(_)IOを積むためです。

本筋とは異なりますがEffの特徴であるfor-comprehensionの綺麗さが出ているのが何となく分かってもらえるでしょうか?

ともあれ後は「UserRepositorySlickInterpretation」とか「UserRepositoryTextInterpretation」とか必要となった環境のインターラプターを用意してやるだけで、updateメソッド側ではStackrunXXの差し替えのみで環境を切り替えることが可能となります。

終わりに

以上、EffでDSLを書いてみるでした。

  • ボイラープレートが多い?
  • importが多い?
  • runXXが煩わしい?
  • 型を明示しないとIDEが重い(IntelliJお前だよ)?

そこはトレードオフというやつです。Effは戻り値の方がEff[R, *]となるため、モナドトランスフォーマー等による型合わせからは解放されます。これは呼び出しが深くなればなるほど効果を発揮します(それに伴ってスタックやcontext boundが増えてしまうというこれまたトレードオフなデメリットを持っていますが・・・)。

自分のようにモナドトランスフォーマーがしんどいと思われる方は使ってみてはいかがでしょうか?

5
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
4