Introduction to gRPC in Python : A Hands-on Guide to Modern Client-Server Communication
In this blog, we will learn about how to use gRPC in Python with the help of some examples where we will have client-server architecture.
Introduction
In this blog, we delve into the world of gRPC, a cutting-edge framework developed by Google for seamless and efficient client-server communication. Designed to work across various programming languages, gRPC is a powerful tool for modern microservices architecture. Through practical examples and detailed explanations, this guide will walk you through how to use gRPC in Python.
What is gRPC?
The official website says, “gRPC is a modern, open-source, high-performance Remote Procedure Call (RPC) framework that can run in any environment”.
The gRPC is a framework by Google used for efficient communication between two services, in request-response architecture. It supports many programming languages like C#, C++, Go, Java, Python, and many more. It is mainly used for communication between different microservices (could be in different programming languages). For example, consider an Order microservice written in Python that needs to process payments by calling the Payment microservice written in Java.
Simply put, gRPC enables applications to call functions across different programming languages and services as if they were local functions - whether they're written in the same language or different ones.
Also, we can’t use gRPC to make communication between the webpage and the server, because all popular browsers (as of 2024) don’t support gRPC. gRPC uses HTTP/2 with TCP.
Video on basics of RPC and gRPC
I would recommend you to watch this video before proceeding. This video by Arpit Bhayani will help you to clear your theory needed for the code that we will discuss.
Prerequisite
Python version 3.7+
pip version 9.0.1+
Now, run these commands in the terminal to install the required libraries :
python -m pip install grpcio
python -m pip install grpcio-tools
Note : If you are not able to install these libraries directly, make a virtual environment for Python and then install them.
Generating Protocol Buffer Header Files
gRPC uses Protocol Buffer (ProtoBuf) which is used to generate a header file (code file) that implements the given service specification in .proto
file. The header file is generated based on which programming language will be using that service.
See how we will generate header files for the Order-Payment example,
As we can see we have to compile the payment.proto
twice to generate code since we have both microservices written in different programming languages, so we have header files in different languages.
If we have both microservices written in the same language. Then we will only need to compile payment.proto
once since both microservices are in the same programming language both can import the same header file.
To compile the .proto
file :
python -m grpc_tools.protoc -I. --grpc_python_out=. --python_out= <file-name>.proto
(Here replace <file-name>
with the actual filename, in our case payment.proto
)
After compiling we get two header files generated in the same folder,
payment_pb2.py
payment_pb2_grpc.py
Making .proto file
payment.proto
syntax = "proto3";
package payment;
service Payment {
rpc MakePayment (PaymentRequest) returns (PaymentResponse) {}
}
message PaymentRequest {
double amount = 1;
int32 card = 2;
}
message PaymentResponse {
string result = 1;
}
Firstly, we give the same name to the package as of the .proto
file, for us it’s payment.
package payment;
We also have a defined service that contains API/functions definition that can be used for request-response between client-server or server-server.
service Payment { ... }
Here, we have one service for making payment, so we define it in service Payment
service Payment {
rpc MakePayment (PaymentRequest) returns (PaymentResponse) {}
}
Now, after have defined all the API/functions definitions, we will give definitions of input and output types used in various API/functions, for us it’s PaymentRequest
and PaymentResponse
.
message PaymentRequest {
double amount = 1; // here 1 signifies 1st position in type
int32 card = 2; // here 2 signifies 2nd position in type
}
message PaymentResponse {
string result = 1; // here cannot use 0 (zero), the field number must be a positive value
}
These message
acts a struct or class (based on language) after getting compiled.
To sum up what we have to define in the .proto
file is
Give
syntax
typeGive
package
nameDefine the
service
and in that give definitions of functions/APIs you’ll need in the communicationsDefine the fields present in each
message
(struct datatype) and a field number (a positive value)(nonzero)Compile the
.proto
file
Understanding a Basic Code of Client and Server Example
As I told you, gRPC is as simple as calling functions/APIs. Now, let’s jump into code
This is straightforward code : the client sends “name” as a string and the server responds with a hello(string) response.
service.proto
: here we will compile it and getservice_pb2.py
andservice_pb2_grpc.py
syntax = "proto3";
package service;
service Service {
rpc Get (Request) returns (Response) {}
}
message Request {
string name = 1;
}
message Response {
string message = 1;
}
server.py
: Uses thread pool to handle multiple requests at once.
import grpc # library for gRPC code
import service_pb2 # generated header file from .proto
import service_pb2_grpc # generated header file from .proto
from concurrent import futures # since server will use threadpool
class Service: # implementing Service function(s) (present in .proto file)
def Get(self, request, context):
return service_pb2.Response(message = "Hello " + request.name) # "Response" as output of function
def serve():
# initializing grpc.server() with threadpool
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
# These lines are boilerplate code for server in gRPC code
service_pb2_grpc.add_ServiceServicer_to_server(Service(), server)
server.add_insecure_port('[::]:50051')
server.start()
server.wait_for_termination()
if __name__ == "__main__":
serve()
client.py
import grpc # library for gRPC code
import service_pb2 # generated header file from .proto
import service_pb2_grpc # generated header file from .proto
def run():
channel = grpc.insecure_channel('localhost:50051')
stub = service_pb2_grpc.ServiceStub(channel)
request = service_pb2.Request(name = "Sam")
try:
response = stub.Get(request)
print(f"Response : {response.message}")
except:
print("Error in getting response")
if __name__ == '__main__':
run()
Now, understand what is happening in this code,
For Server,
In this, we implemented the functions/APIs present in
.proto
fileclass Service: # implementing Service function(s) (present in .proto file) def Get(self, request, context): return service_pb2.Response(message = "Hello " + request.name) # "Response" as output of function
Then we define the server
def serve(): # initializing grpc.server() with threadpool server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) # These lines are boilerplate code for server in gRPC code service_pb2_grpc.add_ServiceServicer_to_server(Service(), server) server.add_insecure_port('[::]:50051') server.start() server.wait_for_termination()
Now understand the code line by line of the server
serve
function,We initialize the gRPC server by giving it a thread pool for concurrency.
In this line, the server registers the Service with the Servicer, enabling gRPC clients to access its functionality.
The server starts an insecure channel on port number 50051.
Start the server
For Client,
Client code is very simple, it just starts communication with the server and just makes a request (execute server function). The client first makes a stub and then uses it to request the server, without a stub it will be very hard for us to make requests to a server directly as they are mostly implemented in different languages.
A Stub is used for the conversion of your Python code request to any desired language in which the Server is implemented, It provides an abstraction to the client for easier usage. In Arpit’s video (mentioned above), he discussed the stub in-depth, please look at that video for a better understanding. You can also look into this blog.
Note : In this example, I am using client-server architecture, but in microservices, it’s like server-server. The reason I am using client-server architecture is that it’s a request-response architecture where the client is making requests and the server is responsible for handling the request and providing the response.
Some More Examples
I generated this code using Google Gemini based on my description [AI Generated Code]
- Multiple functions in one service
# Proto file (shapes.proto)
syntax = "proto3";
package shapes;
service Shapes {
rpc GetArea (RectangleRequest) returns (AreaResponse);
rpc GetPerimeter (RectangleRequest) returns (PerimeterResponse);
rpc GetVolume (CuboidRequest) returns (VolumeResponse);
}
message RectangleRequest {
double length = 1;
double width = 2;
}
message CuboidRequest {
double length = 1;
double width = 2;
double height = 3;
}
message AreaResponse {
double area = 1;
}
message PerimeterResponse {
double perimeter = 1;
}
message VolumeResponse {
double volume = 1;
}
# server.py
import grpc
import shapes_pb2
import shapes_pb2_grpc
from concurrent import futures
import time
class ShapesService(shapes_pb2_grpc.ShapesServicer):
def GetArea(self, request, context):
print(f"Calculating area for: {request}") # Print for demonstration
time.sleep(2) # Simulate some work
area = request.length * request.width
return shapes_pb2.AreaResponse(area=area)
def GetPerimeter(self, request, context):
print(f"Calculating perimeter for: {request}") # Print for demonstration
time.sleep(1) # Simulate some work
perimeter = 2 * (request.length + request.width)
return shapes_pb2.PerimeterResponse(perimeter=perimeter)
def GetVolume(self, request, context):
print(f"Calculating volume for: {request}") # Print for demonstration
time.sleep(3) # Simulate some work
volume = request.length * request.width * request.height
return shapes_pb2.VolumeResponse(volume=volume)
def serve():
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) #Important for parallel execution
shapes_pb2_grpc.add_ShapesServicer_to_server(ShapesService(), server)
server.add_insecure_port('[::]:50051')
server.start()
print("Server started. Listening on port 50051.")
server.wait_for_termination()
if __name__ == '__main__':
serve()
# client.py (using threads)
import grpc
import shapes_pb2
import shapes_pb2_grpc
import threading
def call_get_area(stub):
response = stub.GetArea(shapes_pb2.RectangleRequest(length=5, width=10))
print(f"Area: {response.area}")
def call_get_perimeter(stub):
response = stub.GetPerimeter(shapes_pb2.RectangleRequest(length=3, width=7))
print(f"Perimeter: {response.perimeter}")
def call_get_volume(stub):
response = stub.GetVolume(shapes_pb2.CuboidRequest(length=2, width=4, height=6))
print(f"Volume: {response.volume}")
def run():
with grpc.insecure_channel('localhost:50051') as channel:
stub = shapes_pb2_grpc.ShapesStub(channel)
threads = [
threading.Thread(target=call_get_area, args=(stub,)),
threading.Thread(target=call_get_perimeter, args=(stub,)),
threading.Thread(target=call_get_volume, args=(stub,))
]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
if __name__ == '__main__':
run()
- Using 2 proto files
# shape.proto
syntax = "proto3";
package shapes;
service ShapesService {
rpc GetArea (RectangleRequest) returns (AreaResponse);
}
message RectangleRequest {
double length = 1;
double width = 2;
}
message AreaResponse {
double area = 1;
}
# calculations.proto
syntax = "proto3";
package calculations;
service CalculationService {
rpc Add (AddRequest) returns (AddResponse);
}
message AddRequest {
double num1 = 1;
double num2 = 2;
}
message AddResponse {
double result = 1;
}
# server.py
import grpc
import shapes_pb2
import shapes_pb2_grpc
import calculations_pb2
import calculations_pb2_grpc
from concurrent import futures
class ShapesServicer(shapes_pb2_grpc.ShapesServiceServicer):
def GetArea(self, request, context):
area = request.length * request.width
return shapes_pb2.AreaResponse(area=area)
class CalculationServicer(calculations_pb2_grpc.CalculationServiceServicer):
def Add(self, request, context):
result = request.num1 + request.num2
return calculations_pb2.AddResponse(result=result)
def serve():
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
# Add both servicers to the server
shapes_pb2_grpc.add_ShapesServiceServicer_to_server(ShapesServicer(), server)
calculations_pb2_grpc.add_CalculationServiceServicer_to_server(CalculationServicer(), server)
server.add_insecure_port('[::]:50051')
server.start()
print("Server started. Listening on port 50051.")
server.wait_for_termination()
if __name__ == '__main__':
serve()
# Client code is not provided since, it's easy to implement
- Server running multiple threads apart from the service based on gRPC
import grpc
import shapes_pb2
import shapes_pb2_grpc
from concurrent import futures
import threading
import time
# ... (ShapesServicer implementation as before) ...
def serve():
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
shapes_pb2_grpc.add_ShapesServiceServicer_to_server(ShapesServicer(), server)
server.add_insecure_port('[::]:50051')
server.start()
print("gRPC Server started in separate thread.")
server.wait_for_termination() # This will block the thread it is running on
def other_task():
while True:
print("Doing some other work...")
time.sleep(1)
if __name__ == '__main__':
server_thread = threading.Thread(target=serve)
server_thread.daemon = True # Allow the main thread to exit even if this thread is running
server_thread.start()
other_task_thread = threading.Thread(target=other_task)
other_task_thread.start()
try:
while True:
time.sleep(0.1) # Keep the main thread alive
except KeyboardInterrupt:
print("Stopping...")
#server.stop(0) # If you want to stop the server gracefully
#server_thread.join() # If server_thread was not daemon
exit(0) # or sys.exit(0)
- Client using timeout for gRPC requests (written by me)
# service.proto
syntax = "proto3";
package service;
service Service {
rpc Get (Request) returns (Response) {}
}
message Request {
string name = 1;
}
message Response {
string message = 1;
}
# server.py
import grpc
import service_pb2
import service_pb2_grpc
from concurrent import futures
import time
class Service:
def Get(self, request, context):
# time.sleep(0.3) # use it to check how timeout happens
return service_pb2.Response(message = "Hello " + request.name)
def serve():
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
service_pb2_grpc.add_ServiceServicer_to_server(Service(), server)
server.add_insecure_port('[::]:50051')
server.start()
server.wait_for_termination()
if __name__ == "__main__":
serve()
# client.py
import grpc
import service_pb2
import service_pb2_grpc
def run():
channel = grpc.insecure_channel('localhost:50051')
stub = service_pb2_grpc.ServiceStub(channel)
request = service_pb2.Request(name = "Sam")
try:
response = stub.Get(request,timeout=0.1)
print(f"Response : {response.message}")
except:
print("Error in getting response")
if __name__ == '__main__':
run()
Conclusion
gRPC is a very simple and powerful framework used for inter-server communication and is used by Netflix microservices for efficient communication.
This blog is just an introduction to gRPC with some good examples in Python on how to use gRPC. To study more about gRPC you can read blogs and docs from the official website. Also, you can ask your doubts from Google Gemini but sometimes it may generate buggy code, so just I use it for learning/exploring purposes.
Thank you for reading till the end! 🙌 I hope you found this blog enjoyable and interesting. You can also read my other blogs on my profile.
You can contact me on Twitter(𝕏).