HalogenのhandleActionでExtensible Effects(拡張可能作用)を使ってみる!
以前、Extensible Effects
を用いたClean Architectureなアプリケーションの記事を書きました。
上記はコマンドラインで実行する類の例でしたが、今回はフロントエンドでExtensible Effects
を使う例を紹介しようと思います。
ライブラリとしてはHalogen
を使い、Component
のhandleAction
にExtensible Effects
を組み込んでみたいと思います。
PureScriptにおけるExtensible Effects
のライブラリとして、purescript-runを使います。
今後、この記事内では、単にRun
と呼ぶこととします。
やってみよう
handleAction
では、何らかの処理を行いStateの更新を行うことになります。
想定としてTodoState
という単純なStateの更新を例にとって考えてみます。
type Todo = { title :: String }
type TodoState = {
todos :: Array Todo
}
このStateを更新する一般的な方法は、次のようにMonadState
のmodify
を使うことでしょう。
H.modify_ \s -> s {todos = [{ title: "Todo1" }, { title: "Todo2" }]}
しかし今回はRun
を使いたいので、次のようにRun.State
のmodify
を使います。
displayTodo :: Run (STATE TodoState + ()) Unit
displayTodo = modify \s -> s {todos = [{ title: "Todo1" }, { title: "Todo2" }]}
そして更に実際のアプリケーションでは、上記のような定数値をそのまま使うのではなく、何らかの処理により取得した結果の値を用いてStateを更新するでしょうし、処理が非同期に行われることもよくあるでしょう。
Run
で表現するならこんな感じでしょうか。
(非同期での取得処理findTodos
は、実際はAffJax
などを利用して値を取得することになると思います)
findTodos :: Aff (Array Todo)
findTodos = pure [{ title: "Todo1" }, { title: "Todo2" }]
displayTodos :: Run (AFF + STATE TodoState + ()) Unit
displayTodos = do
todos <- Run.liftAff findTodos
modify (_{ todos = todos})
displayTodos
では、findTodos
で取得した値をRun.liftAff
でAFF
に持ち上げて、更にSTATE
のmodify
を使って状態を更新しています。
さて、上記のdisplayTodo
をhandleAction
で使うにはどうしたらよいでしょうか。
HalogenMに変換せよ
上記の例のRun
を実行して最終的に返す値をHalogenM
にします。
これでOK👍です。
なぜならばhandleAction
は次のようにHalogenM
を期待しているからです。
type EvalSpec state query action slots input output m =
{ handleAction :: action -> HalogenM state action slots output m Unit
, handleQuery :: forall a. query a -> HalogenM state action slots output m (Maybe a)
, receive :: input -> Maybe action
, initialize :: Maybe action
, finalize :: Maybe action
}
HalogenM
に変換しつつRun
を実行するにはこうします。
handleState ::
forall state action slots output m
. State state
~> H.HalogenM state action slots output m
handleState (State ss sa) = H.HalogenM <<< liftF <<< H.State $ Tuple <$> sa <*> ss
runAffState ::
forall state action slots output m
. MonadAff m
=> Run (STATE state + AFF + ())
~> H.HalogenM state action slots output m
runAffState = run $ match { aff: \a -> liftAff a, state: \a -> handleState a }
上記のrunAffState
がRun
の実行で、STATE
とAFF
の2つの副作用を『同時に』除去しています。
非同期処理が含まれないなら次のようにSTATE
の除去だけでよいでしょう。
runState ::
forall state action slots output m
. Run (STATE state + ())
~> H.HalogenM state action slots output m
-- こう書いてもいいが、除去する副作用は1つだけなので、レコード形式で書く必要はないかも
-- runState = run $ match { state: \a -> handleState a }
runState = interpret (on _state handleState case_)
全体像
これまでをまとめた全体像を載せます。
import Prelude
import Control.Monad.Free (liftF)
import Data.Maybe (Maybe(..))
import Data.Tuple (Tuple(..))
import Effect.Aff (Aff)
import Effect.Aff.Class (class MonadAff, liftAff)
import Halogen as H
import Halogen.HTML as HH
import Run (AFF, Run, match, run)
import Run as Run
import Run.State (STATE, State(..), modify)
import Type.Row (type (+))
type Todo = { title :: String }
type TodoState = {
todos :: Array Todo
}
data Action = Initialize
component :: forall q i m. MonadAff m => H.Component q i Void m
component =
H.mkComponent
{ initialState: \_ -> { todos: [] }
, render
, eval: H.mkEval $ H.defaultEval {
initialize = Just Initialize,
handleAction = _handleAction
}
}
where
render :: forall cs. TodoState -> H.ComponentHTML Action cs m
render { todos } =
HH.div_ [ HH.div_ $ todos <#> \todo -> HH.text todo.title ]
_handleAction :: Action -> H.HalogenM TodoState Action () Void m Unit
_handleAction = case _ of
Initialize -> displayTodos # runAffState
displayTodos :: Run (AFF + STATE TodoState + ()) Unit
displayTodos = do
todos <- Run.liftAff findTodos
modify (_{ todos = todos})
findTodos :: Aff (Array Todo)
findTodos = pure [{ title: "Todo1" }, { title: "Todo2" }] -- fake implementation
handleState ::
forall state action slots output m
. State state
~> H.HalogenM state action slots output m
handleState (State ss sa) = H.HalogenM <<< liftF <<< H.State $ Tuple <$> sa <*> ss
runAffState ::
forall state action slots output m
. MonadAff m
=> Run (STATE state + AFF + ())
~> H.HalogenM state action slots output m
runAffState = run $ match { aff: \a -> liftAff a, state: \a -> handleState a }
Clean Architectureの例
説明としては以上なのですが、副作用が増えた場合の例として、前回のClean Architectureの例と同じUseCaseをHalogen
に合わせて作り変えてみました。
コードの全体はこちらです。
一部抜粋して見てみると、UseCase
はこんな感じになっています。
以前の例と比較するとSTATE TodoState
の部分が増えています。
execute
:: UserId
-> Logics
-> Run (TODO_PORT + TODO_OUTPUT_PORT + AFF + STATE TodoState + ()) Unit
execute id logics = do
result <- findTodos id
case result of
Right todos -> setTodos $ logics.completed todos
Left e -> setError e
実行はこんな感じ。
_handleAction :: Action -> H.HalogenM TodoState Action () Void m Unit
_handleAction = case _ of
Initialize -> execute (UserId 1) logics
# runPort createTodoPort
# runOutputPort createOutputPort
# runAffState
テストにおけるUseCaseの実行はこんな感じ。
テスト実行においてはhandleAction
のようにHalogenM
に変換する必要がないため、Run
が用意しているrunState
やrunBaseAff
で十分です。
_ <- execute userId logics
# runPort todoPort
# runOutputPort todoOutputPort
# runState {todos: [], errorMessage: Nothing}
# runBaseAff
verify stateForTodosMock completedTodos
ちょっと解説
実現方法だけ書いて、なぜ動作するのかを全く説明していなかったので、
run $ match { aff: \a -> liftAff a, state: \a -> handleState a }
の部分をちょっと軽く解説しておこうとおもいます。
まずrunAffState
のイータ変換をやめてみます。
runAffState
:: forall state action slots output m
. MonadAff m
=> Run (STATE state + AFF + ())
~> H.HalogenM state action slots output m
runAffState r = (run $ match { aff: \a -> liftAff a, state: \a -> handleState a }) r
match
にRecord
を渡した結果を更にrun
に渡した結果にRun
を渡していますね。
そしてmatch
の定義はこうです。
これはRecord
とVariantF
をとる関数になっています。
match
:: forall rl r r1 r2 a b
. RL.RowToList r rl
=> VariantFMatchCases rl r1 a b
=> R.Union r1 () r2
=> Record r
-> VariantF r2 a
-> b
match r = case_ # onMatch r
match
の引数で渡しているのは { aff: \a -> liftAff a, state: \a -> handleState a }
なので、Record
だけが渡されていることになります。
PureScriptの関数はカリー化されているので、このmatch
は(VariantF r2 a -> b)
という「関数」を返しています(制約は省略)。
続いてrun
の定義はこうなっています。
run
:: forall m a r
. Monad m
=> (VariantF r (Run r a) -> m (Run r a))
-> Run r a
-> m a
run k = loop
where
loop :: Run r a -> m a
loop = resume (\a -> loop =<< k a) pure
run
は(VariantF r (Run r a) -> m (Run r a))
という関数とRun r a
を受け取るようになっているので、先程のmatch
が返す関数(VariantF r2 a -> b)
を渡すことができます。
r
に実際のレコードを代入するとこうなります。
(VariantF (STATE state + AFF + ()) (Run (STATE state + AFF + ()) a)) -> m (Run (STATE state + AFF + ()) a)
制約としてMonad m
を返さなければなりませんが、
{ aff: \a -> liftAff a, state: \a -> handleState a }
が返しているのはどちらもMonad
のインスタンスになっているので問題ありません。
handleState
が返すのは、HalogenM
でこれはMonad
のインスタンスになっていますし、
この場合のliftAff
が返すのは、HalogenM
になり(自動的に選択される)かつHalogenM
はMonadAff
のインスタンスなのです。
返されたMonad
は、loop =<< k a
の部分で再帰呼び出しに使われています(Monad
だから逆束縛=<<
が使える)。
run k = loop
where
loop :: Run r a -> m a
loop = resume (\a -> loop =<< k a) pure
このループによって、{ aff: \a -> liftAff a, state: \a -> handleState a }
のRecord
に書かれているすべてのパターンが実行され、かつこれらが返すのはMonad
のinstanceなので=<<
によって継続処理が繋がることになります。
ちなみにこのレコードのaff
やstate
に紐付けられている関数が呼び出される順序は、Run
の処理の順序と一致します。
今回の場合は、displayTodo
を見るとわかりますが、AFF
のあとにSTATE
の処理が呼ばれているので、\a -> liftAff a
のあとに \a -> handleState a
が呼ばれることになります。
displayTodos :: Run (AFF + STATE TodoState + ()) Unit
displayTodos = do
todos <- Run.liftAff findTodos
modify (_{ todos = todos})
このあたりは、Free
のbind
の実装を見ると理解できます(以下のようにRun
の正体はFree
のラッパーで、derive instanceで各種のinstanceになっているので、実際呼ばれるのはFree
のbind
)。
newtype Run r a = Run (Free (VariantF r) a)
derive instance newtypeRun :: Newtype (Run r a) _
derive newtype instance functorRun :: Functor (Run r)
derive newtype instance applyRun :: Apply (Run r)
derive newtype instance applicativeRun :: Applicative (Run r)
derive newtype instance bindRun :: Bind (Run r)
derive newtype instance monadRun :: Monad (Run r)
data Free f a = Free (FreeView f Val Val) (CatList (ExpF f))
newtype ExpF f = ExpF (Val -> Free f Val)
data FreeView f a b = Return a | Bind (f b) (b -> Free f a)
data Val
instance freeBind :: Bind (Free f) where
bind (Free v s) k = Free v (snoc s (ExpF (unsafeCoerceBind k)))
where
unsafeCoerceBind :: forall a b. (a -> Free f b) -> Val -> Free f Val
unsafeCoerceBind = unsafeCoerce
Free
のbind
は、後続の処理をCatList
に追加した新たなFree
を返すような処理になっています。
つまり後続の処理は次々とCatList
に蓄積されていくわけです。
この蓄積された処理は、resume
の中で取り出されるようになっています。
後書きのようなもの
実現方法を最初の方にさらっと書きましたが、試みた当初はうまくいかずハマっていました。
例えば最初は、runState
とrunBaseAff
でいけるだろ、こうだ。
Initialize -> displayTodos
# runState
# runBaseAff
ってやって・・・・・・コンパイルエラーになるみたいなことを繰り返してました。
なぜならばrunState
の定義はこうなっており、State(今回の例だとTodoState)を渡してやらないといけないからです。
runState :: forall s r a. s -> Run (STATE s + r) a -> Run r (Tuple s a)
runState = runStateAt _state
handleAction
にはStateが渡ってくるわけではなく、普通はこんな感じでHalogen
(正確にはControl.Monad.State.Class)のmodify_
などを使って、渡されてくるStateを更新しますよね。
Initialize -> H.modify_ \s -> s {todos = []}
ということで、runState
はできない、と。
で、あればですよ。modify_
の定義を見てやって、もうその通りの型を返してやればいいのではないかと考えまして。
modify_ :: forall s m. MonadState s m => (s -> s) -> m Unit
modify_ f = state \s -> Tuple unit (f s)
つまり、このようにMonadState
を返すようにしてみたらどうか、と。
displayTodos2 :: forall m. MonadState TodoState m => Run (AFF + ()) (m Unit)
displayTodos2 = do
todos <- Run.liftAff findTodos
pure $ modify_ (_{ todos = todos})
んで実行はこう。
_handleAction :: Action -> H.HalogenM TodoState Action () Void m Unit
_handleAction = case _ of
Initialize -> displayTodos2
# runBaseAff
# liftAff
runBaseAff
が返すのはAff
で、_handleAction
はHalogenM
を期待しているので、liftAff
で持ち上げてやっています。HalogenM
はMonadAff
のinstanceなので持ち上げられるのです。
instance monadAffHalogenM :: MonadAff m => MonadAff (HalogenM state action slots output m) where
liftAff = HalogenM <<< liftF <<< Lift <<< liftAff
さあ、いけるだろう。
が・・・・・ 駄目っ・・・・・!
型が合わない。これが現実。
Could not match type
t2 Unit
with type
Unit
何なのだ、これは!どうすればいいのだ?!
という日々を過ごして、やっとこ解決策にたどり着いて、せっかくだからとこの記事を書くことにしたのです。
Halogen
でRun
を使ってみようという人が果たしてどれくらいいるのか私にはわかりませんが、そんな人のお役に立てれば幸いです。
Discussion