What is gRPC?
Google Remote Procedure Call, (gRPC), is an open-source remote procedure call framework developed by Google. To communicate, clients and servers use gRPC and define a service contract via a language-agnostic binary serialization format called Protocol Buffers, or protobuf..
We can use gRPC to build high-performance distributed systems.
How does gRPC work?
In gRPC, we describe the service interface by defining a set of functions that can be called remotely, and then generate the initial code skeleton for both the server and the client.
The generated server code is then completed with business-logic code as needed. Similarly, the generated client code is used to call server functions as if they are regular functions, without having to deal with the complexities of network communication.
This approach has become popular in microservice architectures and is widely used in cloud-native environments. It also has some features that make it very attractive to developers.
Language independence
Because gRPC is language agnostic , we can implement clients and servers in different programming languages and these implementations will work together seamlessly. This gives us flexibility and interoperability.
This is possible because gRPC has libraries and bindings for many programming languages, including C++, Java, Python, Go, and many others.
This interoperability and multi-language support is possible because gRPC uses Protocol Buffers: a language- and platform-neutral mechanism that allows us both to describe the service interface and specify the message format.
Code generation
In gRPC, we generate the initial client and server code from the service definition specified in a .proto
file. The generated code provides type-safe APIs and abstracts away the complexity of network communication.
Authentication and load balancing
GRPC has built-in support for various authentication mechanisms and load balancing strategies. This makes it suitable for building scalable and secure distributed systems.
Bidirectional streaming
GRPC supports both unary (request-response) and streaming communication. It allows clients and servers to establish bidirectional streaming channels to send and receive multiple messages asynchronously. Such bidirectional streaming uses network resources efficiently because it enables concurrent sending and receiving of messages without waiting for a request-response cycle to complete.
This is well-suited for scenarios where real-time, interactive, and continuous communication is required, such as chat applications, real-time collaboration tools, or data streaming applications.
An example application
Let’s build a simple to-do application.
First, we’ll specify the service interface and the format of exchanged messages. Then we’ll generate the initial server and client code. Finally, we’ll fill the generated code with required business logic.
While the server and the client could be implemented in different programming languages, we’ll use Python for both to keep this example simple.
Describe the service interface with a .proto
file
To start, we describe the service interface and the message types in the file todo.proto
.
The service interface has a set of functions that the server offers and clients can call. The message types define different input and output messages that these functions use.
syntax = "proto3";
package todo;
service TodoService {
rpc AddTodo (Item) returns (Item) {}
rpc GetTodo (TodoRequest) returns (Item) {}
rpc MarkCompleted (TodoRequest) returns (Item) {}
rpc ListTodos (ListTodosRequest) returns (stream Item) {}
}
message Item {
int32 id = 1;
string name = 2;
bool completed = 3;
string timestamp = 4;
}
message TodoRequest {
int32 id = 1;
}
message ListTodosRequest {
int32 skip = 1;
int32 offset = 2;
}
Inside the service
block we specify functions:
- AddTodo
- GetTodo
- MarkCompleted
- ListTodos
The functions specify input parameters, their types, and the return values and corresponding types. The rpc
keyword precedes each function.
Below the service block we have three message
blocks that define required messages. For instance, a to-do Item
consists of:
- an
id
typeint32
- a
name
typestring
- a
bool
denoting whether the task has beencompleted
- the
timestamp
denoting when the task was created as typegoogle.protobuf.Timestamp
.
TodoRequest
represents a data structure that wraps an id
field which is used to query a single to-do item or mark a given item as completed, and similarly the ListTodoRequest
allows querying a list of items where the client can specify additional query parameters.
The integer values assigned to these fields, for instance, int32 id = 1;
denote a unique tag used by the Protocol Buffers binary encoding. While it may appear that these are default values, they are not. These numbers must be unique within each message, and effectively represent fields’ binary names. Changing the value is the same as deleting a field and adding a new one.
For more information regarding message definitions, see the official Protocol Buffers documentation.
Generate the initial code
Once we have specified the service interface, we can generate the initial code skeleton. For this, we need a protoc
compiler for the targeted language. Since we’re using Python, we can install the compiler with pip
.
$ pip install grpcio grpcio-tools
Then we can run the protoc
compiler as follows:
$ python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. todo.proto
This should generate two files, todo_pb2.py
todo_pb2_grpc.py
. The first contains the generated request and response classes and the other contains generated client and server classes.
Implement the server code
Now we have all the required utilities to implement the server and the client. The server implementation in server.py
should contain the following code:
import sys
from concurrent import futures
import grpc
import todo_pb2_grpc
class TodoService(todo_pb2_grpc.TodoServiceServicer):
todos = [] # 'Database' of TODOs
_counter = 0 # Generates unique ids.
def AddTodo(self, request, context):
"""Adds a new TODO to the list"""
self._counter += 1
todo = request
todo.id = self._counter
todo.timestamp.GetCurrentTime()
self.todos.append(todo)
return todo
def GetTodo(self, request, context):
"""Returns a TODO for a given id, error otherwise"""
for todo in self.todos:
if todo.id == request.id:
return todo
context.set_code(grpc.StatusCode.NOT_FOUND)
context.set_details("Todo not found.")
def ListTodos(self, request, context):
"""Returns a stream of TODOs, where optional parameters skip and
offset can be used"""
skip = request.skip if request.skip else 0
offset = request.offset if request.offset else sys.maxsize
for todo in self.todos[skip:skip + offset]:
yield todo
def MarkCompleted(self, request, context):
"""Marks a given TODO as completed"""
for todo in self.todos:
if todo.id == request.id:
todo.completed = True
return todo
context.set_code(grpc.StatusCode.NOT_FOUND)
context.set_details("Todo not found.")
def serve():
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
todo_pb2_grpc.add_TodoServiceServicer_to_server(TodoService(), server)
server.add_insecure_port('[::]:50051')
server.start()
print("Server started. Listening on port 50051...")
server.wait_for_termination()
if __name__ == '__main__':
serve()
Class TodoService
implements the gRPC service’s methods, such as AddTodo
and GetTodo
. These are simple Python methods that process request
and context
parameters. The request
denotes any service input parameters, and context
provides RPC-specific information, like timeout limits.
The AddTodo
method reads the incoming TODO
item from the variable request
, sets a unique id
and the current date and time, and appends the TODO
to the database.
The GetTodo
method shows how to use the context
parameter: when the client requests an item without an id
, we use context
to set the error code to NOT_FOUND
. (In a RESTful service, we would return a 404 not found
response code.) We’ll implement the remaining two service functions in a similar way.
Lastly, to start the server, we implement the method serve()
and call it when the server.py
file runs. The method instantiates a gRPC server on a given port and specifies that it will offer services from class TodoService
.
To start the server, open a terminal and run the following command.
$ python server.py
Implement the client code
Now we just need to implement the client code. The client code depends on which actions the client wishes to make.
In this example, we:
- Add a TODO item to the database
- Query the newly added TODO item
- Mark the newly created item as complete
- Select all TODO items from the database
The client.py
is very straightforward.
import grpc
from grpc import RpcError
import todo_pb2
import todo_pb2_grpc
def run():
channel = grpc.insecure_channel('localhost:50051')
stub = todo_pb2_grpc.TodoServiceStub(channel)
# Add a new TODO item
todo = todo_pb2.Item(name="Hello World! This is a TODO!")
todo = stub.AddTodo(todo)
print(f"Added Todo:", todo.id, todo.name, todo.completed, todo.timestamp.ToDatetime())
print()
# Get a TODO item by ID
try:
todo = stub.GetTodo(todo_pb2.TodoRequest(id=1))
print(f"Got Todo for id = 1:", todo.id, todo.name, todo.completed, todo.timestamp.ToDatetime())
except RpcError as e:
print("Error occurred while getting Todo:", e)
# Mark a TODO completed
try:
todo = stub.MarkCompleted(todo_pb2.TodoRequest(id=1))
print(f"Got Todo for id = 1:", todo.id, todo.name, todo.completed, todo.timestamp.ToDatetime())
except RpcError as e:
print("Error occurred while getting Todo:", e)
# List all TODO items
list_response = stub.ListTodos(todo_pb2.ListTodosRequest(skip=0))
print()
print("List Todos:")
for todo in list_response:
print(todo.id, todo.name, todo.completed, todo.timestamp.ToDatetime())
if __name__ == '__main__':
run()
First, we connect to the server through a channel and then instantiate a stub to call service functions from the server.
Next, we instantiate a new TODO item. We add it to the database by calling the stub.AddTodo(todo)
. Behind the scenes, the stub takes the Python Item
object, serializes it to wire-format, sends it to the server where it is deserialized and given a fresh id
and current timestamp
, and then saved to the database.
We can take a similar approach with other queries. For instance, if we query a TODO item based on its id
, we mark a TODO item complete with the method MarkCompleted
.
We can also list all TODO items in the database with the stub.ListTodos(ListTodosRequest)
. In the latter case, the service returns a stream of TODO items. A stream is like a list where the contents can come with a delay. The method takes in a ListTodoRequest
object that may contain two fields: skip
and offset
. These two fields support pagination and work like the LIMIT
command in SQL.
Results
Let’s run the client.
$ python client.py
python client.py
Added Todo: 1 Hello World! This is a TODO! False 2023-07-14 12:33:56.241896
Got Todo for id = 1: 1 Hello World! This is a TODO! False 2023-07-14 12:33:56.241896
Got Todo for id = 1: 1 Hello World! This is a TODO! True 2023-07-14 12:33:56.241896
List Todos:
1 Hello World! This is a TODO! True 2023-07-14 12:33:56.241896
Conclusion
Notice how a lot of things happened under the hood: - We didn’t have to install and run a dedicated HTTP server. - We didn’t have to decide on a message serialization format. - We didn’t explicitly serialize or deserialize messages.
The service description file generated the service interface. The initial client libraries were generated for us, and we can invoke remote functions as if they were local to us.
Because the development experience is so streamlined, gRPC is becoming more prevalent in developing distributed applications.