Freckle Education the Freckle developer blog

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. no head )

(e.g. no ) A richer Prelude (e.g. you get Data.Maybe , Control.Monad , etc)

(e.g. you get , , 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

was reduced in size, because 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 :

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