Evaluating RIO
by @pbrisbin on April 16, 2019
The rio library is a package by Michael Snoyman aimed at providing a “standard library” for Haskell.
RIO
attempts to provide:
- A safer
Prelude
(e.g. nohead
) - A richer
Prelude
(e.g. you getData.Maybe
,Control.Monad
, etc) - Functions for implementing CLI apps using the
ReaderT
design pattern (as well as encoding other best practices)
It’s very clear this library was extracted to generalize the implementation of the CLI tool, Stack. In my opinion, it generalizes well. The only wart I noticed was Stack’s need for a “sticky” bottom log-line, which any stack
-user should recognize, leaking into the general interface of the logging mechanisms in rio
. A weird but minor wart.
As an experiment to see if we’d be interested in using rio
in our applications at Freckle, I converted the main app in my Restyled side project to use it. What follows is an experience report on how the transition went, and my first impressions of using the library.
NOTE: Interested readers can view the current code and rendered Haddocks for the application I’m discussing.
Transition
restyler
was already using a ReaderT
-based transformer stack called AppT
and a type class called MonadApp
that encapsulated all effects as members. AppT
then had an instance that wrapped the real IO
actions with logging and error-handling. Presumably, tests could use some kind of TestAppT
with mock-ability, an investment I had not made.
I also already had a module, Restyler.Prelude
, that re-exported a safe and extended "Prelude". So I was following all of rio
's best practices myself already.
Prelude
The first step in this transition was:
- Add the package and their suggested
default-extensions
- Change
Restyler.Prelude
to re-exportRIO.Prelude
This was almost a direct drop-in change and a very low-risk way to get started with rio
. So far so good.
Immediate Benefits
Restyler.Prelude
was reduced in size, becauseRIO.Prelude
did a lot of the same exact re-exports and additional, safer prelude replacements- I found
readFileUtf8
, which solves an obscure bug that's common in any application that reads files "in the wild" - such as Restyled
Immediate Warts
RIO
exportsfirst
/second
fromArrow
notBifunctor
I make frequent use of mapping over Left
values with first
. I never need the Arrow
versions.
RIO
's logging is not compatible withmonad-logger
This meant I needed to avoid its own logX
functions for now and make sure my Prelude
did the right export
/ hiding
dance.
Effects
The second step was to move my MonadApp
effects to Has
classes on an RIO env
where env ~ App
. This was a lot of churn but entirely mechanical.
There were two types of effects I was defining in MonadApp
:
- I need to do a thing, like interact with GitHub
- I need to grab some contextual data, like
appConfig
For (1), I made a broad sed
replacement to do the following:
- someAction :: MonadApp m => a -> b -> m c
+ someAction :: a -> b -> RIO env c
That led to errors on uses of something like runGitHub
, so I added
class HasGitHub env where
runGitHub :: ... -> RIO env ...
This left lots of errors like “No instance HasGitHub
for App
".
For (2), I hunted down and replaced MonadReader
actions like:
-someAction :: MonadReader App m => m a
-someAction = do
- options <- asks appOptions
+
+class HasOptions env where
+ optionsL :: Lens' Options env
+
+someAction :: HasOptions env => RIO env a
+someAction = do
+ options <- view optionsL
(Sadly, this wasn’t as easily sed
-automated.)
Doing this repeatedly ended up with all the compilation errors of that “No instance HasX
..." form. So then I broke up MonadApp
into the various instances I needed:
instance HasOptions App where
optionsL = lens appOptions $ \x y -> x { appOptions = y }
instance HasGitHub App where
runGitHub req = do
logDebug $ "GitHub request: " <> displayShow (DisplayGitHubRequest req)
auth <- OAuth . encodeUtf8 . oAccessToken <$> view optionsL
result <- liftIO $ do
mgr <- getGlobalManager
executeRequestWithMgr mgr auth req
either (throwIO . GitHubError) pure result
Immediate Benefits
- I can clearly see what capabilities any function has
- This is pretty 👌
runRestylers
:: ( HasLogFunc env
, HasSystem env
, HasProcess env
)
=> [Restyler]
-> [FilePath]
-> RIO env [RestylerResult]
- Some config-related helpers, which had awkward definitions under
MonadApp
, were natural and obvious withHasConfig
whenConfigJust
:: HasConfig env
=> (Config -> Maybe a) -- ^ Config attribute to check
-> (a -> RIO env ()) -- ^ Action to run if @'Just'@
-> RIO env ()
whenConfigJust check act = traverse_ act . check =<< view configL
- An unsafe situation was resolved totally naturally…
At application startup, I was (shamefully) building a partial (gasp) App
value. It had the data that was available immediately (e.g. command line arguments), but many fields were left as error "..."
. For example, the Config
that gets loaded from the not-yet-cloned repo's .restyled.yaml
.
This was necessary so I could use the existing MonadApp
actions callProcess
and readFile
to ultimately get that Config
to replace the error
field in App
.
With distinct Has
classes, callProcess
and readFile
only required HasProcess
and HasSystem
respectively, which I could run in other env
s, such as one I called StartupApp
. This type only has those fields from App
that I could populate at startup. My richer App
type could be built by actions that only required HasOptions
, HasSystem
, HasProcess
, etc.
-- | Returns the data needed to turn a @'StartupApp'@ into an @'App'@
restylerSetup
:: ( HasCallStack
, HasOptions env
, HasWorkingDirectory env
, HasSystem env
, HasProcess env
, HasGitHub env
)
=> RIO env (PullRequest, Maybe SimplePullRequest, Config)
restylerSetup = undefined
-- | Produce the @'App'@ by running the above with a @'StartupApp'@
bootstrapApp :: MonadIO m => Options -> FilePath -> m App
bootstrapApp options path = runRIO app $ toApp <$> restylerSetup
where
app = StartupApp
{ appLogFunc = restylerLogFunc options
, appOptions = options
, appWorkingDirectory = path
}
toApp (pullRequest, mRestyledPullRequest, config) = App
{ appApp = app
, appPullRequest = pullRequest
, appRestyledPullRequest = mRestyledPullRequest
, appConfig = config
}
As you may have noticed above, a natural next step was to let App
have a StartupApp
as a field. Then any capabilities the two shared would be defined for StartupApp
first and built as a terse pass-through for the App
instance.
instance HasProcess StartupApp where
callProcess cmd args = do
logDebug $ "call: " <> fromString cmd <> " " <> displayShow args
appIO SystemError $ Process.callProcess cmd args
readProcess cmd args stdin' = do
logDebug $ "read: " <> fromString cmd <> " " <> displayShow args
output <- appIO SystemError $ Process.readProcess cmd args stdin'
output <$ logDebug ("output: " <> fromString output)
instance HasProcess App where
callProcess cmd = runApp . callProcess cmd
readProcess cmd args = runApp . readProcess cmd args
runApp :: RIO StartupApp a -> RIO App a
runApp = withRIO appApp
-- | @'withReader'@ for @'RIO'@
withRIO :: (env' -> env) -> RIO env a -> RIO env' a
withRIO f = do
env <- asks f
runRIO env f
Immediate Warts
- The interface for
LogFunc
was hard to figure out:
The prescribed usage seems to push first for a bracket
-like function because Stack needs a “destructor” hook to tear down sticky log messages. Weird and not needed by the vast majority of users, I’d bet.
The next most obvious choice for usage produces a LogFunc
in IO
because it is handling the “use color if terminal device” logic for you. This is probably good for most cases, but I personally prefer tools support a --color=never|always|auto
option, so I want to handle the terminal-device check myself (only for auto
) and pass in a simple color-or-not to the library constructor.
The main point of customization is limited, in that you can request verbose or not. In my opinion, verbose is too much but not-verbose is not enough
In the end, having my own LogFunc
, written naively for my specific needs, was very straight-forward; it was only non-obvious because it requires "advanced" usage:
restylerLogFunc :: Options -> LogFunc
restylerLogFunc Options {..} = mkLogFunc $ \_cs _source level msg ->
when (level >= oLogLevel) $ do
BS8.putStr "["
when oLogColor $ setSGR [levelStyle level]
BS8.putStr $ levelStr level
when oLogColor $ setSGR [Reset]
BS8.putStr "] "
BS8.putStrLn $ toStrictBytes $ toLazyByteString $ getUtf8Builder msg
levelStr :: LogLevel -> ByteString
levelStr = \case
LevelDebug -> "Debug"
LevelInfo -> "Info"
LevelWarn -> "Warn"
LevelError -> "Error"
LevelOther x -> encodeUtf8 x
levelStyle :: LogLevel -> SGR
levelStyle = \case
LevelDebug -> SetColor Foreground Dull Magenta
LevelInfo -> SetColor Foreground Dull Blue
LevelWarn -> SetColor Foreground Dull Yellow
LevelError -> SetColor Foreground Dull Red
LevelOther _ -> Reset
First Impressions
I was already using my own Prelude
module, a central App
and AppError
sum-type, and the ReaderT
pattern, so I've experienced no major ergonomic pros or cons to rio
in those areas. Those are all Good Things™ though, so if trying out rio
brings those to your application, I recommend it.
The main changes I’m personally experiencing are:
- From
MonadFoo m => m a
toHasFoo env => RIO env a
In my opinion, the env
style is no better or worse than a constraint on the overall m
. There are certainly concrete differences on either side, but I personally don't notice either way. I'm now offloading a bit of head-space to a library, so all things being equal, I call it a win.
- From
MonadApp m
/HasItAll env
to(HasX env, HasY env, ...)
The discrete capabilities has been the biggest win so far. Addressing the unsafe App
-building was enabled directly by this. And the way it was resolved (with the split StartupApp
and delegated instances) just gives me that warm fuzzy feeling of clean code. I also fully expect testing to be much easier, once I get around to it. I will probably further break up my capabilities going forward.
- Embracing exceptions instead of
ExceptT
I’ve done this both ways in many apps. I may change my mind later, but for now, I’m a fan. Trying to push unexpected exceptions into ExceptT
to be uniform with actual AppErrors
I intend to throw purely is a fool's errand. The code is much simpler when you throwIO
your own errors and have a single overall handler at the outer-most layer. I've said as much, albeit in a Ruby context, long ago already.
My general takeaway is:
- If you’re already using a
ReaderT
-ish design pattern and/or a centralizedPrelude
module (your own or a library), switching torio
is unlikely to bring magical benefits
I do think it’s a very good generalization of these patterns and personally I would take on the switching costs to replace bespoke code with what it provides, but your mileage may vary.
I will say that the RIO.Prelude
module is very nice. It doesn't go too far and really just hides unsafe things and re-exports the other libraries I always want.
- If you’re not already doing these things, I wholeheartedly recommend switching to
rio
as a means of getting there
In other words, I fully endorse the patterns themselves and (so far) rio
seems like an excellent implementation thereof.
- Discrete capabilities are a huge win, worth any boilerplate involved
Again, see the StartupApp
/ App
example above. I can't overstate how much I enjoy this.
Originally published at https://tech.freckle.com on April 16, 2019.