Introduction to Mu-Haskell for RPC

Many companies have embraced microservices architectures as the best way to scale up their internal software systems, and separate work across different company divisions and development teams. Microservices architectures also allow teams to turn an idea or bug report into a working feature or fix in production more quickly, in accordance to the agile principles.

However, microservices are not without costs. Every connection between microservices becomes now a boundary that requires one service to act as a server, and the other to act as the client. Each service needs to include an implementation of the protocol, the encoding of the data for transmission, etc. The business logic of the application also starts to spread around several code bases, making it difficult to maintain.

What is Mu-Haskell?

The main goal of Mu-Haskell is to allow you to focus on your domain logic, instead of worrying about format and protocol issues. To achieve this goal, Mu-Haskell provides two sets of packages:

Quickstart

Super-quick summary

  1. Create a new project with stack new.
  2. Define your schema and your services in the .proto file.
  3. Map to your Haskell data types in src/Schema.hs, or use optics.
  4. Implement the server in src/Main.hs.

Step by step

As an appetizer we are going to develop the same service as in the gRPC Quickstart Guide. The service is defined as a .proto file, which includes the schema for the messages and the signature for the methods in the service. The library also supports .avdl files which declare the messages in Avro IDL, check the serialization section for more information.

service Service {
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

message HelloRequest { string name = 1; }
message HelloReply { string message = 1; }

To get started with the project, we provide a Stack template (in fact, we recommend that you use Stack as your build tool, although Cabal should also work perfectly fine). You should run:

stack new my_project https://raw.githubusercontent.com/higherkindness/mu-haskell/master/templates/grpc-server-protobuf.hsfiles -p "author-email:your@email.com" -p "author-name:Your name"

WARNING: Do not include a hyphen in your project name, as it will cause the template to generate a ‘.proto’ file containing an invalid package name. Use my_project, not my-project.

This command creates a new folder called my_project, with a few files. The most important from those are the .proto file, in which you will define your service; src/Schema.hs, which loads the service definition at compile-time; and src/Main.hs, which contains the code of the server.

The first step to get your project running is defining the right schema and service. In this case, you can just copy the definition above after the package declaration.

Data type definition

The second step is to define Haskell types corresponding to the message types in the gRPC definition. The recommended route is to create new Haskell data types and check for compatibility at compile-time. The goal is to discourage from making your domain types simple copies of the protocol types. Another possibility is to use the optics bridge and work with lenses for the fields.

Using Haskell types

The aforementioned .proto file defines two messages. The corresponding data types are as follows:

data HelloRequestMessage
  = HelloRequestMessage { name :: T.Text }
  deriving (Eq, Show, Generic
           , ToSchema   TheSchema "HelloRequest"
           , FromSchema TheSchema "HelloRequest")

data HelloReplyMessage
  = HelloReplyMessage { message :: T.Text }
  deriving (Eq, Show, Generic
           , ToSchema   TheSchema "HelloReply"
           , FromSchema TheSchema "HelloReply")

These data types should be added to the file src/Schema.hs, under the line that starts grpc .... (See the RPC services page for information about what that line is doing.)

You can give the data types and their constructors any name you like. However, keep in mind that:

Using optics

As we mentioned above, you may decide to not introduce new Haskell types, at the expense of losing some automatic checks against the current version of the schema. However, you gain access to a set of lenses and optics which can be used to inspect the values. In the Mu jargon, values from a schema which are not Haskell types are called terms, and we usually define type synonyms for each of them.

type HelloRequestMessage' = Term TheSchema (TheSchema :/: "HelloRequest")
type HelloReplyMessage'   = Term TheSchema (TheSchema :/: "HelloReply")

The arguments to Term closely correspond to those in FromSchema and ToSchema described above.

Server implementation

If you try to compile the project right now by means of stack build, you will receive an error about server not having the right type. This is because you haven’t yet defined any implementation for your service. This is one of the advantages of making the compiler aware of your service definitions: if the .proto file changes, you need to adapt your code correspondingly, or otherwise the project doesn’t even compile!

Open the src/Main.hs file. The contents are quite small right now: a main function asks to run the gRPC service defined by server, using Protocol Buffers as serialization layer. The server function, on the other hand, declares that it implements the Service service in its signature, but contains no implementations.

main :: IO ()
main = runGRpcApp msgProtoBuf 8080 server

server :: (MonadServer m) => SingleServerT Service m _
server = singleService ()

The simplest way to provide an implementation for a service is to define one function for each method. You can define those functions completely in terms of Haskell data types; in our case HelloRequestMessage and HelloReplyMessage. Here is an example definition:

sayHello :: (MonadServer m) => HelloRequestMessage -> m HelloReplyMessage
sayHello (HelloRequestMessage nm)
  = pure $ HelloReplyMessage ("hi, " <> nm)

The MonadServer portion in the type is mandated by mu-rpc; it tells us that in a method we can perform any IO actions and additionally throw server errors (for conditions such as not found). We do not make use of any of those here, so we simply use return with a value. We could even make the definition a bit more polymorphic by replacing MonadServer by Monad.

Another possibility is to use the optics-based API in Mu.Schema.Optics. In that case, you access the value of the fields using (^.) followed by the name of the field after #, and build messages by using record followed by a tuple of the components. The previous example would then be written:

{-# language OverloadedLabels #-}

sayHello :: (MonadServer m) => HelloRequestMessage' -> m HelloReplyMessage'
sayHello (HelloRequestMessage nm)
  = pure $ record ("hi, " <> nm ^. #name)

How does server know that sayHello (any of the two versions) is part of the implementation of the service? We have to tell it, by declaring that sayHello implements the SayHello method from the gRPC definition. If you had more methods, you list each of them using tuple syntax.

server = singleService (method @"SayHello" sayHello)

At this point you can build the project using stack build, and then execute via stack run. This spawns a gRPC server at port 8080, which you can test using applications such as BloomRPC.