Reputation: 887
I'm writing some code to do logging in Haskell. In imperative languages I would (have) written 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 log
a global static so I don't have to pass it around. The actual endpoints and setting would be configured at startup from a config file maybe, eg. one for production, one for development.
What's a good pattern to do something like this in Haskell?
Upvotes: 3
Views: 915
Reputation: 35089
The pipes
package lets you separate data generation from data consumption. You write your program as a producer of log String
s, and then you choose at runtime 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 that it is a Producer
of String
s (in this case, log strings), that can also invoke IO
commands using lift
. So for ordinary IO
commands that don't involve logging you just use lift
. Whenever you need to log something you use the respond
command, which produces a String
.
This creates an abstract producer of strings that doesn't specify how they are consumed. This lets us defer the choice of how to use the produced String
s later. Whenever we call the respond
command, we abstractly hand off our log string to some as-yet-unspecified downstream stage that will handle it for us.
Now let's write a program that takes a Bool
flag from the command line that specifies whether or not to write the output to stdout
or to the 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 supply any flag on the command line, useFile
defaults to False
, indicating that we want to log to stdout
. If the user supplies the --file
flag, useFile
defaults to True
, indicating that we want to log to "my.log"
.
Now, check out the two if
branches. The first branch feeds the String
s that program
produces into the file using the (>->)
operator. Think of hPutStrLnD
as something that takes a Handle
and creates an abstract consumer of String
s that writes each string to that handle. when we connect program
to hPutStrLnD
, we send every log string to the file:
$ ./log
Enter a string:
Test<Enter>
User entered: Test
Enter a string:
Apple<Enter>
User entered: Apple
^C
$
The second if
branch feeds the String
s to putStrLnD
, which just 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 generation from production, pipes
still streams everything immediately, so the output stages (i.e. hPutStrLnD
and putStrLnD
) will write out the Strings
immediately after they are generated and won't buffer the String
s or wait until the program finishes.
Notice that by decoupling the String
generation from the actual logging action, we gain the ability to inject the String
consumer dependency at the last moment.
To learn more about how to use pipes
, I recommend you read the pipes
tutorial.
Upvotes: 6
Reputation: 1193
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 straightforward to implement this:
logWarn :: Logger -> String -> IO ()
logWarn (Logger endpoints) message = forM_ logToEndpoint endpoints
where
logToEndpoint :: LoggingEndpoint -> IO ()
logToEndpoint (ConsoleEndpoint ...) = ...
logToEndpoint (FileEndpoint ...) = ...
If you want expandable set of endpoints, there are some ways to do it, the simplest of which is to define LoggingEndpoint
as a record of functions, basically a vtable:
data LoggingEndpoint = LoggingEndpoint {
logMessage :: String -> IO (),
... other methods as needed ...
}
consoleEndpoint :: Settings -> LoggingEndpoint
consoleEndpoint (...) = LoggingEndpoint {
logMessage = \message -> ...
... etc ...
}
Then, logToEndpoint
simply becomes
logToEndpoint ep = logMessage ep message
Upvotes: 5
Reputation: 7536
In Real World Haskell they describe how to use a Writer monad to do just 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
Upvotes: 2