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 withfirst
. I never need theArrow
versions. -
RIO
’s logging is not compatible withmonad-logger
This meant I needed to avoid its own
logX
functions for now and make sure myPrelude
did the rightexport
/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 aserror "..."
. For example, theConfig
that gets loaded from the not-yet-cloned repo’s.restyled.yaml
.This was necessary so I could use the existing
MonadApp
actionscallProcess
andreadFile
to ultimately get thatConfig
to replace theerror
field inApp
.With distinct
Has
classes,callProcess
andreadFile
only requiredHasProcess
andHasSystem
respectively, which I could run in otherenv
s, such as one I calledStartupApp
. This type only has those fields fromApp
that I could populate at startup. My richerApp
type could be built by actions that only requiredHasOptions
,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 aStartupApp
as a field. Then any capabilities the two shared would be defined forStartupApp
first and built as a terse pass-through for theApp
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
inIO
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 forauto
) 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 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 overallm
. 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 splitStartupApp
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 actualAppErrors
I intend to throw purely is a fool’s errand. The code is much simpler when youthrowIO
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 benefitsI 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 thereIn 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.