6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

cats Free Monad、akka-httpでREST API作成

Last updated at Posted at 2020-12-21

これは ただの集団 Advent Calendar 2020 の22日目の記事です。

目次

  • なぜやろうかと思ったのか
  • 環境
  • cats Free Monadとは(自分なりに)
  • akka-httpとは
  • Free Monadで実装すると、何がいいのか
  • 実装の流れ
  • おまけ
  • 所感
  • 参考

なぜやろうかと思ったのか

  • 会社でZIOを使っていて、よくcatsとかscalazとかと比較されるが、そもそもcats、scalaz使ったことないので、cats一回使ってみるか!
  • モナドって何?実際に使ってみることでなんとなく掴もう!(List, Option, Eitherなど以外)
  • cats Free MonadをREST APIに組み込んでみた、みたいな記事がなかったのでやってみよう!

って感じ

環境

  • MacOS Big Sur
  • sbt.version = 1.2.8
  • scala version = 2.13.1
  • akka-http version = 10.2.1
  • cats version = 2.1.1

ソースコードは、ここにあげています。

cats Free Monadとは(自分なりに)

  • Monadを自作すると、flatMapなどメソッドを実装しないといけないが、Free Monadを使うと
    既に必要なメソッド群が定義・実装されているので、簡単にMonadの要素を取り入れることができる

  • List Option Eitherなど、scala標準で実装されている様々なモナドをいつも使っているが、それらは既に実装されていて利用する場面は限定されている

  • Free Monadを使えば、自分なりのモナドを実装することができ、自分にあった必要なモナドを作って、モナドのメリットを教授できる。後述するが、Functor(ここでは、interpreterメソッドで実装している)と組み合わせることで様々な挙動を実現することができる

akka-httpとは

Free Monadで実装すると、何がいいのか

  • プログラムと実行を分けて考えることができるので、プログラムの実装中に副作用を考えずに済む
  • 副作用を考えずにプログラムのテストを書くことができる

後述するが、
例えば、テスト用interpreterを差し替えることで、テスト用にモックデータを返したりなど、柔軟に変更できる。
(DIとかでやっていることを違った形で実現できる)

実装の流れ

iPhoneの株価情報アプリの株価アプリのイメージ

気になる株式コードを検索ボックスに入力すると、対象の株式情報とその株式に関するニュースを取得する検索api

※細かい内容は、githubにあげているので、そっちを確認

ADTを作成

まずは、ADT(代数的データ型)を定義する。
代数的データ型とは、複数のデータ型を一つにまとめた物。
今回で言うと、実際に行う処理概要(SearchやGetNews)をActions型にまとめている。

Actions.scala

object Actions {
  sealed trait Actions[A]
  case class Search(request: SearchRequest)
      extends Actions[Either[RequestError, SearchResponse]]
  case class GetNews(stock: Stock)
      extends Actions[Either[RequestError, SearchResponse]]
}

DSLを作成

次に、スマートコンストラクタを作成する。
ここで、先ほど作成したSearchやGetNews型をFree型にリフトしている。
ここで定義したメソッドを組み合わせて、次の項目でロジックを組み立てていく。

※しれっと、type aliasを定義している。

Actions.scala
object Actions {
...

type Program[A] = Free[Actions, A]
type Result[A] = Either[RequestError, A]

private def search(request: SearchRequest): Program[Result[Stock]] =
  Free.liftF[Actions, Result[SearchResponse]](Search(request))

private def getNews(stock: Stock): Program[Result[News]] =
  Free.liftF[Actions, Result[SearchResponse]](GetNews(stock))

}

ロジックを作成

実際に、ロジックを組み立てる。
for式でsearchとgetNewsを結合できる。

Actions.scala
def searchStocks(
    searchRequest: SearchRequest
  ): Program[Result[SearchResponse]] = {
    StockValidator
      .validateSearchRequest(searchRequest)
      .fold(
        errors =>
          Free.pure[Actions, Result[SearchResponse]](
            Left(
              RequestErrors(
                errors.toNonEmptyList.toList.map(_.message).mkString(",")
              )
            )
        ),
        request => {
          val result = for {
            a <- EitherT(search(request))
            b <- EitherT(getNews(a.stock))
          } yield b
          result.value
        }
      )
  }

ここまでで、重要なロジック作成までを実装できた。
副作用を一切気にせずに実装できていることが分かる。

次は、実際にここまでで作成したプログラムを実行できるような実装をしていく。

interpreterを作成

これまで定義してきたActions型をFuture[_]に変換する実装をしている。
実際の振る舞いを実装している。

object StockUseCase {

  private def searchStock(request: SearchRequest, stockRepository: StockRepository)(
    implicit useCaseExecutor: MessageDispatcher
  ): Future[Either[RequestErrors, SearchResponse]] = {
    println(s"${request.stockCode}でstockを検索しています・・・")
    stockRepository.fetchStock(request.stockCode) map {
      case Some(stock: Stock) => Right(SearchResponse(stock))
        case None               => Left(RequestErrors("該当のstockは存在しません。"))
    }
  }

  private def getNews(stock: Stock)(
      implicit useCaseExecutor: MessageDispatcher
  ): Future[Either[RequestErrors, SearchResponse]] = {
    Future(Right(SearchResponse(stock)))
  }


  private def interpreter(
    stockRepository: StockRepository
  )(implicit useCaseExecutor: MessageDispatcher): Actions ~> Future = {

    new (Actions ~> Future) {
      override def apply[A](fa: Actions[A]): Future[A] = {
        fa match {
          case Search(request: SearchRequest) =>
            searchStock(request, stockRepository)
          case GetNews(stock: Stock) => getNews(stock)
        }
      }
    }
  }
}

実行できるようにする

これで、プログラムと実行環境が整ったので、実際に実行してみる。

program.foldMap(interpreter())で、作成したロジックを作成した実行環境で実行できる。

StockUseCase.scala

object StockUseCase {
  ...

  def apply(
    stockRepository: StockRepository
  )(implicit actorSystem: ActorSystem): StockUseCase[Future] = {
    implicit val ec: MessageDispatcher =
      actorSystem.dispatchers.lookup("repository-executor")

    new StockUseCase[Future] {
      override def run[A](program: Program[A]): Future[A] = {
        import cats.instances.future._
        program.foldMap(interpreter(stockRepository))
      }
    }
  }
}

akka-httpと繋ぐ

Free Monadの部分は実装できたので、あとはそれらをakka-httpとつないでapi化するだけ。

Main.scala

def main(args: Array[String]): Unit = {
    ...

    val stockUseCase: StockUseCase[Future] = StockUseCase(new StockRepository)

    val routes: Route = concat(
      post {
        path("searchStocks") {
          entity(as[SearchRequest]) { request: SearchRequest =>
            {
              onSuccess(stockUseCase.run(Actions.searchStocks(request))) {
                res =>
                  complete(HttpResponse(StatusCodes.OK))
              }
            }
          }
        }
      }    
    )

    println("listen service at http://localhost:8000")
    Http().newServerAt("localhost", 8000).bind(routes)
  }

UT

↓みたいに、interpreterを差し替えれば、ロジックは変えずに挙動はいくらでも変えれる。

val testInterpreter: Actions ~> Future = new (Actions ~> Future) {
  override def apply[A](fa: Actions[A]): Future[A] = fa match {
    case Search(_)  => Future(Right(SearchResponse(Stock())))
    case GetNews(_) => Future(Right(SearchResponse(Stock())))
  }
}

it("stockCodeが存在しない場合、バリデーションに引っかかる") {
  // given
  val searchRequest = SearchRequest(StockCode(""))
  // when
  val result: Program[Result[SearchResponse]] =
    Actions.searchStocks(searchRequest)
  // then
  import cats.instances.future._
  Await.result(result.foldMap(testInterpreter), Duration.Inf) shouldBe Left(
    RequestErrors("sotckcodeが空です。")
  )
}


おまけ

コードの中に、catsのdata型をいくつか利用しているので、さらっと解説

Validated

EitherでflatMapを使って処理していると、1つ目がLeftの場合にはそこで処理が止まって後続の処理がされない。
これだと、Leftの値が1つ目のみの値になるので、全体がわからない。

これに対して、Validatedを使うことで、Validatedを繋いでも1つ目で処理が止まらず最後まで処理が行われるようになる。バリデーションに引っかかった処理に対するLeft値がListで取得できる。

type ValidatedNec[+E, +A] = Validated[NonEmptyChain[E], A]

ValidatedNecは、Eitherで言うLeftにNonEmptyChain[E]型が定義されている。
NonEmptyChainはListのようなデータ構造をしているので、NonEmptyChain(1, 2, 3, 4, 5)みたいに、複数の要素を保持できる。

なので、以下の例のようにバリデーションに引っかかった際のエラーメッセージを複数保持してくれる。

type ValidationResult[A] = ValidatedNec[RequestError, A]

  def validateSearchRequest(
    request: SearchRequest
  ): ValidationResult[SearchRequest] = {
    (
      validateStockCodeIsEmptyText(request),
      validateStockCodeIsSmallLetter(request)
    ).mapN((request, _) => {
      SearchRequest(code = request.stockCode)
    })
  }

  private def validateStockCodeIsEmptyText(
    request: Request
  ): ValidationResult[Request] = {
    if (request.stockCode.value.isEmpty) EmptyStockCodeError.invalidNec
    else request.validNec
  }

  private def validateStockCodeIsSmallLetter(
    request: Request
  ): ValidationResult[Request] = {
    if (request.stockCode.isContainsBigLetter) SizeIsSmallError.invalidNec
    else request.validNec
  }

EitherT

Future[Either[A, B]] Option[Either[A, B]]などの、Eitherがラップされている型をflatmapで合成しようとすると、以下のようにどうしてもネストしてしまう。


def search(): Free[Actions, Either[RequestErrors, SearchResponse]]

def getNews(): Free[Actions, Either[RequestErrors, SearchResponse]]

for {
  eitherSearchRes <- search(request)
  result <- eitherSearchRes match {
    case Right(res) => getNews(res.stock)
    case Left(error: RequestError) =>
      Free.pure[Actions, Result[SearchResponse]](Left(error))
  }
} yield result

そこで、EitherT[F[_], A, B]を使うことで、ネストなく書き換えることができる。

val result = for {
  a <- EitherT(search(request))
  b <- EitherT(getNews(a.stock))
} yield b

↓EitherTのflatMapがいい感じに処理してくれてるから。

def flatMap[AA >: A, D](f: B => EitherT[F, AA, D])(implicit F: Monad[F]): EitherT[F, AA, D] =
  EitherT(F.flatMap(value) {
    case l @ Left(_) => F.pure(l.rightCast)
    case Right(b)    => f(b).value
  })

所感

  • catsは部分的に組み込めるのでいいなぁ
  • Free Monadを使うと、アプリケーションアーキテクチャはどうするのか分からない

参考

なんやかんや、公式ドキュメントがしっかりしている

実際の例など

scala web applicationにfree monadを導入した話

モナド解説(数学的知識なしに)

Freeモナドって何?

6
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
6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?