Using QT's native gRPC support
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.