ReaderTパターンとThree Layer Haskell Cakeから学ぶPureScriptのアプリケーション設計
はじめに
今回は、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つの方法があると言います。
- コンパイル時フラグを使用して、実行ファイルに含まれるloggingのコードを制御する。
-
unsafePerformIO
で設定ファイル(または環境変数)を読み込むグローバルな値を定義する。 -
main
関数で設定ファイルを読み込み、その値を(明示的に、あるいはReaderT
を介して暗黙的に)残りのコードに渡す。
この(3)の方法がReaderTパターンなわけですが、Michael Snoyman氏は次のように(1)および(2)の問題点を指摘した上で(3)を推しています(詳しく知りたい方は原文の『Better globals』の項をご参照ください)。
(1) コードベースに条件付きコンパイルがあるとビルドに失敗する可能性が増える。そもそもこういうことをコンパイル時にやりたいのか?それより設定ファイルを使ったほうがいい。
(2) 設定ファイルを読み込むにしても unsafePerformIO
には色々と問題がある。
もうちょっと解像度を上げましょう。
要約
上記の記事には、パターンの要約が書かれているので見てみましょう(DeepL翻訳)。
※項番は原文にありませんが説明のために私が付けました。
- アプリケーションはコア・データ型(
Env
と呼んでも構わない)を定義しなければならない。- このデータ型には、すべての実行時の設定と、モック可能なグローバル関数(logging関数やDBアクセスなど)が含まれる。
- mutableなstateを持たなければならない場合は、mutableな参照(IORef、TVarなど)をEnvに持たせる。
- アプリケーションのコードは通常、
ReaderT Env IO
に置かれます。必要であれば、App = ReaderT Env IO
型として定義するか、ReaderT
の代わりにnewtypeラッパーを使用してください。- 追加のmtl(モナド変換子)を使うこともできますが、それはアプリケーションの小さなサブセットに対してだけで、そのサブセットは純粋なコードであるのがベストです。
- Optional: Appのデータ型を直接使う代わりに、
MonadReader
やMonadIO
のようなmtlスタイルの型クラスで関数を書く。
※PureScriptにないもの(IORef
,TVar
,MonadIO
など)がありますが、適宜別のものに置き換えられると思います(MonadIO
→MonadEffect
)。
解説
元の記事には、説明のためのコードが載せてあるので、それを(PureScript
のコードに書き換えた上で)使わせてもらいます。
で、先にコードの全体を載せようと思ったのですが、上記の要約に含まれず追加で解説が必要な部分が入り混じっているため、最初は要約に関する部分だけを抜粋しながらお見せして、その後に全体をお見せすることにします(気になるようでしたら先に全体をご覧になっていただいても大丈夫です)。
要約の(1)(2)(3)
まず、(1)(2)(3)にあたる部分のコード例はこうです。
data Env = Env {
envLog :: String -> Effect Unit,
envBalance :: Ref Int
}
-
Env
というデータ型を定義している - このデータ型にはモック可能なグローバル関数
envLog
,envBalance
が含まれている -
Env
はmutableな参照envBalance
を持っている。
と、まぁこのようになっています。
元記事のenvBalance
はTVar
型だったのですが、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のデータ型を直接使う代わりに、MonadReader
やMonadIO
のようなmtlスタイルの型クラスで関数を書く』の例です。
logSomething
:: forall m env
. HasLog env
=> MonadReader env m
=> MonadEffect m
=> String
-> m Unit
logSomething msg = do
env <- ask
liftEffect $ getLog env msg
MonadReader
やMonadEffect
が使われています。
コード例の全体を見てみよう
(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型クラスです。
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
の値を変更するmodify
にenvLog
は不要ですよね?) - 型シグネチャから、そのコードが何をしているのかを知ることができない。
- テストが難しくなる。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です。
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です。
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
この例では非常にシンプルです。
newtype Name = Name String
getName :: Name -> String
getName (Name s) = s
言うまでもありませんが、ここは他のレイヤーに依存していません。
処理の実行
処理を実行する例です。
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
テスト
最後にテストです。
ログ出力されたことを確認するためにモックを使っています。
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 instance
やrunApp
などが、Onion Architectureのレイヤー2 (API)にあたり、
Three Layer Haskell Cakeのレイヤー1の、各インスタンスの実装がOnion Architectureのレイヤー1 (Infrastructure)にあたります。
ただ前述した通り、これらは別のモジュールに分割できない(Orphanインスタンスになるから)ので、あくまでJordan氏はOnion Architectureに当てはめるなら、という説明をしているという理解でよいでしょう。
クリーンアーキテクチャ的な図で描いてみるとこんな感じでしょうか
おわりに
ReaderTパターン登場からの流れで、Three Layer Haskell Cakeまで続けて説明してみましたが、いかがだったでしょうか?
純粋な関数と、副作用のある関数を分離する、ということは多くの人が意識されていることだと思いますが、その具体的なやり方を提示し名前をつけるという意味で、これらは価値のあるものだなと個人的には思います。
大きめのアプリケーションを開発する際は、しっかりしたアーキテクチャーがないと育ってきたときにグチャグチャになってしまいますので、アーキテクチャの一つとして今回の記事が何かの参考になれば幸いです。
Discussion