Published:

Jarle Aase

Using QT's native gRPC support

bookmark 6 min read

I wrote the first 10 parts of this series in 2023. In December 2023 I started working on an application I have been thinking about for a decade. It's a Personal Organizer using the "Getting Things Done" approach + some other ideas. The application is designed to work on PC and Mobile, using a server to keep track of everything. The idea is that I plan my time using the PC's comfortable big monitor and keyboard, and then switch to a laptop or mobile phone when I'm on the road. So, basically it use an event-driven MVC pattern where the model resides on a server. gRPC is perfect for something like this. I can use simple RPC methods to add and change data, and a stream from the server with all the changes from all the active client devices.

I am just a single guy, with no VC funding or big corporation sponsoring my work. So I need to re-use the code for the UI application on PC and Mobile. For this, QT is great. It let me write a C++ UI application that can run on PC's (Linux, MacBook, Windows) and Mobile (Android, IOS) with just minor differences to adjust to the different platforms.

In the past I have played with QT and gRPC. That was before I invested some time in understanding how to work efficiently with gRPC - but the biggest pain-point was that QT use QString while gRPC use std::string. So in addition to handle the communication, I also had to convert the data in both directions. No fun at all!

What I discovered in December 2023 was that QT was working on adding gRPC support to their tooling. They already had their own code generator and CMake macros to serialize protobuf directly to C++ classes, using their familiar classes, like QString and QList. It was pretty much undocumented at the time. But I with a little help from the "Qt Forum", I was able to use it.

In June 2024, they released QT 6.8-beta1, with a much more mature version of the gRPC tooling. If you use gRPC with your QT app, I would definitely recommend that you take a closer look at it. It's really great, and much simpler to use than the standard protoc generated code.

The protobuf classes they generate are derived from QObject. Each property is a full property with getters and setters and signals to notify changes. They can be used directly in QML. In edit dialogs, in my own code, I now get a instance of data received from the server, do changes to it, and send it back to my C++ code who after some validation sends it back to the server. It makes it really convenient and fast to write the code for the apps.

So, lets take a closer look at how it works. I have written a new example for the "Route Guide" interface in QT, using their gRPC support. The application has a very simple C++ class; ServerComm, and a simple QML UI.

Lets start with CMake

First we must tell CMake what modules we will use. For gRPC, we need Protobuf, ProtobufQuick and Grpc.

 1
 2cmake_minimum_required(VERSION 3.16)
 3
 4set(CMAKE_CXX_STANDARD 20)
 5set(CMAKE_CXX_STANDARD_REQUIRED ON)
 6
 7project(QtGrpcExample VERSION 0.1.0 LANGUAGES CXX)
 8
 9find_package(Qt6 6.8 REQUIRED COMPONENTS Core Gui Quick QuickControls2 Protobuf ProtobufQuick Grpc Concurrent)
10
11qt_policy(
12    SET QTP0001 NEW
13)
14
15qt_standard_project_setup()
16
17set (protofile "${CMAKE_CURRENT_SOURCE_DIR}/../grpc/route_guide.proto")
18
19qt_add_protobuf(RouteGuideLib
20    QML
21    QML_URI routeguide.pb
22    PROTO_FILES ${protofile}
23)
24
25qt_add_grpc(GrpcClient CLIENT
26    PROTO_FILES ${protofile}
27)
28
29qt_add_executable(${PROJECT_NAME}
30    main.cpp
31)
32
33target_include_directories(${PROJECT_NAME}
34    PRIVATE
35    $<BUILD_INTERFACE:${FUN_ROOT}/include>
36    $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}>
37    $<BUILD_INTERFACE:${CMAKE_CURRENT_BINARY_DIR}>
38    $<BUILD_INTERFACE: ${CMAKE_BINARY_DIR}/generated-include>
39    )
40
41add_dependencies(${PROJECT_NAME}
42    logfault
43)
44
45qt_add_qml_module(${PROJECT_NAME}
46    URI appExample
47    VERSION ${VERSION}
48    QML_FILES
49        Main.qml
50    RESOURCES
51    SOURCES
52        ServerComm.h
53        ServerComm.cpp
54)
55
56
57add_dependencies(${PROJECT_NAME} logfault GrpcClient RouteGuideLib)
58
59target_link_libraries(GrpcClient
60    PRIVATE
61    RouteGuideLib
62    Qt6::Core
63    Qt6::Protobuf
64    Qt6::Grpc
65)
66
67target_link_libraries(${PROJECT_NAME}
68    PRIVATE
69        GrpcClient
70        RouteGuideLib
71        Qt6::Core
72        Qt6::Concurrent
73        Qt6::Gui
74        Qt6::Quick
75        Qt6::QuickControls2
76        Qt6::Protobuf
77        Qt6::ProtobufQuick
78        Qt6::Grpc
79)
80
81qt_import_qml_plugins(${PROJECT_NAME})
82qt_finalize_executable(${PROJECT_NAME})
83

Note the new qt_add_protobuf and qt_add_grpc macros. For the app, we link with the targets these create.

In the main.cpp file, we just start the QML engine, like in any other QML application.

 1
 2    QQmlApplicationEngine engine;
 3    engine.loadFromModule("appExample", "Main");
 4    if (engine.rootObjects().isEmpty()) {
 5        LOG_ERROR << "Failed to initialize engine!";
 6        return -1;
 7    }
 8
 9    return app.exec();
10

The ServerComm class is very simple. It's basically a naive QML enabled class with a few properties and a few methods we can call from QML to test the four gRPC methods in "Route Guide".

 1
 2class ServerComm : public QObject
 3{
 4    Q_OBJECT
 5    QML_ELEMENT
 6    QML_SINGLETON
 7
 8    Q_PROPERTY(QString status READ status NOTIFY statusChanged)
 9    Q_PROPERTY(bool ready READ ready NOTIFY readyChanged)
10
11public:
12    ServerComm(QObject *parent = {});
13
14    /*! Start the gRPC client.
15    * This method is called from QML.
16    *
17    * We can call it again to change the server address or for example
18    * if the server restarted.
19    */
20    Q_INVOKABLE void start(const QString& serverAddress);
21
22    /*! Call's GetFeature on the server */
23    Q_INVOKABLE void getFeature();
24    Q_INVOKABLE void listFeatures();
25    Q_INVOKABLE void recordRoute();
26    Q_INVOKABLE void sendRouteUpdate();
27    Q_INVOKABLE void finishRecordRoute();
28
29    Q_INVOKABLE void routeChat();
30    Q_INVOKABLE void sendChatMessage(const QString& message);
31    Q_INVOKABLE void finishRouteChat();
32

Then I have added a simple template to make calling normal (unary) gRPC methods even simpler.

 1
 2// Simple template to hide the complexity of calling a normal gRPC method.
 3// It takes a method to call with its arguments and a functor to be called when the result is ready.
 4template <typename respT, typename callT, typename doneT, typename ...Args>
 5void callRpc(callT&& call, doneT && done, Args... args) {
 6    auto exec = [this, call=std::move(call), done=std::move(done), args...]() {
 7        auto rpc_method = call(args...);
 8        rpc_method->subscribe(this, [this, rpc_method, done=std::move(done)] () {
 9                std::optional<respT> rval = rpc_method-> template read<respT>();
10                    done(rval);
11            },
12            [this](QGrpcStatus status) {
13                LOG_ERROR << "Comm error: " << status.message();
14            });
15    };
16
17    exec();
18}
19

Finally, the class has some members for the gRPC stream (connection) and two of the streams.

1
2routeguide::RouteGuide::Client client_;
3std::shared_ptr<QGrpcClientStream> recordRouteStream_;
4std::shared_ptr<QGrpcBidirStream> routeChatStream_;
5

In the constructor, we subscribe for comm errors from gRPC.

1
2ServerComm::ServerComm(QObject *parent)
3    : QObject(parent)
4{
5    connect(&client_, &routeguide::RouteGuide::Client::errorOccurred,
6            this, &ServerComm::errorOccurred);
7
8}
9

In the start() function we prepare to connect the client to the gRPC server we specify in the UI.

We could have added properties to the channelOptions below, for example, for authentication or session information.

 1
 2void ServerComm::start(const QString &serverAddress)
 3{
 4    auto channelOptions = QGrpcChannelOptions{};
 5
 6    client_.attachChannel(std::make_shared<QGrpcHttp2Channel>(
 7        QUrl(serverAddress, QUrl::StrictMode),
 8        channelOptions));
 9    LOG_INFO << "Using server at " << serverAddress;
10
11    setReady(true);
12    setStatus("Ready");
13}
14

One nice thing with the the code above, is that it can be called again and again if you lost the connection with the server, or if you want to connect to another server.

Note that we get no errors if the server address is invalid or there are other problems, until we call a gRPC method.

Below is GetFeature. It is async and the lambda is called in the main thread when the RPC call is complete.

 1
 2void ServerComm::getFeature()
 3{
 4    routeguide::Point point;
 5    point.setLatitude(1);
 6    point.setLongitude(2);
 7
 8    callRpc<routeguide::Feature>([&] {
 9        LOG_DEBUG << "Calling GetFeature...";
10        return client_.GetFeature(point); // Call the gRPC method
11    }, [this](const std::optional<routeguide::Feature>& feature) {
12        // Handle the result
13        if (feature) {
14            LOG_DEBUG << "Got Feature!";
15            setStatus("Got Feature: " + feature->name());
16        } else {
17            LOG_DEBUG << "Failed to get Feature!";
18            setStatus("Failed to get Feature");
19        }
20    });
21}
22

To use a stream from the server, we first create an async stream by calling the appropriate gRPC method. Then we subscribe to QGrpcServerStream::messageReceived and QGrpcServerStream::finished.

 1
 2void ServerComm::listFeatures()
 3{
 4    // Update the status in the UI.
 5    setStatus("...\n");
 6
 7    // Prepare some data to send to the server.
 8    routeguide::Rectangle rect;
 9    rect.hi().setLatitude(1);
10    rect.hi().setLatitude(2);
11    rect.lo().setLatitude(3);
12    rect.lo().setLatitude(4);
13
14    // The stream is owned by client_.
15    auto stream = client_.ListFeatures(rect);
16
17    // Subscribe for incoming messages.
18    connect(stream.get(), &QGrpcServerStream::messageReceived, [this, stream=stream.get()] {
19        LOG_DEBUG << "Got message signal";
20        if (stream->isFinished()) {
21            LOG_DEBUG << "Stream finished";
22            emit streamFinished();
23            return;
24        }
25
26        if (const auto msg = stream->read<routeguide::Feature>()) {
27            emit receivedMessage("Got feature: " + msg->name());
28            setStatus(status_ + "Got feature: " + msg->name() + "\n");
29        }
30    });
31
32    // Subscribe for the stream finished signal.
33    connect (stream.get(), &QGrpcServerStream::finished, [this] {
34        LOG_DEBUG << "Stream finished signal.";
35        emit streamFinished();
36    });
37}
38

For the outgoing stream, we start by creating it by calling the appropriate gRPC method.

We also connect to QGrpcClientStream::finished to get the message the server sends to us after we have told it that we are done sending messages.

 1
 2void ServerComm::recordRoute()
 3{
 4    // The stream is owned by client_.
 5    auto stream = client_.RecordRoute(routeguide::Point());
 6    recordRouteStream_ = stream;
 7
 8    setStatus("Send messages...\n");
 9
10    connect(stream.get(), &QGrpcClientStream::finished, [this, stream=stream.get()] {
11        LOG_DEBUG << "Stream finished signal.";
12
13        if (auto msg = stream->read<routeguide::RouteSummary>()) {
14            setStatus(status_ + "Finished trip with " + QString::number(msg->pointCount()) + " points\n");
15        }
16    });
17}
18

Then we need a method that out UI can call to send one message to the server.

1
2void ServerComm::sendRouteUpdate()
3{
4    routeguide::Point point;
5    point.setLatitude(1);
6    point.setLongitude(2);
7    recordRouteStream_->writeMessage(point);
8}
9

And finally for RouteUpdate, we need a method to tell the server that we are done.

1
2void ServerComm::finishRecordRoute()
3{
4    recordRouteStream_->writesDone();
5}
6

For RouteChat, we combine the pattern for the last two methods. We create a bidirectional stream by calling the appropriate gRPC method. Then we subscribe for incoming messages and the finished signal. For outgoing messages and to end the chat, we implement two methods as above.

First we create the stream and subscribe the the relevant events:

 1
 2void ServerComm::routeChat()
 3{
 4    routeChatStream_ = client_.RouteChat(routeguide::RouteNote());
 5
 6    connect(routeChatStream_.get(), &QGrpcBidirStream::messageReceived, [this, stream=routeChatStream_.get()] {
 7        if (const auto msg = stream->read<routeguide::RouteNote>()) {
 8            emit receivedMessage("Got chat message: " + msg->message());
 9            setStatus(status_ + "Got chat message: " + msg->message() + "\n");
10        }
11    });
12
13    connect(routeChatStream_.get(), &QGrpcBidirStream::finished, [this] {
14        LOG_DEBUG << "Stream finished signal.";
15        emit streamFinished();
16    });
17}
18

Then we add a method to send a message.

1
2void ServerComm::sendChatMessage(const QString& message)
3{
4    routeguide::RouteNote note;
5    note.setMessage(message);
6    routeChatStream_->writeMessage(note);
7}
8

And finally we add a message to tell the server that we are done chatting with her.

1
2void ServerComm::finishRouteChat()
3{
4    routeChatStream_->writesDone();
5}
6

The error handler in our implantation is quite simple. It just logs the error, set the status text in the UI and triggers a signal to make the UI disable the data buttons.

1
2void ServerComm::errorOccurred(const QGrpcStatus &status)
3{
4    LOG_ERROR << "errorOccurred: Call to gRPC server failed: " << status.message();
5    setStatus(QString{"Error: Call to gRPC server failed: "} + status.message());
6    setReady(false);
7}
8

That's all for basic use. Much simpler, in my opinion, than the interfaces and stubs generated by protoc.

The full code for the QT project is here: qt_client

For a more extensive example of how I use gRPC in a real QT Desktop/Mobile application, you can look at Next-app UI.