Septeni Engineer's Blog

セプテーニ・オリジナルのエンジニアが綴る技術ブログ

関数型ニキ達にインスパイアされた最近の Scala 開発の取り組み

こんにちは、中途三年目の堀越です。

近頃、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("ぺろ")))
}

実装側も具体的にする必要がない場合は抽象化されていたほうが汎用性が増します。 その場合は、必要に応じて ApplicativeMonad 等の制約をつけてやる必要があるのですが、このあたりは Cats を使ってます。

import cats.Applicative

class CatRepositoryImpl[F[_]: Applicative] extends CatRepository[F] {
  override def resolveAll: F[Seq[Cat]] =
    Applicative[F].pure(Seq(Cat("たま")))
}

利用する側の実装

利用する側の実装です。
こちらも mapflatMap を利用したい場合は MonadApplicative 等の制約を追加。

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
    }

TryFutureEither が混在するようなロジックで相互変換が辛かった経験があります。
抽象化することで解決されました。

具体的な型を決めて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] を返します。 ここでの ADBSession です。なので実行コードは以下のような実装となります。

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 も抽象化する

Kleislirun するレイヤーで 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 の実装に依存するようになったので FMonadError インスタンスである必要があります。

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 でエラーを扱う
  • NaturalTransformationF ~> G を解決

今回取り扱ったサンプルコードの完成版です。参考までに。
GetAnimalsApplication.scala · GitHub

サンプルコードで使っている実行環境およびライブラリは以下のラインナップ。

参考文献

弊社の @AoiroAoino をはじめ、多くの資料を参考にさせていただいてます。Scala コミュニティは最&高です。

おわりに

Functional Programming はとても魅力的です。コードでの表現力の幅が広がります。取り残されないようにキャッチアップと発信の継続をしていきたいです。

人材募集!!!

株式会社セプテーニ・オリジナルでは一緒に働いていただけるエンジニアを積極募集中ですのでどしどしご応募ください。一緒にアニキ達と Functional Programming やりましょう。

詳しくは採用担当の @taket0ra1 まで。

*1:関数型つよつよお兄さん

*2:import 文とか一部端折ってる部分あり。

*3:名前が typed-final に変わっていたり、そもそもこの手法は本来の typed-final とは異なるという話があるのですがここでは詳細には触れません。