Published:

Jarle Aase

Implementing an async server with one message and one stream.

bookmark 7 min read

What we have covered in the previous parts are all of what some RPC frameworks can offer. One of the cool things with gRPC is that in addition to a request and a reply, it can also handle streams of messages. In this article we will look at an async server that implements three RPC calls:

So, added from the first iteration is how to deal with multiple request types, and a stream in one direction.

The proto file looks like:

 1syntax = "proto3";
 2package routeguide;
 3
 4// Interface exported by the server.
 5service RouteGuide {
 6  rpc GetFeature(Point) returns (Feature) {}
 7  rpc ListFeatures(Rectangle) returns (stream Feature) {}
 8  rpc RecordRoute(stream Point) returns (RouteSummary) {}
 9}
10
11message Point {
12  int32 latitude = 1;
13  int32 longitude = 2;
14}
15
16message Rectangle {
17  Point lo = 1;
18  Point hi = 2;
19}
20
21// A feature names something at a given point.
22message Feature {
23  string name = 1;
24  Point location = 2;
25}
26
27message RouteSummary {
28  int32 point_count = 1;
29  int32 feature_count = 2;
30  int32 distance = 3;
31  int32 elapsed_time = 4;
32}

You can see at the full proto-file with comments here.

Now, the first thing we must do is to generalize a bit. We'll try to keep the event-loop as simple as before, and we'll try to avoid copying and pasting code around to facilitate the various requests.

In order to use a request's implementation's this pointer as tag, and allow the event-loop to effortlessly call procees(), we'll start by creating a base-class for the requests. I have also added a reference to the server class, so we can easily pass information all the way from the command-line to the instance of a request-handler.

 1    /*! Base class for requests
 2    *
 3    *  In order to use `this` as a tag and avoid any special processing in the
 4    *  event-loop, the simplest approacch in C++ is to let the request implementations
 5    *  inherit form a base-class that contains the shared code they all need, and
 6    *  a pure virtual method for the state-machine.
 7    */
 8class RequestBase {
 9public:
10    RequestBase(UnaryAndSingleStreamSvc& parent,
11                ::routeguide::RouteGuide::AsyncService& service,
12                ::grpc::ServerCompletionQueue& cq)
13        : parent_{parent}, service_{service}, cq_{cq} {}
14
15    virtual ~RequestBase() = default;
16
17    // The state-machine
18    virtual void proceed(bool ok) = 0;
19
20    void done() {
21        // Ugly, ugly, ugly
22        LOG_TRACE << "If the program crash now, it was a bad idea to delete this ;)";
23        delete this;
24    }
25
26
27protected:
28    // The state required for all requests
29    UnaryAndSingleStreamSvc& parent_;
30    ::routeguide::RouteGuide::AsyncService& service_;
31    ::grpc::ServerCompletionQueue& cq_;
32    ::grpc::ServerContext ctx_;
33};

As you can see, it's quite simple. The only thing we need to implement in the derived classes is the constructor and proceed().

GetFeature

The implementation of the GetFeature RPC call, is almost exactly as it was in our first iteration.

We derive from the base class, declare our State enum and then we initialize with gRPC as before in the constructor.

 1    /*! Implementation for the `GetFeature()` RPC call.
 2    */
 3class GetFeatureRequest : public RequestBase {
 4public:
 5    enum class State {
 6        CREATED,
 7        REPLIED,
 8        DONE
 9    };
10
11    GetFeatureRequest(UnaryAndSingleStreamSvc& parent,
12                        ::routeguide::RouteGuide::AsyncService& service,
13                        ::grpc::ServerCompletionQueue& cq)
14        : RequestBase(parent, service, cq) {
15
16        // Register this instance with the event-queue and the service.
17        // The first event received over the queue is that we have a request.
18        service_.RequestGetFeature(&ctx_, &req_, &resp_, &cq_, &cq_, this);
19    }
20
21    ...
22
23private:
24    ::routeguide::Point req_;
25    ::routeguide::Feature reply_;
26    ::grpc::ServerAsyncResponseWriter<::routeguide::Feature> resp_{&ctx_};
27    State state_ = State::CREATED;

The only significant changes in proceed() is the call to create a new instance to deal with the next request. Since we have more than one type, I have refactored createNew() to be a template-method in the server class. We also call Clear() on the reply_` variable before re-use. That's required to not unintentionally leak information from a previous stream-message to a new stream-message.

1    ...
2    // Before we do anything else, we must create a new instance
3    // so the service can handle a new request from a client.
4    createNew<ListFeaturesRequest>(parent_, service_, cq_);
5    ...
6
7    // Prepare the reply-object to be re-used.
8    // This is usually cheaper than creating a new one for each write operation.
9    reply_.Clear();

Our new server implementation is the class UnaryAndSingleStreamSvc. It's quite similar to the class SimpleReqRespSvc from our first server-iteration. The event-loop is identical.

ListFeatures

Let's take a look at how the ListFeatures()'s request-handler ListFeaturesRequest is implemented.

 1 /*! Implementation for the `ListFeatures()` RPC call.
 2     *
 3     *  This is a bit more advanced. We receive a normal request message,
 4     *  but the reply is a stream of messages.
 5     */
 6    class ListFeaturesRequest : public RequestBase {
 7    public:
 8        enum class State {
 9            CREATED,
10            REPLYING,
11            FINISHING,
12            DONE
13        };
14
15        ListFeaturesRequest(UnaryAndSingleStreamSvc& parent,
16                            ::routeguide::RouteGuide::AsyncService& service,
17                            ::grpc::ServerCompletionQueue& cq)
18            : RequestBase(parent, service, cq) {
19
20            // Register this instance with the event-queue and the service.
21            // The first event received over the queue is that we have a request.
22            service_.RequestListFeatures(&ctx_, &req_, &resp_, &cq_, &cq_, this);
23        }
24
25        ...
26private:
27        ::routeguide::Rectangle req_;
28        ::routeguide::Feature reply_;
29        ::grpc::ServerAsyncWriter<::routeguide::Feature> resp_{&ctx_};
30        State state_ = State::CREATED;
31        size_t replies_ = 0;
32

As you may notice, it has an additional state, compared to the simpler class GetFeatureRequest. That's because it may send many messages in the response to a request, one at the time, until it send the finish message. It also have a different type for the reply, a ServerAsyncWriter in stead of a ServerAsyncResponseWriter. This allows us to write as many messages to the client over the reply-stream as we wish. Some servers, for example a "Breaking News" service, may never enter the Finish state.

As I promised before, the streams add complexity to the state-machine. This is the proceed() implementation for ListFeaturesRequest.

 1     // State-machine to deal with a single request
 2    // This works almost like a co-routine, where we work our way down for each
 3    // time we are called. The State_ could just as well have been an integer/counter;
 4    void proceed(bool ok) override {
 5        switch(state_) {
 6        case State::CREATED:
 7            if (!ok) [[unlikely]] {
 8                // The operation failed.
 9                // Let's end it here.
10                LOG_WARN << "The request-operation failed. Assuming we are shutting down";
11                return done();
12            }
13
14            // Before we do anything else, we must create a new instance
15            // so the service can handle a new request from a client.
16            createNew<ListFeaturesRequest>(parent_, service_, cq_);
17
18            state_ = State::REPLYING;
19            //fallthrough
20
21        case State::REPLYING:
22            if (!ok) [[unlikely]] {
23                // The operation failed.
24                LOG_WARN << "The reply-operation failed.";
25            }
26
27            if (++replies_ > parent_.config_.num_stream_messages) {
28                // We have reached the desired number of replies
29                state_ = State::FINISHING;
30
31                // *Finish* will relay the event that the write is completed on the queue, using *this* as tag.
32                resp_.Finish(::grpc::Status::OK, this);
33
34                // Now, wait for the client to be aware of use finishing.
35                break;
36            }
37
38            // This is where we have the request, and may formulate another answer.
39            // If this was code for a framework, this is where we would have called
40            // the `onRpcRequestListFeaturesOnceAgain()` method, or unblocked the next statement
41            // in a co-routine awaiting the next state-change.
42            //
43            // In our case, let's just return something.
44
45            // Prepare the reply-object to be re-used.
46            // This is usually cheaper than creating a new one for each write operation.
47            reply_.Clear();
48
49            // Since it's a stream, it make sense to return different data for each message.
50            reply_.set_name(std::string{"stream-reply #"} + std::to_string(replies_));
51
52            // *Write* will relay the event that the write is completed on the queue, using *this* as tag.
53            resp_.Write(reply_, this);
54
55            // Now, we wait for the write to complete
56            break;
57
58        case State::FINISHING:
59            if (!ok) [[unlikely]] {
60                // The operation failed.
61                LOG_WARN << "The finish-operation failed.";
62            }
63
64            state_ = State::DONE; // Not required, but may be useful if we investigate a crash.
65
66            // We are done. There will be no further events for this instance.
67            return done();
68
69        default:
70            LOG_ERROR << "Logic error / unexpected state in proceed()!";
71        } // switch
72    }
73

Reading trough the code above, keep in mind that the calls to Write() and Finish() are just initiating an asynchronous operation. The methods return immediately, and the result will arrive as new events on the queue when they are completed.

RecordRoute

Finally in this article, we will look at the implementation of a request with an incoming stream and a (one) normal reply. The proto-definition for this request looks like this:

1    rpc RecordRoute(stream Point) returns (RouteSummary) {}

As you can see from the implementation, it looks pretty similar to the previous one. We have a ::grpc::ServerAsyncReader instead of a ::grpc::ServerAsyncWriter, and we call RequestRecordRoute() to start the request-flow.

 1/*! Implementation for the `RecordRoute()` RPC call.
 2    *
 3    *  This is a bit more advanced. We receive a normal request message,
 4    *  but the reply is a stream of messages.
 5    */
 6class RecordRouteRequest : public RequestBase {
 7public:
 8    enum class State {
 9        CREATED,
10        READING,
11        FINISHING,
12        DONE
13    };
14
15    RecordRouteRequest(UnaryAndSingleStreamSvc& parent,
16                        ::routeguide::RouteGuide::AsyncService& service,
17                        ::grpc::ServerCompletionQueue& cq)
18        : RequestBase(parent, service, cq) {
19
20        // Register this instance with the event-queue and the service.
21        // The first event received over the queue is that we have a request.
22        service_.RequestRecordRoute(&ctx_, &reader_, &cq_, &cq_, this);
23    }
24
25    ...
26
27private:
28    ::routeguide::Point req_;
29    ::routeguide::RouteSummary reply_;
30    ::grpc::ServerAsyncReader< ::routeguide::RouteSummary, ::routeguide::Point> reader_{&ctx_};
31    State state_ = State::CREATED;

The state-machine is also similar to the previous example. The big difference is that we don't know how many messages to read before the client is done and we can reply.

We started by initiating the RecordRoute request in our constructor. Then, when we have a client calling this method and the state-machine gets its first invocation (in state CREATED), we initiate the first Read() operation. Then we basically loop by initiating a new read operation each time the state-machine gets called with ok == true. When ok != true, we initiate the Finish() operation, and when proceed() is called next, we call done() to delete this instance of the request object.

 1    void proceed(bool ok) override {
 2        switch(state_) {
 3        case State::CREATED:
 4            if (!ok) [[unlikely]] {
 5                // The operation failed.
 6                // Let's end it here.
 7                LOG_WARN << "The request-operation failed. Assuming we are shutting down";
 8                return done();
 9            }
10
11            // Before we do anything else, we must create a new instance
12            // so the service can handle a new request from a client.
13            createNew<RecordRouteRequest>(parent_, service_, cq_);
14
15            // Initiate the first read operation
16            reader_.Read(&req_, this);
17            state_ = State::READING;
18            break;
19
20        case State::READING:
21            if (!ok) [[unlikely]] {
22                // The operation failed.
23                // This is normal on an incoming stream, when there are no more messages.
24                // As far as I know, there is no way at this point to deduce if the false status is
25                // because the client is done sending messages, or because we encountered
26                // an error.
27                LOG_TRACE << "The read-operation failed. It's probably not an error :)";
28
29                // Initiate the finish operation
30
31                // This is where we have received the request, with all it's parts,
32                // and may formulate another answer.
33                // If this was code for a framework, this is where we would have called
34                // the `onRpcRequestRecordRouteDone()` method, or unblocked the next statement
35                // in a co-routine awaiting the next state-change.
36                //
37                // In our case, let's just return something.
38
39                reply_.set_distance(100);
40                reply_.set_distance(300);
41                reader_.Finish(reply_, ::grpc::Status::OK, this);
42                state_ = State::FINISHING;
43                break;
44            }
45
46            // This is where we have read a message from the request.
47            // If this was code for a framework, this is where we would have called
48            // the `onRpcRequestRecordRouteGotMessage()` method, or unblocked the next statement
49            // in a co-routine awaiting the next state-change.
50            //
51            // In our case, let's just log it.
52            LOG_TRACE << "Got message: longitude=" << req_.longitude()
53                        << ", latitude=" << req_.latitude();
54
55            // Prepare the reply-object to be re-used.
56            // This is usually cheaper than creating a new one for each read operation.
57            req_.Clear();
58
59            // *Read* will relay the event that the write is completed on the queue, using *this* as tag.
60            // Initiate the first read operation
61            reader_.Read(&req_, this);
62
63            // Now, we wait for the read to complete
64            break;
65
66        case State::FINISHING:
67            if (!ok) [[unlikely]] {
68                // The operation failed.
69                LOG_WARN << "The finish-operation failed.";
70            }
71
72            state_ = State::DONE; // Not required, but may be useful if we investigate a crash.
73
74            // We are done. There will be no further events for this instance.
75            return done();
76
77        default:
78            LOG_ERROR << "Logic error / unexpected state in proceed()!";
79        } // switch
80    }
81
82

As you may notice, we handle both a new message, and the end of the message-stream and the Finish in the State::READING: code-block.

In this code I don't really care about the data in the request or what we send in reply. The focus is entirely on the boilerplate code required in order to use the async interface for gRPC. If you implement something using async gRPC, your focus will probably be on the correct implementation of your RPC requests. That means that your code will probably be of a magnitude more complex than this. I would recommend that you add your own logic entirely in classes or modules dedicated to that, and call from those into the gRPC classes or from the gRPC classes into your interfaces. Even that will probably end up as significantly more complex code than what I present here.

The complete source code.