ReaderT Design Pattern: Parametrize the Environment

只谈情不闲聊 提交于 2021-02-07 20:32:58

问题


I build a project based on the ReaderT design pattern. Instead of using a typeclass approach for dependency injection, I choose to use simple injection of handlers as function arguments. This part works fine as one is able to construct a dependency tree statically and define an environment dynamically.

The environment may contain configuration as well as a logging effect :: String -> IO (), an effect of time :: IO UTCDate etc. Consider the following minified example

import Control.Monad.Reader (runReaderT, liftIO, reader, MonadReader, MonadIO)

data SomeEnv 
  = SomeEnv
  { a :: Int
  , logger :: String -> IO ()
  }

class HasLogger a where 
  getLogger :: a -> (String -> IO())

instance HasLogger SomeEnv where 
  getLogger = logger 

myFun :: (MonadIO m, MonadReader e m, HasLogger e) => Int -> m Int
myFun x = do
  logger <- reader getLogger
  liftIO $ logger "I'm going to multiply a number by itself!"
  return $ x * x

doIt :: IO Int
doIt = runReaderT (myFun 1337) (SomeEnv 13 putStrLn)

Is it possible to generalize over the effect of the logger?

logger :: String -> m ()

With the motivation to use a logger which fits into the monad stack

myFun x = do
  logger <- reader getLogger
  logger "I'm going to multiply a number by itself!"
  return $ x * x

回答1:


We could try the following changes:

  • Parameterize the environment record with the "base" monad.
  • Make HasLogger a two-parameter typeclass that relates the environment to the "base" monad.

Something like this:

{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE FunctionalDependencies #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE StandaloneKindSignatures #-}
import Control.Monad.IO.Class
import Control.Monad.Reader
import Data.Kind (Constraint, Type)

type RT m = ReaderT (SomeEnv m) m

type SomeEnv :: (Type -> Type) -> Type
data SomeEnv m = SomeEnv
  { a :: Int,
    logger :: String -> RT m (),
    -- I'm putting the main fuction in the record,
    -- perhaps we'll want to inject it into other logic, later.
    myFun :: Int -> RT m Int
  }

type HasLogger :: Type -> (Type -> Type) -> Constraint
class HasLogger r m | r -> m where
  getLogger :: r -> String -> m ()

instance HasLogger (SomeEnv m) (RT m) where
  getLogger = logger

_myFun :: (MonadReader e m, HasLogger e m) => Int -> m Int
_myFun x = do
  logger <- reader getLogger
  logger "I'm going to multiply a number by itself!"
  return $ x * x

Now _myFun doesn't have the MonadIO constraint.

We can create a sample environment and run myFun:

env =
  SomeEnv
    { a = 13,
      logger = liftIO . putStrLn,
      myFun = _myFun
    }

doIt :: IO Int
doIt = runReaderT (myFun env 1337) env

One disadvantage of this solution is that the function signatures in the environment become more involved, even with the RT type synonym.


Edit: In order to simplify the signatures in the environment, I tried these alternative definitions:

type SomeEnv :: (Type -> Type) -> Type
data SomeEnv m = SomeEnv
  { a :: Int,
    logger :: String -> m (), -- no more annoying ReaderT here.
    myFun :: Int -> m Int
  }

instance HasLogger (SomeEnv m) m where
  getLogger = logger

-- Yeah, scary. This newtype seems necessary to avoid an "infinite type" error.
-- Only needs to be defined once. Could we avoid it completely?
type DepT :: ((Type -> Type) -> Type) -> (Type -> Type) -> Type -> Type
newtype DepT env m r = DepT { runDepT :: ReaderT (env (DepT env m)) m r } 
    deriving (Functor,Applicative,Monad,MonadIO,MonadReader (env (DepT env m)))
instance MonadTrans (DepT env) where
    lift = DepT . lift

env' :: SomeEnv (DepT SomeEnv IO) -- only the signature changes here
env' = 
    SomeEnv
    { a = 13,
      logger = liftIO . putStrLn,
      myFun = _myFun
    }

doIt :: IO Int
doIt = runReaderT (runDepT (myFun env' 1337)) env'

DepT is basically a ReaderT, but one aware that its environment is parameterized by DeptT itself. It has the usual instances.

_myFun doesn't need to change in this alternative definition.




回答2:


I want to summarize some results from applying danidiaz approach.

As my project is currently at a GHC version which does not support the second approach, I've followed the first approach. The application consists out of two sub-applications

  • a servant application

    type RT m = ReaderT (Env m) m

  • an internal application

    type HRT m = CFSM.HouseT (ReaderT (AutomationEnvironment m) m)

the first approach avoids infinite recursive types at the cost of a relation between the monadic stack and the environment. As the sub-applications use different monadic stacks, specific environment had to be introduced. It seems that this is avoidable by the second approach due to the introduction of DepT.

MonadIO constraints could be removed from functions, for example

mkPostStatusService
  :: (MonadIO m, MonadThrow m, MonadReader e m, HasCurrentTime e, HasRandomUUID e)
  => C.InsertStatusRepository m
  -> PostStatusService m

became

mkPostStatusService
  :: (MonadThrow m, MonadReader e m, HasCurrentTime e m, HasRandomUUID e m)
  => C.InsertStatusRepository m
  -> PostStatusService m

Because the environment relates to the application stack, join is the substitute for liftIO

currentTime <- reader getCurrentTime >>= liftIO
-- becomes
currentTime <- join (reader getCurrentTime)

For unit testing, mock environments are constructed. Due to the removal of MonadIO, the mock environment can be constructed without side-effect monads. An inspection of services which had MonadIO and MonadThrow were previously performed by defining mock environments like

data DummyEnvironment = DummyEnvironment (IO T.UTCTime) (IO U.UUID)
instance HasCurrentTime DummyEnvironment where
  getCurrentTime (DummyEnvironment t _) = t
instance HasRandomUUID DummyEnvironment where
  getRandomUUID (DummyEnvironment _ u) = u

with the new approach, the side-effects could be remove

type RT = ReaderT DummyEnvironment (CatchT Identity)
data DummyEnvironment = DummyEnvironment (RT T.UTCTime) (RT U.UUID)
instance HasCurrentTime DummyEnvironment RT where
  getCurrentTime (DummyEnvironment t _) =  t
instance HasRandomUUID DummyEnvironment RT where
  getRandomUUID (DummyEnvironment _ u) =  u

As I pointed out, the first approach connects the environment to a specific stack, thus the stack defines the environment. Next step will be integrating the second approach as it seems to decouple the stack from the environment again using DepT.



来源:https://stackoverflow.com/questions/61780295/readert-design-pattern-parametrize-the-environment

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!