Evaluating RIO

Pat Brisbin
Renaissance Learning R&D
8 min readApr 16, 2019

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. no head)
  • A richer Prelude (e.g. you get Data.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-export RIO.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, because RIO.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 exports first/second from Arrow not Bifunctor

I make frequent use of mapping over Left values with first. I never need the Arrow versions.

  • RIO's logging is not compatible with monad-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:

  1. I need to do a thing, like interact with GitHub
  2. 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 with HasConfig
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 envs, 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 to HasFoo 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 centralized Prelude module (your own or a library), switching to rio 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.

--

--