gRPC is a high-performance library for transferring data using protocol buffers. This article introduces gRPC and protocol buffers, and then shows how this technology can be accessed in C++.
As applications move from the desktop to the Internet, it becomes more necessary to transfer data at high speed. One powerful technology for networked communication is gRPC, which is the focus of this article. gRPC isn't easy to understand, but once you've ascended the learning curve, you'll be able to quickly develop high-performance client-server applications.
The goal of gRPC development is to generate code for two systems: a client that requests one or more methods and a server capable of executing the methods. The overall process has three steps:
- Write a proto file that defines services and message types.
- Convert the proto file into traditional source code, such as Python, Java, or C++.
- Integrate the source code into client and server applications.
This article focuses on using gRPC to build a simple client-server application in C++. The client submits a request for the TimesTwo
method and provides an integer as the request object. The server executes its TimesTwo
routine and provides a response containing an integer twice as large as the integer in the request.
1. Overview of gRPC
In the early days of networking, engineers searched for a mechanism that would allow one system to execute routines on another system. This is called a remote procedure call (RPC), and many technologies were developed for this purpose, including the Common Object Request Broker Architecture (CORBA) and Java Remote Method Invocation (RMI).
In 2015, Google released a software package that implemented a new form of RPC communication. The package was called gRPC, and the client and server transfer data using high-performance data structures called protocol buffers. This section explores the basic operation of gRPC and protocol buffers, and then shows how proto files can be converted into C++ code.
1.1 Fundamentals of gRPC
To understand gRPC, it's important to be familiar with the different types of procedures that a client can request. gRPC refers to these procedures as methods, and a client can ask a server to perform four types of methods. The method type depends on the nature of the data in the client's request and the server's response.
- unary - the request contains simple data and the response contains simple data
- server streaming - the client makes a request and receives a stream of data in response
- client streaming - the client sends a stream of data to the server and receives a simple response
- bidirectional streaming - the client sends a stream of data to the server and receives a stream of data in response
Figure 1 illustrates how unary procedure calls work. The server provides three methods within a service, and the client makes a remote procedure call to execute one of the methods. As part of the RPC, the client sends a request to the server and the server replies with a response.
Figure 1: Client-Server Interaction in a gRPC Session
For a unary remote procedure call, the request-response process consists of six steps:
- The client calls a method (called a stub) that initiates the RPC.
- The server receives notification that the client intends to request an RPC, including a metadata description of the client.
- (Optional) The server can send a metadata description of itself to the client.
- The server waits for the full request message from the client.
- The server executes the service requested in the client's message.
- If the service executed successfully, the server will send its response to the client.
To implement this in code, developers need to specify the methods that can be accessed and the type of the data contained in the request and response. This is accomplished by writing special files called proto files.
1.2 Writing Proto Files
At minimum, a proto file in a gRPC application needs to specify three items:
- A service that provides one or more methods that can be executed by the server
- The type of the data contained in the client's request
- The type of the data contained in the server's response
In a proto file, each of these items is represented by a named code block. The basic structure of a code block is fairly simple:
type name {
...
}
The type identifier can be set to service
(for a service definition) or message
(for a data definition). The name identifier is a unique name for the service or message.
1.2.1 Service Definitions
To define a service, a code block must be written with the type identifier set to service
. Inside the curly braces, the block needs to define each method that can be invoked. Each method definition has four important properties:
- It starts with
rpc
. - It contains the name of the method to be invoked when the service executes.
- The method name is followed by the request data type in parentheses.
- The request data type is followed by
returns
and the response data type in parentheses.
An example will clarify how services and methods are defined. The following code defines a service named SimpleMath
that contains a definition for a method named TimesTwo
.
service SimpleMath {
rpc TimesTwo(ReqType) returns (RespType);
}
To initiate gRPC, the client accesses the SimpleMath
service and invokes a special routine (a stub) corresponding to the TimesTwo
method. When the client calls the TimesTwo
stub, the server's TimesTwo
routine will receive the request data given by the ReqType
type. After executing the routine, the server creates a response of type RespType
and sends it to the client.
A method can be followed by options that constrain how the server will execute the method. Each option line contains the option
keyword followed by the option's name in parentheses, which is set equal to a value. The following code demonstrates how a method option can be set:
service SimpleMath {
rpc TimesTwo(ReqType) returns (RespType) {
option(opt_name) = "opt_value";
}
}
Options are an advanced topic in gRPC, and lie beyond the scope of this article.
1.2.2 Message Type Definitions
Just as a service definition starts with service
, a message type definition is a block that starts with message
. In essence, a message is a data structure that can serve as a client's request or a server's response. A message type definition identifies the data fields contained in the message.
An example will clarify how this works. Suppose a client needs to provide a server with a string and an integer when submitting a request. The following code defines a ReqData
message type with both fields:
message ReqType {
string str = 1;
int32 num = 2;
}
Each field has three parts: a data type supported for protocol buffers, a name, and a field number that uniquely identifies the field. When assigning field numbers, there are five points to be aware of:
- Each field number must be an integer between 1 and 536,870,911.
- Once the application starts, field numbers shouldn't be changed.
- Field numbers don't have to be sequential, but they must be unique within a message.
- Frequently-used fields should be assigned numbers between 1 and 15.
- Field numbers 19,000 to 19,999 are reserved by the Protocol Buffers implementation.
A field in a message can be set to one of fifteen primitive data types, including string
and int32
. Table 1 lists these built-in types and their corresponding types in C++.
Table 1: Message Field Types and C++ Types
Built-in Type | C++ Type |
double | double |
float | float |
int32 | int32 |
int64 | int64 |
uint32 | uint32 |
uint64 | uint64 |
sint32 | int32 |
sint64 | int64 |
fixed32 | uint32 |
fixed64 | uint64 |
sfixed32 | int32 |
sfixed64 | int64 |
bool | bool |
string | string |
bytes | string |
In addition to these types, a message field can be set to an imported type, an enumerated type, or another message type.
1.2.3 Enumerated Type Definitions
An enumerated type is a custom type that defines a limited set of values. Structurally, an enumerated type definition is similar to a message type definition, but the block starts with enum
instead of message. As an example, the following code defines an enumerated type named Direction
:
enum Direction {
NORTH = 0;
EAST = 1;
SOUTH = 2;
WEST = 3;
}
As shown, each value needs to be set to a different positive integer. Unlike field numbers, a value in an enumerated type can be set to 0. In fact, gRPC requires that the first value in every enumerated type is set to 0. This serves as the type's default value.
Once an enumerated type is defined, message fields can be assigned to the type. As an example, the following message type definition contains a field named dir
of type Direction
:
message Example {
...
Direction dir = 7;
...
}
After conversion to C++, an enumerated type will take the form of a traditional C++ enum
.
1.2.4 Packages and Imports
In addition to the code blocks mentioned above, a proto file can have a statement that specifies a package name. This prevents name clashes between message types in different proto files. As an example, the following statement specifies that every type in the file belongs to the Foo
package:
package Foo;
One proto file can import types from another proto file using an import
statement. For example, the following statement imports the message types defined in the other.proto file:
import "other.proto";
Google provides several proto files that define useful types. If you want to use the Any
type in your proto file, you need to import google/protobuf/any.proto
.
1.3 Compiling Proto Files
Once the proto file is written, the next step is to convert it into source code that can run on the client and server. This conversion is commonly called compilation, and the name of the compiler is protoc. There are places on the web where you can download the binary, but the default compiler can only generate code for messages, and can't generate code for gRPC service definitions. This is very frustrating.
For this reason, I found it necessary to build the protobuf package from source code. This isn't pleasant, but it will produce plugins that enable the compiler to compile service definitions. The instructions for building the protobuf package are here.
Once you've built the compiler and its plugins, you can compile proto files by running the protoc
command in a terminal. This accepts options and the names of one or more proto files. Table 2 lists eleven of the options that can be set.
Table 2: Compile Options for the protoc Compiler
Option | Description |
--proto-path=PATH | Sets the path where the compiler should look for imports |
--descriptor_set_out=FILE | Generates a file containing all the input files |
--error_format=FORMAT | Identifies how error messages should be printed |
--fatal_warnings | Tells the compiler to treat warnings as errors |
--plugin=NAME=PATH | Tells the compiler where to access plugins |
--grpc_out=DIR | Sets the directory to contain generated gRPC code |
--cpp_out=DIR | Sets the directory to contain generated C++ code |
--java_out=DIR | Sets the directory to contain generated Java code |
--python_out=DIR | Sets the directory to contain generated Python code |
--ruby_out=DIR | Sets the directory to contain generated Ruby code |
--kotlin_out=DIR | Sets the directory to contain generated Kotlin code |
If you run protoc without any options, it won't generate any code. To tell the compiler what type of code to generate, you need to use at least one of the --language_out
options. If you want to compile message types in example.proto to C++, you can use the following command:
protoc --cpp_out=. example.proto
If you want to generate C++ code for a gRPC application, two additional options are required:
--grpc_out=DIR
- tells the compiler to compile service definitions and store the source files in the given directory --plugin=protoc-gen-grpc=$INSTALL/bin/grpc_cpp_plugin
- tells the compiler where to find the plugin needed to compile gRPC services
The second option is important to understand. Without assistance, protoc can't compile the service definitions in a proto file. This assistance must be provided with a plugin, and the plugin that enables protoc to compile gRPC services is called grpc_cpp_plugin. If you've built protobuf from the source code, you should find this file in the bin directory. Note that the --plugin
option must identify the full path of grcp_cpp_plugin.
On my system, the grpc_cpp_plugin file is in the /usr/local/bin directory. The following command tells protoc to read example.proto and generate code in C++:
protoc --plugin=protoc-gen-grpc=/usr/local/bin/grpc_cpp_plugin \
--grpc_out=. --cpp_out=. example.proto
If the compilation is successful, the compiler will create four files:
- example.pb.h - Declares generated message classes
- example.pb.cc - Implementation code for message classes
- example.grpc.pb.h - Declares generated service classes
- example.grpc.pb.cc - Implementation code for service classes
There's a lot of code in these source files, but most applications will only use a small portion of the generated functions and data structures. The following section discusses this in greater depth.
2. Exploring the Generated Code
In this section, we'll put aside the description of gRPC and proto files, and focus instead on C++ development. If you decompress the example.zip file attached to this article, you'll find a file named example.proto, and its content is given as follows:
syntax = "proto3";
service SimpleMath {
rpc TimesTwo(ReqType) returns (RespType);
}
message ReqType {
int32 num = 1;
}
message RespType {
int32 num = 1;
}
When this is compiled with protoc, the C++ classes for the ReqType
and RespType
messages will be declared in example.pb.h and the class representing the SimpleMath
service will be declared in example.grpc.pb.h. These files contain a bewildering amount of code, and the goal of this section is to provide insight into what the generated code accomplishes.
2.1 The Message Class
If you look through example.pb.h and example.pb.cc, you may be surprised that the ReqType
and RespType
structures require so much code. You don't need to know every function and data structure, but you should know that ReqType
and RespType
are both subclasses of the Message
class, which is a subclass of the MessageLite
class.
Each Message
subclass has a constructor that accepts no arguments. In addition, every Message
instance can access the eight functions listed in Table 3.
Table 3: Functions of the Message Class
Function | Description |
CopyFrom(const Message&) | Copies the specified message to the current message |
MergeFrom(const Message&) | Merges the specified message into the current message |
Swap(Message*) | Swaps the specified message with the current message |
Clear() | Clears all of the fields set in the message |
DebugString() | Returns a human-readable string that describes the message |
SerializeToString(string*) | Converts the message to a string |
ParseFromString(string_view) | Parses a string into a message |
ByteSizeLong() | The size of the message after serialization |
The MessageLite
class provides serialization functions that convert the message into different formats. It also provides parsing (deserialization) functions that convert the data back into a message. For example, an application can serialize a Message
to a string by calling SerializeToString
, and then get the Message
back by calling ParseFromString
.
Clients and servers will need to access individual fields of the message. To provide this access, the compiler generates functions named after the message's fields. In example.proto, ReqType
and RespType
both contain a single field named num
. As a result, both classes can access three functions:
num()
- returns the value of the num field set_num(int)
- sets the value of the num field clear_num()
- clears the value of the num field
The compiler will create similar functions for each field in the message. For the simple application discussed in this article, the only functions we'll need are num()
and set_num()
.
2.2 The SimpleMath Class
The example.grpc.pb.h and example.grpc.pb.cc files contain code generated for the gRPC service. The name of the service in example.proto is SimpleMath
, so the name of the class declared in example.grpc.pb.h is SimpleMath
. This class contains several nested classes, but for simple applications, only three are necessary:
StubInterface
- abstract class for stubs Stub
- concrete class for stubs Service
- defines the service and its methods
The goal of this section is to explain the StubInterface
, Stub
, and Service
classes. We'll also look at the Channel
and ServerBuilder classes.
2.2.1 The StubInterface and Stub Classes
As mentioned earlier, a client initiates a remote procedure call by invoking a stub routine that corresponds to a service method. In a C++ application, the client accesses the stub routine through the Stub
nested class associated with the service. The server doesn't access the Stub
class at all.
The Stub
class inherits from the abstract StubInterface
class, which is helpful when you need to code a custom stub. This is common for applications that perform unit testing. The functions of StubInterface
are all pure virtual, and each method of the service will have a corresponding function.
For example, the SimpleMath
service has a method named TimesTwo
, so the generated StubInterface
class defines the following function:
virtual ::grpc::Status TimesTwo(::grpc::ClientContext* context,
const ::ReqType& request, ::RespType* response) = 0;
Most clients don't need to access the StubInterface
class. Instead, they'll initiate RPC using a four-step process:
- Create a
Channel
that identifies the server's location and required credentials. - Use the
Channel
to create a new Stub
instance. - Create the request message to be passed to the server.
- Call the
Stub
function corresponding to the desired method and pass the request message.
For the second step, the client instantiates a Stub
by calling the static NewStub
function. The declaration of NewStub
is given as follows:
static std::unique_ptr<Stub> NewStub(
const std::shared_ptr< ::grpc::ChannelInterface>& channel,
const ::grpc::StubOptions& options = ::grpc::StubOptions());
The NewStub
function accepts two parameters: one that identifies a channel and one that sets options for the new Stub
instance. The second parameter can usually be left to its default value, but every client needs to provide a channel. I'll discuss this next.
2.2.2 Creating a Channel
Before the client can request a method, it needs to specify the location of the server and provide any required credentials. The gRPC API provides the Channel
class to hold this information, and a client can obtain a shared pointer to a new Channel
by calling CreateChannel
:
grpc::CreateChannel(const grpc::string &target,
const std::shared_ptr<ChannelCredentials> &creds)
The first parameter is a string composed of the server's IP address, a colon, and the port on which the server will be listening. For IPv4 addresses, the address must be preceded by ipv4
, as shown in the following code:
std::string target = "ipv4:192.168.0.1:54321";
Because gRPC communication is based on HTTP/2, applications may want to use common HTTP ports like 80 and 443. But any port is acceptable as long as the server will be listening for messages. If the client and server are running on the same system, the IP address can be set to localhost
.
The second parameter of CreateChannel
is a ChannelCredentials
object that the server can access to authenticate the client. Currently, gRPC enables authentication based on SSL/TLS, ALTS, and Google tokens. To demonstrate, the following code creates a ChannelCredentials
object for SSL-based authentication:
ChannelCredentials creds = grpc::SslCredentials(grpc::SslCredentialsOptions());
If no authentication is required, the second parameter can be set to grpc::InsecureChannelCredentials()
. This is shown in the following code, which creates a Channel
and uses it to create a new Stub
instance for the SimpleMath
service.
std::shared_ptr<grpc::Channel> ch =
grpc::CreateChannel("localhost:54321", grpc::InsecureChannelCredentials());
std::unique_ptr<SimpleMath::Stub> st = SimpleMath::NewStub(ch);
After creating the Stub
object, the client initiates RPC by calling a function that corresponds to a service method. A later section will demonstrate how this works in practice.
2.2.3 The Service Class
While the client is concerned with the Stub
nested class of SimpleMath
, the server is concerned with the Service
nested class. This is a subclass of grpc::Service
, and it contains a function for each method that can be invoked.
For example, if you compiled example.proto, you'll find the following class definition in example.grpc.pb.h:
class Service : public ::grpc::Service {
public:
Service();
virtual ~Service();
virtual ::grpc::Status TimesTwo(::grpc::ServerContext* context,
const ::ReqType* request, ::RespType* response);
};
As shown, the Service
class has a function, TimesTwo
, corresponding to the TimesTwo
method defined in example.proto. This function accepts a ServerContext
, a ReqType
object containing the client's request data, and a RespType
object to be populated by the server. The ServerContext
makes it possible to access advanced gRPC capabilities that lie beyond the scope of this article.
The server doesn't instantiate this Service
class, but instead creates a subclass of it, configuring how each method (TimesTwo
in the example) operates. Then it creates an instance of the subclass and passes it to a ServerBuilder
, which plays a major role in configuring the server's operation.
2.2.4 The ServerBuilder Class
After defining functions for the service methods, the server application needs to instantiate a ServerBuilder
to configure and manage the server's operation. Three functions of this class are particularly important:
AddListeningPort
- accepts the IP address, port, and credentials for communication (similar to the CreateChannel
function discussed earlier) RegisterService
- accepts the instance of the Service
subclass that defines the service methods BuildAndStart
- creates a Server
that will listen to the configured port and respond to client requests
After calling BuildAndStart
, the application can call the Server
's Wait
function, which blocks the application's thread until the gRPC server shuts down. The following section demonstrates how this can be called in practice.
3. The Example Project
At this point, you should have a solid understanding of gRPC operation and a basic understanding of the C++ code needed by the client and server. This section walks through the coding and compilation of two executables named client
and server
. The server
executable listens for requests on Port 54321 and the client
executable sends a request for the TimesTwo
method on Port 54321. Both executables are configured to run on the same system.
3.1 The Client Application
The code in client.cpp performs five operations:
- Creates a
Channel
that identifies the server's IP address and port. - Creates a
Stub
for the SimpleMath
service. - Creates a request object containing the number 7.
- Calls a
Stub
function to submit a request for the TimesTwo
method. - Prints the content of the response.
The content of client.cpp is given as follows:
int main() {
std::shared_ptr<grpc::Channel> ch = grpc::CreateChannel("localhost:54321",
grpc::InsecureChannelCredentials());
std::unique_ptr<SimpleMath::Stub> stub = SimpleMath::NewStub(ch);
ReqType request;
request.set_num(7);
grpc::ClientContext ctx;
RespType response;
grpc::Status status = stub->TimesTwo(&ctx, request, &response);
if (status.ok()) {
std::cout << "The result is " << response.num() << std::endl;
} else {
std::cout << status.error_code() << ": "
<< status.error_message() << std::endl;
}
return 0;
}
As shown, the client executes the server's method by calling the stub's TimesTwo
function. This accepts three parameters: a ClientContext
, the request object, and the response object. The ClientContext
enables the client to provide metadata, set authentication parameters, assign deadlines, and configure compression. These capabilities lie beyond the scope of this article.
TimesTwo
returns a Status
object that identifies whether the RPC call executed successfully. If the call was successful, the client will print the content of the response. If not, it will print the error code and error message.
For example, if the server doesn't respond at all, the error code will be 14. The printed message on my system is given as follows:
14: failed to connect to all addresses; last error: UNKNOWN: ipv4:127.0.0.1:54321:
Failed to connect to remote host: Connection refused
3.2 The Server Application
The server code is more complex because it needs to define a subclass of the Service
nested class of SimpleMath
. This subclass is called SimpleMathImpl
, and the code is given as follows:
class SimpleMathImpl final : public SimpleMath::Service {
grpc::Status TimesTwo(grpc::ServerContext* context, const ReqType* req,
RespType* resp) override {
resp->set_num(req->num() * 2);
return grpc::Status::OK;
}
};
The subclass needs to provide code for each method function in the service. The only method in the SimpleMath
service is TimesTwo
, so the subclass provides code for a function namedTimesTwo
. This accepts a ServerContext
, a request object, and a response object. The server accesses the request's value by calling req->num()
, multiplies the value by 2, and sets the response's num
field equal to the result by calling resp->set_num
.
When the server application executes, its main
function performs five operations:
- Creates a
SimpleMathImpl
instance and a ServerBuilder
instance. - Sets the server's IP address and port by calling the
AddListeningPort
function of the ServerBuilder
. - Registers the
Service
subclass (SimpleMathImpl
) by calling the RegisterService
function of the ServerBuilder
. - Obtains a
Server
instance by calling the BuildAndStart
function of the ServerBuilder
. - Begins port listening by calling the Wait function of the new Server instance.
The following code shows how these steps are implemented in server.cpp:
int main() {
SimpleMathImpl service;
grpc::ServerBuilder builder;
builder.AddListeningPort("localhost:54321", grpc::InsecureServerCredentials());
builder.RegisterService(&service);
std::unique_ptr<grpc::Server> server(builder.BuildAndStart());
server->Wait();
return 0;
}
When the Wait
function of the Server
is called, the executable will block until the server is halted. For this reason, it's a good idea to run this code in a separate process the client initiates an RPC call.
3.3 Building and Execution
This article's example project relies on CMake to manage the build process, and the build instructions are given in CMakeLists.txt. To build the client and server executables, open a terminal and change to the directory containing CMakeLists.txt. Then enter three commands:
mkdir build
cd build
cmake ..
If CMake can find the packages required for the build, the last command will generate the necessary build files. Once this is done, you can build the executables with the following command:
cmake --build .
The build process takes time because there are several dependencies that need to be linked into the executables. To be specific, the gRPC package relies on numerous Abseil libraries. It took me hours to figure out which libraries were required and the order in which they needed to be listed in the build.
If the compilation finishes successfully, you'll have two new executables (client
and server
) in the build folder. You can launch the server in a separate process with the following command:
./server &
Then you can run the client with the following command:
./client
The client will invoke the server's TimesTwo
routine through the stub, and if the routine executes successfully, the client will receive the server's response and print the following message:
The result is 14
Though trivially simple, the application provides a clear idea of how client-server applications can be coded with gRPC.
4. History
This article was initially submitted on 5/27/2024.