Published on
Sat May 27, 2023

Part 1 - A simple gRPC service

This is Part 1 in the gRPC Tutorial Series show casing best practises and tooling in building and deploying services using gRPC (mostly in Go).

Introduction

Client-server communication is a fundamental part of modern software architectures. Clients (on various platforms - web, mobile, desktop, an even IoT devices) request functionality (data and views) that servers that compute, generate and provide. There have been several paradigms facilitating this - REST/Http, SOAP, XML-RPC and others.

gRPC is a modern, open source and highly performant remote procedure call (RPC) framework developed by Google enabling efficient communication in distributed systems. gRPC also uses an interface definition language (IDL) - protobuf - to define services, define methods, messages as well as in serializing structure data between servers and clients. Protobuf as a data serialization format is powerful and efficient - especially compared to text based formats (like Json). This makes a great choice for applications that require high performance and scalability.

A major advantage gRPC confers is its ability to generate code for several clients and servers in several languages (Java, Python, Go, C++, JS/TS) as well as targeting various platforms and frameworks. This simplifies implementing and maintaining consistent APIs (via a source-of-truth IDL) in a language and platform agnostic way. gRPC also offers features like streaming (one way and bi-directional), flow control and flexible middleware/interceptor mechanisms making it a superb choice for real-time applications and microservice architectures.

What makes the gRPC shine is its plugin facility and ecosystem for extending it on several fronts. With plugins just some of the things you can do are:

  • Generate server stubs to implement your service logic
  • Generate clients to talk to these servers
  • Target several languages (golang, python, typescript etc)
  • Even targeting several transport types (HTTP, TCP etc)

Here is an awesome list of curated plugins for the grpc ecosystem. For example - you can even generate an http proxy gateway along with its own OpenAPI spec for those still needing them to consume your APIs by using the appropriate plugin.

There are some disadvantages to using gRPC:

  • Being a relatively recent technology it has some complexities in learning, setting up and use. This may especially be true for developers coming from more traditional technologies (like REST)
  • Browser support for gRPC may be limited. Even though web clients can be generated opening up access to non custom ports (hosting gRPC services) may not be feasible due to org security policies.

Despite this (we feel) its advantages outweight the disadvantages. Improved tooling over time, increase in familiriaty and a robust plugin ecosystem all have made gRPC a popular choice. The browser support limitation will be addressed in the next article in this series.

In this article we will build a simple gRPC service to show case common features and patterns. This will server as a foundation for the upcoming guides in this series. Let us get started!

Motivating Example

Let us build a simple service for power a group chat application (eg like Whatsapp or Zulip or Slack or Teams etc). Our goal is not to displace any of the existing popular services but rather to demonstrate the various aspects in a robust service powering a popular application genre. Our chat service - OneHub - is simple enough. It has:

  • Topics - A place where a group of related users (by team, project, or interest) can share messages to communicate with each other. Very similar to (but also much more simpler than) channels in Slack or Microsoft.
  • Messages - The message being sent in the topic by users.

(Kudos if you have noticed that the “User” is missing. For now we will ignore logins/auth and treat users simply an opaque user id. This will simply testing our service across a number of features without worrying about login mechanisms etc. We will come to all things about Auth, User management and even social features in a future article). This service is rudimentary yet provides enough scope to take it in several directions which will be the topic of future dedicated posts.

Prerequisites

This tutorial assumes you have the following already installed:

  • golang (1.18+)
  • Install protoc. On OSX it is as simple as brew install protobuf
  • gRPC protoc tools for generating Go
    • go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
    • go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

Optional - We wont go too much into building services outside Go but just for fun we will also generate some of the Python stubs to show easy it all is and if there is popular demand one day there could be an extension to this series covering other languages in more detail.

  • gRPC protoc tools for generating Python
    • pyenv virtualenv onehub
    • pyenv activate onehub
    • pip install grpcio
    • pip install grpcio-tools

Setup your project

Code for this can already be found in the OneHub repo. The repo is organized by service and branches are used as checkpoints aligned with end of each part in this series for easier revisiting.

mkdir onehub
cd onehub
# Replace this with your own github repo path
go mod init github.com/panyam/onehub
mkdir -p protos/onehub/v1
touch protos/onehub/v1/models.proto
touch protos/onehub/v1/messages.proto
touch protos/onehub/v1/topics.proto

# Install dependencies
go get google.golang.org/grpc
go get github.com/stretchr/testify/assert

Note when creating your protos it is good practise to have them versioned (v1 above).

There are several ways to organize your protos, for example (and not limited to):

  1. One giant proto for the entire service encompassing all models and protos (eg onehub.proto)
  2. All Foo entity related models and services in foo.proto
  3. All models in a single proto (models.proto) accompanied by services for entity Foo in foo.proto.

In this series we are using the 3rd approach as it also allows us to share models across service while still seperating the individual entity services cleanly.

Define your Service

The .proto files are the starting point of a gRPC, so we can start there with some basic details:

Note each entity was relegated to its own service - though this does not translate to a seperate server (or even process). This is merely for convinience.

For the most part, resource oriented designs have been adopted for the entities, their respective services and methods. As a summary:

  • Entities (in models.proto) have an id field to denote their primary key/object ID
  • All entities have a created/updated timestamp which are set in the create and update methods respectively.
  • All services have the typical CRUD methods.
  • The methods (rpcs) in each service follow similar patterns for their CRUD methods, eg:
    • FooService.Get => method(GetFooRequest) => GetFooResponse
    • FooService.Delete => method(DeleteFooRequest) => DeleteFooResponse
    • FooService.Create => method(CreateFooRequest) => CreateFooResponse
    • FooService.Update => method(UpdateFooRequest) => UpdateFooResponse
  • FooServer.Create methods take a Foo instance and set the instances id, created_at and updated_at fields
  • FooService.Update methods take a Foo instance along with a update_mask to highlight fields being changed and updates the fields. Additionally it also ignores the id method so an id cannot be over-written.

The entities (and relationships) are very straightforward. Some (slightly) noteworthy aspects are:

  1. Topics have a list of ids representing the participants in the topic (we are not focussing on the scalability bottlenecks from a large number of users in a Topic yet).
  2. Messages hold a reference to the Topic (via topic_id).
  3. The Message is very simple and only supports text messages (along with a way to pass in extra information or slightly custom message types - via content_data).

Generate the service stubs and clients

The protoc command-line tool ensures that server (stubs) and clients are generated from this basic stub.

The magic of protoc is that it does not generate anything on its own. Instead it uses plugins for different “purposes” to generate custom artifacts. First let us generate go artifacts:

SRC_DIR=<ABSOLUTE_PATH_OF_ONEHUB>
PROTO_DIR:=$SRC_DIR/protos
OUT_DIR:=$SRC_DIR/gen/go
protoc --go_out=$OUT_DIR --go_opt=paths=source_relative               \
       --go-grpc_out=$OUT_DIR --go-grpc_opt=paths=source_relative     \
       --proto_path=$(PROTO_DIR)                                      \
        $PROTO_DIR/onehub/v1/*.proto

This is quite cumbersome so we can add this into a Makefile and simply run make all to generate protos and build everything going forward.

Now all generated stubs can be found in the gen/go/onehub/v1 folder (because our protos folder hosted the service defs within onehub/v1).

Briefly the following are created:

  • For every X.proto file a gen/go/onehub/v1/X.pb.go is created. This file contains the model definition of every “message” in the .proto file (eg Topic and Message).
  • For every Y.proto file that contains service definitions a X_grpc.pb.go file is generated that contains:
    • A server interface that must be implemented (coming in the next section).
    • For a service X, an interface called XService is generated where the methods are all the rpc methods stipulated in the Y.proto file.
    • A client is generated that can talk to a running implementation of the XService interface (coming below).

Pretty powerful isn’t it! Now let us look at actually implementing the services.

Implementing your Service

Our services are very simple. They store the different instances in memory as a simple collection of elements added in the order in which they were created (we will look at using a real database in the next part) and serve them by querying and updating this collection. Since all the services have (mostly) similar implementations (CRUD) a base store object has been created to represent the in-memory collection and the services simply use this store.

This (simple) base entity looks like:

Using this the Topic service is now very simple:

The Message service is also eerily similar and can here.

Wrap it all with a runner

We have implemented the services with our logic but the services need to be brought up.

The general steps are:

  • create a GRPC Server instance
  • register each our service implementations with this server
  • run this server on a specific port

This server can now be run (by default on port 9000) with:

go run cmd/server.go

Note this is a simple service with Unary RPC methods. ie the client sends a single request to the server and waits for a single response. There are also other types of methods

  1. Server Streaming RPC - The client sends a request to the server and receives a stream of responses (similar to long-polling in http where the client listens to chunks of messages on the open connection).
  2. Client streaming RPC - Here the client sends a stream of messages in a single request and receives a single response from the server. For example a single request from the client could involve multiple location updates (spread out over time) and the response from the server could be a single “path” object the client travelled along.
  3. Bidirectional streaming RPC - The client initiates a connection with the server and both the client and server can send messages independent of one another. The similarity for this in the HTTP universe would be Websocket connections.

We will implement one or more of these in future tutorials.

Client calls to the server

Now it is time to test our server. Note the grpc server is not a REST endpoint. So curl would not work (we will cover this in Part 2). We can make calls against the server in a couple of ways - using a CLI utility (much like curl for REST/http services) or by using the clients generated by the protoc tool. Even better, we can also make client calls from other languages - if we had opted to generate libraries targeting those languages too.

Calling the server via grpc_cli utility

A grpc client (grpc_cli) exists to make direct calls from the command line. On OSX, this can be installed with brew install grpc.

If the server is not running then go ahead and start it (as per the previous section). We can now start calling operations on the server itself - either to make calls or reflect on it!

List all operations

grpc_cli ls localhost:9000 -l

 1filename: grpc/reflection/v1/reflection.proto
 2package: grpc.reflection.v1;
 3service ServerReflection {
 4  rpc ServerReflectionInfo(stream grpc.reflection.v1.ServerReflectionRequest) returns (stream grpc.reflection.v1.ServerReflectionResponse) {}
 5}
 6
 7filename: grpc/reflection/v1alpha/reflection.proto
 8package: grpc.reflection.v1alpha;
 9service ServerReflection {
10  rpc ServerReflectionInfo(stream grpc.reflection.v1alpha.ServerReflectionRequest) returns (stream grpc.reflection.v1alpha.ServerReflectionResponse) {}
11}
12
13filename: onehub/v1/messages.proto
14package: onehub.v1;
15service MessageService {
16  rpc CreateMessage(onehub.v1.CreateMessageRequest) returns (onehub.v1.CreateMessageResponse) {}
17  rpc ListMessages(onehub.v1.ListMessagesRequest) returns (onehub.v1.ListMessagesResponse) {}
18  rpc GetMessage(onehub.v1.GetMessageRequest) returns (onehub.v1.GetMessageResponse) {}
19  rpc GetMessages(onehub.v1.GetMessagesRequest) returns (onehub.v1.GetMessagesResponse) {}
20  rpc DeleteMessage(onehub.v1.DeleteMessageRequest) returns (onehub.v1.DeleteMessageResponse) {}
21  rpc UpdateMessage(onehub.v1.UpdateMessageRequest) returns (onehub.v1.UpdateMessageResponse) {}
22}
23
24filename: onehub/v1/topics.proto
25package: onehub.v1;
26service TopicService {
27  rpc CreateTopic(onehub.v1.CreateTopicRequest) returns (onehub.v1.CreateTopicResponse) {}
28  rpc ListTopics(onehub.v1.ListTopicsRequest) returns (onehub.v1.ListTopicsResponse) {}
29  rpc GetTopic(onehub.v1.GetTopicRequest) returns (onehub.v1.GetTopicResponse) {}
30  rpc GetTopics(onehub.v1.GetTopicsRequest) returns (onehub.v1.GetTopicsResponse) {}
31  rpc DeleteTopic(onehub.v1.DeleteTopicRequest) returns (onehub.v1.DeleteTopicResponse) {}
32  rpc UpdateTopic(onehub.v1.UpdateTopicRequest) returns (onehub.v1.UpdateTopicResponse) {}
33}

Create a Topic

grpc_cli --json_input --json_output call localhost:9000 CreateTopic '{topic: {name: "First Topic", creator_id: "user1"}}'

1{
2 "topic": {
3  "createdAt": "2023-07-28T07:30:54.633005Z",
4  "updatedAt": "2023-07-28T07:30:54.633006Z",
5  "id": "1",
6  "creatorId": "user1",
7  "name": "First Topic"
8 }
9}

and another

grpc_cli --json_input --json_output call localhost:9000 CreateTopic '{topic: {name: "Urgent topic", creator_id: "user2", users: ["user1", "user2", "user3"]}}'

 1{
 2 "topic": {
 3  "createdAt": "2023-07-28T07:32:04.821800Z",
 4  "updatedAt": "2023-07-28T07:32:04.821801Z",
 5  "id": "2",
 6  "creatorId": "user2",
 7  "name": "Urgent topic",
 8  "users": [
 9   "user1",
10   "user2",
11   "user3"
12  ]
13 }
14}

List all topics

grpc_cli --json_input --json_output call localhost:9000 ListTopics {}

 1{
 2 "topics": [
 3  {
 4   "createdAt": "2023-07-28T07:30:54.633005Z",
 5   "updatedAt": "2023-07-28T07:30:54.633006Z",
 6   "id": "1",
 7   "creatorId": "user1",
 8   "name": "First Topic"
 9  },
10  {
11   "createdAt": "2023-07-28T07:32:04.821800Z",
12   "updatedAt": "2023-07-28T07:32:04.821801Z",
13   "id": "2",
14   "creatorId": "user2",
15   "name": "Urgent topic",
16   "users": [
17    "user1",
18    "user2",
19    "user3"
20   ]
21  }
22 ]
23}

Get Topics by IDs

grpc_cli --json_input --json_output call localhost:9000 GetTopics '{"ids": ["1", "2"]}'

 1{
 2 "topics": {
 3  "1": {
 4   "createdAt": "2023-07-28T07:30:54.633005Z",
 5   "updatedAt": "2023-07-28T07:30:54.633006Z",
 6   "id": "1",
 7   "creatorId": "user1",
 8   "name": "First Topic"
 9  },
10  "2": {
11   "createdAt": "2023-07-28T07:32:04.821800Z",
12   "updatedAt": "2023-07-28T07:32:04.821801Z",
13   "id": "2",
14   "creatorId": "user2",
15   "name": "Urgent topic",
16   "users": [
17    "user1",
18    "user2",
19    "user3"
20   ]
21  }
22 }
23}

Delete a topic followed by a Listing

grpc_cli --json_input --json_output call localhost:9000 DeleteTopic '{"id": "1"}'

1connecting to localhost:9000
2{}
3Rpc succeeded with OK status

grpc_cli --json_input --json_output call localhost:9000 ListTopics {}

 1{
 2 "topics": [
 3  {
 4   "createdAt": "2023-07-28T07:32:04.821800Z",
 5   "updatedAt": "2023-07-28T07:32:04.821801Z",
 6   "id": "2",
 7   "creatorId": "user2",
 8   "name": "Urgent topic",
 9   "users": [
10    "user1",
11    "user2",
12    "user3"
13   ]
14  }
15 ]
16}

Programmatically calling the server

Instead of going into this deep, the tests in the service folder show how clients can be created as well as in how to write tests.

Conclusion

That was a lot to cover but we made it. Even though it was a basic example, (hopefully) it set a good foundation for the topics in the rest of the series.

In summary, gRPC is a crucial component of modern software development that allows developers to build high-performance, scalable, and interoperable systems. Its features and benefits have made it a popular choice among companies that need to handle large amounts of data or need to support real-time communication across multiple platforms.

  • Created a grpc service in Go from the ground up, with a very simple implementation (sadly lacking in persistence),
  • Exercised the CLI utility to make calls to the running service
  • Exercised the generated clients while also writing tests

In the next article we will explore how to generate REST/Http bindings for a grpc service. This would enable us to use standard http clients (like curl) to access our service.