Stanby Tech Blog

求人検索エンジン「スタンバイ」を運営するスタンバイの開発組織やエンジニアリングについて発信するブログです。

Cats MTL のご紹介

f:id:stanbyblog:20220228152130p:plain

Cats MTL のご紹介

はじめに

スタンバイではシステム開発に主に Scala を使用しています。またその一部では、モナドやエフェクトを使用して型安全で堅牢なシステムを構築しているシステムもあります。 モナドやエフェクトの合成においては、それらを書きやすい形でラップアップした Eff などがありますが、一方で Scala の関数型ライブラリである Cats とシームレスに統合可能である Cats MTL というライブラリがあり、書きやすさの面でも過去のバージョンと比較してかなり向上しています。 ここでは、 Cats MTL をアプリケーションにどのように取り入れるのかをサンプルコードを元に紹介していきます。 本文中のサンプルコードで使用した各ライブラリのバージョンは以下の通りです。

scala       : 3.1.0
cats        : 2.6.1
cats-mtl    : 1.2.1
cats-effect : 3.3.1

また、Scala のコンパイルオプションは kind projector を有効にするため -Ykind-projector を加える必要があります。詳細な情報は下記URLを参照してください。

https://docs.scala-lang.org/scala3/guides/migration/plugin-kind-projector.html

Cats MTL とは

Cats MTL (Monad Transformer Library) はその名の通りScalaの関数型ライブラリである Cats の Applicative などに対応するモナドトランスフォーマー用の型クラスを提供するライブラリです。 例えば、下記のようにモナドトランスフォーマーを複数使用する場合、ネストしたモナドトランスフォーマーをそのまま使用するとコードが非常に複雑になってしまいます。

import cats.data._

def checkState: EitherT[StateT[List, Int, ?], Exception, String] = for {
  currentState <- EitherT.liftF(StateT.get[List, Int])
  result <- if (currentState > 10) 
              EitherT.leftT[StateT[List, Int, ?], String](new Exception("Too large"))
            else 
              EitherT.rightT[StateT[List, Int, ?], Exception]("All good")
} yield result

Cats MTL では、このような問題を解決しモナドトランスフォーマーをハンドリングするコードを抑えることで、コードの記述性や可読性を高めます。

import cats.MonadError
import cats.syntax.all._
import cats.mtl.Stateful

def checkState[F[_]](implicit S: Stateful[F, Int], E: MonadError[F, Exception]): F[String] = for {
  currentState <- S.get
  result <- if (currentState > 10) E.raiseError(new Exception("Too large"))
            else E.pure("All good")
} yield result

同じ内容のコードが非常に見やすくなりました。

ユースケース

Cats MTL で提供される型クラスの内、アプリケーション内でよく利用される AskRaise についてサンプルコードを交えて紹介していきます。 ここでは、IDに一致するユーザー情報を取得し、特定の条件に適合すればユーザー情報をそのまま返し、適合しなければエラーを返すという簡単な ユースーケースを例として考えてみます。

AskKleisli#ask を型クラスとして表したもので、何かしらの値を保持し、それを読み出して利用するために使われます。 この型クラスを利用することで、処理の中で動的にアプリケーションの設定を読み込んだり、依存するインスタンスを注入(DI)したりできます。

import cats._
import cats.syntax.all._
import cats.mtl._

object UserUseCase:

  def user[F[_]: Monad]( id: UserId )(implicit
      A: Ask[F, UserAlgebra]
  ): F[IdentifiedUser] =
    for {
      alg        <- A.ask
      user       <- alg.user[F](id)
    } yield user

RaiseFunctor に対して例外やエラーの質を表す型,例えば Either[E, A] を発生させる機能を提供するものです。 ApplicativeError#mapError と同等の機能を提供しますが、型は ApplicativeError である必要はありません。 この型クラスを利用することで、処理条件によってエラーとして Left を生成できます。 特定の条件に適合しない場合にエラーを返す、という仕様をRaise を使用して実装してみましょう。

import cats._
import cats.syntax.all._
import cats.mtl._

object UserUseCase:

  def user[F[_]: Monad]( id: UserId )(implicit
      R: Raise[F, UserError],
      A: Ask[F, UserAlgebra]
  ): F[IdentifiedUser] =
    for {
      alg        <- A.ask
      user       <- alg.user[F](id)
      identified <- if(user.invalid) R.raise(UserError(id)) else user
    } yield identified

上記で作成したプログラムを実行します。実行結果を 実行するメソッドの型引数には型クラスのインスタンスである EitherTKleisli を指定します。(実際には型引数は別途 type として宣言しておくと見やすくなります。) 実行結果として取得できた EitherT[Kleisli[...]] に対して EitherT#value および Kleisli#run を行い、最終的に Either[UserError, IdentifiedUser] を取得します。

import cats._
import cats.data._
import cats.effect.IO

object Main:
  def main(args: List[String]) =
    UserUseCase.user[EitherT[Kleisli[IO, UserAlgebra, _], UserError, _]]
      (UserId(args(0)))
        .value
          .run(UserAlgebra)

おわりに

Cats MTLを使用することで、コードの記述と実行をうまく分離しつつ、アプリケーションを構築するために必要となる機能(エラー処理や依存性の注入など)を簡易なコードで実現できました。 また モナドトランスフォーマーの lift を行わずとも、平坦な for 文のみでUseCase を記述することが出来、記述性、可読性がともに大きく向上しました。 モナドの性質や使い方を学習していくにはそれなりにコストがかかりますが、Cats MTL の様なライブラリを導入することであまりモナドやモナドトランスフォーマーに対する造詣が無くてもアプリケーションの 機能や記述性を損なうこと無く適切な制約を持たせた強いコードを書くことができます。

参照

Cats MTL: https://typelevel.org/cats-mtl/

積極採用中です

スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。

www.wantedly.com