QtQuick Map Application (Part 3) : Creating communication between several instances
In this article, the final part in a series of three, our basic QtQuick application will be enhanced with a networking panel allowing it to connect to a remote server by entering an IP and port, and then allowing it to create and edit features, while broadcasting the changes to potential other connected clients.
As the application has grown larger, this article will be slightly different from the others in the series and will focus more on explaining important parts of the application rather than doing a step-by-step edit of the base project.
The application is structured into two different projects:
- QtQuickClient: our base application, restructured to make it a bit cleaner.
- QtQuickServer: a simplistic server to store and broadcast drawing data updates, designed as a console application.
At the end of this article, this is what you will be able to do:
ChangeLog
The changes from the previous articles are the following:
- The existing application is now part of a subfolder called QtQuickClient.
- The QML has been split into QML components corresponding to each panel:
- Icons have been moved to a resources folder.
Project Structure
Communications
In this section, we will detail how the server and client components interact with one another.
In essence, our communications can be summarized as maintaining a shared MemoryDataSet across several applications, using GeoJSON as exchange format.
Upon pressing the connect button in the GUI, after setting an IP and port, the socket will connect to the listening server. In return, the server will return all features it currently holds to the connecting client.
When a user creates a Feature using the different tools available through the GUI, the FeatureCreated event is fired. In the event handler, the client serializes the created Feature and sends it to the server.
When a user edits a Feature using the standard tools, the FeatureInteractedHandler
is triggered. In this handler, the client serializes the edited Feature to GeoJSON. The server updates its stored copy of the Feature based on its ID and then forwards the changes to other connected clients which are then able to update their local features.
Here, we choose to connect to tools’ events. It is also possible to connect directly to a MemoryDataSet‘s events, after setting its enableEvents property to
True
. It is a bit more conventional, especially in applications where features can be modified in other ways than using tools. However, this design also needs to disable the events whenever the updates come from the server, in order to avoid infinite loops.
Note that at least two more use-cases could be necessary here but have not been implemented: handling server disconnection & handling feature removal.
Additionally, client disconnection is supported only in a basic way.
Networking
Our components communicate over TCP, using QTcpSocket & QTcpServer. The communication design takes some inspiration from here.
Features are sent between server and clients as GeoJSON strings.
Server
The QtQuickServer console application is made of two classes. The server class is a wrapper around a QTcpServer and handles listening on a port, receiving and sending messages, as well as keeping a list of connected clients as a list of socket addresses. The backend class is connected to server signals and adds handling to the different messages as well as broadcasting any changes.
Client
The QtQuickClient has been extended with a client class which is a wrapper around a QTcpSocket. It connects to an IP and port & can send and receive messages. It is then connected to the controller class through slots and signals to synchronize the shared MemoryDataSet state with the server and across other connected clients.
JSON Serialization
With Carmenta Engine 5.16, it is possible to natively read GeoJSON, making it easier to exchange features on this format. The main serialization and de-serialization work is done in the GeoJSON class directly, namely the GeoJSON::toString
& GeoJSON::fromString
methods.
More information on the GeoJSON specification.
Networking Implementation Details
Networking Panel in QML
We can see in the QML for the networking panel that the client is connected to the panel, directly setting the IP (client.host
) and port (client.port
). We also ensure that the IP is properly formatted using a regular expression.
Item { Column { Row { spacing: 2 leftPadding: 4 rightPadding: 4 bottomPadding: 4 topPadding: 4 Text { text: "IP: " verticalAlignment: Text.AlignVCenter anchors.verticalCenter: ipTextField.verticalCenter width: 40 } TextField { id: ipTextField width: 150 validator: RegularExpressionValidator { regularExpression: /^((?:[0-1]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5]).){0,3}(?:[0-1]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])$/ } text: client.host onEditingFinished : { client.host = text } } } Row { id: portRow spacing: 2 leftPadding: 4 rightPadding: 4 bottomPadding: 4 Text { text: "Port: " verticalAlignment: Text.AlignVCenter anchors.verticalCenter: portTextField.verticalCenter width: 40 } TextField { id: portTextField text: client.port width: 150 inputMethodHints: Qt.ImhDigitsOnly onEditingFinished : { client.port = text } } } RowLayout { width: parent.width Button { id: btn_connect Layout.preferredWidth: 80 Layout.leftMargin: 4 Layout.topMargin: 6 text: "Connect" background: Rectangle { color: parent.enabled ? parent.down ? "#78C37F" : "#87DB8D" : "gray" border.color: "#78C37F" } onClicked: { client.onConnectClicked(); this.enabled = false; } } Button { id: btn_disconnect enabled: !btn_connect.enabled Layout.alignment: Qt.AlignRight Layout.rightMargin: 4 Layout.topMargin: 6 text: "Disconnect" Layout.preferredWidth: 80 background: Rectangle { color: parent.enabled ? parent.down ? "#DB7A74" : "#FF7E79" : "gray" border.color: "#DB7A74" } onClicked: { client.closeConnection(); btn_connect.enabled = true; } } } } }
In order to be available from the QML, the client class is registered as a context property in the main.cpp file. We also take this opportunity to connect signals and slots between the controller and the client. The IP and port are exposed as Q_PROPERTY, and we will have a closer look at the implementation for the connection and disconnection in the following sections. In main.cpp:
Client client{}; ctxt->setContextProperty("client", &client); QObject::connect(&Controller::instance(), &Controller::featureInserted, &client, &Client::pushMessageToServer); QObject::connect(&Controller::instance(), &Controller::featureChanged, &client, &Client::pushMessageToServer); QObject::connect(&client, &Client::messageReceived, &Controller::instance(), &Controller::onFeaturesReceived);
Finally, before moving on, let’s have a look at the resulting panel:
Networking with Qt
A client connects to a server by setting an IP and port and pressing the Connect button. The client application then sends a connection request through its socket and if the server is listening on the designated IP and port, it will reply with a list of existing features. If the server is not available, the connection will fail. In client.cpp:
void Client::onConnectClicked() { _socket.connectToHost(host, port); connect(&_socket, &QTcpSocket::readyRead, this, &Client::onReadyRead); }
Upon pressing the button, the code above is executed. The socket connects to the host and we bind a slot to the socket’s readyRead()
signal. This allows us to read incoming messages. The readyRead()
signal is emitted every time a new chunk of data has arrived. bytesAvailable()
then returns the number of bytes that are available for reading.
A message can thus be read in the following way, where onReadyRead()
is a slot connected to readyRead()
in client.cpp:
void Client::onReadyRead() { QDataStream in(&_socket); for (;;) { if (!m_nNextBlockSize) { if (_socket.bytesAvailable() < sizeof(quint16)) { break; } in >> m_nNextBlockSize; } if (_socket.bytesAvailable() < m_nNextBlockSize) { break; } QString str; in >> str; if (str == "0") { str = "Connection closed"; closeConnection(); } emit messageReceived(str); // for handling by the application m_nNextBlockSize = 0; } }
Conversely, it is possible to send message through the socket in the following way:
void Client::pushMessageToServer(QString msg) { if (_socket.state() != QAbstractSocket::ConnectedState) return; QByteArray arrBlock; QDataStream out(&arrBlock, QIODevice::WriteOnly); out << quint16(0) << msg; out.device()->seek(0); out << quint16(arrBlock.size() - sizeof(quint16)); _socket.write(arrBlock); }
Finally, in order for the connection to be established, the server needs to be listening on a given IP address and port and it also needs to accept the incoming connection request and add the socket to a list of clients. This is done in the following way in server.cpp:
void Server::startListening() { if (!_tcpServer.listen(QHostAddress::Any, 6547)) { qDebug() << "Error! The port is taken by some other service"; } else { connect(&_tcpServer, &QTcpServer::newConnection, this, &Server::onNewConnection); } } void Server::onNewConnection() { QTcpSocket* clientSocket = _tcpServer.nextPendingConnection(); connect(clientSocket, &QTcpSocket::disconnected, clientSocket, &QTcpSocket::deleteLater); connect(clientSocket, &QTcpSocket::readyRead, this, &Server::readClient); connect(clientSocket, &QTcpSocket::disconnected, this, &Server::onDisconnected); _connectedClients << clientSocket; emit clientConnected(clientSocket); }
Geospatial Features Handling
On the Server Side
Our server handles two different types of interaction:
- It accepts client connections and sends back a list of existing features as a GeoJSON FeatureCollection.
- Upon receiving a GeoJSON Feature, the server updates its own cache and broadcasts the information to other connected clients.
Below is how our server notifies connecting clients of existing features in backend.cpp:
void Backend::onClientConnected(QTcpSocket* clientSocket) { // Send all feature to connecting client qDebug() << "Client connected"; // Collect all cached Features auto features = _dataSet->getFeatures(); FeatureCollectionPtr feats = new FeatureCollection(); while(features->moveNext()) { auto feature = features->current(); feats->add(features->current()); } // send them to client if (feats->size() > 0) { // serialize Feautres as GeoJSON QString json = QString(Carmenta::Engine::GeoJSON::toString(feats, false).c_str()); _server.pushToClient(clientSocket, json); } }
And upon receiving a message, the server tries to parse it as a GeoJSON Feature and handles it appropriately.
void Backend::onNewMessage(QTcpSocket* client, QString msg) { //handle message here try { auto features = Carmenta::Engine::GeoJSON::fromString(msg.toStdString().c_str()); for(auto feature = features->begin(); feature != features->end(); ++feature) { FeaturePtr f = *feature; // get feature quuid std::string featId = getAttributeValueString(f->attributes(), "geojson_id", ""); QUuid uuid = QUuid::fromString(QString(featId.c_str())); // Existing feature received, update the cache first if (_cache.contains(uuid)) { auto localFeature = _cache[uuid]; _dataSet->remove(localFeature->id()); } _dataSet->insert(f); _cache[uuid] = f; } // Broadcast the feature update/insertion to other clients _server.broadcastToClients(msg, client); } catch (...) { qDebug() << "an error occured processing : " << msg; } }
On the Client Side
Our client can both send messages after triggering tools’ events as well as receive messages from our server.
The controller contains the implementations of tool event handlers. The typical flow is to receive the event, serialize the feature contained in the event argument and push the message to server. In controller.cpp:
void Controller::featureCreatedHandler( const Carmenta::Engine::EngineObjectPtr&, const Carmenta::Engine::FeatureCreatedEventArgs& e, void* data) { auto controller = static_cast<Controller*>(data); controller->swapToStandardTool(); // Add feature to our local cache auto localFeature = e.feature(); std::string featId = getAttributeValueString(e.feature()->attributes(), "geojson_id", ""); QUuid uuid = QUuid::fromString(QString(featId.c_str())); controller->_cache.insert(uuid, localFeature); // Serialize created feature auto featureCollection = new Carmenta::Engine::FeatureCollection(); featureCollection->add(localFeature); auto geojsonF = Carmenta::Engine::GeoJSON::toString(featureCollection, false); // Emit signal that will push serialized feature to server emit controller->featureInserted(QString(geojsonF.c_str())); } void Controller::featureInteractedHandler( const EngineObjectPtr&, const InteractedEventArgs& e, void* data ) { auto controller = static_cast<Controller*>(data); // Convert features to geoJSON auto geojsonFeature = GeoJSON::toString(e.features(), false); // Emit signal that will push serialized feature to server emit controller->featureChanged(QString(geojsonFeature.c_str())); }
In order to avoid duplicate features between clients we tag each feature created with a unique id. For our sample, we use QUuid::createUuid() from the Qt API to create a unique id for us. Our createTools are updated so each time we call them a new QUuid is created:
void Controller::swapToCreateTool(Carmenta::Engine::ToolCreateMode& mode, Carmenta::Engine::CreateToolParametersPtr toolParameters) { // Setting the correct mode on our create tool static_cast<CreateTool*>(_createTool.get())->createMode(mode); static_cast<CreateTool*>(_createTool.get())->parameters(toolParameters); static_cast<CreateTool*>(_createTool.get())->attributes()->set("geojson_id", QUuid::createUuid().toString().toStdString().c_str()); static_cast<CreateTouchTool*>(_createTouchTool.get())->createMode(mode); static_cast<CreateTouchTool*>(_createTouchTool.get())->parameters(toolParameters); static_cast<CreateTouchTool*>(_createTouchTool.get())->attributes()->set("geojson_id", QUuid::createUuid().toString().toStdString().c_str()); // Setting the create tool on the map control mapControl->tool(_createTool); mapControl->touchTool(_createTouchTool); } void Controller::tacticalDrawOnMap(QString sidc) { String sidcString = sidc.toStdString().c_str(); _createTacticalTool->parameters(new MilStd2525CCreateToolParameters(sidcString)); _createTacticalTool->attributes()->set("geojson_id", QUuid::createUuid().toString().toStdString().c_str()); _createTacticalTouchTool->parameters(new MilStd2525CCreateToolParameters(sidcString)); _createTacticalTouchTool->attributes()->set("geojson_id", QUuid::createUuid().toString().toStdString().c_str()); mapControl->tool(_createTacticalTool); mapControl->touchTool(_createTacticalTouchTool); }
The other client-server interaction is the ability to receive messages. The client can parse the message argument and convert it to features. Each feature’s geojson_id is tested so we avoid generating duplicate features.
void Controller::onFeaturesReceived(QString message) { auto featureCollection = Carmenta::Engine::GeoJSON::fromString(message.toStdString().c_str()); for(auto feature = featureCollection->begin(); feature != featureCollection->end(); ++feature) { FeaturePtr f = *feature; // get feature quuid std::string featId = getAttributeValueString(f->attributes(), "geojson_id", ""); QUuid uuid = QUuid::fromString(QString(featId.c_str())); // check if we have a sidc (a tactical symbol) std::string sidc = getAttributeValueString(f->attributes(), "sidc", ""); if (sidc != "") { UpdateCache(uuid, f, _tacticalDrawingsMemDs); } else { UpdateCache(uuid, f, _drawnObjectsMemDs); } } mapControl->updateView(); } void Controller::UpdateCache(QUuid uuid, Carmenta::Engine::FeaturePtr feature, Carmenta::Engine::MemoryDataSetPtr dataset) { if (_cache.contains(uuid)) { Guard g(dataset); auto localFeature = _cache[uuid]; dataset->remove(localFeature->id()); } Guard g(dataset); dataset->insert(feature); _cache[uuid] = feature; dataset->refreshFeaturePresentation(feature, false); }
And that’s it! We can now connect several clients to a server and have them communicate and synchronize.
Conclusion & Going forward
As you may have noticed by now, we have extended our QtQuickSample to handle communication via TCP sockets between several instances.
It is of course not a fully functional server/client implementation as some use-cases are missing, which we detail right below. The actual information exchange is also quite simplified.
- There is no identification for clients nor any authorization.
- There is no way to delete features.
- Synchronous editing of features is not handled; the last to edit a feature wins!
- Our server stores everything in memory and its cache isn’t fully functional either. Using MemoryDataSet::save with SaveMode::SaveModeIncludeFeatures we can make some very simple backups to disk.
- It can also be interesting to look into the MapPackage API to write to OGC GeoPackages, which is in fact a set of standards to interact with an SQLite container.
Although simple, this extension of the sample gives some insight about the way Carmenta Engine-based applications can communicate geospatial information in a networked environment.
In case you are interested in geospatial servers, it would make a lot of sense to check out the following:
- Our Simple tile server sample, a very basic implementation of a tile server.
- If you are in need of a reliable and scalable way to publish map services, do check out Carmenta Server, a Carmenta Engine-based map server implementing OGC Web interface standards for maps and analysis results distribution.
- If you are looking for a lightweight solution for supplying the newer OGC API Tiles, please have a look at our Carmenta Tile Engine technical preview. This is a containerized and trimmed-down platform for network/internet delivery of Carmenta Engine-based map image tiles in a resource-efficient, no-nonsense type of way.
A few other interesting things about this example:
- It shows a way to deal with dynamic data in a simple client/server environment.
- It is a good example of how to use Carmenta Engine without any visualization.
- It is also an example of how to export features from Engine’s data flow to other applications, which in this instance are using Carmenta Engine as well, but don’t necessarily need to.
All that remains now is to wish you good luck in developing applications using Carmenta Engine and QtQuick!