Comparing gRPC and OpenAPI using Kotlin

This blog has been written by a senior software engineer at Matillion. 

At a recent hack-day, I decided to try using gRPC in a simple client-server project to see how easy it would be and gauge its performance. I had read about gRPC a while ago, and it sounded like something I would like to investigate further. Google makes heavy use of gRPC for its internal services and even some customer-facing ones. It sounded like something we could potentially use here at Matillion.

What is gRPC?

Originally developed by Google, gRPC is a high-performance, open-source Remote Procedure Call (RPC)  framework that is supported across many different languages and platforms. It allows a client to invoke a method on a server as though it were calling a local object method or function. It makes use of protocol buffers (another Google project), which provide an efficient binary serialization format for the data. Protocol buffers (or protobufs from now on) are faster and more efficient than XML or JSON, are strongly typed, schema-based and are backward and forward-compatible.

The plan

The aim of this project was to create a very simple client and server talking to each other using gRPC so I went for a simple counter service that would increment an atomic long and return it to the client when the RPC was called. This would allow me to more fairly compare the performance of using gRPC over an equivalent OpenAPI service where most of the time will be spent handling the request instead of performing any lengthy or complicated service activities – such as accessing a database.

Enter Kotlin

It would have been easier to tackle this mini-project in Java as it doesn’t require any extra configuration or tooling. However, I’m a fan of Kotlin and try to do all my hack-day projects using it, so I decided not to make an exception in this case and see how hard it would be with this extra hurdle. Spoiler: It turned out not to be too much trouble at all.

Defining the message

Unlike OpenAPI, the procedures and messages used by gRPC have to be clearly defined beforehand. This is done using the Protocol Buffers (protobuf) language to specify the data structure passed between client and server. From this definition, a client can be written for a particular service using any of the supported languages as the interfacing code will be auto-generated for you; this means that, like OpenAPI, the client and server do not have to be implemented in the same language. Over the wire, the messages are transmitted in a compact and efficient binary format; this reduces the amount of data transferred and speeds up serialization and de-serialization compared to JSON or XML. As I mentioned above, message definitions are strongly typed and are both forward and backward-compatible. Adding or removing fields can be done safely without breaking older clients.

Here’s what my message definition looks like:

syntax = "proto3";


option java_multiple_files = true;
option java_package = "com.matillion.kotlingrpc";
option java_outer_classname = "CountProto";


package count;


// (1) - Define the service interface
service Counter {
  rpc Count (CountRequest) returns (CountReply) {}
}


// (2) - Request message
message CountRequest {
}


// (3) - Response message
message CountReply {
  int64 count = 1;
}

Here (1), we can see that I define the service interface, describing the remote procedure name, its parameter type, and return type. In this case, the parameter is CountRequest (2), and the return type is CountReply (3). Only one parameter type and return type can be specified, if you need to specify multiple parameters then they must be wrapped in a message type.

As I’m not actually passing in any values in the CountRequest, I could have just used google.protobuf.Empty as the type. CountReply just consists of a 64-bit integer, but messages can be much more complex than this if necessary; protobuf supports nested types, enumerations, maps, lists and so on.

The Service

Once you’ve created your protobuf definitions, and configured the project correctly, you can then build the project. This will generate the classes required to implement the client and server.

The code to implement the service looks like this:

companion object {
    val counter = AtomicLong()
}


internal class CountService : CounterGrpcKt.CounterCoroutineImplBase() {
    override suspend fun count(request: CountRequest): CountReply {
        return CountReply.newBuilder()
            .setCount(counter.getAndIncrement())
            .build()
    }
}

There’s some boilerplate that needs to go with this to actually create a server listening on a port but that’s not the interesting part. As you can see here, it also uses coroutines for creating non-blocking, performant services.

The Client

The client is similarly small and easy to implement. All the code you need to perform the RPC is auto-generated for you so all you need to do is to call the method.

class CountClient(private val channel: ManagedChannel) : Closeable {
    private val stub = CounterGrpcKt.CounterCoroutineStub(channel)
    suspend fun count(): CountReply {
        return stub.count(countRequest {  })
    }
    override fun close() {
        channel.shutdown().awaitTermination(5, TimeUnit.SECONDS)
    }
}

Code which uses this client would look something like this:

suspend fun main() {
    val port = 9999
    val channel = ManagedChannelBuilder
                    .forAddress("localhost", port)
                    .usePlaintext()
                    .build()
    val client = CountClient(channel)
    println("Count: ${response.count}")
}

And that’s it! Looks simple? Well, there was a bit of configuration involved, but not too much, and there were a few minor stumbling blocks along the way to get it right, but really it wasn’t a problem at all; it only took a few hours starting from scratch. As I had some time left during the hack-day, I decided to put together an equivalent OpenAPI JSON web service and run some simple performance tests.

gRPC vs OpenAPI Performance Comparison

Bearing in mind that the size of the payload is very small and does not represent real-world usage and that my tests weren’t particularly scientific, I was still surprised with the result. It gives an initial idea of what extra performance might be obtained through using gRPC instead of OpenAPI with JSON payloads.

The chart shows the number of milliseconds taken to perform 5000 requests by the client; gRPC is the clear winner, taking 34.7% less time than the OpenAPI version. With larger payloads this difference should only get bigger as JSON payloads take much longer to de-serialise than protobufs.

On the surface, this seems great and suggests that you might want to switch to using gRPC instead of OpenAPI. However, there are some reasons why you might not want to do this everywhere and only think about using it in certain situations. I’ll highlight some of the limitations that stand out to me.

Limitations of gRPC

HTTP/2

gRPC uses the more efficient HTTP/2 protocol but this is not supported on any web browser (at least not via any API, browsers may support it under the hood), so using it from your web application is not straightforward. There are ways around this, such as using Envoy as a bridge proxy or the gRPC-Web Javascript library.

Load Balancing

HTTP/2 multiplexes multiple requests and responses over a single TCP connection, which improves performance but prevents an L3/L4 load balancer from seeing the individual gRPC calls. Therefore, gRPC load balancing requires a different approach than traditional HTTP/1.1 services, as it requires an L7 load balancer that is aware of different requests on the same connection. If you are using Kubernetes, its load balancers are usually L4, so you would need to deploy an L7 load balancer into your cluster.

Message size

By default, gRPC has a limit of 4 MB for incoming messages. It is possible to increase this, however, because gRPC de-serialises an entire message before handing it over to the handling server code, large messages will be held entirely in memory; it’s not possible to stream bytes from a client while writing them to disk for example.

Conclusion

In the 1-day hackday project, I have merely scratched the surface of gRPC and the features available. It offers many useful features, such as bi-directional sequential message streaming, compression, built-in load-balancing, and more.

While gRPC is a useful technology, using OpenAPI web services is a more standard way of exposing public endpoints. It offers great support and tooling across many languages and platforms.

You could, however, consider using gRPC in performance-sensitive, back-end, server-to-server calls that are not exposed to external clients—basically, any service that deals with a large number of internal requests and is not exposed to external customers. Services such as these would benefit from the improved performance that gRPC offers over traditional web services.

Resources

Wayne Bagguley
Wayne Bagguley

Senior Software Engineer

Wayne has been with Matillion for over four years and has recently been designing and implementing Spring-Boot microservices for the Data Productivity Cloud.