🍰

ReaderTパターンとThree Layer Haskell Cakeから学ぶPureScriptのアプリケーション設計

2023/08/14に公開

はじめに

今回は、ReaderTパターンと、ReaderTパターンをベースとしたThree Layer Haskell Cakeという設計についてご紹介したいと思います。

Three Layer Haskell Cake は Haskell が含まれているように元々は Haskell 向けの記事として書かれたものになります。
しかしこの設計は PureScript にも転用することができます。
実際、 readworld-halogen はこの設計です。

冒頭に書いたように Three Layer Cake は ReaderTパターンを使っているため、先にReaderTパターンの話をし、それを踏まえた上で Three Layer Haskell Cake の説明をしていきます。

ReaderTパターンとは

ReaderTパターンは、名前の通りmtl(モナド変換子)のReaderTを用いたデザインパターンです。
Michael Snoyman氏 (HaskellのYesodとかStackとかの開発者)の記事が初出と思われます(これはこのひとがVPoEやってるFP Completeの記事)。

上記の記事では、アプリケーションで「loggingのレベルを設定する」という例を使ってReaderTパターンの説明がされています。

こういったことをHaskellで実現するためには一般的に3つの方法があると言います。

  1. コンパイル時フラグを使用して、実行ファイルに含まれるloggingのコードを制御する。
  2. unsafePerformIOで設定ファイル(または環境変数)を読み込むグローバルな値を定義する。
  3. main関数で設定ファイルを読み込み、その値を(明示的に、あるいはReaderTを介して暗黙的に)残りのコードに渡す。

この(3)の方法がReaderTパターンなわけですが、Michael Snoyman氏は次のように(1)および(2)の問題点を指摘した上で(3)を推しています(詳しく知りたい方は原文の『Better globals』の項をご参照ください)。
(1) コードベースに条件付きコンパイルがあるとビルドに失敗する可能性が増える。そもそもこういうことをコンパイル時にやりたいのか?それより設定ファイルを使ったほうがいい。
(2) 設定ファイルを読み込むにしても unsafePerformIOには色々と問題がある。

もうちょっと解像度を上げましょう。

要約

上記の記事には、パターンの要約が書かれているので見てみましょう(DeepL翻訳)。
※項番は原文にありませんが説明のために私が付けました。

  1. アプリケーションはコア・データ型(Envと呼んでも構わない)を定義しなければならない。
  2. このデータ型には、すべての実行時の設定と、モック可能なグローバル関数(logging関数やDBアクセスなど)が含まれる。
  3. mutableなstateを持たなければならない場合は、mutableな参照(IORef、TVarなど)をEnvに持たせる。
  4. アプリケーションのコードは通常、ReaderT Env IOに置かれます。必要であれば、App = ReaderT Env IO型として定義するか、ReaderTの代わりにnewtypeラッパーを使用してください。
  5. 追加のmtl(モナド変換子)を使うこともできますが、それはアプリケーションの小さなサブセットに対してだけで、そのサブセットは純粋なコードであるのがベストです。
  6. Optional: Appのデータ型を直接使う代わりに、MonadReaderMonadIOのようなmtlスタイルの型クラスで関数を書く。

※PureScriptにないもの(IORef,TVar,MonadIOなど)がありますが、適宜別のものに置き換えられると思います(MonadIOMonadEffect)。

解説

元の記事には、説明のためのコードが載せてあるので、それを(PureScriptのコードに書き換えた上で)使わせてもらいます。

で、先にコードの全体を載せようと思ったのですが、上記の要約に含まれず追加で解説が必要な部分が入り混じっているため、最初は要約に関する部分だけを抜粋しながらお見せして、その後に全体をお見せすることにします(気になるようでしたら先に全体をご覧になっていただいても大丈夫です)。

要約の(1)(2)(3)

まず、(1)(2)(3)にあたる部分のコード例はこうです。

Env
data Env = Env { 
  envLog :: String -> Effect Unit,
  envBalance :: Ref Int
}
  1. Envというデータ型を定義している
  2. このデータ型にはモック可能なグローバル関数 envLog, envBalanceが含まれている
  3. Envはmutableな参照envBalanceを持っている。

と、まぁこのようになっています。
元記事のenvBalanceTVar型だったのですが、STMはPureScriptには無いのでRefで代用しました。

要約の(4)

次に(4) 『アプリケーションのコードは通常、ReaderT Env IOに置かれます。必要であれば、App = ReaderT Env IO型として定義するか、ReaderTの代わりにnewtypeラッパーを使用する』のコード例です。

class Monad m <= MonadBalance m where
  modifyBalance :: (Int -> Int) -> m Unit

instance (HasBalance env, MonadEffect m) => MonadBalance (ReaderT env m) where
  modifyBalance f = do
    env <- ask
    liftEffect $ Ref.modify_ f (getBalance env)

型クラス(MonadBalance)を定義し、ReaderTをそのインスタンスとしています。
HasBalanceという型クラスがありますが、後で説明しますので一旦今は気にしないでください。

このように何かに特化した型クラス(この例ではenvBalanceの値を扱う型クラス)を定義して、ReaderTをインスタンスにするということだけ覚えておいてください。

要約の(5)

(5) 『追加のmtlを使うこともできるが、それはアプリケーションのサブセットに対して使う』に関しては、コード例はありませんでした。いや、正確にはStateTを使用している部分があるのです(後述します)が、テスト用のコードなのでここでいうサブセットの例としては適当ではないでしょう。

ちなみにMichael Snoyman氏は、この記事(が対象としているユースケース)においてWriterT,StateT,StateTといったmtlを使う場合の問題点を挙げ、これらは基本的には使用を避けるべきだとしています。
その例外が、アプリケーションのサブセットでかつ純粋なコードということです。

要約の(6)

(6) 『Appのデータ型を直接使う代わりに、MonadReaderMonadIOのようなmtlスタイルの型クラスで関数を書く』の例です。

logSomething
  :: forall m env
   . HasLog env
  => MonadReader env m 
  => MonadEffect m
  => String
  -> m Unit
logSomething msg = do
  env <- ask
  liftEffect $ getLog env msg

MonadReaderMonadEffectが使われています。

コード例の全体を見てみよう

(1)~(6)までコードの断片を見ながら説明したところで、今度は全体を見てみましょう。
テストコードも含まれており、見てみるとテスタブルな設計になるよう工夫されていることがわかるでしょう。
それと、説明を後回しにしていた部分も今度はしっかり見ていきます。

全体
import Prelude

import Control.Monad.State as State
import Control.Monad.Reader (class MonadReader, ReaderT, ask, runReaderT)
import Effect (Effect)
import Effect.Class (class MonadEffect, liftEffect)
import Effect.Ref (Ref, new, read)
import Effect.Ref as Ref
import Test.Spec (Spec, describe, it)
import Test.Spec.Assertions (shouldEqual)

-- (1)(2)(3)
data Env = Env { 
  envLog :: String -> Effect Unit,
  envBalance :: Ref Int
}

class HasLog a where
  getLog :: a -> (String -> Effect Unit)
instance HasLog (String -> Effect Unit) where
  getLog = identity
else instance HasLog Env where
  getLog (Env {envLog}) = envLog

class HasBalance a where
  getBalance :: a -> Ref Int
instance HasBalance (Ref Int) where
  getBalance = identity
instance HasBalance Env where
  getBalance (Env {envBalance}) = envBalance

class Monad m <= MonadBalance m where
  modifyBalance :: (Int -> Int) -> m Unit
-- (4)
instance (HasBalance env, MonadEffect m) => MonadBalance (ReaderT env m) where
  modifyBalance f = do
    env <- ask
    liftEffect $ Ref.modify_ f (getBalance env)
instance Monad m => MonadBalance (State.StateT Int m) where
  modifyBalance = State.modify_

modify :: forall m. MonadBalance m => (Int -> Int) -> m Unit
modify f = do
  modifyBalance f

-- (6)
logSomething
  :: forall m env
   . HasLog env
  => MonadReader env m 
  => MonadEffect m
  => String
  -> m Unit
logSomething msg = do
  env <- ask
  liftEffect $ getLog env msg

spec :: Spec Unit
spec = do
  describe "ReaderT Pattern test" do
    describe "modify" do
      it "works, Effect" do
        liftEffect do
          var <- new 1
          runReaderT (modify (_ + 2)) var
          res <- read var
          res `shouldEqual` 3

      it "works, pure" do
        let
          res = State.execState (modify (_ + 2)) 1
        res `shouldEqual` 3

    describe "logSomething" $ do
      it "works" $ do
        liftEffect do
          var <- new ""
          let
            logFunc msg = Ref.modify_ (_ <> msg) var
            msg1 = "Hello "
            msg2 = "World\n"

          runReaderT (do
            logSomething msg1 
            logSomething msg2
          ) logFunc

          res <- read var
          res `shouldEqual` (msg1 <> msg2)

Has型クラス

要約に含まれておらず、説明を先延ばしにしていたのが、これらのHas型クラスです。

Has型クラス
class HasLog a where
  getLog :: a -> (String -> Effect Unit)
instance HasLog (String -> Effect Unit) where
  getLog = identity
else instance HasLog Env where
  getLog (Env {envLog}) = envLog

class HasBalance a where
  getBalance :: a -> Ref Int
instance HasBalance (Ref Int) where
  getBalance = identity
instance HasBalance Env where
  getBalance (Env {envBalance}) = envBalance

この型クラスはEnvのそれぞれの値について、その値を「持っている」ということを表現しています。

なぜこのような型クラスを定義するのか?Envをそのまま渡せばいいのではないか?

このような疑問とアイデアに対しMichael Snoyman氏は以下の点で良くないと言っています。

  • 関数にとって不要な値まで渡すことになる。(今回の例では、loggingを行うlogSomething関数にenvBalanceは不要で、envBalanceの値を変更するmodifyenvLogは不要ですよね?)
  • 型シグネチャから、そのコードが何をしているのかを知ることができない。
  • テストが難しくなる。modifyが正しいことをしているかどうかを確認するためには、ゴミを記録する機能を提供する必要がある。(ゴミを記録する機能というのはダミーの関数ということでしょう。)

ここまでがReaderTパターンの説明となります。

ReaderTパターンから得られた洞察

このようなReaderTパターンですが、このデザイン・パターンは、他の人たちによって別の方法で解釈され、『Capabilityデザインパターン』という記事の投稿に繋がったようです。

この情報の元ネタは、PureScriptの学習用ドキュメントをめっちゃ書いてる Jordan Martrinez氏が書いたこの記事なのですが、記事の中でJordan Martrinez氏は次のように書いています。

Capability デザインパターンの要点は、Monad [Word]型クラスは、ある関数でどのような効果が使われるかを定義するのであって、必ずしもそれがどのように達成されるかを定義するのではないということです。この重要な洞察によって、ビジネスロジックのコードのテストがよりシンプルになります。

つまり『Capabilityデザインパターン』の著者は、ReaderTパターンの記事の中の『関数が使用するを作用に記述した拡張型クラスを使用することについての話』に着目し、そのような拡張型クラスをCapabilityと呼んでいるということです。

ちなみにこの『Capabilityデザインパターン』の記事には、元ネタのReaderTパターンには、非常に多くの定型文(ボイラープレート)が必要という問題があると書かれています。
例えばカスタム型のクラス定義や、各メイン・エントリー・ポイント(モナドの具象型が定義されている場所)でのインスタンスの数々など。
その問題の解決にあたりcapabilityというライブラリが紹介されています。

要するに(若干重複することを書きますが)ReaderTパターンについて

  • 関数が使用する作用を記述した拡張型クラスをcapabilitiesと呼んでいる。
  • Capabilityライブラリを使えばボイラープレートなしにReaderTパターンが使えるよ

ということを書いている記事だと私は理解しました。

ちなみにこのライブラリはボイラープレートを削るためのインスタンスの導出に、他のインスタンスの導出を利用できるDerivingViaという拡張を使用していますが、PureScriptには(少なくとも2023年8月現在は)この機能がないため、同様の手段でボイラープレートは削れないと思われます(他にもPureScriptにはない機能が使われています)。

Three Layer Haskell Cake

ようやく Three Layer Haskell Cake までやってきました。

こちらの元ネタは、 『Production Haskell』という本の著者でもある Matt Parsons氏 が書いた『Three Layer Haskell Cake 』という記事です

この Three Layer Haskell Cake では基本的にはReaderTパターンを使います。

また上記の記事ではCapabilityライブラリは使用していません。
ただし、"capability"というキーワード自体は同様の意味で用いています。

ちなみにIOHK(ブロックチェーンの研究開発で有名な会社)には、モナドに関する大きな設計書があり、その中で、何をもって"capability"とするのか、あるいはしないのかが述べられているそうですが、IOHKはこの設計書を削除し、それに従うのは良くないと判断したようです。なので仮に見かけても今のベストプラクティスだと考えてはいけないと思います(あくまでここに書かれた判断基準の話)。

Three Layer Haskell Cake とは

Three Layer Haskell Cakeは、Three Layer とあるようにレイヤーを3つ分けるアーキテクチャーです。

このアーキテクチャーの基本方針は
『純粋な関数と、副作用のある関数を異なるレイヤーに分離する』
です。

そしてこれらの分離したレイヤーの橋渡しをする役割を持つレイヤーを設け、合計3層のレイヤーになるというわけです。

そしてそのレイヤーの一部がcapabilityになっている。というわけで前述の話と繋がりました。

このアーキテクチャーも他のレイヤードアーキテクチャーと同様、依存関係に(一方向の)方向性があり
レイヤー1 -> レイヤー2 -> レイヤー3
となっています。

それぞれのレイヤーの責務はこうです。

  • レイヤー1: 基本的にはReaderTデザインパターン
  • レイヤー2: layer 1とlayer 3の橋渡しをする。主に外部サービスや依存関係をモックすることに興味がある。ここがCapabilityにあたります。
  • レイヤー3: 純粋な関数および比較的単純なデータ型

実際のコードでの説明

では実際のコード例を見ていきましょう。

こちらはJordan Martrinez氏の記事のサンプルコードを拝借して、私がテストコードに手を加えたものになります。
(ちなみに元記事は4層で書かれていますが、3層にしてあります)

レイヤー1

まずレイヤー1です。

Layer1
import Prelude

import Control.Monad.Reader (class MonadAsk, ReaderT, ask, runReaderT)
import Effect (Effect)
import Effect.Class (class MonadEffect, liftEffect)
import Effect.Console (log)
import Pattern.ThreeLayer.Layer2 (class GetUserName, class LogToScreen)
import Pattern.ThreeLayer.Layer3 (Name(..))

-- Environment type
type Environment = { someValue :: Int } -- mutable state, read-only values, etc. go in this record

-- newtyped ReaderT that implements the capabilities
newtype AppM a = AppM (ReaderT Environment Effect a)
derive newtype instance functorTestM    :: Functor AppM
derive newtype instance applyAppM       :: Apply AppM
derive newtype instance Applicative AppM
derive newtype instance bindAppM        :: Bind AppM
derive newtype instance monadAppM       :: Monad AppM
derive newtype instance monadEffect     :: MonadEffect AppM
derive newtype instance monadAsk        :: MonadAsk Environment AppM

runApp :: forall a. AppM a -> Environment -> Effect a
runApp (AppM r) env = runReaderT r env

instance LogToScreen AppM where
  log = liftEffect <<< log

instance GetUserName AppM where
  getUserName = do
    env <- ask
    liftEffect do
      -- 文字列を生成する何らかのeffect
      pure $ Name $ "some name " <> show env.someValue

EnvであるEnvironmentが定義されており、それを利用するReaderTが使われていますね。
ReaderTパターンの説明でありましたが、newtypeが使われており、ラッパー型AppMとして定義されています。
その上でderive newtype instanceにより各種型クラスにインスタンスになっています。

importを見ると、レイヤー2とレイヤー3に依存していることがわかります。

そしてAppMはレイヤー2のCapabilityのインスタンスになっています。
(Orphanインスタンスになるので、このモジュールに定義せざるを得ない)

レイヤー2

次にレイヤー2です。

Layer2
import Prelude

import Pattern.ThreeLayer.Layer3 (Name, getName)

-- Capability type classes:
class Monad m <= LogToScreen m where
  log :: String -> m Unit
 
class Monad m <= GetUserName m where
  getUserName :: m Name
 
-- capabilitiesを利用するビジネスロジック
program
  :: forall m
   . LogToScreen m 
  => GetUserName m
  => m Unit
program = do
  log "What is your name?"
  name <- getUserName
  log $ "You name is " <> getName name

Capabilityの型クラスが定義されています。
ここではloggingのLogToScreenとユーザ名を取得するGetUserNameという型クラスが定義されています。

これらのCapabilityを利用するロジックprogramもここに書かれています。
このロジックは"What is your name?"という文字列をログ出力した後、ユーザ名を取得し、そのユーザー名をログ出力するという単純なロジックです。
この関数がテスト対象になります。

レイヤー3に依存しており、レイヤー1には依存していないことがわかるでしょう。

レイヤー3

この例では非常にシンプルです。

Layer3
newtype Name = Name String
 
getName :: Name -> String
getName (Name s) = s

言うまでもありませんが、ここは他のレイヤーに依存していません。

処理の実行

処理を実行する例です。

main
import Prelude

import Effect (Effect)
import Pattern.ThreeLayer.Layer1 (runApp)
import Pattern.ThreeLayer.Layer2 (program)

main :: Effect Unit
main = do
  let globalEnvironmentInfo = { someValue: 1000 }
  runApp program globalEnvironmentInfo

ユーザ名としてEnvの値をそのまま使っているので、次のように出力されます。

What is your name?
You name is some name 1000

テスト

最後にテストです。
ログ出力されたことを確認するためにモックを使っています。

test
import Prelude

import Control.Monad.Reader (class MonadAsk, Reader, ask, runReader)
import Data.Either (Either(..))
import Pattern.ThreeLayer.Layer2 (class GetUserName, class LogToScreen, program)
import Pattern.ThreeLayer.Layer3 (Name(..))
import Test.PMock (any, fun, mock, verifySequence, (:>))
import Test.Spec (Spec, describe, it)
import Test.Spec.Assertions (shouldEqual)

{-
  Layer 1 のテスト用実装は必要
-}
newtype TestM a = TestM (Reader TestFunctions a)
derive newtype instance functorTestM     :: Functor TestM
derive newtype instance applyTestM       :: Apply TestM
derive newtype instance Applicative TestM
derive newtype instance bindTestM        :: Bind TestM
derive newtype instance monadTestM       :: Monad TestM
derive newtype instance monadAsk         :: MonadAsk TestFunctions TestM

runTest :: forall a. TestM a -> TestFunctions -> a
runTest (TestM reader) e = runReader reader e

type TestFunctions = {
  log :: String -> TestM Unit,
  getUserName :: TestM Name
}

instance LogToScreen TestM where
  log message = do
    functions <- ask
    functions.log message
    pure unit

instance GetUserName TestM where
  getUserName = do
    functions <- ask
    functions.getUserName

spec :: Spec Unit
spec = do
  describe "Three Layer Test" do
    it "program test" do
      let
        logMock = mock $ any :> (pure unit :: TestM Unit)
        functions = { 
          log: fun logMock,
          getUserName: pure (Name "John")
        }

      -- run  
      runTest program functions `shouldEqual` unit

      -- verify
      verifySequence logMock [
        "What is your name?",
        "You name is John"
      ]

コメントに記載されているようにレイヤー1のテスト用の実装は必要になります。

テスト対象は、レイヤー2の関数programです。

ReaderにEnvを持たせる必然性はないので、代わりにモック関数を持たせて、テスト用インスタンスで使用しています。

type TestFunctions = {
  log :: String -> TestM Unit,
  getUserName :: TestM Name
}

instance LogToScreen TestM where
  log message = do
    functions <- ask      -- モック関数を取得
    functions.log message -- モック関数を呼び出す
    pure unit

Onion Architectureで言うならば

Jordan Martrinez氏は記事の中で、Three Layer Haskell CakeをOnion Architectureと対比させており、次のように書いています。

Layer Level Onion Architecture Term General idea
レイヤー4 Core 明確に定義されたプロパティを持つ強い型と、それらを操作する純粋な全関数。
レイヤー3 Domain エフェクトを使用する「ビジネス・ロジック」コード
レイヤー2 API これらの効果/能力を実装に「リンク」する「プロダクション」または「テスト」モナド
レイヤー1 Infrastructure effects/capabilitiesを実装するために使用するプラットフォーム固有のframework/monad(例: Node.ReadLine/Halogen/StateTなど)
Layer 0(これに相当する用語はない) Machine Code プログラムを実行する 基本のモナド(例:production: Effect/Aff、test: Identity/Trampoline)

Three Layer Haskell Cake のレイヤーとの関係はこうなっています

Onion Architecture Layer Level Three Layer Haskekl Cake Layer Level
レイヤー4 (Core) レイヤー3
レイヤー3 (Domain) レイヤー2
レイヤー2 (API) レイヤー1
レイヤー1 (Infrastructure) レイヤー1
レイヤー0 (Machine Code) ここには言及されていない

Three Layer Haskell Cakeのレイヤー1の、AppMの定義やそのderive newtype instancerunAppなどが、Onion Architectureのレイヤー2 (API)にあたり、
Three Layer Haskell Cakeのレイヤー1の、各インスタンスの実装がOnion Architectureのレイヤー1 (Infrastructure)にあたります。
ただ前述した通り、これらは別のモジュールに分割できない(Orphanインスタンスになるから)ので、あくまでJordan氏はOnion Architectureに当てはめるなら、という説明をしているという理解でよいでしょう。

クリーンアーキテクチャ的な図で描いてみるとこんな感じでしょうか

おわりに

ReaderTパターン登場からの流れで、Three Layer Haskell Cakeまで続けて説明してみましたが、いかがだったでしょうか?

純粋な関数と、副作用のある関数を分離する、ということは多くの人が意識されていることだと思いますが、その具体的なやり方を提示し名前をつけるという意味で、これらは価値のあるものだなと個人的には思います。

大きめのアプリケーションを開発する際は、しっかりしたアーキテクチャーがないと育ってきたときにグチャグチャになってしまいますので、アーキテクチャの一つとして今回の記事が何かの参考になれば幸いです。

Discussion