Haskell registration pattern
I am writing some code for logging in Haskell. In imperative languages, I would write something like:
log = new Logger();
log.registerEndpoint(new ConsoleEndpoint(settings));
log.registerEndpoint(new FileEndpoint(...));
log.registerEndpoint(new ScribeEndpoint(...));
...
log.warn("beware!")
log.info("hello world");
Maybe even make it a log
global static so I don't need to pass it. The actual endpoints and settings will be configured when run from a config file like eg. one for production, one for development.
What's a good picture to do something like this in Haskell?
source to share
The package pipes
allows you to decouple data from data consumption. You write your program as a log producer String
s, and then at runtime you choose how to consume those String
s.
For example, let's say you have the following simple program:
import Control.Proxy
program :: (Proxy p) => () -> Producer p String IO r
program () = runIdentityP $ forever $ do
lift $ putStrLn "Enter a string:"
str <- lift getLine
respond $ "User entered: " ++ str
The type says it's Producer
of String
(in this case, log lines), which can also invoke commands IO
using lift
. So for normal commands IO
that don't involve logging, you just use lift
. Whenever you need to record something, you use the command respond
that creates String
.
This creates an abstract string producer that does not specify how they are consumed. This allows you to postpone the choice of how to use the received String
later. Whenever we invoke a command respond
, we abstractly pass our log line to some other undefined downstream stage that will process it for us.
Now, let's write a program that accepts a Bool
command line flag that indicates whether to write output to stdout
or to a file "my.log"
.
import System.IO
import Options.Applicative
options :: Parser Bool
options = switch (long "file")
main = do
useFile <- execParser $ info (helper <*> options) fullDesc
if useFile
then do
withFile "my.log" WriteMode $ \h ->
runProxy $ program >-> hPutStrLnD h
else runProxy $ program >-> putStrLnD
If the user does not add any flag useFile
on the command line, it defaults to False
indicating that we want to log into stdout
. If the user supplies a flag --file
, useFile
the default matches True
, indicating that we want to log in "my.log"
.
Now check out two branches if
. The first branch commits String
, which program
produces to a file using a statement (>->)
. Think of it hPutStrLnD
as something that takes Handle
and creates an abstract consumer String
that writes each line to that descriptor. when we connect program
to hPutStrLnD
, we send each log line to a file:
$ ./log
Enter a string:
Test<Enter>
User entered: Test
Enter a string:
Apple<Enter>
User entered: Apple
^C
$
The second branch if
transfers String
to putStrLnD
, which simply writes them to stdout
:
$ ./log --file
Enter a string:
Test<Enter>
Enter a string:
Apple<Enter>
^C
$ cat my.log
User entered: Test
User entered: Apple
$
Despite decoupling the generation from production, pipes
it still transmits everything at once, so the output stages (i.e. hPutStrLnD
and putStrLnD
) will be written out Strings
immediately after they are generated and will not buffer String
or wait until the program finishes.
Note that by decoupling the generation String
from the actual registration action, we are able to insert the consumer dependency String
at the last moment.
To learn more about how to use pipes
, I recommend you read the tutorialpipes
.
source to share
If you only have a fixed set of endpoints, this is a possible design:
data Logger = Logger [LoggingEndpoint]
data LoggingEndpoint = ConsoleEndpoint ... | FileEndpoint ... | ScribeEndpoint ... | ...
Then it should be easy to implement:
logWarn :: Logger -> String -> IO ()
logWarn (Logger endpoints) message = forM_ logToEndpoint endpoints
where
logToEndpoint :: LoggingEndpoint -> IO ()
logToEndpoint (ConsoleEndpoint ...) = ...
logToEndpoint (FileEndpoint ...) = ...
If you want an extensible set of endpoints, there are some ways to do it, the simplest of which is to define LoggingEndpoint
as function entries, basically a vtable:
data LoggingEndpoint = LoggingEndpoint {
logMessage :: String -> IO (),
... other methods as needed ...
}
consoleEndpoint :: Settings -> LoggingEndpoint
consoleEndpoint (...) = LoggingEndpoint {
logMessage = \message -> ...
... etc ...
}
Then it logToEndpoint
just becomes
logToEndpoint ep = logMessage ep message
source to share
In Real World Haskell, they describe how to use the Writer monad for this, much better than I can explain it: http://book.realworldhaskell.org/read/programming-with-monads.html#id649416
Also see the chapter on monad transformers: http://book.realworldhaskell.org/read/monad-transformers.html
source to share