Well-Typed are delighted to announce the release of grapesy
(Hackage, GitHub), an industial strength
Haskell library providing support for gRPC, a modern open source
high performance Remote Procedure Call (RPC) framework developed by Google. The
library has the following features:
Support for both gRPC clients and gRPC servers.
Full compliance with the core gRPC specification, passing all official interoperability tests.
Parametric in the choice of message format; Protobuf is the most common choice for gRPC and is of course supported, as is JSON1. There is also support for general binary (“raw”) messages, and adding additional formats is easy and can be done without modifying
grapesy
itself.Client-side and server-side support for the Protobuf common communication patterns: non-streaming, client-side streaming, server-side streaming, and bidirectional streaming. Use of these patterns is independent of the choice of message encoding, and is strictly optional.
For the specific case of Protobuf, support for Protobuf rich errors.
Support for metadata: request initial metadata, response initial metadata, and response trailing metadata.
Support for all the common gRPC compression algorithms:
gzip
andzlib
(both through the zlib package), as well assnappy
(through a new package snappy-c, developed for this purpose). Bespoke compression algorithms can also be used, and compression can be disabled on a per-message basis.Support for both unencrypted and encrypted connections (TLS).
Support for cancellation, both through deadlines/timeouts (server-side cancellation) as well as through terminating a RPC early (client-side cancellation).2
Flow control: we are careful to use back-pressure to limit traffic, ultimately relying on HTTP2 flow control (which can be adjusted through the HTTP2Settings, primarily the stream window size and the connection window size).
Support for
Wait-for-Ready
, where the connection to a server can be (re)established in the background, rather than an RPC failing if the server is not immediately available. Note that this must be enabled explicitly (as per the spec).Asynchronous design: operations happen in the background whenever possible (opening a connection, initiating an RPC, sending a message), and exceptions are only raised when directly interacting with those background processes. For example, when a client disconnects from the server, the corresponding handler will only get an exception if it attempts any further communication with that client. This is particularly important in RPC servers, which may need to complete certain operations even if the client that requested those operations did not stick around to wait for them.
Type safety: the types of inputs (messages sent from the client to the server) and outputs (messages from the server to the client), as well as the types of the request and response metadata, are all determined from the choice of a specific RPC. In addition, for Protobuf servers we can guarantee at the type-level that all methods are handled (or explicitly declared as unsupported).
Extensive documentation: this blog post contains a number of tutorials that highlight the various parts of
grapesy
, and the Haddock documentation is comprehensive.
The library is designed to be robust:
Exception safety: all exceptions, in both client and in server contexts, are caught and handled in context appropriate ways; they are never simply “lost”. Server-side exceptions are reported as gRPC errors on the client; handlers can also throw any of the standard gRPC errors.
Deals correctly with broken deployments (clients or servers that do not conform to the gRPC specification). This includes things such as dealing with non-200 HTTP status codes, correctly responding to unsupported content types (for which the gRPC spec mandates a different resolution on servers and clients), dealing with servers that don’t respect timeouts, etc.
Native Haskell library (does not bind to any C or C++ libraries).
Comes with a comprehensive test suite, which has been instrumental in achieving high reliability, as well as finding problems elsewhere in the network stack; as part of the development of
grapesy
we have also made numerous improvements tohttp2
and related libraries3. Many thanks to Kazu Yamamoto for being so receptive to all our PRs and willing to discuss all the issues we found, as well as his hard work on these core infrastructure libraries!No memory leaks: even under stress conditions, memory usage is completely flat in both the client and the server.
Good performance, on par with the official Java implementation.
Developing a library of this nature is a significant investment, and so Well-Typed is thankful to Anduril for sponsoring the work.
Quickstart
In this section we explain how to get started, in the style of the official Quickstart guide. You can also use the Quickstart example as a basic template for your own gRPC applications.
gRPC tools
Neither gRPC nor grapesy
requires the use of Protobuf, but it is the most
common way of using gRPC, and it is used by both the Quickstart tutorial as well
as the Basics tutorial. You will therefore need to install the
protobuf buffer compiler protoc
, which can usually be done using your system’s
package manager; see Protobuf Buffer Compiler
Installation for details.
Download the example
If you want to work through this quick start, you will need to clone the
grapesy
repository:
$ git clone https://github.com/well-typed/grapesy.git
$ cd grapesy/tutorials/quickstart
Run a gRPC application
From the grapesy/tutorials/quickstart
directory, run the server
$ cabal run greeter_server
From another terminal, run the client:
$ cabal run greeter_client
If all went well, you should see the server responding to the client with
Proto {message: "Hello, you"}
Update the gRPC service
Now let’s try to add another method to the Greeter
service. This service is
defined using protocol buffers; for an introduction to gRPC in general and
Protobuf specifically, you may wish to read the official Introduction to
gRPC; we will also see more examples of Protobuf below in the
Basics tutorial. You can find the definition for the quickstart
tutorial in tutorials/quickstart/proto/helloworld.proto
:
"proto3";
syntax =
// The greeting service definition.
service Greeter {// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply) {}
}
// The request message containing the user's name.
message HelloRequest {
string name = 1;
}
// The response message containing the greetings
message HelloReply {
string message = 1;
}
Let’s add another method to this service, with the same request and response types:
service Greeter {// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply) {}
// Sends another greeting
rpc SayHelloAgain (HelloRequest) returns (HelloReply) {} }
Generate gRPC code
The example is set up to use a custom Cabal setup script to automatically
compile the proto
definitions; see
proto-lens-setup for a detailed discussion on how to
do this. If you prefer not to use custom setup scripts in your own projects, it
is also possible to run the Protobuf compiler manually; see section Manually
running the protocol compiler of the
proto-lens-protoc
documentation.
This means that to re-run the Protobuf compiler, it suffices to build either the client or the server; let’s attempt to build the server:
$ cabal build greeter_server
You should see a type error:
app/Server.hs:13:7: error: [GHC-83865]
• Couldn't match type: '[]
with: '[Protobuf Greeter "sayHelloAgain"]
This is telling you that the server is incomplete: we are missing a handler
for the new sayHelloAgain
method.
Update the server
To update the server, edit Server.hs
and add:
sayHelloAgain :: Proto HelloRequest -> IO (Proto HelloReply)
= do
sayHelloAgain req let resp = defMessage & #message .~ "Hello again, " <> req ^. #name
return resp
Then update methods
to list the new handler:
methods :: Methods IO (ProtobufMethodsOf Greeter)
=
methods Method (mkNonStreaming sayHello)
$ Method (mkNonStreaming sayHelloAgain)
$ NoMoreMethods
Update the client
Unlike the server, the change to the service definition does not require
changes to the client. The server must implement the new method, but the client
does not have to call it. Of course, it is more interesting when it does, so
let’s add another call to Client.hs
:
$ \conn -> do
withConnection def server let req = defMessage & #name .~ "you"
<- nonStreaming conn (rpc @(Protobuf Greeter "sayHello")) req
resp print resp
<- nonStreaming conn (rpc @(Protobuf Greeter "sayHelloAgain")) req
resp2 print resp2
Run
After restarting greeter_server
, running greeter_client
should now output
Proto {message: "Hello, you"}
Proto {message: "Hello again, you"}
Basics
In this section we delve a little deeper, following the official Basics
tutorial, which introduces the RouteGuide
service.
From the official docs:
Our example is a simple route mapping application that lets clients get information about features on their route, create a summary of their route, and exchange route information such as traffic updates with the server and other clients.
You can find the example in the tutorials/basics
directory of the
grapesy
repo.
Defining the service
The RouteGuide
example illustrates the four different kinds of communication
patterns that Protobuf services can have. You can find the full service
definition in tutorials/basics/proto/route_guide.proto
:
Non-streaming: client sends a single input, server replies with a single output:
// Obtains the feature at a given position. rpc GetFeature(Point) returns (Feature) {}
Server-side streaming: client sends a single input, server can respond with any number of outputs:
// Obtains the Features available within the given Rectangle. rpc ListFeatures(Rectangle) returns (stream Feature) {}
Client-side streaming: client can send any number of inputs, after which the server responds with a single output:
// Accepts a stream of Points on a route being traversed, returning a // RouteSummary when traversal is completed. rpc RecordRoute(stream Point) returns (RouteSummary) {}
Bidirectional streaming: the client and the server can exchange messages at will:
// Accepts a stream of RouteNotes sent while a route is being traversed, // while receiving other RouteNotes (e.g. from other users). rpc RouteChat(stream RouteNote) returns (stream RouteNote) {}
There is explicit support in grapesy
for these four communication patterns,
both for defining servers and for defining clients. In addition, there is a
lower-level API which provides more control over the communication; we will
see some examples in Beyond the basics.
Generated code
As in the Quickstart, we have set things up in the
example to automatically generate Haskell code from the .proto
definition.
There is however one more thing that we need to take care of, which we glossed
over previously. The .proto
definition is sufficient to determine the types
of the methods of the service, their arguments, and their results. But it does
not say anything about the type of any metadata. We don’t need any metadata
in this example, so we can declare the following module:
module Proto.API.RouteGuide (
module Proto.RouteGuide
where
)
import Network.GRPC.Common
import Network.GRPC.Common.Protobuf
import Proto.RouteGuide
type instance RequestMetadata (Protobuf RouteGuide meth) = NoMetadata
type instance ResponseInitialMetadata (Protobuf RouteGuide meth) = NoMetadata
type instance ResponseTrailingMetadata (Protobuf RouteGuide meth) = NoMetadata
This re-exports module Proto.RouteGuide
(which was generated), along with
three type family instances that indicate that none of the methods of the
RouteGuide
require metadata. We will see an example of using
metadata later.
Proto wrapper
In the repository you will find an implementation of the logic of the
RouteGuide
example as a collection of pure functions; see
tutorials/basics/src/RouteGuide.hs
. For example, the type of the function that
looks up which feature exists at a particular point, given the example database
of features, is given by:
featureAt :: DB -> Proto Point -> Maybe (Proto Feature)
The precise implementation is not very important for our purposes here, but we
should discuss that Proto
wrapper. This is a type-level
marker that explicitly identifies Protobuf values. Such values don’t behave like
regular Haskell values; for example, record fields always have defaults, enums
might have unknown values, etc. The idiomatic way of accessing fields of a
Proto
value is using a lens access and an
(overloaded) label; for example, the following
expression extracts a field #location
from a feature (f :: Proto Feature)
:
^. #location f
To construct a Proto
value you first create an empty value using
defMessage
, and then update individual fields with a lens
update. For example, here is how we might construct a
Proto RouteSummary
:
defMessage& #pointCount .~ ..
& #featureCount .~ ..
& #distance .~ ..
& #elapsedTime .~ ..
Everything required to work with Protobuf values is (re-)exported from
Network.GRPC.Common.Protobuf
. In addition,
Network.GRPC.Common.Protobuf.Any
provides
functionality for working with the Protobuf Any
type.
Implementing the server
We can use the type checker to help us in the development of the server. We
know that we want to implement the methods of the RouteGuide
service; if we
define
methods :: DB -> Methods IO (ProtobufMethodsOf RouteGuide)
= _ methods db
the type checker will tell us that it expects something of this type4:
_ :: Methods IO [
Protobuf RouteGuide "getFeature"
, Protobuf RouteGuide "listFeatures"
, Protobuf RouteGuide "recordRoute"
, Protobuf RouteGuide "routeChat"
]
We can therefore refine methods
to
methods :: DB -> Methods IO (ProtobufMethodsOf RouteGuide)
=
methods db Method _getFeature
$ Method _listFeatures
$ Method _recordRoute
$ Method _routeChat
$ NoMoreMethods
at which point the type checker informs us:
_getFeature :: ServerHandler' NonStreaming IO (Protobuf RouteGuide "getFeature")
_listFeatures :: ServerHandler' ServerStreaming IO (Protobuf RouteGuide "listFeatures")
_recordRoute :: ServerHandler' ClientStreaming IO (Protobuf RouteGuide "recordRoute")
_routeChat :: ServerHandler' BiDiStreaming IO (Protobuf RouteGuide "routeChat")
We can therefore refine once more to
methods :: DB -> Methods IO (ProtobufMethodsOf RouteGuide)
=
methods db Method (mkNonStreaming $ _getFeature)
$ Method (mkServerStreaming $ _listFeatures)
$ Method (mkClientStreaming $ _recordRoute)
$ Method (mkBiDiStreaming $ _routeChat)
$ NoMoreMethods
The resulting types will depend on the communication pattern (non-streaming, client-side streaming, etc.). We will discuss them one by one.
Non-streaming RPC
The first method is a non-streaming RPC, for which the type checker infers:
_getFeature :: Proto Point -> IO (Proto Feature)
That is, we are given a point of interest, and must return “the” feature at
that point. We will also need the database of features. The implementation is
straight-forward, and essentially just wraps the pure function featureAt
:
getFeature :: DB -> Proto Point -> IO (Proto Feature)
= return $ fromMaybe (defMessage & #location .~ p) (featureAt db p) getFeature db p
The only minor complication here is that we need to construct some kind of
default location for when there is no feature found at point p
.
Server-side streaming
For server-side streaming we are given the input from the client, along with a function that we can use to send outputs back to the client:
_listFeatures :: Proto Rectangle -> (NextElem (Proto Feature) -> IO ()) -> IO ()
NextElem
is similar to Maybe
:
data NextElem a = NoNextElem | NextElem !a
but with a more specialized API. For example, it offers
forM_ :: Monad m => [a] -> (NextElem a -> m ()) -> m ()
which will invoke the specified callback NextElem x
for all x
in the list,
and then invoke the callback once more with NoNextElem
. We can use this
to implement listFeatures
:
listFeatures :: DB -> Proto Rectangle -> (NextElem (Proto Feature) -> IO ()) -> IO ()
= NextElem.forM_ (featuresIn db r) send listFeatures db r send
Client-side streaming
For client-side streaming we are given a function to receive inputs from the client, and must produce a single output to be sent back to the client:
_recordRoute :: IO (NextElem (Proto Point)) -> IO (Proto RouteSummary)
To implement it, we can use another function from the
NextElem
API:
collect :: Monad m => m (NextElem a) -> m [a]
The only other complication is that the function which constructs the
RouteSummary
also wants to know how long it took to collect all points:
recordRoute :: DB -> IO (NextElem (Proto Point)) -> IO (Proto RouteSummary)
= do
recordRoute db recv <- getCurrentTime
start <- NextElem.collect recv
ps <- getCurrentTime
stop return $ summary db (stop `diffUTCTime` start) ps
Bidirectional streaming
For bidirectional streaming finally we get two functions: one to receive inputs from the client, and one to send outputs back to the client:
_routeChat ::
IO (NextElem (Proto RouteNote))
-> (NextElem (Proto RouteNote) -> IO ())
-> IO ()
The implementation is straight-forward and does not require any new grapesy
features; you can find it in tutorials/basics/app/Server.hs
.
Top-level application
The main server application then looks like this:
main :: IO ()
= do
main <- getDB
db $ fromMethods (methods db)
runServerWithHandlers def config where
config :: ServerConfig
= ServerConfig {
config = Just (InsecureConfig Nothing defaultInsecurePort)
serverInsecure = Nothing
, serverSecure }
The first parameter to
runServerWithHandlers
are the server
parameters. The most important parameters to
consider are serverTopLevel
and
serverExceptionToClient
. These two
are related, and describe how to deal with exceptions:
serverTopLevel
says what to do with exceptions server-side; by default it simply prints them tostderr
serverExceptionToClient
says what information to include in the error sent to the client; by default it callsdisplayException
. You may wish to override this if you are concerned about leaking security sensitive information.
Implementing the client
You can find the complete client in tutorials/basics/app/Client.hs
.
Connecting to the server
Before we can make any RPCs, we have to connect to the server:
main :: IO ()
=
main $ \conn -> do
withConnection def server ..
where
server :: Server
= ServerInsecure $ Address "127.0.0.1" defaultInsecurePort Nothing server
The first argument are the connection parameters, the most important of which is probably the reconnection policy which (amongst other things) is used to enable Wait-for-Ready semantics.
Simple RPC
We already saw how to make a simple non-streaming RPC in the quickstart:
getFeature :: Connection -> IO ()
= do
getFeature conn let req = defMessage
& #latitude .~ 409146138
& #longitude .~ -746188906
<- nonStreaming conn (rpc @(Protobuf RouteGuide "getFeature")) req
resp print resp
We construct a request, do the RPC, and print the response.
Server-side streaming
When we make a server-side streaming RPC, we are given a function we can call to get all of the server outputs:
listFeatures :: Connection -> IO ()
= do
listFeatures conn let req = ..
@(Protobuf RouteGuide "listFeatures")) req $ \recv ->
serverStreaming conn (rpc print NextElem.whileNext_ recv
Here we are using another function from the
NextElem
API, in a sense dual to the one we used
server-side; for comparison, both types:
forM_ :: Monad m => [a] -> (NextElem a -> m ()) -> m ()
whileNext_ :: Monad m => m (NextElem a) -> (a -> m b) -> m ()
Client-side streaming
To make a client-side streaming RPC, we are given a function that we can use to send inputs to the server; once we are done sending all inputs, we then receive the final (and only) output from the server:
recordRoute :: Connection -> IO ()
= do
recordRoute conn <- clientStreaming_ conn (rpc @(Protobuf RouteGuide "recordRoute")) $ \send -> do
resp 10 $ do
replicateM_ let p = (db !! i) ^. #location
$ NextElem p
send 500_000 -- 0.5 seconds
threadDelay NoNextElem
send print resp
Bidirectional streaming
Finally, for bidirectional streaming we are given two functions, one to send, and one to receive. In this particular case, we can first send all inputs and then receive all outputs, but in general these can be interleaved in any order:
routeChat :: Connection -> IO ()
= do
routeChat conn @(Protobuf RouteGuide "routeChat")) $ \send recv -> do
biDiStreaming conn (rpc
NextElem.forM_ messages sendprint
NextElem.whileNext_ recv where
= .. messages
See also The Haskell Unfolder, episode 27: duality for a more in-depth look into the duality between these various communication patterns.
End of output
When we discussed the client-side implementation of a client-side streaming
RPC, we used function
clientStreaming_
:
clientStreaming_ ::
..
-> ( (NextElem (Input rpc) -> m ())
-> m ()
)-> m (Output rpc)
The callback is given a function (which we called send
) to send outputs to the
server. The problem with this approach is that it’s possible to forget to call
this function; in particular, it’s quite easy to forget the final
NoNextElem send
to indicate to the server that there is no further input coming. In some cases
iteration functions such as NextElem.forM_
can
take care of this, but this could also result in the opposite problem, calling
send
on a NextElem
after NoNextElem
has already been sent.
In short: make sure to send NoNextElem
in clients or servers that
stream values to their peer:
If you forget to do this in a server handler,
grapesy
will assume this is a bug and throw aHandlerTerminated
exception, which will be reported as a gRPC exception with an unknown error on the client.If you forget to do this in a client,
grapesy
will assume that you intend to cancel the RPC. The server will see call closed suddenly5, and on the client this will result in a gRPC exception with “cancelled” as the error.
Sending more elements after sending NoNextElem
will result in SendAfterFinal
exception.
Side note. In principle it is possible to give clientStreaming_
a
different type:
-- Alternative definition, not actually used in grapesy
clientStreaming_ ::
..
-> m (NextElem (Input rpc))
-> m (Output rpc)
In this style there is no callback at all; instead, we must provide an action
that produces the next element one by one, and the library will ensure that the
function is called repeatedly until it returns NoNextElem
. This amounts to
inversion of control: you don’t call a function to send
each value, but the
library calls you to ask what the next value to be sent is. This provides
stronger guarantees that the communication pattern is implemented correctly, but
we deemed the cost too high: it results in quite an awkward programming model.
Of course, if you want to, nothing stops you from defining such an API on top
of the API offered by grapesy
.
Beyond the basics
In this section we describe some of the more advanced features of grapesy
.
Using the low-level API
Both the Quickstart and the Basics tutorial used the
StreamType
API, which captures the four different communication patterns (aka
streaming types) used in Protobuf, both on the
server and on the
client: non-streaming, server-side streaming,
client-side streaming, and bidirectional streaming. Although these four
communication patterns originate in Protobuf, in grapesy
they are not
Protobuf specific and can also be used with other message encodings.
The high-level API will probably suffice for the vast majority of gRPC
applications, but not quite all, and grapesy
also offers a low-level API. The
most important reasons to use the low-level API instead are:
Making sure that the final message is marked as final; we discuss this in more detail in this section in Final elements.
Sending and receiving metadata; we will discuss this in detail in the next section Using metadata.
Preference: some people may simpler prefer the style of the low-level API over the high-level API.
Although the use of the low-level API does come with some responsibilities that are taken care of for you in the high-level API, it is not significantly more difficult to use.
Final elements
When we discussed the high-level API, we saw the
NextElem
type. The low-level API uses
StreamElem
instead; here they are side by side:
data NextElem a = NoNextElem | NextElem !a
data StreamElem b a = NoMoreElems !b | StreamElem !a | FinalElem !a !b
There are two differences here:
When there are no more elements, we record an additional value. This is the metadata to be sent or received after the final element. We will see an example of this below; for
RouteGuide
this metadata will always beNoMetadata
, which is a trivial type isomorphic to()
:data NoMetadata = NoMetadata
The final element can be marked as final, rather than requiring a separate
NoMoreElems
value. This may feel like an insignificant difference, but although it is a technicality, in some cases it’s an important technicality.
To understand the need for marking the final element, we need to understand that
gRPC
messages are transferred over HTTP2 DATA
frames.
It’s not necessarily true that one frame corresponds to one message, but let’s
for the sake of simplicity assume that it is. Then in order to send 3 messages,
we have two options:
Option 1: empty final frame | Option 2: mark final message |
---|---|
frame 1: message 1 | frame 1: message 1 |
frame 2: message 2 | frame 2: message 2 |
frame 3: message 3 | frame 3: message 3, marked END_STREAM |
frame 4: empty, marked END_STREAM |
corresponding to
[StreamElem 1, StreamElem 2, StreamElem 3, NoMoreElems NoMetadata]
and [StreamElem 1, StreamElem 2, FinalElem 3 NoMetadata]
respectively. This matters because some servers report an error if they receive
a message that they expect will be the final message, but the corresponding
HTTP2 DATA
frame is not marked END_STREAM
. This is not completely
unreasonable: after all, waiting to receive the next DATA
frame might be a
blocking operation.
This is particularly important in cases where a server (or client) only expects
a single message (non-streaming, client-side streaming, expecting a single
output from the server, or server-side streaming, expecting a single input from
the client). It is much less critical in other situations, which is why the
high-level API gets away with using NextElem
instead of StreamElem
(which it
uses only when multiple messages are expected).
On the server
To use the low-level API on the server, you can either use RawMethod
to use
the low-level API for some (or all) of the methods of an API, or you avoid the
use of fromMethods
altogether. The latter option is primarily useful if you
don’t have a type-level description of your service available. If you do,
the first option is safer:
methods :: DB -> Methods IO (ProtobufMethodsOf RouteGuide)
=
methods db RawMethod (mkRpcHandler $ getFeature db)
$ RawMethod (mkRpcHandler $ listFeatures db)
$ RawMethod (mkRpcHandler $ recordRoute db)
$ RawMethod (mkRpcHandler $ routeChat )
$ NoMoreMethods
It is also possible to use the high-level API for most methods, and escape to the low-level API for those methods that need it.
Unlike with the high-level API, the signature of all handlers that use the low-level API is the same:
getFeature :: DB -> Call (Protobuf RouteGuide "getFeature") -> IO ()
listFeatures :: DB -> Call (Protobuf RouteGuide "listFeatures") -> IO ()
recordRoute :: DB -> Call (Protobuf RouteGuide "recordRoute") -> IO ()
routeChat :: Call (Protobuf RouteGuide "routeChat") -> IO ()
The most important two functions6 for communication on
the server are recvInput
and
sendOutput
:
recvInput :: Call rpc -> IO (StreamElem NoMetadata (Input rpc))
sendOutput :: Call rpc -> StreamElem (ResponseTrailingMetadata rpc) (Output rpc) -> IO ()
For convenience there are also some derived functions available; for example,
here is getFeature
again, now using the low-level API:
getFeature :: DB -> Call (Protobuf RouteGuide "getFeature") -> IO ()
= do
getFeature db call <- recvFinalInput call
p
sendFinalOutput call (& #location .~ p) (featureAt db p)
fromMaybe (defMessage NoMetadata
, )
The StreamElem
API also offers some iteration
functions similar to the ones offered by NextElem
;
for example, here is listFeatures
:
listFeatures :: DB -> Call (Protobuf RouteGuide "listFeatures") -> IO ()
= do
listFeatures db call <- recvFinalInput call
r NoMetadata (sendOutput call) StreamElem.forM_ (featuresIn db r)
The full server definition is available in tutorials/lowlevel/app/Server.hs
.
On the client
The main function to make an RPC using the low-level API is
withRPC
. For example, here is getFeature
:
getFeature :: Connection -> IO ()
= do
getFeature conn let req = ..
Proxy @(Protobuf RouteGuide "getFeature")) $ \call -> do
withRPC conn def (
sendFinalInput call req<- recvFinalOutput call
resp print resp
The second argument to withRPC
are the call
parameters, of which there are two important ones:
the timeout for this RPC, and the request
metadata. (When using the high-level API the
only way to set a timeout is to specify the default RPC
timeout for the connection.)
End of output, revisited
At the end of the basics tutorial, we emphasized the importance of indicating end of output for streaming clients and server handlers. The discussion there is relevant when using the low-level API as well, with some additional caveats:
In the high-level API, the library can take care of marking the (only) value for non-streaming output as final; in the low-level API, this is your own responsibility, either through calling
sendFinalInput
/sendFinalOutput
or through callingsendInput
/sendOutput
and constructing theStreamElem
manually.For streaming outputs, you can use
sendEndOfInput
(clients) orsendTrailers
(servers) to indicate end of output after the fact (likeNoNextElem
does), or usesendFinalInput
/sendFinalOutput
to mark the final element as final when you send it. This should be preferred whenever possible.
Using metadata
As an example of using metadata, let’s construct a simple file server which tells the client the size of the file to be downloaded as the initial response metadata, then streams the contents of the file as a series of chunks, and finally reports a SHA256 hash of the file contents in the trailing response metadata. The client can use the initial file size metadata to show a progress bar, and the hash in the trailing metadata to verify that everything went well.
You can find the full example in tutorials/metadata
.
Service definition
The .proto
file is straight-forward:
"proto3";
syntax =
package fileserver;
service Fileserver {
rpc Download (File) returns (stream Partial) {}
}
message File {
string name = 1;
}
message Partial {
bytes chunk = 1;
}
As mentioned above, however, the .proto
definition does not
tell us the type of the metadata. We need to do this in Haskell:
type instance RequestMetadata (Protobuf Fileserver "download") = NoMetadata
type instance ResponseInitialMetadata (Protobuf Fileserver "download") = DownloadStart
type instance ResponseTrailingMetadata (Protobuf Fileserver "download") = DownloadDone
data DownloadStart = DownloadStart {
downloadSize :: Integer
}deriving stock (Show)
data DownloadDone = DownloadDone {
downloadHash :: ByteString
}deriving stock (Show)
(In this example we make no use of request metadata; see
callRequestMetadata
for the main entry
point for setting request metadata.)
Serialization
In order for the server to be able to send the metadata to the client, we need
to be able serialize it as one (or more, or zero) headers/trailers. This means
we must give an instance of BuildMetadata
:
instance BuildMetadata DownloadStart where
DownloadStart{downloadSize} = [
buildMetadata CustomMetadata "download-size" $ C8.pack (show downloadSize)
]
instance BuildMetadata DownloadDone where
DownloadDone{downloadHash} = [
buildMetadata CustomMetadata "download-hash-bin" downloadHash
]
Note the use of the -bin
suffix for the name of the download-hash-bin
trailer: this indicates that this is metadata containing binary data, and that
it must be Base64-encoded; grapesy
will automatically take care of encoding
and decoding for binary metadata.
We need to take care of one more thing. The HTTP2 spec mandates that clients
must be informed up-front which trailing headers they can
expect. In grapesy
this comes down to giving an instance
of StaticMetadata
:
instance StaticMetadata DownloadDone where
= ["download-hash-bin"] metadataHeaderNames _
This can be an over-approximation but not an under-approximation; if you return
a trailer in BuildMetadata
that was not declared in StaticMetadata
, then
grapesy
will throw an exception.
Deserialization
For deserialization we must provide an instance of
ParseMetadata
, which is given all metadata
headers to parse. In our example this is relatively simple because our metadata
uses only a single header:
instance ParseMetadata DownloadStart where
=
parseMetadata md case md of
CustomMetadata "download-size" value]
[| Just downloadSize <- readMaybe (C8.unpack value)
-> return $ DownloadStart{downloadSize}
_otherwise-> throwM $ UnexpectedMetadata md
instance ParseMetadata DownloadDone where
=
parseMetadata md case md of
CustomMetadata "download-hash-bin" downloadHash]
[-> return $ DownloadDone{downloadHash}
_otherwise-> throwM $ UnexpectedMetadata md
These particular instances will throw an error if additional metadata is present. This is a choice, and instead we could simply ignore any additional headers. There is no single right answer here: ignoring additional metadata runs the risk of not realizing that the peer is trying to tell you something important, but throwing an error runs the risk of unnecessarily aborting an RPC.
Specifying initial response metadata
The metadata that is sent to the client with the response headers can be
overridden with
setResponseInitialMetadata
. This
can be done at any point before initiating the request, either explicitly using
initiateResponse
or implicitly by sending the
first output to the client using sendOutput
and
related functions.
Most server handlers however don’t care about metadata, and prefer not to have
to call to setResponseInitialMetadata
at all. For this reason
mkRpcHandler
has type
mkRpcHandler :: Default (ResponseInitialMetadata rpc) => ..
This constraint is inherited by the high-level API, which doesn’t support metadata at all:
Method :: (Default (ResponseInitialMetadata rpc), Default (ResponseTrailingMetadata rpc)) => ..
Crucially, there is a Default
instance for
NoMetadata
:
instance Default NoMetadata where
= NoMetadata def
In our case however we cannot provide a Default
instance, because the initial
metadata depends on the file size. We therefore use mkRpcHandlerNoDefMetadata
instead:
methods :: Methods IO (ProtobufMethodsOf Fileserver)
=
methods RawMethod (mkRpcHandlerNoDefMetadata download)
$ NoMoreMethods
This means we must call setResponseInitialMetadata
in the handler; if we
don’t, an exception will be raised when the response is initiated.
Server handler
Since we are using the low-level API (we must, if we want to deal with metadata), the server handler has this signature:
download :: Call (Protobuf Fileserver "download") -> IO ()
= do download call
We wait for the request from the client, get the file size, set the response initial metadata, and initiate the response. Explicitly initiating the response in this manner is not essential, but it means that the file size is sent to the client (along with the rest of the response headers) before the first chunk is sent; in some cases this may be important:
req :: Proto File <- recvFinalInput call
let fp :: FilePath
= Text.unpack (req ^. #name)
fp
<- getFileSize fp
fileSize $ DownloadStart fileSize
setResponseInitialMetadata call initiateResponse call
We then open the file the client requested, and keep reading chunks until we have reached end of file. Although it is probably not particularly critical in this case, we follow the recommendations from End of output, revisited and mark the final chunk as the final output to the client, as opposed to telling the client that no more outputs are available after the fact.
ReadMode $ \h -> do
withFile fp let loop :: SHA256.Ctx -> IO ()
= do
loop ctx <- BS.hGet h defaultChunkSize
chunk <- hIsEOF h
eof
let resp :: Proto Partial
= defMessage & #chunk .~ chunk
resp
ctx' :: SHA256.Ctx
= SHA256.update ctx chunk
ctx'
if eof then
DownloadDone $ SHA256.finalize ctx')
sendFinalOutput call (resp, else do
sendNextOutput call resp
loop ctx'
loop SHA256.init
When we send the final output, we must also include the hash that we computed as we were streaming the file to the client.
Client
Let’s first consider how to process the individual chunks that we get from the
server. We do this in an auxiliary function processPartial
:
processPartial ::
Handle
-> Proto Partial
-> ProgressT (StateT SHA256.Ctx IO) ()
= do
processPartial h partial $ BS.hPut h chunk
liftIO $ flip SHA256.update chunk
modify $ BS.length chunk
updateProgressBar where
chunk :: ByteString
= partial ^. #chunk chunk
We do three things in this function: write the chunk to disk, update the hash,
and update the progress bar; this uses StateT
to keep track of the partially
computed hash, and ProgressT
for a simple progress bar (ProgressT
is defined
in tutorials/metadata/app/ProgressT.hs
; its details are not important here).
This in hand, we can now define the main client function. We are given some file
inp
that we are interested in downloading, and a path out
where we want to
store it locally. Like in the server, here too we must use the low-level API, so
the client starts like this:
download :: Connection -> String -> String -> IO ()
= do
download conn inp out Proxy @(Protobuf Fileserver "download")) $ \call -> do
withRPC conn def ($ defMessage & #name .~ Text.pack inp sendFinalInput call
We then wait for the initial response metadata, telling us how big the file is:
DownloadStart{downloadSize} <- recvResponseInitialMetadata call
We then use StreamElem.whileNext_
again
to process all the chunks using processPartial
that we already discussed,
unwrap the monad stack, and finally do a hash comparison:
DownloadDone{downloadHash = theirHash}, ourHash) <-
(WriteMode $ \h ->
withFile out flip runStateT SHA256.init . runProgressT downloadSize $
StreamElem.whileNext_ (recvOutput call) (processPartial h)
putStrLn $ "Hash match: " ++ show (theirHash == SHA256.finalize ourHash)
Custom monad stack
In this section we will briefly discuss how to use custom monad stacks.
You can find the full tutorial in tutorials/monadstack
; it is a variant on
Basics tutorial.
On the server
Most of the server handlers in for the RouteGuide
service need to take the
DB
as an argument:
getFeature :: DB -> Proto Point -> IO (Proto Feature)
listFeatures :: DB -> Proto Rectangle -> (NextElem (Proto Feature) -> IO ()) -> IO ()
recordRoute :: DB -> IO (NextElem (Proto Point)) -> IO (Proto RouteSummary)
It might be more convenient to define a custom Handler
monad stack in which
we have access to the DB at all times:
newtype Handler a = WrapHandler {
unwrapHandler :: ReaderT DB IO a
}deriving newtype (Functor, Applicative, Monad, MonadIO, MonadReader DB)
runHandler :: DB -> Handler a -> IO a
= flip runReaderT db . unwrapHandler runHandler db
The types of our handlers then becomes
getFeature :: Proto Point -> Handler (Proto Feature)
listFeatures :: Proto Rectangle -> (NextElem (Proto Feature) -> IO ()) -> Handler ()
recordRoute :: IO (NextElem (Proto Point)) -> Handler (Proto RouteSummary)
Note that the callbacks to send or receive values still live in IO
. The DB
argument now disappears from methods
also:
methods :: Methods Handler (ProtobufMethodsOf RouteGuide)
=
methods Method (mkNonStreaming getFeature )
$ Method (mkServerStreaming listFeatures)
$ Method (mkClientStreaming recordRoute )
$ Method (mkBiDiStreaming routeChat )
$ NoMoreMethods
The only requirement from grapesy
is that at the top-level we can hoist this
monad stack into IO
, using hoistMethods
:
hoistMethods :: (forall a. m a -> n a) -> Methods m rpcs -> Methods n rpcs
Here’s how we can run the server:
$ fromMethods $
runServerWithHandlers def config hoistMethods (runHandler db) methods
On the client
For the high-level API there is support for custom monad stacks also. One reason
why you might want to do this is to avoid having to pass the Connection
object
around all the time. In the Basics tutorial our client functions had these
signatures:
getFeature :: Connection -> IO ()
listFeatures :: Connection -> IO ()
recordRoute :: Connection -> IO ()
routeChat :: Connection -> IO ()
Like on the server, we can define a custom monad stack to reduce syntactic overhead:
newtype Client a = WrapClient {
unwrapClient :: ReaderT ClientEnv IO a
}deriving newtype (Functor, Applicative, Monad, MonadIO, MonadCatch, MonadThrow, MonadMask)
data ClientEnv = ClientEnv {
conn :: Connection
}
In order for such a monad stack to be useable, it needs to implement MonadIO
and MonadMask
, as well as CanCallRPC
; it’s this last class that tells
grapesy
to get get access to the Connection
object:
instance CanCallRPC Client where
= WrapClient $ conn <$> ask getConnection
With this defined, we can now avoid having to pass the connection around at all.
Instead of importing from
Network.GRPC.Client.StreamType.IO
we
import from
Network.GRPC.Client.StreamType.CanCallRPC
instead, which gives us a different definition of nonStreaming
and friends.
For example, here is getFeature
:
getFeature :: Client ()
= do
getFeature let req = ..
<- nonStreaming (rpc @(Protobuf RouteGuide "getFeature")) req
resp $ print resp liftIO
As for the server handlers, the callbacks provided to send and receive messages
still live in IO
; this means that we’ll need to liftIO
them where
appropriate:
listFeatures :: Client ()
= do
listFeatures let req = ..
@(Protobuf RouteGuide "listFeatures")) req $ \recv -> liftIO $
serverStreaming (rpc print NextElem.whileNext_ recv
Using conduits
We discussed the simplest form of serverStreaming
and co when we discussed the
implementation of the client in the Basics tutorial,
and we have also seen the generalized form to arbitrary monad
stacks. There is one more form, provided in
Network.GRPC.Client.StreamType.Conduit
,
which provides an API using conduits. You can find this
example in tutorials/conduit
; there is currently no conduit support on the
server side.
The main idea is that serverStreaming
provides a source to stream from,
and clientStreaming_
provides a sink to stream to:
listFeatures :: Connection -> IO ()
= do
listFeatures conn let req = ..
let sink :: ConduitT (Proto Feature) Void IO ()
= ..
sink
@(Protobuf RouteGuide "listFeatures")) req $ \source ->
serverStreaming conn (rpc $ source .| sink
runConduit
recordRoute :: Connection -> IO ()
= do
recordRoute conn let source :: ConduitT () (Proto Point) IO ()
= ..
source
<- clientStreaming_ conn (rpc @(Protobuf RouteGuide "recordRoute")) $ \sink ->
resp $ source .| sink
runConduit print resp
In bidirectional streaming finally we get two conduits, one in each direction (that is, one source and one sink).
(Ab)using Trailers-Only
For this final section we need to consider some more low-level details about how
gRPC is sent over HTTP2. When we discussed final elements, we
mentioned that gRPC messages are sent using HTTP2 DATA
frames, but we didn’t
talk about headers. In general, a gRPC request looks like this:
One or more
HEADERS
frames, containing the request headers. One of the most important headers here is the:path
(pseudo) header, which indicates which RPC we want to invoke; for example, this might be/routeguide.RouteGuide/ListFeatures
.One or more
DATA
frames, the last of which is markedEND_STREAM
. We discussed these before.
This is probably as expected, but the structure of the response may look a bit more surprising:
Just like the request, we first get one or more
HEADERS
. An important example here is thecontent-type
response header, which indicates what kind of message encoding is being used (for example,application/grpc+proto
for Protobuf).One or more
DATA
frames, the last of which is markedEND_STREAM
.Finally, another set of headers, also known as trailers. This set of trailers provides some concluding information about how the RPC went; for example, if the RPC failed, then the trailers will include a
grpc-status
header with a non-zero value. Any application specific response trailing metadata (such as the checksum we discussed in the file server example) is included here as well.
There is however a special case, known as Trailers-Only
: if there is no data
to be sent at all, it is possible to send only HEADERS
frames, the last of
which is marked END_STREAM
, and no DATA
frames at all. Put another way, the
two sets of headers (headers and trailers) are combined, and the data frames are
omitted entirely.
The gRPC specification is very explicit about the use of
Trailers-Only
, and states that it can be used only in RPCs that result in an
error:
Most responses are expected to have both headers and trailers but Trailers-Only is permitted for calls that produce an immediate error.
In grapesy
this will happen automatically: if a server handler raises an
error, and no outputs have as yet been sent to the client, then grapesy
will
automatically take advantage of Trailers-Only
and only send a single set of
headers.
However, some gRPC servers also make use of Trailers-Only
in non-error
cases, when there is no output (e.g. for server-side streaming). Since this does
not conform to the gRPC specification, grapesy
will not do this automatically,
but it is possible if really needed. In tutorials/trailers-only
you can find
an example RouteGuide
server which will take advantage of Trailers-Only
in
the listFeatures
method, when there are no features to return:
listFeatures :: DB -> Call (Protobuf RouteGuide "listFeatures") -> IO ()
= do
listFeatures db call <- recvFinalInput call
r case featuresIn db r of
-> sendTrailersOnly call NoMetadata
[] -> StreamElem.forM_ ps NoMetadata (sendOutput call) ps
The difference between this implementation and the previous one can only be observed when we look at the raw network traffic; the difference is not visible at the gRPC level. Since this violates the specification, however, it’s possible (though perhaps unlikely) that some clients will be confused by this server.
Future work
The gRPC specification is only the core of the gRPC ecosystem.
There are additional features that are defined on top, some of which are
supported by grapesy
(see list of features at the start of this post), but not
all; the features that are not yet supported are listed below. Note that these
are optional features, which have various degrees of support in the official
clients and servers. If you or your company needs any of these features, we’d be
happy to discuss options; please contact us at
info@well-typed.com.
Authentication. The gRPC Authentication Guide mentions three ways to authenticate: SSL/TLS, Application Layer Transport Security (ALTS) and token-based authentication, possibly through OAuth2. Of these three only SSL/TLS is currently supported by
grapesy
.Interceptors are essentially a form of middleware that are applied to every request, and can be used for things like metrics (see below).
Custom Backend Metrics. There is support in
grapesy
for parsing or including an Open Request Cost Aggregation (ORCA) load report in the response trailers through theendpoint-load-metrics-bin
trailer, but there is otherwise no support for ORCA or custom backend metrics in general.Load balancing. There is very limited support for load balancing in the
ReconnectPolicy
, but we have no support for load balancing as described in the Custom Load Balancing Policies Guide.Automatic deadline propagation. There is of course support for setting timeouts, but there is no support for automatic propagation from one server to another, adjusting for clock skew. See the section “Deadline Propagation” in Deadlines Guide for server.
Introspection, services that allow to query the state of the server:
Admin services, used by tools such as
grpcdebug
.OpenTelemetry. We do support parsing or including a trace context in the
grpc-trace-bin
request header, but do not otherwise provide any support forOpenTelemetry
yet.
True binary metadata. There is support in
grapesy
for sending binary metadata (in-bin
headers/trailers), using base64 encoding (as per the spec). True binary metadata is about avoiding this encoding overhead.Sending keep-alive pings (this will require adding this feature to the
http2
library).Retry policies. The gRPC documentation currently identifies two such policies: request hedging, which sends the same request to a number of servers, waiting for the first response it receives; and automatic retries of failed requests. There is support in
grapesy
for thegrpc-previous-rpc-attempts
request header as well as thegrpc-retry-pushback-ms
response trailer, necessary to support these features.
Footnotes
There are actually two ways to use JSON with gRPC. It can be a very general term, simply meaning using an otherwise-unspecified JSON encoding, or it can specifically refer to “Protobuf over JSON”. The former is supported by
grapesy
, the latter is not yet↩︎The cancellation guide describes client-side cancellation as “A client cancels an RPC call by calling a method on the call object or, in some languages, on the accompanying context object.”. In
grapesy
this is handled slightly differently: cancellation corresponds to leaving the scope ofwithRPC
early.↩︎The full list: http2#72, http2#74, http2#77, http2#78, http2#79, http2#80, http2#81, http2#82, http2#83, http2#84, http2#92, http2#97, http2#99, http2#101, http2#104, http2#105, http2#106, http2#107, http2#108, http2#115, http2#116, http2#117, http2#119, http2#120, http2#122, http2#124, http2#126, http2#133, http2#135, http2#136, http2#137, http2#138, http2#140, http2#142, http2#146, http2#147, http2#155, http-semantics#1, http-semantics#2, http-semantics#3, http-semantics#4, http-semantics#5, http-semantics#9, http-semantics#10, http-semantics#11, http2-tls#2, http2-tls#3, http2-tls#4, http2-tls#5, http2-tls#6, http2-tls#8, http2-tls#9, http2-tls#10, http2-tls#11, http2-tls#14, http2-tls#15, http2-tls#16, http2-tls#17, http2-tls#19, http2-tls#20, http2-tls#21, network-run#3, network-run#6, network-run#8, network-run#9, network-run#12, network-run#13, network-control#4, network-control#7, tls#458, tls#459, tls#477, tls#478, and network#588.↩︎
Layout of the type error slightly modified for improved readability↩︎
Since gRPC does not support client-side trailers, client-side cancellation is made visible to the server by sending a HTTP2
RST_STREAM
frame.↩︎They are however not primitive; see
recvInputWithMeta
andsendOutputWithMeta
.↩︎