问题
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
HasLoggera 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) man 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