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?

+3


source to share


3 answers


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

.

+6


source


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

      

+5


source


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

+2


source







All Articles