こんにちは、中途三年目の堀越です。
近頃、Scalaのコミュニティにおいて Functional Programming による実装テクニックを紹介する記事や発表を見たり聞いたりすることは珍しいことではなくなってきました。弊社にもたくさんの関数型ニキ*1が在籍しており、わたしも日々影響を受けています。
ということで、本日はわたしが所属するチームでの日々の Scala 開発における取組みや戦略をサンプルコード*2と合わせて紹介していきます。
高カインド型によるEffect型の抽象化
私達はドメイン駆動設計を実践しています。なのでドメインロジックはドメインの関心事に集中できるのが理想です。ドメイン層を抽象化し、特定の実行環境や技術的関心事に依存しない戦略として 高カインド型 を用いてEffect型を抽象化します。
インターフェース定義
例えば Repository
のインターフェイスのは以下のように定義します。
trait
に高カインド型のパラメータを取り、メソッドの返り値を包んでやります。
// model sealed trait Animal case class Cat(name: String) extends Animal case class Dog(name: String) extends Animal // repository trait CatRepository[F[_]] { def resolveAll: F[Seq[Cat]] } trait DogRepository[F[_]] { def resolveAll: F[Seq[Dog]] }
実装する
F[_]
を具体的な型にして実装します。Monix.Task を使います。
import monix.eval.Task class CatRepositoryImpl extends CatRepository[Task] { override def resolveAll: Task[Seq[Cat]] = Task(Seq(Cat("たま"))) } class DogRepositoryImpl extends DogRepository[Task] { override def resolveAll: Task[Seq[Dog]] = Task(Seq(Dog("ぺろ"))) }
実装側も具体的にする必要がない場合は抽象化されていたほうが汎用性が増します。
その場合は、必要に応じて Applicative
や Monad
等の制約をつけてやる必要があるのですが、このあたりは Cats を使ってます。
import cats.Applicative class CatRepositoryImpl[F[_]: Applicative] extends CatRepository[F] { override def resolveAll: F[Seq[Cat]] = Applicative[F].pure(Seq(Cat("たま"))) }
利用する側の実装
利用する側の実装です。
こちらも map
や flatMap
を利用したい場合は Monad
や Applicative
等の制約を追加。
import cats.Applicative trait AnimalService[F[_]] { def resolveAll: F[Seq[Animal]] } class AnimalServiceImpl[F[_]: Applicative]( catRepository: CatRepository[F], dogRepository: DogRepository[F] ) extends AnimalService[F] { def resolveAll: F[Seq[Animal]] = Applicative[F].map2[Seq[Cat], Seq[Dog], Seq[Animal]]( catRepository.resolveAll, dogRepository.resolveAll ) { (cats, dogs) => cats ++ dogs }
Try
、 Future
、Either
が混在するようなロジックで相互変換が辛かった経験があります。
抽象化することで解決されました。
具体的な型を決めてDIする
DI するときに抽象化されたモジュールの具体的な型を決め、利用します。DI のライブラリには macwire を採用してます。
import com.softwaremill.macwire._ import monix.eval.Task object AnimalServiceComponent { lazy val catRepository: CatRepository[Task] = wire[CatRepositoryImpl] lazy val dogRepository: DogRepository[Task] = wire[DogRepositoryImpl] lazy val animalService: AnimalService[Task] = wire[AnimalServiceImpl[Task]] } object Main { import monix.execution.Scheduler.Implicits.global import AnimalServiceComponent._ val animals: Seq[Animal] = Await.result(animalService.resolveAll.run(()).runToFuture, 50.millisecond) // List(Cat(たま), Dog(ぺろ)) }
Kleisli の活用
Effect型に Kleisli を適用
先に紹介した Repository
の実装コードはベタ書きした結果を返していましたが、実際にはデータベースの取得結果を返したりすることが多いと思います。その場合、避けて通れないのがトランザクションやセッションといった情報ですが Kleisli を使ってこれらの文脈を露出させないようにします。
いわゆる “tagless final” と呼ばれていた手法です。*3
import monix.eval.Task import cats.data.Kleisli import scalikejdbc.{AutoSession, DB, DBSession} object Type { type R[A] = Kleisli[Task, DBSession, A] } class CatRepositoryImpl extends CatRepository[R] { override def resolveAll: R[Seq[Cat]] = Kleisli { implicit dbSession => Task(Seq(Cat("たま"))) } } class DogRepositoryImpl extends DogRepository[R] { override def resolveAll: R[Seq[Dog]] = Kleisli { implicit dbSession => Task(Seq(Dog("ぺろ"))) } }
先程、F[_]
の具体的な型として Task
を注入していた箇所に Kleisli
を指定。
import com.softwaremill.macwire._ object AnimalServiceComponent { // type R[A] = Kleisli[Task, DBSession, A] lazy val catRepository: CatRepository[R] = wire[CatRepositoryImpl] lazy val dogRepository: DogRepository[R] = wire[DogRepositoryImpl] lazy val animalService: AnimalService[R] = wire[AnimalServiceImpl[R]] }
Kleisli[F[_], A, B]
は A => F[B]
のラッパーで run
を実行することで F[B]
を返します。
ここでの A
は DBSession
です。なので実行コードは以下のような実装となります。
import scala.concurrent.Await import scala.concurrent.duration._ object Main { import monix.execution.Scheduler.Implicits.global import AnimalServiceComponent._ val animals: Seq[Animal] = Await.result( animalService.resolveAll.run((AutoSession)).runToFuture, 50.millisecond ) }
DBSession も抽象化する
Kleisli
を run
するレイヤーで DBSession
など、具体的な値を指定したくないことがほとんどです。なので、Repository
などと同様に利用側では 抽象化したインターフェイスを参照し、実装は DI して解決します。
// インターフェイス trait IOContextManager[F[_], Ctx] { def context: Ctx def transactionalContext[T](execution: (Ctx) => F[T]): F[T] } // 実装 class IOContextManagerOnJDBC extends IOContextManager[Task, DBSession] { override def context: DBSession = AutoSession override def transactionalContext[T]( execution: (DBSession) => Task[T] ): Task[T] = Task.deferFutureAction { implicit scheduler => DB.futureLocalTx { session => execution(session).runToFuture } } } // DI object AnimalServiceComponent { /* 他のDI定義(省略) */ lazy val ioContext: IOContextManager[Task, DBSession] = wire[IOContextManagerOnJDBC] } // 利用する側 object Main { import monix.execution.Scheduler.Implicits.global import AnimalServiceComponent._ val animals: Seq[Animal] = Await.result( animalService.resolveAll.run((ioContext.context)).runToFuture, 50.millisecond ) }
これによりデータベースに特化した関心事はそれに特化したレイヤーに封じ込めることができました。
MonadError の活用
MonadError を用いてエラーを扱う
抽象化したロジックでのエラーを扱いたい場合には、MonadError
を活用します。 例えば以下のように AnimalSerivce[F[_]]
にエラーの文脈を埋め込めます。取得結果が空の時にエラーを返してみます。
import cats.implicits._ import cats.{Applicative, MonadError} trait AnimalService[F[_]] { def resolveAll: F[Seq[Animal]] } class AnimalServiceImpl[F[_]]( catRepository: CatRepository[F], dogRepository: DogRepository[F] )(implicit me: MonadError[F, Error]) extends AnimalService[F] { def resolveAll: F[Seq[Animal]] = { val result = Applicative[F].map2[Seq[Cat], Seq[Dog], Seq[Animal]]( catRepository.resolveAll, dogRepository.resolveAll ) { (cats, dogs) => cats ++ dogs } result.flatMap { case r if r.nonEmpty => me.pure(r) case _ => me.raiseError(NotFoundError) } } }
MonadError
にはさまざまな関数が実装されていて柔軟にエラーを扱うことができます。例えば上のコードのresult.flatMap {...}
は以下のように書き直すことができます。
// def ensure[A](fa: F[A])(e: E)(f: A => Boolean): F[A]
me.ensure(result)(NotFoundError)(_.nonEmpty)
def ensure
は特定の条件においてエラーを発生させることができる便利関数で、とても気に入ってます。
インスタンスの生成
AnimalService[F[_]]
が MonadError
の実装に依存するようになったので F
は MonadError
インスタンスである必要があります。
Kleisli を活用 で紹介した型を MonadError
インスタンスに差し替えて利用するようにします。
object Type { type E[A] = EitherT[Task, Error, A] type R[A] = Kleisli[Task, DBSession, A] type RE[A] = Kleisli[E, DBSession, A] } object AnimalServiceComponent { /* 他のDI定義(省略) */ lazy val animalService: AnimalService[RE] = wire[AnimalServiceImpl[RE]] }
もちろんこのままではコンパイルは通りません。
$ sbt compile [error] ... Cannot find a value of type: [CatRepository[Type.RE]] [error] wire[AnimalServiceImpl[RE]] [error] ^ [error] one error found
CatRepository[R]
の実装しかないので CatRepository[RE]
を AnimalServiceImpl[RE]
は見つけることができないからですね。
class CatRepositoryImpl extends CatRepository[R] {...}
DogRepository
にも同じことが言えます。
これを解決する手段について次で説明します。
NaturalTransformation の活用
CatRepository[RE]
が見つけられないのであれば R ~> RE
へ変換できればよいのです。わざわざ実装を書き換える必要はありません。 NaturalTransFormation を使って解決します。
import cats.~> import cats.implicits._ // CatRepository[F] ~> CatRepository[G] への変換の実装を追加 trait CatRepository[F[_]] { self => def mapK[G[_]](nat: F ~> G): CatRepository[G] = new CatRepository[G] { override def resolveAll: G[Seq[Cat]] = nat(self.resolveAll) } def resolveAll: F[Seq[Cat]] } // 同様に DogRepository[F] ~> DogRepository[G] への変換の実装を追加 trait DogRepository[F[_]] { self => def mapK[G[_]](nat: F ~> G): DogRepository[G] = new DogRepository[G] { override def resolveAll: G[Seq[Dog]] = nat(self.resolveAll) } def resolveAll: F[Seq[Dog]] }
R ~> RE
の関数を作成し、実装した mapK
に渡すことで インスタンスを作成することができます。
import cats.~> import cats.implicits._ object AnimalServiceComponent { /* 他のDI定義(省略) */ implicit val TaskToE: Task ~> E = new (Task ~> E) { def apply[A](fa: Task[A]): E[A] = EitherT(fa.map(_.asRight)) } implicit val RToRE: R ~> RE = new (R ~> RE) { def apply[A](fa: R[A]): RE[A] = fa.mapF(TaskToE(_)) } lazy val catRepository: CatRepository[R] = wire[CatRepositoryImpl] lazy val catRepository2: CatRepository[RE] = wire[CatRepositoryImpl].mapK(RToRE) lazy val dogRepository: DogRepository[R] = wire[DogRepositoryImpl] lazy val dogRepository2: DogRepository[RE] = wire[DogRepositoryImpl].mapK(RToRE) lazy val animalService: AnimalService[RE] = wire[AnimalServiceImpl[RE]] }
これで先程のコンパイルエラーも解決となります。
まとめ
- 高カインド型によるドメイン層でのEffect型抽象化
Kleisli
を使って技術的関心事を隠蔽(いわゆる “tagless final” と呼ばれていたアプローチ)MonadError
でエラーを扱うNaturalTransformation
でF ~> G
を解決
今回取り扱ったサンプルコードの完成版です。参考までに。
GetAnimalsApplication.scala · GitHub
サンプルコードで使っている実行環境およびライブラリは以下のラインナップ。
- SBT: 1.3.3
- Scala: 2.12.8 & 2.13.1
- ScalikeJDBC
- Cats
- macwire
- Monix.Task
参考文献
弊社の @AoiroAoino をはじめ、多くの資料を参考にさせていただいてます。Scala コミュニティは最&高です。
- www.slideshare.net
- speakerdeck.com
- MonadError の嬉しみ (Scala Advent Calendar 2015 ADVENTAR 19th) · GitHub
おわりに
Functional Programming
はとても魅力的です。コードでの表現力の幅が広がります。取り残されないようにキャッチアップと発信の継続をしていきたいです。
人材募集!!!
株式会社セプテーニ・オリジナルでは一緒に働いていただけるエンジニアを積極募集中ですのでどしどしご応募ください。一緒にアニキ達と Functional Programming やりましょう。
詳しくは採用担当の @taket0ra1 まで。