A simple in-memory key-value store
Welcome to the first daemons
tutorial, in which we walk through
writing an in-memory key-value store with an RPC-like interface. The
code for this tutorial is
Memo.hs.
Concretely, we want a program such that:
-
memo put x 42
associates the value42
with the keyx
, and -
memo get x
returns the value42
.
First of all, the extensions and imports:
{-# LANGUAGE DeriveGeneric, OverloadedStrings #-}
We need DeriveGenerics
for
cereal to generate
serializers and deserializers automatically, and we enable
OverloadedStrings
because it makes working with ByteString
s much
nicer.
module Main where
import Control.Concurrent.MVar ( MVar, newMVar, modifyMVar )
import Data.ByteString.Char8 ( ByteString )
import Data.Default ( def )
import Data.Serialize ( Serialize )
import Data.String ( fromString )
import qualified Data.Map as M
import GHC.Generics
import System.Environment ( getArgs )
import System.Daemon
Our key-value store will be a Map ByteString ByteString
and we'll
store it in an MVar
to synchronize concurrent accesses. Instead of
handcrafting a binary protocol for our daemon, we take the easy road
and generate it automatically with Data.Serialize
and
GHC.Generics.
We import System.Daemon
which is the high-level interface to the
daemons
library. The daemons' configuration is an instance of
Data.Default, so
we'll be able to use the defaults.
data Command = Put ByteString ByteString
| Get ByteString
deriving ( Generic, Show )
instance Serialize Command
We define a datatype for the put <key> <value>
and get <key>
commands. We let GHC derive the Generics
instance, which gives us a
pure Haskell representation of the type; this is used by the
Serialize
instance to generate all the necessary binary
serialization and deserialization code.
data Response = Failed String
| Value ByteString
deriving ( Generic, Show )
instance Serialize Response
Similarly, we define a datatype for the possible responses. These can
either be values requested by get <key>
, or failure messages.
type Book = M.Map ByteString ByteString
handleCommand :: MVar Book -> Command -> IO Response
handleCommand bookVar comm = modifyMVar bookVar $ \book -> return $
Our "book" is just a map of ByteString
s; our command handler takes
this map and a command, and returns a response.
Whenever the daemon receives a command, it spawns a new thread and runs the command handler. We want to share the book between these concurrent calls to the handler, so we stick it in an MVar.
An MVar
is basically a thread-safe box which holds at most one
item. We use modifyMVar
which takes the book out of the MVar
,
runs our function with it, and puts the returned book back in the
MVar
.
case comm of
Get key -> ( book
, maybe (Failed "not found") Value (M.lookup key book) )
A get <key>
command does not change the book, so we just return it.
We look up the key and return its value or a failure message.
Put key value -> ( M.insert key value book
, Value "ok" )
A put <key> <value>
command inserts the key-value pair into the
book, and returns a confirmation message.
main :: IO ()
main = do
bookVar <- newMVar M.empty
let options = def { daemonPort = 7856 }
ensureDaemonRunning "memo" options (handleCommand bookVar)
Before doing anything else, we need to ensure that the daemon is
running: we create an empty book, customize the daemon's default
options, and finally start it. Note that ensureDaemonRunning
checks
if the daemon is running and starts it otherwise; so, the daemon will
be started the first time the program is run, and all later runs will
use the initial daemon.
args <- getArgs
let args' = map fromString args
Now it's time to handle the user input. First, we convert all the
arguments to ByteString
s for ease of use.
res <- case args' of
["get", key] -> runClient "localhost" 7856 (Get key)
["put", key, value] -> runClient "localhost" 7856 (Put key value)
_ -> error "invalid command"
Next, we parse the arguments into a command and send it to the daemon.
We call runClient
with the port we gave earlier to
ensureDaemonRunning
and with the parsed command.
print (res :: Maybe Response)
Finally, we print the returned response. Note that runClient
is
polymorphic in its return so we need to specify the type of the
response.
Now let's see it in action:
{-
% dist/build/memo/memo get apples
Daemon started on port 7856
Just (Failed "not found")
% dist/build/memo/memo put apples 23
Just (Value "ok")
% dist/build/memo/memo get apples
Just (Value "23")
-}
To recap, we:
-
wrote data-types for commands and responses and gave them
Serialize
instances, -
wrote a handler that takes a command and returns a response,
-
ensured that our daemon is running with
ensureDaemonRunning
, and -
sent commands and received responses with
runClient
.
This tutorial illustrates the basic concepts behind daemons
, but
hides a powerful feature: the interface is streaming. See the
Queue
(Poor Man's Task Queue) tutorial for an example use of the streaming
interface.