Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C++

Implementing gRPC with C++

4.94/5 (7 votes)
27 May 2024CPOL20 min read 6.8K   192  
This article explains how to code applications that rely on gRPC to transfer data using protocol buffers.
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:

  1. Write a proto file that defines services and message types.
  2. Convert the proto file into traditional source code, such as Python, Java, or C++.
  3. 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.

  1. unary - the request contains simple data and the response contains simple data
  2. server streaming - the client makes a request and receives a stream of data in response
  3. client streaming - the client sends a stream of data to the server and receives a simple response
  4. 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.

Client-Server Communication in gRPC

Figure 1: Client-Server Interaction in a gRPC Session

For a unary remote procedure call, the request-response process consists of six steps:

  1. The client calls a method (called a stub) that initiates the RPC.
  2. The server receives notification that the client intends to request an RPC, including a metadata description of the client.
  3. (Optional) The server can send a metadata description of itself to the client.
  4. The server waits for the full request message from the client.
  5. The server executes the service requested in the client's message.
  6. 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:

  1. Each field number must be an integer between 1 and 536,870,911.
  2. Once the application starts, field numbers shouldn't be changed.
  3. Field numbers don't have to be sequential, but they must be unique within a message.
  4. Frequently-used fields should be assigned numbers between 1 and 15.
  5. 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:

  1. example.pb.h - Declares generated message classes
  2. example.pb.cc - Implementation code for message classes
  3. example.grpc.pb.h - Declares generated service classes
  4. 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:

C++
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:

  1. Create a Channel that identifies the server's location and required credentials.
  2. Use the Channel to create a new Stub instance.
  3. Create the request message to be passed to the server.
  4. 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:

C++
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:

C++
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:

C++
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:

C++
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.

C++
// Create the Channel
std::shared_ptr<grpc::Channel> ch = 
    grpc::CreateChannel("localhost:54321", grpc::InsecureChannelCredentials());

// Create the Stub
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:

C++
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:

  1. Creates a Channel that identifies the server's IP address and port.
  2. Creates a Stub for the SimpleMath service.
  3. Creates a request object containing the number 7.
  4. Calls a Stub function to submit a request for the TimesTwo method.
  5. Prints the content of the response.

The content of client.cpp is given as follows:

C++
int main() {

    // Create the channel
    std::shared_ptr<grpc::Channel> ch = grpc::CreateChannel("localhost:54321",
        grpc::InsecureChannelCredentials());

    // Create the stub
    std::unique_ptr<SimpleMath::Stub> stub = SimpleMath::NewStub(ch);

    // Create the client message
    ReqType request;
    request.set_num(7);

    // Invoke the method
    grpc::ClientContext ctx;
    RespType response;
    grpc::Status status = stub->TimesTwo(&ctx, request, &response);

    // Check status
    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:

C++
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:

  1. Creates a SimpleMathImpl instance and a ServerBuilder instance.
  2. Sets the server's IP address and port by calling the AddListeningPort function of the ServerBuilder.
  3. Registers the Service subclass (SimpleMathImpl) by calling the RegisterService function of the ServerBuilder.
  4. Obtains a Server instance by calling the BuildAndStart function of the ServerBuilder.
  5. 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:

C++
int main() {

    SimpleMathImpl service;
    grpc::ServerBuilder builder;

    // Set the server's IP address and port
    builder.AddListeningPort("localhost:54321", grpc::InsecureServerCredentials());

    // Register the service
    builder.RegisterService(&service);

    // Obtain a Server instance
    std::unique_ptr<grpc::Server> server(builder.BuildAndStart());

    // Block until the Server is halted
    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.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)