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:

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:

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

Immediate Warts

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

Immediate Warts

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:

  1. 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.

  2. 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.

  3. 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:

  1. 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.

  2. 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.

  3. 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.

Back to the home page.