はじめに
以前にEff(Extensible Effects)でDSLを書いてみたんですが 、ADT(代数的データ型)を利用するとインタープリターの戻り値にasInstanceOf[]
を使う必要があり型安全ではなくなるという弱点がありました。
!!2019/10/26訂正!! Twitterで型安全でいけると指摘いただきました! ごめんなさい! IntelliJが解釈してくれないだけでした😢
そこでADTを必要としない構築方法である「Tagless Final」を用いて型安全かつEffの利点を利用できるDSLを書いてみたいと思います。
※Tagless FinalやEffの詳細な解説はしません
環境
- eff 5.5.2
- eff-cats-effect 5.5.2
- eff-doobie 5.5.2
- cats-core 2.0.0
- cats-effect 2.0.0
- doobbie-core 0.8.0-RC1
- h2 1.4.200
成果物
参考
[Interpreting Tagless Final DSLs with Eff] (https://www.becompany.ch/en/blog/2018/09/27/tagless-final-and-eff)
前提条件
前回と同じで、「Userモデルを検索し、更新・保存するメソッドを必要としている」として、今回も環境依存を排除したDSLを構築したいと思います。
Userモデル
final case class User(id: Int, name: String)
インターフェース
Tagless Finalでは高カインド型を用いたインターフェースでDSLを表現するようです。
trait UserRepository[F[_]] {
// 検索
def find(id: Int): F[Option[User]]
// 保存
def save(user: User): F[Unit]
}
Effインタープリター
Eff用のインタープリターです。これはあくまで上っ面であり、環境依存(Effで言うとこのStack)を積んだインタープリター(引数のinterpreter: UserRepository[F]
)を受け取り、エフェクトを起こします。
import org.atnos.eff._
trait UserRepositoryEffInterpreter {
// Eff用インタープリター
// F型はR型の「Member」
def effInterpreter[R, F[_]](
interpreter: UserRepository[F]
)(implicit evidence: F |= R): UserRepository[Eff[R, *]] =
new UserRepository[Eff[R, *]] {
override def find(id: Int): Eff[R, Option[User]] =
Eff.send(interpreter.find(id))
override def save(user: User): Eff[R, Unit] =
Eff.send(interpreter.save(user))
}
}
object UserRepositoryEffInterpreter extends UserRepositoryEffInterpreter
ConnectionIOインタープリター
環境であるdoobie.ConnectionIO
を実装したインタープリターです。これは見たまま正に実装です。
import doobie._
import doobie.implicits._
trait UserRepositoryConnectionIOInterpreter {
// ConnectionIO用インタープリター
val connectionIOInterpreter: UserRepository[ConnectionIO] =
new UserRepository[ConnectionIO] {
override def find(id: Int): ConnectionIO[Option[User]] =
sql"""|SELECT
| id
| , name
|FROM
| users
|WHERE
| id = $id
|""".stripMargin.query[User].option
override 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(_ => ()) // Int to Unit
}
}
object UserRepositoryConnectionIOInterpreter
extends UserRepositoryConnectionIOInterpreter
インタープリターを組み合わせる
この辺の定義方法は好みが分かれそうなのでお好きなように。
自分的にはこう↓した。
import doobie._
import org.atnos.eff._
import org.atnos.eff.addon.doobie.DoobieConnectionIOEffect._
trait Syntax {
implicit final def toEffConnectionIOInterpreter[R: _connectionIO]
: UserRepository[Eff[R, *]] =
UserRepositoryEffInterpreter.effInterpreter[R, ConnectionIO](
UserRepositoryConnectionIOInterpreter.connectionIOInterpreter
)
}
object Syntax extends Syntax
Main
今回はH2にデータを用意することにしたので前半部はその準備です。
本来のTagless Finalであればupdateメソッドはdef update[F: Monad](id: Int, name: String)(implicit userRepository: UserRepository[F])
としたいところですが、Effの積み上げを利用したかったのでEff[R, *]
と決め打ちしています。今回Eff自体は排除対象ではないので良しとしました。
import scala.concurrent.ExecutionContext
import cats.effect._
import cats.implicits._
import doobie._
import doobie.implicits._
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._
object Main extends App {
import Syntax._ // 必須
implicit val cs: ContextShift[IO] = IO.contextShift(ExecutionContext.global)
val tx = Transactor.fromDriverManager[IO](
"org.h2.Driver",
"jdbc:h2:mem:test;DATABASE_TO_UPPER=false;MODE=MYSQL;DB_CLOSE_DELAY=-1",
"sa",
""
)
// テーブル定義
val create =
sql"""|CREATE TABLE users
|(
| id INT NOT NULL PRIMARY KEY
| , name VARCHAR(50)
|)
|""".stripMargin.update.run
// User(1, "")を追加
val insert =
sql"""INSERT INTO users
|(
| id
| , name
|)
|VALUES
|(
| 1,
| ''
|)
|""".stripMargin.update.run
// 上記2つを実行
(create, insert).mapN(_ + _).transact(tx).unsafeRunSync()
// 欲しかったメソッド
// ThrowableEitherを積み上げているけどConnectionIOはまだ出てきていない
def update[R: _throwableEither](id: Int, name: String)(
implicit userRepository: UserRepository[Eff[R, *]]
): Eff[R, User] =
for {
optionalUser <- userRepository.find(id)
user <- fromEither(
optionalUser.toRight[Throwable](new Exception("Not Found"))
)
updated = user.copy(name = name)
_ <- userRepository.save(updated)
} yield updated
// EffのStack
// ConnectionIOはIOを積み上げる
type Stack = Fx.fx3[ThrowableEither, ConnectionIO, IO]
// 実行
// ここでSyntax.toEffConnectionIOInterpreter[R: _connectionIO]が解決され、
// ConnectionIOが積み上がる
val result = update[Stack](1, "test")
.runEither[Throwable]
.runConnectionIO(tx)
.unsafeRunSync
println(result) // Right(User(1, test))
}
おわりに
ボイラープレート(?)量はあまり変わらないかも。インタープリターを多重化してるところがミソではあるけども、それがトリッキーである気もする。
とりあえず~~「Eff + ADT」の型安全では無かったDSLが~~「Tagless Final + Eff」で型安全なDSLになりましたとさ。
2019/10/26 追記
「Eff + ADT」が型安全なことが判明したので当初の目的がなくなってしまいましたが、DSL構築のテクニックの一つとしてこのまま載せておきます。