gRPC is a well known protocol in microservice software architectures. Its low latency and strong contract makes it suitable for inter-service communication compared to plain http. But, did you know that gRPC is built atop of http? Client and servers in gRPC are communicating using set of contracts on top of http. Which means, it’s possible to send gRPC request through http utility tools like curl directly, instead of using its generated client.
In this post, we’ll go through some gRPC specifications, implementation of a simple client, and use case of such implementation. But first, let us define our project scope.
Take the hello world proto file from the grpc quickstart example.
package helloworld;
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply) {}
}
...
The snippet define a simple grpc service with a single method SayHello
.
Now, let’s reference the grpc http2 protocol docs to map the grpc service into a http one.
Other options are ommited as we’re going to implement only the most basic of a gRPC client. But, readers are encouraged to read the complete documentation.
Accordingly, the http request would look like this.
POST /helloworld.Greeter/SayHello HTTP/2
Content-Type: application/grpc+proto
Notice that the path is capitalized after the proto service definition. It’s due to its definition from the docs.
Path is case-sensitive. Some gRPC implementations may allow the Path format shown above to be overridden, but this functionality is strongly discouraged.
Content-Type
header with application/grpc
value is also required, as gRPC servers will deny request without it.
For the data, it consists of repeated sequence of Length-Prefixed-Message
items.
It’s quite simple to compose data payload. First, we serialize the payload to data structure of our choice (in this case protobuf). Then, calculate the length of the serialized data, and encode the length as 4 byte unsigned integer (big endian). Prepend the length to the serialized payload. Add compressed flag as the prefix for the final payload.
Readers might notice that the data frame consist of repeated
sequence of message, implying that the data payload might contains more than one message.
If you’re wondering whether this is true, then you’re correct!
Streaming gRPC will send multiple sequence of message in request / response (depend on whether it’s server, client, or bidirectional streaming).
The server / client are required to read the data until EOS (end-of-stream) which indicated by END_STREAM
flag.
As mentioned, we’re going to use protobuf-c for serialization. We need to install both protobuf compiler (protoc) and protobuf-c. Then we can generate .pb-c.* files for the project.
$ protoc --c_out=. helloworld.proto
Generated header need to be included in the project.
#include "helloworld.pb-c.h"
Remember to also include the generated code and protobuf-c library.
# meson.build
deps = [
dependency('libprotobuf-c', version: '>= 1.0.0'),
...
]
sources = [..., 'helloworld.pb-c.c']
The project will use meson as its build system instead of autotools due to its simplicity. I might elaborate more on this further in a future post.
Talk is cheap, show me the code.
As usual, the post will only contains snippet of codes. Readers can find the full repository on my github.
#define PREFIX_LENGTH 5
...
Helloworld__HelloRequest request = HELLOWORLD__HELLO_REQUEST__INIT;
uint8_t *buf;
size_t len;
request.name = argv[1];
len = helloworld__hello_request__get_packed_size(&request);
buf = malloc(PREFIX_LENGTH + len);
First, we initialize the request message with command argument as its payload.
Then, we calculate its packed size to allocate a buffer for the serialized data.
Notice that we add the 5
prefix length to reserve the prefix data (compression flag and payload size).
buf[0] = 0; // Set the compression flag to zero
// set message length in big endian
int *bin = dec_to_bin(len, 32);
for (int idx = 0; idx < 4; idx++) {
long num = bin_to_dec(bin + idx * 8, 8);
buf[4 - idx] = num;
}
free(bin);
Setting the compression flag to false (0
) for simplicity sake.
The length need to be encoded as 4 byte big-endian number.
helloworld__hello_request__pack(&request, buf + PREFIX_LENGTH);
Last, the request payload need to be packed to the malloced buffer. Remember, the first 5 byte is reserved for the prefix.
All done for the message building. We can then send this buffer to our grpc server.
curl_easy_setopt(curl, CURLOPT_URL,
"http://localhost:50051/helloworld.Greeter/SayHello");
curl_easy_setopt(curl, CURLOPT_HTTP_VERSION,
CURL_HTTP_VERSION_2_PRIOR_KNOWLEDGE);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, buf);
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, len + PREFIX_LENGTH);
struct curl_slist *headers = NULL;
headers = curl_slist_append(headers, "Content-Type: application/grpc+proto");
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, handle_callback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, NULL);
As explained, we only set the necessary headers option to keep the project simple.
Notice that we set CURL_HTTP_VERSION_2_PRIOR_KNOWLEDGE
, this way curl will use http/2
directly instead of trying to upgrade the connection from http/1.1
.
size_t handle_callback(char *ptr, size_t size, size_t nmemb, void *userdata) {
size_t realsize = size * nmemb;
Helloworld__HelloReply *response = helloworld__hello_reply__unpack(
NULL, realsize - PREFIX_LENGTH, ptr + PREFIX_LENGTH);
if (response == NULL) {
fprintf(stderr, "error unpacking incoming message\n");
exit(1);
}
printf("response msg: %s\n", response->msg);
helloworld__hello_reply__free_unpacked(response, NULL);
return realsize;
}
We took a shortcut on handling the response.
Because our server is set to only return a single message, we can simply read all the response and be done with it.
On a production code though, we need to read the response until END_STREAM
flag.
And instead of treating it as a single message, we also need to read the first 5 byte prefix and then handle it appropriately.
But for now, we can simply ignore the prefix and deserialize it into a single message.
We can use the helloworld example from the grpc-go repository as the test server.
$ git clone git@github.com:grpc/grpc-go.git
$ cd grpc-go/examples/helloworld/greeter_server/
$ go run main.go
Now, we can test our simple gRPC client.
$ ./builddir/curl_grpc curl_world
response msg: Hello curl_world
While the approach works, the project is nothing more than a proof of concept. On a production code, using a proper grpc client library (e.g. juniper grpc-c) is strongly recommended. Our main goal for this project is to demystify grpc protocol by recreating it from the simplest form.
While not ideal for production usage, this approach might come in handy in development tools, like bloomrpc. For such tools, generic approach using basic library is more favourable due to its flexibility. (In fact, I’m also working on a tool exactly like that!)
gRPC itself isn’t complete until we discuss proto serialization in details. Hence, it might be the theme of my future post. Thanks for reading!
← home