In February 2015, Google open-sourced a robust framework for universal Remote Procedure Calls (RPC). This language-agnostic and highly efficient framework revolutionized the way microservices or any distributed systems interact and communicate with one another.
With the gRPC framework, clients can easily invoke methods on servers located on different machines, as if they were local object methods. This makes for a much more streamlined and seamless process, eliminating the messiness that often comes with distributed system communication.
It operates based on the concept of defining a service, which outlines the available methods along with their parameters and return types inside a .proto file. The server implements these methods and operates a gRPC server to handle client calls, while on the client-side, there is a stub or client that offers the same methods as that of the server.
gRPC utilizes the HTTP/2 protocol for transmitting binary data and Protocol Buffers (protobuf) as its interface definition language (IDL). This empowers developers to specify the structure of the service and messages in a particular format, and subsequently generate language-specific bindings for their server and client based on this predefined format.
Protocol Buffers, or protobuf, is a powerful binary serialization toolset and language used by gRPC instead of text-based formats like JSON or XML. Using this, gRPC achieves faster serialization/deserialization of data and reduces payload size. Additionally, gRPC is flexible and developers can plug in custom codecs to support other data formats.
There are several other benefits of adopting this framework, including but not limited to significant advantages over traditional HTTP/1.1 in terms of concurrency, bi-directional streaming, server push, header compression, and more. It is also very developer-friendly due to its API-oriented approach and automatic code generation feature for multiple languages using the protocol compiler.
To get the protobuf compiler, protoc, using a package manager for Linux or macOS, we can use the following commands:
# linux, using apt or apt-get
apt install -y protobuf-compiler
# macOS, using Homebrew
brew install protobuf
To install it on a windows system, follow this awesome article: https://www.geeksforgeeks.org/how-to-install-protocol-buffers-on-windows/
To verify if the compiler installation was successful and to check the version, we can run the following command:
protoc --version
# output: libprotoc 23.3 (should be 3+)
We would also require to install some plugins for the protoc compiler:
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
protoc-gen-go
: Generates Go code for Protocol Buffers.protoc-gen-go-grpc
: Generates Go gRPC service code.
Note: Make sure the PATH
environment variable includes the bin directory of the GOPATH
, this will be necessary to run the Go binaries. We can set the path using the following commands:
# unix/linux based systems
export PATH="${PATH}:$(go env GOPATH)/bin";
# windows based systems
set PATH=%PATH%;%GOPATH%\bin
Let’s explore a simple example to understand how to implement a gRPC-based chat service. The project consists of two components: an application written in Golang that can either work as a server or a client and another client app in Node.js.
Our project structure will look like this:
.
├── proto
│ └── chat.proto
├── gen
│ └── chat
│ ├── chat.pb.go // generated by protoc
│ └── chat_grpc.pb.go // generated by protoc
├── server
│ └── server.go
├── client
│ └── client.go
├── main.go
├── js-client
│ └── client.js
Looking into the project structure, we see the following files:
chat.proto
The Protocol Buffers file, commonly referred to as the proto file, defines the data structures and services in a format that can be understood by both the server and client.
syntax = "proto3";
package main;
option go_package = "./chat";
service ChatApi {
rpc Connect (User) returns (stream Message);
rpc Broadcast (Message) returns (Message);
}
message User {
string id = 1;
}
message Message {
User user = 1;
string content = 2;
}
In this file we have defined a ChatApi
service with two RPC methods:
Connect
: This function establishes a connection with a user and starts streaming messages. It takes a User object and returns a stream of Messages.Broadcast
: This function takes in a Message and sends it to all the connected clients. It returns a Message object as well.
Additionally, there are two message types:
User
: It only contains the user's id.Message
: It contains a User and the content of the message.
Our next step is to use the protoc
compiler to generate the Go code from the .proto files which can be used by our application to interact with gRPC services.
protoc --proto_path=proto proto/*.proto --go_out=gen/ --go-grpc_out=gen/
Let’s break down each part of the command:
protoc
: This is the Protocol Buffers compiler. It reads.proto
files and generates code in the specified language.--proto_path=proto
: This option tellsprotoc
where to look for.proto
files. Here, it is set to look in theproto
directory.proto/*.proto
: This specifies the.proto
files thatprotoc
should compile. Here, it's using a wildcard (*) to compile all.proto
files in theproto
directory.--go_out=gen/
: This option tellsprotoc
to generate Go code and specifies where to put the generated files. Here, it's set to output to thegen/
directory. The generated files will include message types defined in the.proto
files.--go-grpc_out=gen/
: This option tellsprotoc
to generate Go gRPC code and specifies where to put the generated files. Here, it's set to output to thegen/
directory. The generated files will include gRPC client and server code for services defined in the.proto
files.
After successfully executing the above command, two new files will be generated: chat.pb.go and chat_grpc.pb.go inside a chat folder, serving as the output for options 4 and 5 respectively.
server.go
This file contains the implementation of the gRPC server. It has the StartGrpcServer
function which starts the gRPC server, listening on port 8080.
package server
import (
"context"
"log"
"net"
"sync"
"github.com/anik-ghosh-au7/grpc-messenger/gen/chat"
"google.golang.org/grpc"
)
type server struct {
chat.UnimplementedChatApiServer
clients map[string]chat.ChatApi_ConnectServer
mu sync.Mutex
}
func (s *server) Connect(user *chat.User, stream chat.ChatApi_ConnectServer) error {
s.mu.Lock()
s.clients[user.Id] = stream
s.mu.Unlock()
log.Println("Client Connected: ", user.Id)
<-stream.Context().Done()
s.mu.Lock()
delete(s.clients, user.Id)
s.mu.Unlock()
log.Println("Client Disconnected: ", user.Id)
return nil
}
func (s *server) Broadcast(ctx context.Context, message *chat.Message) (*chat.Message, error) {
s.mu.Lock()
defer s.mu.Unlock()
for id, clientStream := range s.clients {
if id != message.User.Id {
if err := clientStream.Send(message); err != nil {
log.Println("Error broadcasting message to", id, ":", err)
}
}
}
return message, nil
}
func StartGrpcServer() error {
listener, err := net.Listen("tcp", ":8080")
if err != nil {
return err
}
srv := grpc.NewServer()
chat.RegisterChatApiServer(srv, &server{
clients: make(map[string]chat.ChatApi_ConnectServer),
})
log.Println("Server started. Listening on port 8080.")
return srv.Serve(listener)
}
The code above also contains the implementation of ChatApi
server, with methods Connect
and Broadcast
:
Connect
: adds the client’s stream to the server’sclients
map using the user’s ID as the key. Then it waits for the client’s context to be done (which means the client has disconnected), after which it removes the client from theclients
map.Broadcast
: sends the received message to all connected clients except the sender. It locks the clients map using a mutex to prevent data races.
client.go
This file contains the implementation of the gRPC client. It has the StartGrpcClient
function which:
- Reads the server URL and the client’s ID from the console.
- Establishes a gRPC connection with the server and initiates the
Connect
RPC with the client’s User object. - Starts a go-routine that constantly reads messages from the server and prints them on the console.
- Reads messages from the console and calls the
Broadcast
RPC to send them to the server.
package client
import (
"bufio"
"context"
"fmt"
"os"
"strings"
"github.com/anik-ghosh-au7/grpc-messenger/gen/chat"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func StartGrpcClient() error {
reader := bufio.NewReader(os.Stdin)
fmt.Print("Enter the server url (example: localhost:8080): ")
serverURL, _ := reader.ReadString('\n')
serverURL = strings.TrimSpace(serverURL)
conn, err := grpc.Dial(serverURL, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return err
}
defer conn.Close()
client := chat.NewChatApiClient(conn)
fmt.Print("Enter your client ID: ")
clientID, _ := reader.ReadString('\n')
clientID = strings.TrimSpace(clientID)
user := &chat.User{Id: clientID}
stream, err := client.Connect(context.Background(), user)
if err != nil {
return err
}
go func() {
for {
message, err := stream.Recv()
if err != nil {
fmt.Println("Disconnected from server.")
return
}
fmt.Println(message.User.Id+": ", message.Content)
}
}()
for {
messageContent, _ := reader.ReadString('\n')
msg := &chat.Message{
User: user,
Content: strings.TrimSpace(messageContent),
}
client.Broadcast(context.Background(), msg)
}
}
Note: The code uses insecure transport credentials, which means the communication between the client and server isn’t encrypted.
main.go
This is the entry point for our Go application. It reads from the console and determines whether to start as a server or client:
- If the user enters
s
, it calls theStartGrpcServer
function from server package to start the server. - If the user enters
c
,it calls theStartGrpcClient
function from client package to start the client.
If any error occurs during these processes, it logs the error message and exits.
package main
import (
"bufio"
"fmt"
"log"
"os"
"strings"
"github.com/anik-ghosh-au7/grpc-messenger/client"
"github.com/anik-ghosh-au7/grpc-messenger/server"
)
func main() {
reader := bufio.NewReader(os.Stdin)
fmt.Print("Enter 's' to start as a server, or 'c' to start as a client: ")
option, _ := reader.ReadString('\n')
option = strings.TrimSpace(option)
switch option {
case "s":
err := server.StartGrpcServer()
if err != nil {
log.Fatalf("Failed to start the server: %v", err)
}
case "c":
err := client.StartGrpcClient()
if err != nil {
log.Fatalf("Failed to start the client: %v", err)
}
default:
fmt.Println("Invalid option. Exiting.")
}
}
Now that we are done with our Go app, let’s take a look at the node.js client.
We need to install the following packages inside the js_client
folder using npm
or any other node package managers.
@grpc/grpc-js
: This package provides a JavaScript implementation of gRPC for Node.js.@grpc/proto-loader
: This package is a utility for loading .proto files, which define gRPC services.
npm install --save @grpc/grpc-js @grpc/proto-loader
client.js
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');
const readline = require('readline'); // built-in node module for reading data from a stream
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
rl.question("Enter the server url (example: localhost:8080): ", (serverURL) => {
rl.question("Enter your client ID: ", (clientID) => {
const packageDefinition = protoLoader.loadSync('../proto/chat.proto', {
keepCase: true, // original field names will be used
longs: String, // represent long values as strings
enums: String, // represent enum values as strings
defaults: true, // set fields to default value if not set
oneofs: true // generates a property for oneof fields in the message to indicate which field is set
});
const chat = grpc.loadPackageDefinition(packageDefinition);
const client = new chat.main.ChatApi(
serverURL,
grpc.credentials.createInsecure()
);
const stream = client.Connect({ id: clientID });
stream.on('data', (message) => {
console.log(`${message.user.id}: ${message.content}`);
});
stream.on('error', (error) => {
console.log('Disconnected from server due to error:', error.message);
process.exit(1);
});
stream.on('end', () => {
console.log('Disconnected from server.');
process.exit();
});
rl.on('line', (line) => {
client.Broadcast(
{ user: { id: clientID }, content: line.trim() },
(err) => {
if (err) {
console.log('Error broadcasting message:', err);
}
}
);
});
});
});
This script contains the implementation of the gRPC chat client in Node.js
Here’s a high-level overview of what it does:
- Uses the
readline
module to create an interface for input and output in the console. - Prompts the user to enter a server URL and a client ID via the command line interface.
- The chat.proto file, which defines the
chat
service, is loaded using theproto-loader
module. This file includes theChatApi
service definition with two methods:Connect
andBroadcast
. These definitions are then loaded into achat
object. - Creates a gRPC client that connects to the specified server URL. It uses the
ChatApi
service defined in the chat.proto file for this connection. - Establishes a server-side streaming gRPC connection to the server by using the
Connect
method passing in the client ID. The server will maintain this open stream and send messages to the client whenever a new chat message is available. - Listeners are configured for the
data
,error
, andend
events of the stream. Thedata
event handler logs the user’s ID and message content. If an error occurs or the server closes the stream, theerror
andend
event handlers log a message and terminate the process. - A listener is established on the
line
event of thereadline
interface, which is triggered when the user enters text in the console. The event handler sends a chat message to the server using theBroadcast
method, including the client ID and message content.
That’s it, we have put together a chat service powered by gRPC, which effectively allows different programming languages to interact via the ChatApi
service.
To extend our chat service app and provide a REST endpoint to retrieve all the connected clients from the server, we can make use of the grpc-gateway package from the grpc-ecosystem.
It enables us to create a reverse-proxy server that translates RESTful JSON API calls into gRPC calls. This allows us to have both a REST endpoint and a gRPC service.
To get the protoc-gen-grpc-gateway
plugin, we can use the following command:
go get -u github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway
The first change we need to make is in our chat.proto file by introducing a new RPC method in the ChatApi
service. We will name it GetClients
, and it would return a array of client ids. The updated file would look like this:
syntax = "proto3";
package main;
option go_package = "./chat";
import "google/api/annotations.proto";
service ChatApi {
rpc Connect (User) returns (stream Message);
rpc Broadcast (Message) returns (Message);
rpc GetClients (Empty) returns (ClientList) {
option (google.api.http) = {
get: "/clients"
};
};
}
message User {
string id = 1;
}
message Message {
User user = 1;
string content = 2;
}
message ClientList {
repeated string client_ids = 1; // field can contain zero or more elements (like an array)
}
message Empty {}
The GetClients
method doesn’t accept any parameters (Empty
message) and returns a ClientList
. The option (google.api.http)
is a HTTP-to-gRPC mapping annotation, which specifies that this method can be invoked with a simple HTTP GET request to the /clients
endpoint.
We are also importing annotations.proto
which includes service configuration definitions used in HTTP mappings. The rules for these mappings are defined in a http.proto
file.
To download these protobuf files, we can get them either directly from the official Google APIs GitHub repository or use the following curl
commands:
curl https://raw.githubusercontent.com/googleapis/googleapis/master/google/api/annotations.proto > proto/google/api/annotations.proto
curl https://raw.githubusercontent.com/googleapis/googleapis/master/google/api/http.proto > proto/google/api/http.proto
Note: The files need to be in the following path: proto/google/api
inside our project directory.
Next, we need to recompile our updated chat.proto file using protoc, same as before, but this time with some added options.
protoc --proto_path=proto proto/*.proto --go_out=gen/ --go-grpc_out=gen/ -I . \
--grpc-gateway_out ./gen \
--grpc-gateway_opt logtostderr=true \
--grpc-gateway_opt generate_unbound_methods=true
The added options serves as follows:
-I
: This includes the current directory in the search path, allowing protoc to locate any imports specified relative to the current directory.--grpc-gateway_out ./gen
: This tells the compiler to generate reverse-proxy gateway code using the grpc-gateway plugin and saves it to the./gen
directory. This code enables the translation of HTTP/JSON requests into gRPC calls and vice versa, allowing seamless communication between the two protocols.--grpc-gateway_opt logtostderr=true
: This option tells the grpc-gateway to log errors to the standard error output (stderr).--grpc-gateway_opt generate_unbound_methods=true
: This option tells the grpc-gateway plugin to generate gateway code for all methods in the service, even if they don’t have custom HTTP annotations. These unbound methods will be accessible via HTTP POST requests to a path that matches the RPC’s full method name, e.g./package.Service/Method
Once the command is executed, a new file named chat.pb.gw.go will be generated in the gen/chat/
path. This file enables the exposure of our gRPC services over HTTP, making it possible to provide APIs that can be accessed by a broader range of clients, even those that do not have native support for gRPC.
The updated project structure would look like this:
.
├── proto
│ ├── chat.proto
│ └── google
│ └── api
│ ├── annotations.proto
│ └── http.proto
├── gen
│ └── chat // files generated by protoc will be stored here
│ ├── chat.pb.go
│ ├── chat_grpc.pb.go
│ └── chat.pb.gw.go
├── server
│ └── server.go
├── client
│ └── client.go
├── main.go
├── js-client
│ └── client.js
The next step would be update the server.go file to implement the GetClients
method for our ChatApi
service and to set up an HTTP server to host the REST API, which will act as a gRPC-HTTP gateway.
package server
import (
"context"
"log"
"net"
"net/http"
"sync"
"github.com/anik-ghosh-au7/grpc-messenger/gen/chat"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"google.golang.org/grpc"
)
type server struct {
chat.UnimplementedChatApiServer
clients map[string]chat.ChatApi_ConnectServer
mu sync.Mutex
}
func (s *server) Connect(user *chat.User, stream chat.ChatApi_ConnectServer) error {
s.mu.Lock()
s.clients[user.Id] = stream
s.mu.Unlock()
log.Println("Client Connected: ", user.Id)
<-stream.Context().Done()
s.mu.Lock()
delete(s.clients, user.Id)
s.mu.Unlock()
log.Println("Client Disconnected: ", user.Id)
return nil
}
func (s *server) Broadcast(ctx context.Context, message *chat.Message) (*chat.Message, error) {
s.mu.Lock()
defer s.mu.Unlock()
for id, clientStream := range s.clients {
if id != message.User.Id {
if err := clientStream.Send(message); err != nil {
log.Println("Error broadcasting message to", id, ":", err)
}
}
}
return message, nil
}
func (s *server) GetClients(ctx context.Context, empty *chat.Empty) (*chat.ClientList, error) {
s.mu.Lock()
defer s.mu.Unlock()
clientIDs := make([]string, 0, len(s.clients))
for id := range s.clients {
clientIDs = append(clientIDs, id)
}
return &chat.ClientList{
ClientIds: clientIDs,
}, nil
}
func StartGrpcServer() error {
s := &server{
clients: make(map[string]chat.ChatApi_ConnectServer),
}
go func() {
mux := runtime.NewServeMux()
chat.RegisterChatApiHandlerServer(context.Background(), mux, s)
log.Fatalln(http.ListenAndServe(":8000", mux))
}()
listener, err := net.Listen("tcp", ":8080")
if err != nil {
return err
}
srv := grpc.NewServer()
chat.RegisterChatApiServer(srv, s)
log.Println("Server started. Listening on port 8080.")
return srv.Serve(listener)
}
The GetClients
method will perform the following operations:
- Handles a
*chat.Empty
argument which serves as a placeholder since it does not require any real input parameters. - Ensures safe concurrent access to the
s.clients
map. - Creates a slice called
clientIDs
to hold the IDs of the connected clients. - Loops over the keys in the
s.clients
map and appends each ID to theclientIDs
slice. - Returns a pointer to a
chat.ClientList
object that contains theclientIDs
slice, andnil
to indicate no error.
The updated StartGrpcServer
function now starts a go-routine that creates a new ServeMux
from the runtime
package of grpc-gateway
. This ServeMux
acts as a multiplexer for routing HTTP requests to their respective handlers. The function then registers the gRPC gateway with this mux
, starts an HTTP server, and begins listening on port 8000.
If the gRPC server is up and running with the HTTP gateway properly configured, and the GetClients
method is implemented and mapped to the /clients
path, executing the following command will give us back a valid JSON response.
curl http://localhost:8000/clients
The last step is to update our client.js
to add an includeDirs
option to packageDefinition
. This instructs the protoLoader.loadSync
method where to find imported .proto
files.
const packageDefinition = protoLoader.loadSync('../proto/chat.proto', {
... all other fields
// this is to load proto files from google, otherwise we would see error:
// unresolvable extensions: 'extend google.protobuf.MethodOptions' in .google.api
includeDirs: ['../proto/google/api', '../proto'],
});
That’s it, we are finally done !!!
The gRPC framework is an excellent example of how technology can bridge language barriers, enhancing global communication and promoting a more interconnected world.
Note: The full source code can be found in this GitHub repository: https://github.com/anik-ghosh-au7/grpc-messenger.git