WIP-How to create a simple chat application: Difference between revisions
No edit summary |
(Fixed copy/paste error) |
||
(21 intermediate revisions by 3 users not shown) | |||
Line 1: | Line 1: | ||
== Introduction == | |||
This article will illustrate a simple chat client and server communicating over TCP. | This article will illustrate a simple chat client and server communicating over TCP. | ||
The aim is to clarify aspects of QTcpSocket/QTcpServer that are not developed in the official Qt Fortune example. | The aim is to clarify aspects of QTcpSocket/QTcpServer that are not developed in the official Qt Fortune example. | ||
This has no intention to be a fully featured chat application. | This has no intention to be a fully featured chat application. | ||
You can find the source code relating to this example [https://github.com/VSRonin/ChatExample on GitHub] | You can find the source code relating to this example [https://github.com/VSRonin/ChatExample on GitHub] The article is split into two large parts ([https://github.com/VSRonin/ChatExample/tree/master the master branch] for part 1 and [https://github.com/VSRonin/ChatExample/tree/commonlib the commonlib branch] for part 2) that are targeted at audiences of different levels - the first one is to introduce you to the concepts that are used and how a TCP server-client application can be developed, while the second focuses on how the code can be improved, and how to redesign the basic ideas to gain reusability and extensibility. | ||
=== The Logic === | === The Logic === | ||
This application will use a central server that will manage the communication among clients via [https://www.json.org/ JSON] messages. | This application will use a central server that will manage the communication among clients via [https://www.json.org/ JSON] messages. | ||
Two version of the server are implemented, one that runs in a single thread and one that distributes the client sockets among multiple threads. | |||
== Part 1: Creating the basic client-server application == | |||
=== Prerequisites === | |||
* Qt 5.7 or later | |||
* An intermediate knowledge of C++, familiarity with C++11 concepts, especially [http://en.cppreference.com/w/cpp/language/lambda lambdas] and [http://www.cplusplus.com/reference/functional/bind/ std::bind] | |||
* An intermediate level of knowledge of Qt5 | |||
=== The Client === | === The Client === | ||
The client in this example is a simple QtWidgets application, the ui is minimal, just a button to connect to the server, a list view to display the messages received, a line edit to type your messages and a button to send them. | The client in this example is a simple QtWidgets application, the ui is minimal, just a button to connect to the server, a list view to display the messages received, a line edit to type your messages and a button to send them. | ||
The core of the functionality is in the <tt>ChatClient</tt> class. | The core of the functionality is in the <tt>ChatClient</tt> class. | ||
< | <nowiki> | ||
class ChatClient : public QObject | class ChatClient : public QObject | ||
{ | { | ||
Line 43: | Line 47: | ||
void jsonReceived(const QJsonObject &doc); | void jsonReceived(const QJsonObject &doc); | ||
}; | }; | ||
</ | </nowiki> | ||
Let's break down what each of these methods and members do: | |||
{| class="wikitable" | {| class="wikitable" | ||
|- | |- | ||
Line 91: | Line 93: | ||
|- | |- | ||
|} | |} | ||
Let's now look at the implementation: | |||
<nowiki> | |||
ChatClient::ChatClient(QObject *parent) | |||
: QObject(parent) | |||
, m_clientSocket(new QTcpSocket(this)) | |||
, m_loggedIn(false) | |||
{ | |||
// Forward the connected and disconnected signals | |||
connect(m_clientSocket, &QTcpSocket::connected, this, &ChatClient::connected); | |||
connect(m_clientSocket, &QTcpSocket::disconnected, this, &ChatClient::disconnected); | |||
// connect readyRead() to the slot that will take care of reading the data in | |||
connect(m_clientSocket, &QTcpSocket::readyRead, this, &ChatClient::onReadyRead); | |||
// Forward the error signal, QOverload is necessary as error() is overloaded, see the Qt docs | |||
connect(m_clientSocket, QOverload<QAbstractSocket::SocketError>::of(&QAbstractSocket::error), this, &ChatClient::error); | |||
// Reset the m_loggedIn variable when we disconnect. Since the operation is trivial we use a lambda instead of creating another slot | |||
connect(m_clientSocket, &QTcpSocket::disconnected, this, [this]()->void{m_loggedIn = false;}); | |||
} | |||
</nowiki> | |||
In the constructor we initialize the parent class and the members, then we take care of connecting to the signals coming from the socket. | |||
<nowiki> | |||
void ChatClient::login(const QString &userName) | |||
{ | |||
if (m_clientSocket->state() == QAbstractSocket::ConnectedState) { // if the client is connected | |||
// create a QDataStream operating on the socket | |||
QDataStream clientStream(m_clientSocket); | |||
// set the version so that programs compiled with different versions of Qt can agree on how to serialise | |||
clientStream.setVersion(QDataStream::Qt_5_7); | |||
// Create the JSON we want to send | |||
QJsonObject message; | |||
message["type"] = QStringLiteral("login"); | |||
message["username"] = userName; | |||
// send the JSON using QDataStream | |||
clientStream << QJsonDocument(message).toJson(); | |||
} | |||
} | |||
void ChatClient::sendMessage(const QString &text) | |||
{ | |||
if (text.isEmpty()) | |||
return; // We don't send empty messages | |||
// create a QDataStream operating on the socket | |||
QDataStream clientStream(m_clientSocket); | |||
// set the version so that programs compiled with different versions of Qt can agree on how to serialise | |||
clientStream.setVersion(QDataStream::Qt_5_7); | |||
// Create the JSON we want to send | |||
QJsonObject message; | |||
message["type"] = QStringLiteral("message"); | |||
message["text"] = text; | |||
// send the JSON using QDataStream | |||
clientStream << QJsonDocument(message).toJson(QJsonDocument::Compact); | |||
} | |||
</nowiki> | |||
The <tt>login</tt> and <tt>sendMessage</tt> are very similar as both of them send a JSON over the TCP socket. | |||
<nowiki> | |||
void ChatClient::disconnectFromHost() | |||
{ | |||
m_clientSocket->disconnectFromHost(); | |||
} | |||
void ChatClient::connectToServer(const QHostAddress &address, quint16 port) | |||
{ | |||
m_clientSocket->connectToHost(address, port); | |||
} | |||
</nowiki> | |||
The <tt>connectToServer</tt> and <tt>disconnectFromHost</tt> slots just call the corresponding method on the socket | |||
<nowiki> | |||
void ChatClient::jsonReceived(const QJsonObject &docObj) | |||
{ | |||
// actions depend on the type of message | |||
const QJsonValue typeVal = docObj.value(QLatin1String("type")); | |||
if (typeVal.isNull() || !typeVal.isString()) | |||
return; // a message with no type was received so we just ignore it | |||
if (typeVal.toString().compare(QLatin1String("login"), Qt::CaseInsensitive) == 0) { //It's a login message | |||
if (m_loggedIn) | |||
return; // if we are already logged in we ignore | |||
// the success field will contain the result of our attempt to login | |||
const QJsonValue resultVal = docObj.value(QLatin1String("success")); | |||
if (resultVal.isNull() || !resultVal.isBool()) | |||
return; // the message had no success field so we ignore | |||
const bool loginSuccess = resultVal.toBool(); | |||
if (loginSuccess) { | |||
// we logged in successfully and we notify it via the loggedIn signal | |||
emit loggedIn(); | |||
return; | |||
} | |||
// the login attempt failed, we extract the reason of the failure from the JSON | |||
// and notify it via the loginError signal | |||
const QJsonValue reasonVal = docObj.value(QLatin1String("reason")); | |||
emit loginError(reasonVal.toString()); | |||
} else if (typeVal.toString().compare(QLatin1String("message"), Qt::CaseInsensitive) == 0) { //It's a chat message | |||
// we extract the text field containing the chat text | |||
const QJsonValue textVal = docObj.value(QLatin1String("text")); | |||
// we extract the sender field containing the username of the sender | |||
const QJsonValue senderVal = docObj.value(QLatin1String("sender")); | |||
if (textVal.isNull() || !textVal.isString()) | |||
return; // the text field was invalid so we ignore | |||
if (senderVal.isNull() || !senderVal.isString()) | |||
return; // the sender field was invalid so we ignore | |||
// we notify a new message was received via the messageReceived signal | |||
emit messageReceived(senderVal.toString(), textVal.toString()); | |||
} else if (typeVal.toString().compare(QLatin1String("newuser"), Qt::CaseInsensitive) == 0) { // A user joined the chat | |||
// we extract the username of the new user | |||
const QJsonValue usernameVal = docObj.value(QLatin1String("username")); | |||
if (usernameVal.isNull() || !usernameVal.isString()) | |||
return; // the username was invalid so we ignore | |||
// we notify of the new user via the userJoined signal | |||
emit userJoined(usernameVal.toString()); | |||
} else if (typeVal.toString().compare(QLatin1String("userdisconnected"), Qt::CaseInsensitive) == 0) { // A user left the chat | |||
// we extract the username of the new user | |||
const QJsonValue usernameVal = docObj.value(QLatin1String("username")); | |||
if (usernameVal.isNull() || !usernameVal.isString()) | |||
return; // the username was invalid so we ignore | |||
// we notify of the user disconnection the userLeft signal | |||
emit userLeft(usernameVal.toString()); | |||
} | |||
} | |||
</nowiki> | |||
The <tt>jsonReceived</tt> method is all about JSON parsing, since it's outside the scope of this example we won't spend any-more words on it as the code itself is well commented | |||
<nowiki> | |||
void ChatClient::onReadyRead() | |||
{ | |||
// prepare a container to hold the UTF-8 encoded JSON we receive from the socket | |||
QByteArray jsonData; | |||
// create a QDataStream operating on the socket | |||
QDataStream socketStream(m_clientSocket); | |||
// set the version so that programs compiled with different versions of Qt can agree on how to serialise | |||
socketStream.setVersion(QDataStream::Qt_5_7); | |||
// start an infinite loop | |||
for (;;) { | |||
// we start a transaction so we can revert to the previous state in case we try to read more data than is available on the socket | |||
socketStream.startTransaction(); | |||
// we try to read the JSON data | |||
socketStream >> jsonData; | |||
if (socketStream.commitTransaction()) { | |||
// we successfully read some data | |||
// we now need to make sure it's in fact a valid JSON | |||
QJsonParseError parseError; | |||
// we try to create a json document with the data we received | |||
const QJsonDocument jsonDoc = QJsonDocument::fromJson(jsonData, &parseError); | |||
if (parseError.error == QJsonParseError::NoError) { | |||
// if the data was indeed valid JSON | |||
if (jsonDoc.isObject()) // and is a JSON object | |||
jsonReceived(jsonDoc.object()); // parse the JSON | |||
} | |||
// loop and try to read more JSONs if they are available | |||
} else { | |||
// the read failed, the socket goes automatically back to the state it was in before the transaction started | |||
// we just exit the loop and wait for more data to become available | |||
break; | |||
} | |||
} | |||
} | |||
</nowiki> | |||
The <tt>onReadyRead</tt> slot is really the most interesting one. | |||
The first thing to remember is that when we receive the <tt>readyRead</tt> we can't make any assumption on ''how much'' data is available, the signal only tells us ''some'' data is there hence there are 4 cases we need to handle: | |||
<ol><li>The socket did not receive enough data to be a complete message, we only received a partial</li> | |||
<li>There is exactly enough data in the socket buffer to read a message</li> | |||
<li>There is more than enough data in the socket buffer to read a message but not enough to read 2 messages</li> | |||
<li>There is enough data in the socket buffer to read multiple messages</li></ol> | |||
Cases 1 and 2 are entirely handled by <tt>socketStream.commitTransaction()</tt>. If there was not enough data to complete the reading when we called <tt>socketStream >> jsonData;</tt> this method will return false and we'll just exit the function and wait for more data to come in otherwise it will proceed and parse the data as a complete JSON message. | |||
Cases 3 and 4 are handled by the infinite loop. After we read the first message we try to read another one in the same way as before and break the loop only when the data in the buffer is no longer enough to be a complete JSON message. | |||
To facilitate the handling of differences in the expected size of the data received and the actual size, since Qt 5.7, <tt>QIODevice</tt> and <tt>QDataStream</tt> introduced transactions. | |||
Transactions work very similarly to SQL transactions: start the transaction, try and do something with the data, if everything went as expected you commit the transaction, otherwise you can go back as if you did nothing at all. | |||
<tt>QDataStream::commitTransaction</tt> will actually rollback if it detects there was an error and return <tt>false</tt>. We use this to check if <tt>socketStream >> jsonData;</tt> retrieved a full JSON document or not.". | |||
Internally <tt>socketStream >> jsonData;</tt> will read a 32bit unsigned integer that will be <tt>jsonData.size()</tt> after the read. Then it will read raw bytes of data to fill that lenght. If it detects any error it will set <tt>socketStream.status()</tt> to something different form <tt>QDataStream::Ok</tt>. <tt>socketStream.commitTransaction()</tt> then checks that status. If it is <tt>QDataStream::Ok</tt> it will shorten the buffer in the socket by the amount of data we successfully read and return true, otherwise it will return false maintaining the socket buffer identical to what it was before. | |||
=== The Client Interface === | |||
Now that we have the chat client object let's build a UI for it, we'll use the Qt Widgets module for this. | |||
[[File:ChatClientUi.png|frameless|right|ChatClientUi]] The interface is very minimalistic, it has: | |||
* one button to connect to the server | |||
* a list view to display all the chat message | |||
* a line edit to type messages into | |||
* a button to send the message to the server | |||
At the start of the program, the only element enabled is the connect button while the rest will only be enabled once the client is connected to the server. | |||
Let's analyse the header of the widget | |||
<nowiki> | |||
class ChatWindow : public QWidget | |||
{ | |||
Q_OBJECT | |||
Q_DISABLE_COPY(ChatWindow) | |||
public: | |||
explicit ChatWindow(QWidget *parent = nullptr); | |||
~ChatWindow(); | |||
private: | |||
Ui::ChatWindow *ui; | |||
ChatClient *m_chatClient; | |||
QStandardItemModel *m_chatModel; | |||
QString m_lastUserName; | |||
private slots: | |||
void attemptConnection(); | |||
void connectedToServer(); | |||
void attemptLogin(const QString &userName); | |||
void loggedIn(); | |||
void loginFailed(const QString &reason); | |||
void messageReceived(const QString &sender, const QString &text); | |||
void sendMessage(); | |||
void disconnectedFromServer(); | |||
void userJoined(const QString &username); | |||
void userLeft(const QString &username); | |||
void error(QAbstractSocket::SocketError socketError); | |||
}; | |||
</nowiki> | |||
Let's break down what each of these methods and members do: | |||
{| class="wikitable" | |||
|- | |||
! colspan="2" | Private Members | |||
|- | |||
| <tt>ui</tt> || This will hold the elements that we laid out in the .ui file | |||
|- | |||
| <tt>m_chatClient</tt> || The client object we developed above | |||
|- | |||
| <tt>m_chatModel</tt> || This model will be displayed in the main list view and will hold the chat messages | |||
|- | |||
| <tt>m_lastUserName</tt> || The username of the last person that spoke in the chat, we use this only for aesthetic purposes | |||
|- | |||
! colspan="2" | Private Slots | |||
|- | |||
| <tt>attemptConnection()</tt> || Asks the user to enter the server address and attempts a connection to it | |||
|- | |||
| <tt>connectedToServer()</tt> || This slot is executed once the client signals it successfully connected to the server | |||
|- | |||
| <tt>attemptLogin()</tt> || Tells the client to attempt a login with a given username | |||
|- | |||
| <tt>loggedIn()</tt> || This slot is executed once the client signals it successfully logged in | |||
|- | |||
| <tt>loginFailed()</tt> || This slot is executed once the client signals it failed in its log in attempt | |||
|- | |||
| <tt>messageReceived()</tt> || This slot is executed every time a new message is received from the server | |||
|- | |||
| <tt>sendMessage()</tt> || Sends the message in the line edit to the server | |||
|- | |||
| <tt>disconnectedFromServer()</tt> || This slot is executed when the clients loses connection with the server | |||
|- | |||
| <tt>userJoined()</tt> || This slot is executed when a new user logs in the chat | |||
|- | |||
| <tt>userLeft()</tt> || This slot is executed when a user leaves the chat | |||
|- | |||
| <tt>error()</tt> || This slot is executed when the client detects an error | |||
|- | |||
|} | |||
And now the implementations: | |||
<nowiki> | |||
ChatWindow::ChatWindow(QWidget *parent) | |||
: QWidget(parent) | |||
, ui(new Ui::ChatWindow) // create the elements defined in the .ui file | |||
, m_chatClient(new ChatClient(this)) // create the chat client | |||
, m_chatModel(new QStandardItemModel(this)) // create the model to hold the messages | |||
{ | |||
// set up of the .ui file | |||
ui->setupUi(this); | |||
// the model for the messages will have 1 column | |||
m_chatModel->insertColumn(0); | |||
// set the model as the data source vor the list view | |||
ui->chatView->setModel(m_chatModel); | |||
// connect the signals from the chat client to the slots in this ui | |||
connect(m_chatClient, &ChatClient::connected, this, &ChatWindow::connectedToServer); | |||
connect(m_chatClient, &ChatClient::loggedIn, this, &ChatWindow::loggedIn); | |||
connect(m_chatClient, &ChatClient::loginError, this, &ChatWindow::loginFailed); | |||
connect(m_chatClient, &ChatClient::messageReceived, this, &ChatWindow::messageReceived); | |||
connect(m_chatClient, &ChatClient::disconnected, this, &ChatWindow::disconnectedFromServer); | |||
connect(m_chatClient, &ChatClient::error, this, &ChatWindow::error); | |||
connect(m_chatClient, &ChatClient::userJoined, this, &ChatWindow::userJoined); | |||
connect(m_chatClient, &ChatClient::userLeft, this, &ChatWindow::userLeft); | |||
// connect the connect button to a slot that will attempt the connection | |||
connect(ui->connectButton, &QPushButton::clicked, this, &ChatWindow::attemptConnection); | |||
// connect the click of the "send" button and the press of the enter while typing to the slot that sends the message | |||
connect(ui->sendButton, &QPushButton::clicked, this, &ChatWindow::sendMessage); | |||
connect(ui->messageEdit, &QLineEdit::returnPressed, this, &ChatWindow::sendMessage); | |||
} | |||
</nowiki> | |||
The constructor creates the client and connects slots to the signals it sends. It also sets up the ui and connect a few slots to implement functionalities on button clicks | |||
<nowiki> | |||
ChatWindow::~ChatWindow() | |||
{ | |||
// delete the elements created from the .ui file | |||
delete ui; | |||
} | |||
</nowiki> | |||
<nowiki> | |||
void ChatWindow::attemptConnection() | |||
{ | |||
// We ask the user for the address of the server, we use 127.0.0.1 (aka localhost) as default | |||
const QString hostAddress = QInputDialog::getText( | |||
this | |||
, tr("Chose Server") | |||
, tr("Server Address") | |||
, QLineEdit::Normal | |||
, QStringLiteral("127.0.0.1") | |||
); | |||
if (hostAddress.isEmpty()) | |||
return; // the user pressed cancel or typed nothing | |||
// disable the connect button to prevent the user clicking it again | |||
ui->connectButton->setEnabled(false); | |||
// tell the client to connect to the host using the port 1967 | |||
m_chatClient->connectToServer(QHostAddress(hostAddress), 1967); | |||
} | |||
</nowiki> | |||
In <tt>attemptConnection</tt> we ask the user to input the IP address of the server. We decide to use port 1967 but you are free to use whatever port you prefer or even ask the port as an input from the user. This slots will be executed when the user clicks the "Connect" button. | |||
<nowiki> | |||
void ChatWindow::connectedToServer() | |||
{ | |||
// once we connected to the server we ask the user for what username they would like to use | |||
const QString newUsername = QInputDialog::getText(this, tr("Chose Username"), tr("Username")); | |||
if (newUsername.isEmpty()){ | |||
// if the user clicked cancel or typed nothing, we just disconnect from the server | |||
return m_chatClient->disconnectFromHost(); | |||
} | |||
// try to login with the given username | |||
attemptLogin(newUsername); | |||
} | |||
</nowiki> | |||
Once we establish a connection with the server we ask the user to select a username. | |||
<nowiki> | |||
void ChatWindow::attemptLogin(const QString &userName) | |||
{ | |||
// use the client to attempt a log in with the given username | |||
m_chatClient->login(userName); | |||
} | |||
</nowiki> | |||
The client will negotiate the username with the server | |||
<nowiki> | |||
void ChatWindow::loggedIn() | |||
{ | |||
// once we successfully log in we enable the ui to display and send messages | |||
ui->sendButton->setEnabled(true); | |||
ui->messageEdit->setEnabled(true); | |||
ui->chatView->setEnabled(true); | |||
// clear the user name record | |||
m_lastUserName.clear(); | |||
} | |||
</nowiki> | |||
<nowiki> | |||
void ChatWindow::loginFailed(const QString &reason) | |||
{ | |||
// the server rejected the login attempt | |||
// display the reason for the rejection in a message box | |||
QMessageBox::critical(this, tr("Error"), reason); | |||
// allow the user to retry, execute the same slot as when just connected | |||
connectedToServer(); | |||
} | |||
</nowiki> | |||
<nowiki> | |||
void ChatWindow::messageReceived(const QString &sender, const QString &text) | |||
{ | |||
// store the index of the new row to append to the model containing the messages | |||
int newRow = m_chatModel->rowCount(); | |||
// we display a line containing the username only if it's different from the last username we displayed | |||
if (m_lastUserName != sender) { | |||
// store the last displayed username | |||
m_lastUserName = sender; | |||
// create a bold default font | |||
QFont boldFont; | |||
boldFont.setBold(true); | |||
// insert 2 row, one for the message and one for the username | |||
m_chatModel->insertRows(newRow, 2); | |||
// store the username in the model | |||
m_chatModel->setData(m_chatModel->index(newRow, 0), sender + ':'); | |||
// set the alignment for the username | |||
m_chatModel->setData(m_chatModel->index(newRow, 0), int(Qt::AlignLeft | Qt::AlignVCenter), Qt::TextAlignmentRole); | |||
// set the for the username | |||
m_chatModel->setData(m_chatModel->index(newRow, 0), boldFont, Qt::FontRole); | |||
++newRow; | |||
} else { | |||
// insert a row for the message | |||
m_chatModel->insertRow(newRow); | |||
} | |||
// store the message in the model | |||
m_chatModel->setData(m_chatModel->index(newRow, 0), text); | |||
// set the alignment for the message | |||
m_chatModel->setData(m_chatModel->index(newRow, 0), int(Qt::AlignLeft | Qt::AlignVCenter), Qt::TextAlignmentRole); | |||
// scroll the view to display the new message | |||
ui->chatView->scrollToBottom(); | |||
} | |||
</nowiki> | |||
This slot will be called once a message is received from the server. It adds the message to the model aligned to the left. It also adds the username of the sender only if it's different from the previous sender, this avoids clogging the interface repeating the username of the sender for every message. | |||
<nowiki> | |||
void ChatWindow::sendMessage() | |||
{ | |||
// we use the client to send the message that the user typed | |||
m_chatClient->sendMessage(ui->messageEdit->text()); | |||
// now we add the message to the list | |||
// store the index of the new row to append to the model containing the messages | |||
const int newRow = m_chatModel->rowCount(); | |||
// insert a row for the message | |||
m_chatModel->insertRow(newRow); | |||
// store the message in the model | |||
m_chatModel->setData(m_chatModel->index(newRow, 0), ui->messageEdit->text()); | |||
// set the alignment for the message | |||
m_chatModel->setData(m_chatModel->index(newRow, 0), int(Qt::AlignRight | Qt::AlignVCenter), Qt::TextAlignmentRole); | |||
// clear the content of the message editor | |||
ui->messageEdit->clear(); | |||
// scroll the view to display the new message | |||
ui->chatView->scrollToBottom(); | |||
// reset the last printed username | |||
m_lastUserName.clear(); | |||
} | |||
</nowiki> | |||
This method sends the message to the server and adds it to the model aligned to the right | |||
<nowiki> | |||
void ChatWindow::disconnectedFromServer() | |||
{ | |||
// if the client loses connection to the server | |||
// communicate the event to the user via a message box | |||
QMessageBox::warning(this, tr("Disconnected"), tr("The host terminated the connection")); | |||
// disable the ui to send and display messages | |||
ui->sendButton->setEnabled(false); | |||
ui->messageEdit->setEnabled(false); | |||
ui->chatView->setEnabled(false); | |||
// enable the button to connect to the server again | |||
ui->connectButton->setEnabled(true); | |||
// reset the last printed username | |||
m_lastUserName.clear(); | |||
} | |||
</nowiki> | |||
<nowiki> | |||
void ChatWindow::userJoined(const QString &username) | |||
{ | |||
// store the index of the new row to append to the model containing the messages | |||
const int newRow = m_chatModel->rowCount(); | |||
// insert a row | |||
m_chatModel->insertRow(newRow); | |||
// store in the model the message to communicate a user joined | |||
m_chatModel->setData(m_chatModel->index(newRow, 0), tr("%1 Joined the Chat").arg(username)); | |||
// set the alignment for the text | |||
m_chatModel->setData(m_chatModel->index(newRow, 0), Qt::AlignCenter, Qt::TextAlignmentRole); | |||
// set the color for the text | |||
m_chatModel->setData(m_chatModel->index(newRow, 0), QBrush(Qt::blue), Qt::ForegroundRole); | |||
// scroll the view to display the new message | |||
ui->chatView->scrollToBottom(); | |||
// reset the last printed username | |||
m_lastUserName.clear(); | |||
} | |||
void ChatWindow::userLeft(const QString &username) | |||
{ | |||
// store the index of the new row to append to the model containing the messages | |||
const int newRow = m_chatModel->rowCount(); | |||
// insert a row | |||
m_chatModel->insertRow(newRow); | |||
// store in the model the message to communicate a user left | |||
m_chatModel->setData(m_chatModel->index(newRow, 0), tr("%1 Left the Chat").arg(username)); | |||
// set the alignment for the text | |||
m_chatModel->setData(m_chatModel->index(newRow, 0), Qt::AlignCenter, Qt::TextAlignmentRole); | |||
// set the color for the text | |||
m_chatModel->setData(m_chatModel->index(newRow, 0), QBrush(Qt::red), Qt::ForegroundRole); | |||
// scroll the view to display the new message | |||
ui->chatView->scrollToBottom(); | |||
// reset the last printed username | |||
m_lastUserName.clear(); | |||
} | |||
</nowiki> | |||
These 2 methods are almost identical, all they do is adding a header in the chat window to warn about users joining/leaving the chat | |||
<nowiki> | |||
void ChatWindow::error(QAbstractSocket::SocketError socketError) | |||
{ | |||
// show a message to the user that informs of what kind of error occurred | |||
switch (socketError) { | |||
case QAbstractSocket::RemoteHostClosedError: | |||
case QAbstractSocket::ProxyConnectionClosedError: | |||
return; // handled by disconnectedFromServer | |||
case QAbstractSocket::ConnectionRefusedError: | |||
QMessageBox::critical(this, tr("Error"), tr("The host refused the connection")); | |||
break; | |||
case QAbstractSocket::ProxyConnectionRefusedError: | |||
QMessageBox::critical(this, tr("Error"), tr("The proxy refused the connection")); | |||
break; | |||
case QAbstractSocket::ProxyNotFoundError: | |||
QMessageBox::critical(this, tr("Error"), tr("Could not find the proxy")); | |||
break; | |||
case QAbstractSocket::HostNotFoundError: | |||
QMessageBox::critical(this, tr("Error"), tr("Could not find the server")); | |||
break; | |||
case QAbstractSocket::SocketAccessError: | |||
QMessageBox::critical(this, tr("Error"), tr("You don't have permissions to execute this operation")); | |||
break; | |||
case QAbstractSocket::SocketResourceError: | |||
QMessageBox::critical(this, tr("Error"), tr("Too many connections opened")); | |||
break; | |||
case QAbstractSocket::SocketTimeoutError: | |||
QMessageBox::warning(this, tr("Error"), tr("Operation timed out")); | |||
return; | |||
case QAbstractSocket::ProxyConnectionTimeoutError: | |||
QMessageBox::critical(this, tr("Error"), tr("Proxy timed out")); | |||
break; | |||
case QAbstractSocket::NetworkError: | |||
QMessageBox::critical(this, tr("Error"), tr("Unable to reach the network")); | |||
break; | |||
case QAbstractSocket::UnknownSocketError: | |||
QMessageBox::critical(this, tr("Error"), tr("An unknown error occurred")); | |||
break; | |||
case QAbstractSocket::UnsupportedSocketOperationError: | |||
QMessageBox::critical(this, tr("Error"), tr("Operation not supported")); | |||
break; | |||
case QAbstractSocket::ProxyAuthenticationRequiredError: | |||
QMessageBox::critical(this, tr("Error"), tr("Your proxy requires authentication")); | |||
break; | |||
case QAbstractSocket::ProxyProtocolError: | |||
QMessageBox::critical(this, tr("Error"), tr("Proxy communication failed")); | |||
break; | |||
case QAbstractSocket::TemporaryError: | |||
case QAbstractSocket::OperationError: | |||
QMessageBox::warning(this, tr("Error"), tr("Operation failed, please try again")); | |||
return; | |||
default: | |||
Q_UNREACHABLE(); | |||
} | |||
// enable the button to connect to the server again | |||
ui->connectButton->setEnabled(true); | |||
// disable the ui to send and display messages | |||
ui->sendButton->setEnabled(false); | |||
ui->messageEdit->setEnabled(false); | |||
ui->chatView->setEnabled(false); | |||
// reset the last printed username | |||
m_lastUserName.clear(); | |||
} | |||
</nowiki> | |||
Finally we take care of any error that could happen on the client. For irrecoverable errors we disable the ui to send and display messages. | |||
<nowiki> | |||
int main(int argc, char *argv[]) | |||
{ | |||
QApplication a(argc, argv); | |||
ChatWindow chatWin; | |||
chatWin.show(); | |||
return a.exec(); | |||
} | |||
</nowiki> | |||
The main function is as standard as they get. We create the application and the chat window. We show the window and start the main event loop. | |||
=== The Single Thread Server === | |||
One common misconception is that, to handle multiple clients, the server needs to implement a multi-thread infrastructure, this is not the case as we'll see in this section. | |||
We'll start by devising an object that will take care of communicating with a single client and manage the socket on the server side. | |||
<nowiki> | |||
class ServerWorker : public QObject | |||
{ | |||
Q_OBJECT | |||
Q_DISABLE_COPY(ServerWorker) | |||
public: | |||
explicit ServerWorker(QObject *parent = nullptr); | |||
virtual bool setSocketDescriptor(qintptr socketDescriptor); | |||
QString userName() const; | |||
void setUserName(const QString &userName); | |||
void sendJson(const QJsonObject &jsonData); | |||
signals: | |||
void jsonReceived(const QJsonObject &jsonDoc); | |||
void disconnectedFromClient(); | |||
void error(); | |||
void logMessage(const QString &msg); | |||
public slots: | |||
void disconnectFromClient(); | |||
private slots: | |||
void receiveJson(); | |||
private: | |||
QTcpSocket *m_serverSocket; | |||
QString m_userName; | |||
}; | |||
</nowiki> | |||
Let's break down what each of these methods and members do: | |||
{| class="wikitable" | |||
|- | |||
! colspan="2" | Private Members | |||
|- | |||
| <tt>m_serverSocket</tt> || This will hold the socket that will communicate with the client | |||
|- | |||
| <tt>m_userName</tt> || The username of the client connected to this object. | |||
|- | |||
! colspan="2" | Private Slots | |||
|- | |||
| <tt>receiveJson()</tt> || This method will take care of reading the incoming messages from the network and send them to the main server object. | |||
|- | |||
|- | |||
! colspan="2" | Public Slots | |||
|- | |||
| <tt>disconnectFromClient()</tt> || This method will close the connection with the client. | |||
|- | |||
! colspan="2" | Public Methods | |||
|- | |||
| <tt>userName() / setUserName()</tt> || Getter and setter for the username of the client. | |||
|- | |||
| <tt>setSocketDescriptor()</tt> || Used by the server to direct the object toward what client it should listen to. | |||
|- | |||
|- | |||
! colspan="2" | Signals | |||
|- | |||
| <tt>disconnectedFromClient()</tt> || Emitted when the client closes the connection. | |||
|- | |||
| <tt>jsonReceived()</tt> || Used to send to the central server a message that was received. | |||
|- | |||
| <tt>logMessage()</tt> || Used to send to notify the central server of internal events that should be logged. | |||
|- | |||
| <tt>error()</tt> || Used to send to notify the central server of an error. | |||
|- | |||
|} | |||
The implementation is pretty simple | |||
<nowiki> | |||
ServerWorker::ServerWorker(QObject *parent) | |||
: QObject(parent) | |||
, m_serverSocket(new QTcpSocket(this)) | |||
{ | |||
// connect readyRead() to the slot that will take care of reading the data in | |||
connect(m_serverSocket, &QTcpSocket::readyRead, this, &ServerWorker::receiveJson); | |||
// forward the disconnected and error signals coming from the socket | |||
connect(m_serverSocket, &QTcpSocket::disconnected, this, &ServerWorker::disconnectedFromClient); | |||
connect(m_serverSocket, QOverload<QAbstractSocket::SocketError>::of(&QAbstractSocket::error), this, &ServerWorker::error); | |||
} | |||
</nowiki> | |||
The constructor creates the socket and connects the signals coming from it | |||
<nowiki> | |||
bool ServerWorker::setSocketDescriptor(qintptr socketDescriptor) | |||
{ | |||
return m_serverSocket->setSocketDescriptor(socketDescriptor); | |||
} | |||
void ServerWorker::disconnectFromClient() | |||
{ | |||
m_serverSocket->disconnectFromHost(); | |||
} | |||
QString ServerWorker::userName() const | |||
{ | |||
return m_userName; | |||
} | |||
void ServerWorker::setUserName(const QString &userName) | |||
{ | |||
m_userName = userName; | |||
} | |||
</nowiki> | |||
The methods above are trivial, they either just call the corresponding method in the socket or read/write the username | |||
<nowiki> | |||
void ServerWorker::receiveJson() | |||
{ | |||
// prepare a container to hold the UTF-8 encoded JSON we receive from the socket | |||
QByteArray jsonData; | |||
// create a QDataStream operating on the socket | |||
QDataStream socketStream(m_serverSocket); | |||
// set the version so that programs compiled with different versions of Qt can agree on how to serialise | |||
socketStream.setVersion(QDataStream::Qt_5_7); | |||
// start an infinite loop | |||
for (;;) { | |||
// we start a transaction so we can revert to the previous state in case we try to read more data than is available on the socket | |||
socketStream.startTransaction(); | |||
// we try to read the JSON data | |||
socketStream >> jsonData; | |||
if (socketStream.commitTransaction()) { | |||
// we successfully read some data | |||
// we now need to make sure it's in fact a valid JSON | |||
QJsonParseError parseError; | |||
// we try to create a json document with the data we received | |||
const QJsonDocument jsonDoc = QJsonDocument::fromJson(jsonData, &parseError); | |||
if (parseError.error == QJsonParseError::NoError) { | |||
// if the data was indeed valid JSON | |||
if (jsonDoc.isObject()) // and is a JSON object | |||
emit jsonReceived(jsonDoc.object()); // send the message to the central server | |||
else | |||
emit logMessage("Invalid message: " + QString::fromUtf8(jsonData)); //notify the server of invalid data | |||
} else { | |||
emit logMessage("Invalid message: " + QString::fromUtf8(jsonData)); //notify the server of invalid data | |||
} | |||
// loop and try to read more JSONs if they are available | |||
} else { | |||
// the read failed, the socket goes automatically back to the state it was in before the transaction started | |||
// we just exit the loop and wait for more data to become available | |||
break; | |||
} | |||
} | |||
} | |||
</nowiki> | |||
This method is almost identical to <tt>ChatClient::onReadyRead()</tt>, the only difference is that in case an ill-formatted message is received the central server gets notified via the <tt>logMessage()</tt> signal | |||
<nowiki> | |||
void ServerWorker::sendJson(const QJsonObject &json) | |||
{ | |||
// we crate a temporary QJsonDocument forom the object and then convert it | |||
// to its UTF-8 encoded version. We use QJsonDocument::Compact to save bandwidth | |||
const QByteArray jsonData = QJsonDocument(json).toJson(QJsonDocument::Compact); | |||
// we notify the central server we are about to send the message | |||
emit logMessage("Sending to " + userName() + " - " + QString::fromUtf8(jsonData)); | |||
// we send the message to the socket in the exact same way we did in the client | |||
QDataStream socketStream(m_serverSocket); | |||
socketStream.setVersion(QDataStream::Qt_5_7); | |||
socketStream << jsonData; | |||
} | |||
</nowiki> | |||
Again nothing new here, all we do is write the message to the socket using <tt>QDataStream</tt> | |||
Let's now look at the central server object. | |||
<nowiki> | |||
class ChatServer : public QTcpServer | |||
{ | |||
Q_OBJECT | |||
Q_DISABLE_COPY(ChatServer) | |||
public: | |||
explicit ChatServer(QObject *parent = nullptr); | |||
protected: | |||
void incomingConnection(qintptr socketDescriptor) override; | |||
signals: | |||
void logMessage(const QString &msg); | |||
public slots: | |||
void stopServer(); | |||
private slots: | |||
void broadcast(const QJsonObject &message, ServerWorker *exclude); | |||
void jsonReceived(ServerWorker *sender, const QJsonObject &doc); | |||
void userDisconnected(ServerWorker *sender); | |||
void userError(ServerWorker *sender); | |||
private: | |||
void jsonFromLoggedOut(ServerWorker *sender, const QJsonObject &doc); | |||
void jsonFromLoggedIn(ServerWorker *sender, const QJsonObject &doc); | |||
void sendJson(ServerWorker *destination, const QJsonObject &message); | |||
QVector<ServerWorker *> m_clients; | |||
}; | |||
</nowiki> | |||
We inherit <tt>QTcpServer</tt> just for convenience so we don't have to forward the interface in this object. | |||
Let's break down what each of these methods and members do: | |||
{| class="wikitable" | |||
|- | |||
! colspan="2" | Private Members | |||
|- | |||
| <tt>m_clients</tt> || This will hold a list of the objects communicating with the clients | |||
|- | |||
! colspan="2" | Private Methods | |||
|- | |||
| <tt>jsonFromLoggedOut()</tt> || This method is executed when we receive a message from a client that did not negotiated a username yet | |||
|- | |||
| <tt>jsonFromLoggedIn()</tt> || This method is executed when we receive a message from a client that already negotiated a username | |||
|- | |||
| <tt>sendJson()</tt> || This method sends a specific message to a single client | |||
|- | |||
! colspan="2" | Protected Members | |||
|- | |||
| <tt>incomingConnection</tt> || Override from <tt>QTcpServer</tt>. This gets executed every time a client attempts a connection with the server | |||
|- | |||
! colspan="2" | Public Slots | |||
|- | |||
| <tt>stopServer()</tt> || This slots stops the server performing any functionality. | |||
|- | |||
! colspan="2" | Private Slots | |||
|- | |||
| <tt>broadcast()</tt> || Sends a message to all connected clients with an option to exclude a specific one. | |||
|- | |||
| <tt>jsonReceived()</tt> || This slot is executed every time a message is received from a client. | |||
|- | |||
| <tt>userDisconnected()</tt> || This slot is executed every time client disconnects. | |||
|- | |||
| <tt>userError()</tt> || This slot is executed every time a socket connected to a client encounters an error. | |||
|- | |||
! colspan="2" | Signals | |||
|- | |||
| <tt>logMessage()</tt> || Emitted when the server encounters any event that should be logged. | |||
|- | |||
|} | |||
Let's now look at the implementation: | |||
<nowiki> | |||
void ChatServer::incomingConnection(qintptr socketDescriptor) | |||
{ | |||
// This method will get called every time a client tries to connect. | |||
// We create an object that will take care of the communication with this client | |||
ServerWorker *worker = new ServerWorker(this); | |||
// we attempt to bind the worker to the client | |||
if (!worker->setSocketDescriptor(socketDescriptor)) { | |||
// if we fail we clean up | |||
worker->deleteLater(); | |||
return; | |||
} | |||
// connect the signals coming from the object that will take care of the | |||
// communication with this client to the slots in the central server | |||
connect(worker, &ServerWorker::disconnectedFromClient, this, std::bind(&ChatServer::userDisconnected, this, worker)); | |||
connect(worker, &ServerWorker::error, this, std::bind(&ChatServer::userError, this, worker)); | |||
connect(worker, &ServerWorker::jsonReceived, this, std::bind(&ChatServer::jsonReceived, this, worker, std::placeholders::_1)); | |||
connect(worker, &ServerWorker::logMessage, this, &ChatServer::logMessage); | |||
// we append the new worker to a list of all the objects that communicate to a single client | |||
m_clients.append(worker); | |||
// we log the event | |||
emit logMessage(QStringLiteral("New client Connected")); | |||
} | |||
</nowiki> | |||
The first method we implement is the override from <tt>QTcpSocket</tt>. For readers less familiar with the functional header of the C++11 standard, <tt>std::bind</tt> creates a functor with some fixed arguments. | |||
For example <tt>connect(worker, &ServerWorker::error, this, std::bind(&ChatServer::userError, this, worker));</tt> will result in <tt>this->userError(worker);</tt> to be called every time the <tt>worker</tt> emits the <tt>error</tt> signal. | |||
<nowiki> | |||
void ChatServer::sendJson(ServerWorker *destination, const QJsonObject &message) | |||
{ | |||
Q_ASSERT(destination); // make sure destination is not null | |||
destination->sendJson(message); // call directly the worker method | |||
} | |||
void ChatServer::broadcast(const QJsonObject &message, ServerWorker *exclude) | |||
{ | |||
// iterate over all the workers that interact with the clients | |||
for (ServerWorker *worker : m_clients) { | |||
if (worker == exclude) | |||
continue; // skip the worker that should be excluded | |||
sendJson(worker, message); //send the message to the worker | |||
} | |||
} | |||
</nowiki> | |||
These 2 methods are straightforward, they just call <tt>ServerWorker::sendJson</tt> for a specific or multiple workers. | |||
== Part 2: Redesigning the TCP client-server for profit and glory == | |||
=== Prerequisites === | |||
* Qt 5.7 or later | |||
* An intermediate knowledge of C++, familiarity with C++11 concepts, especially [http://en.cppreference.com/w/cpp/language/lambda lambdas] and [http://www.cplusplus.com/reference/functional/bind/ std::bind] | |||
* An intermediate level of knowledge of Qt5 | |||
* Basic knowledge about software design principles | |||
* Basic knowledge about binary layout and creating libraries, familiarity with qmake | |||
=== Motivation === | |||
One of the weak points that can be seen in the first part of this tutorial is that it is quite verbose. There is quite a lot of code duplication, which makes future maintainability and the possibilities for extension rather limited. | |||
So to improve in that regard the following was done: | |||
# Separate the data reception from the data processing by introducing a session class to handle the TCP communication. | |||
# Introduce messages as a type and encapsulate the [https://www.json.org/ JSON] parsing into that class. | |||
# Create a common library that will hold the code to be reused. This includes: | |||
#* The generic TCP server code. | |||
#* The TCP server main window and form, as the interface is reused between the threaded and non-threaded example. | |||
#* The message class, which provides the [https://www.json.org/ JSON] parsing and data conversion. | |||
#* The session class, which provides the generic code for the TCP data exchange. | |||
#* And finally a tiny utility class to facilitate the threading management for the multithreaded TCP server. | |||
=== Project structure === | |||
Beside a common project file, each of the subprojects in this example lives in its own subdirectory with its own [http://doc.qt.io/qt-5/qmake-project-files.html qmake project file]. | |||
The [https://github.com/VSRonin/ChatExample/blob/commonlib/QtSimpleChat.pro QtSimpleChat.pro] in the top-level directory makes use of the subdirs template to build all the subprojects and to set the dependencies between them, as the actual library has to be built before the applications:<code> | |||
TEMPLATE = subdirs | |||
SUBDIRS = QtSimpleChat QtSimpleChatClient QtSimpleChatServer QtSimpleChatServerThreaded | |||
QtSimpleChatClient.depends = QtSimpleChat | |||
QtSimpleChatServer.depends = QtSimpleChat | |||
QtSimpleChatServerThreaded.depends = QtSimpleChat | |||
</code>Here [https://github.com/VSRonin/ChatExample/tree/commonlib/QtSimpleChat QtSimpleChat] refers to the common library while the rest of the subprojects are straightforwardly self-explanatory. The common project configurations were put, as usually done, in a project include file [https://github.com/VSRonin/ChatExample/blob/commonlib/QtSimpleChat.pri QtSimpleChat.pri] that is included by each of the subprojects. The common configuration just facilitates shadow building for each of the projects by setting up the proper output directories:<code> | |||
CONFIG(release, debug|release): DESTDIR = $$PWD/release | |||
CONFIG(debug, debug|release): DESTDIR = $$PWD/debug | |||
OBJECTS_DIR = $$DESTDIR/.obj/$$TARGET | |||
MOC_DIR = $$DESTDIR/.moc/$$TARGET | |||
RCC_DIR = $$DESTDIR/.rcc/$$TARGET | |||
UI_DIR = $$DESTDIR/.ui/$$TARGET | |||
</code>Without getting in too much details, the aforementioned files ensure that the binaries are put in separate folders - release and debug - respective of the build configuration; and that the intermediate sources/headers generated by Qt's [http://doc.qt.io/qt-5/moc.html moc], [http://doc.qt.io/qt-5/rcc.html rcc] and [http://doc.qt.io/qt-5/uic.html uic], and the compiled object files are all put together in their own sub-directories named after the project's name. Additional information on the [http://doc.qt.io/qt-5/qmake-variable-reference.html variables] and [http://doc.qt.io/qt-5/qmake-language.html qmake syntax] can be found at the relevant [http://doc.qt.io/qt-5/qmake-manual.html documentation pages]. | |||
=== The common library === | |||
The [https://github.com/VSRonin/ChatExample/blob/commonlib/QtSimpleChat/QtSimpleChat.pro project file for the common library] is fairly standard. The important points are the include at the top of the file:<code> | |||
include(../QtSimpleChat.pri) | |||
</code>which allows to make use of the already discussed [https://github.com/VSRonin/ChatExample/blob/commonlib/QtSimpleChat.pri common configuration file], and the definition of the <code>QTSIMPLECHAT_LIBRARY</code> macro:<code> | |||
DEFINES += QTSIMPLECHAT_LIBRARY QT_DEPRECATED_WARNINGS | |||
</code>which is necessary to be defined whenever the library itself is built and must not be defined whenever a project is built against the library. It is [http://doc.qt.io/qt-5/sharedlibrary.html a common pattern] to allow for proper handling exported symbols from within a shared library, and can be seen in the [https://github.com/VSRonin/ChatExample/blob/commonlib/QtSimpleChat/qtsimplechat.h library's global include]:<code> | |||
#if defined(QTSIMPLECHAT_LIBRARY) | |||
# define QTSIMPLECHAT_EXPORT Q_DECL_EXPORT | |||
#else | |||
# define QTSIMPLECHAT_EXPORT Q_DECL_IMPORT | |||
#endif | |||
</code>Beside the above the [https://github.com/VSRonin/ChatExample/blob/commonlib/QtSimpleChat/qtsimplechat.h global include] contains a couple of forward declarations that are needed throughout the code:<code> | |||
template <class T> | |||
class QSharedDataPointer; | |||
class ChatMessage; | |||
typedef QSharedDataPointer<ChatMessage> ChatMessagePointer; | |||
</code> | |||
==== The chat message class ==== | |||
The chat messages marshaled between the client and the server are self contained in their own type(s) ([https://github.com/VSRonin/ChatExample/blob/commonlib/QtSimpleChat/chatmessage.h header file]), which allows for the low-level [https://www.json.org/ JSON] parsing to be separated out from the application logic. Additionally, different message types are supported through the abstract base class ChatMessage, which is also making use of [http://doc.qt.io/qt-5/qshareddatapointer.html#details implicit sharing] as [http://doc.qt.io/qt-5/implicit-sharing.html many Qt data types] do: | |||
<nowiki> | |||
class QTSIMPLECHAT_EXPORT ChatMessage : public QSharedData | |||
{ | |||
friend ChatMessagePointer; | |||
</nowiki> | |||
Here ChatMessagePointer is a friend class so as to hide the [http://doc.qt.io/qt-5/qshareddatapointer.html#clone virtual copy constructor] from the user. The ChatMessage abstract base class defines the interface for all message types and the common data. Each supported message type gets an enumeration value: | |||
<nowiki> | |||
public: | |||
enum Type { UnknownType = -1, LoginType = 0, LoginStatusType, LogoutType, TextType }; | |||
</code>and disallows explicit copying of the object:<code> | |||
public: | |||
ChatMessage() = default; | |||
virtual ~ChatMessage(); | |||
protected: | |||
ChatMessage(const ChatMessage &) = default; | |||
ChatMessage & operator = (const ChatMessage &) = default; | |||
</nowiki> | |||
The class also includes a utility to distinguish the message type out from a [http://doc.qt.io/qt-5/qjsonobject.html QJsonObject QJsonObject]: | |||
<nowiki> | |||
public: | |||
static Type type(const QJsonObject &); | |||
</nowiki> | |||
Each concrete implementation is to support at least querying for the message type: | |||
<nowiki> | |||
virtual Type type() const = 0; | |||
</nowiki> | |||
a pair of a setter and a getter for the name of the user that sent the message: | |||
<nowiki> | |||
void setUsername(const QString &); | |||
QString username() const; | |||
</nowiki> | |||
a pair of serialization to [http://doc.qt.io/qt-5/qjsonobject.html JSON] and deserialization from [http://doc.qt.io/qt-5/qjsonobject.html JSON] routines: | |||
<nowiki> | |||
virtual bool fromJson(const QJsonObject &); | |||
virtual QJsonObject toJson() const; | |||
</nowiki> | |||
and [http://doc.qt.io/qt-5/qshareddatapointer.html#clone a virtual copy constructor] (copying the object through a base pointer): | |||
<nowiki> | |||
protected: | |||
virtual ChatMessage * clone() const = 0; | |||
</nowiki> | |||
Accounting for the variable that stores the peer's username concludes the class declaration: | |||
<nowiki> | |||
protected: | |||
QString user; | |||
}; | |||
</nowiki> | |||
One additional function is required, however, to allow the types that derive from the ChatMessage base to be passed around through the ChatMessagePointer properly, namely a [http://doc.qt.io/qt-5/qshareddatapointer.html#clone T *QSharedDataPointer::clone() specialization] for the ChatMessage type. It is just a trivial template specialization that calls the ChatMessage::clone() virtual method: | |||
<nowiki> | |||
template <> | |||
inline ChatMessage * ChatMessagePointer::clone() | |||
{ | |||
return d->clone(); | |||
} | |||
</nowiki> |
Latest revision as of 09:28, 5 November 2020
Introduction
This article will illustrate a simple chat client and server communicating over TCP. The aim is to clarify aspects of QTcpSocket/QTcpServer that are not developed in the official Qt Fortune example. This has no intention to be a fully featured chat application.
You can find the source code relating to this example on GitHub The article is split into two large parts (the master branch for part 1 and the commonlib branch for part 2) that are targeted at audiences of different levels - the first one is to introduce you to the concepts that are used and how a TCP server-client application can be developed, while the second focuses on how the code can be improved, and how to redesign the basic ideas to gain reusability and extensibility.
The Logic
This application will use a central server that will manage the communication among clients via JSON messages. Two version of the server are implemented, one that runs in a single thread and one that distributes the client sockets among multiple threads.
Part 1: Creating the basic client-server application
Prerequisites
- Qt 5.7 or later
- An intermediate knowledge of C++, familiarity with C++11 concepts, especially lambdas and std::bind
- An intermediate level of knowledge of Qt5
The Client
The client in this example is a simple QtWidgets application, the ui is minimal, just a button to connect to the server, a list view to display the messages received, a line edit to type your messages and a button to send them.
The core of the functionality is in the ChatClient class.
class ChatClient : public QObject { Q_OBJECT Q_DISABLE_COPY(ChatClient) public: explicit ChatClient(QObject *parent = nullptr); public slots: void connectToServer(const QHostAddress &address, quint16 port); void login(const QString &userName); void sendMessage(const QString &text); void disconnectFromHost(); private slots: void onReadyRead(); signals: void connected(); void loggedIn(); void loginError(const QString &reason); void disconnected(); void messageReceived(const QString &sender, const QString &text); void error(QAbstractSocket::SocketError socketError); void userJoined(const QString &username); void userLeft(const QString &username); private: QTcpSocket *m_clientSocket; bool m_loggedIn; void jsonReceived(const QJsonObject &doc); };
Let's break down what each of these methods and members do:
Private Members | |
---|---|
m_clientSocket | The socket that will interact with the server |
m_loggedIn | Used to record whether the client already agreed a username with the server or not |
Private Methods and Slots | |
onReadyRead() | Slot that reacts to data being available on the socket. It will download the data and pass it to jsonReceived() |
jsonReceived() | Method to process the messages received from the server |
Signals | |
connected() | The client connected to the server successfully |
disconnected() | The client is not connected any more |
loggedIn() | The client successfully agreed a username with the server |
loginError() | The server rejected the username provided |
messageReceived() | The client received a chat message |
userJoined() | A new user joined the chat |
userLeft() | A user left the chat |
error() | Just a forward of the QTcpSocket::error signal |
Public Slots | |
connectToServer() | Attempts the connection to a server |
login() | Attempts to agree the given username with the server |
disconnectFromHost() | Closes the connection with the server |
sendMessage() | Sends a chat message to the server |
Let's now look at the implementation:
ChatClient::ChatClient(QObject *parent) : QObject(parent) , m_clientSocket(new QTcpSocket(this)) , m_loggedIn(false) { // Forward the connected and disconnected signals connect(m_clientSocket, &QTcpSocket::connected, this, &ChatClient::connected); connect(m_clientSocket, &QTcpSocket::disconnected, this, &ChatClient::disconnected); // connect readyRead() to the slot that will take care of reading the data in connect(m_clientSocket, &QTcpSocket::readyRead, this, &ChatClient::onReadyRead); // Forward the error signal, QOverload is necessary as error() is overloaded, see the Qt docs connect(m_clientSocket, QOverload<QAbstractSocket::SocketError>::of(&QAbstractSocket::error), this, &ChatClient::error); // Reset the m_loggedIn variable when we disconnect. Since the operation is trivial we use a lambda instead of creating another slot connect(m_clientSocket, &QTcpSocket::disconnected, this, [this]()->void{m_loggedIn = false;}); }
In the constructor we initialize the parent class and the members, then we take care of connecting to the signals coming from the socket.
void ChatClient::login(const QString &userName) { if (m_clientSocket->state() == QAbstractSocket::ConnectedState) { // if the client is connected // create a QDataStream operating on the socket QDataStream clientStream(m_clientSocket); // set the version so that programs compiled with different versions of Qt can agree on how to serialise clientStream.setVersion(QDataStream::Qt_5_7); // Create the JSON we want to send QJsonObject message; message["type"] = QStringLiteral("login"); message["username"] = userName; // send the JSON using QDataStream clientStream << QJsonDocument(message).toJson(); } } void ChatClient::sendMessage(const QString &text) { if (text.isEmpty()) return; // We don't send empty messages // create a QDataStream operating on the socket QDataStream clientStream(m_clientSocket); // set the version so that programs compiled with different versions of Qt can agree on how to serialise clientStream.setVersion(QDataStream::Qt_5_7); // Create the JSON we want to send QJsonObject message; message["type"] = QStringLiteral("message"); message["text"] = text; // send the JSON using QDataStream clientStream << QJsonDocument(message).toJson(QJsonDocument::Compact); }
The login and sendMessage are very similar as both of them send a JSON over the TCP socket.
void ChatClient::disconnectFromHost() { m_clientSocket->disconnectFromHost(); } void ChatClient::connectToServer(const QHostAddress &address, quint16 port) { m_clientSocket->connectToHost(address, port); }
The connectToServer and disconnectFromHost slots just call the corresponding method on the socket
void ChatClient::jsonReceived(const QJsonObject &docObj) { // actions depend on the type of message const QJsonValue typeVal = docObj.value(QLatin1String("type")); if (typeVal.isNull() || !typeVal.isString()) return; // a message with no type was received so we just ignore it if (typeVal.toString().compare(QLatin1String("login"), Qt::CaseInsensitive) == 0) { //It's a login message if (m_loggedIn) return; // if we are already logged in we ignore // the success field will contain the result of our attempt to login const QJsonValue resultVal = docObj.value(QLatin1String("success")); if (resultVal.isNull() || !resultVal.isBool()) return; // the message had no success field so we ignore const bool loginSuccess = resultVal.toBool(); if (loginSuccess) { // we logged in successfully and we notify it via the loggedIn signal emit loggedIn(); return; } // the login attempt failed, we extract the reason of the failure from the JSON // and notify it via the loginError signal const QJsonValue reasonVal = docObj.value(QLatin1String("reason")); emit loginError(reasonVal.toString()); } else if (typeVal.toString().compare(QLatin1String("message"), Qt::CaseInsensitive) == 0) { //It's a chat message // we extract the text field containing the chat text const QJsonValue textVal = docObj.value(QLatin1String("text")); // we extract the sender field containing the username of the sender const QJsonValue senderVal = docObj.value(QLatin1String("sender")); if (textVal.isNull() || !textVal.isString()) return; // the text field was invalid so we ignore if (senderVal.isNull() || !senderVal.isString()) return; // the sender field was invalid so we ignore // we notify a new message was received via the messageReceived signal emit messageReceived(senderVal.toString(), textVal.toString()); } else if (typeVal.toString().compare(QLatin1String("newuser"), Qt::CaseInsensitive) == 0) { // A user joined the chat // we extract the username of the new user const QJsonValue usernameVal = docObj.value(QLatin1String("username")); if (usernameVal.isNull() || !usernameVal.isString()) return; // the username was invalid so we ignore // we notify of the new user via the userJoined signal emit userJoined(usernameVal.toString()); } else if (typeVal.toString().compare(QLatin1String("userdisconnected"), Qt::CaseInsensitive) == 0) { // A user left the chat // we extract the username of the new user const QJsonValue usernameVal = docObj.value(QLatin1String("username")); if (usernameVal.isNull() || !usernameVal.isString()) return; // the username was invalid so we ignore // we notify of the user disconnection the userLeft signal emit userLeft(usernameVal.toString()); } }
The jsonReceived method is all about JSON parsing, since it's outside the scope of this example we won't spend any-more words on it as the code itself is well commented
void ChatClient::onReadyRead() { // prepare a container to hold the UTF-8 encoded JSON we receive from the socket QByteArray jsonData; // create a QDataStream operating on the socket QDataStream socketStream(m_clientSocket); // set the version so that programs compiled with different versions of Qt can agree on how to serialise socketStream.setVersion(QDataStream::Qt_5_7); // start an infinite loop for (;;) { // we start a transaction so we can revert to the previous state in case we try to read more data than is available on the socket socketStream.startTransaction(); // we try to read the JSON data socketStream >> jsonData; if (socketStream.commitTransaction()) { // we successfully read some data // we now need to make sure it's in fact a valid JSON QJsonParseError parseError; // we try to create a json document with the data we received const QJsonDocument jsonDoc = QJsonDocument::fromJson(jsonData, &parseError); if (parseError.error == QJsonParseError::NoError) { // if the data was indeed valid JSON if (jsonDoc.isObject()) // and is a JSON object jsonReceived(jsonDoc.object()); // parse the JSON } // loop and try to read more JSONs if they are available } else { // the read failed, the socket goes automatically back to the state it was in before the transaction started // we just exit the loop and wait for more data to become available break; } } }
The onReadyRead slot is really the most interesting one. The first thing to remember is that when we receive the readyRead we can't make any assumption on how much data is available, the signal only tells us some data is there hence there are 4 cases we need to handle:
- The socket did not receive enough data to be a complete message, we only received a partial
- There is exactly enough data in the socket buffer to read a message
- There is more than enough data in the socket buffer to read a message but not enough to read 2 messages
- There is enough data in the socket buffer to read multiple messages
Cases 1 and 2 are entirely handled by socketStream.commitTransaction(). If there was not enough data to complete the reading when we called socketStream >> jsonData; this method will return false and we'll just exit the function and wait for more data to come in otherwise it will proceed and parse the data as a complete JSON message. Cases 3 and 4 are handled by the infinite loop. After we read the first message we try to read another one in the same way as before and break the loop only when the data in the buffer is no longer enough to be a complete JSON message.
To facilitate the handling of differences in the expected size of the data received and the actual size, since Qt 5.7, QIODevice and QDataStream introduced transactions. Transactions work very similarly to SQL transactions: start the transaction, try and do something with the data, if everything went as expected you commit the transaction, otherwise you can go back as if you did nothing at all. QDataStream::commitTransaction will actually rollback if it detects there was an error and return false. We use this to check if socketStream >> jsonData; retrieved a full JSON document or not.". Internally socketStream >> jsonData; will read a 32bit unsigned integer that will be jsonData.size() after the read. Then it will read raw bytes of data to fill that lenght. If it detects any error it will set socketStream.status() to something different form QDataStream::Ok. socketStream.commitTransaction() then checks that status. If it is QDataStream::Ok it will shorten the buffer in the socket by the amount of data we successfully read and return true, otherwise it will return false maintaining the socket buffer identical to what it was before.
The Client Interface
Now that we have the chat client object let's build a UI for it, we'll use the Qt Widgets module for this.
The interface is very minimalistic, it has:
- one button to connect to the server
- a list view to display all the chat message
- a line edit to type messages into
- a button to send the message to the server
At the start of the program, the only element enabled is the connect button while the rest will only be enabled once the client is connected to the server.
Let's analyse the header of the widget
class ChatWindow : public QWidget { Q_OBJECT Q_DISABLE_COPY(ChatWindow) public: explicit ChatWindow(QWidget *parent = nullptr); ~ChatWindow(); private: Ui::ChatWindow *ui; ChatClient *m_chatClient; QStandardItemModel *m_chatModel; QString m_lastUserName; private slots: void attemptConnection(); void connectedToServer(); void attemptLogin(const QString &userName); void loggedIn(); void loginFailed(const QString &reason); void messageReceived(const QString &sender, const QString &text); void sendMessage(); void disconnectedFromServer(); void userJoined(const QString &username); void userLeft(const QString &username); void error(QAbstractSocket::SocketError socketError); };
Let's break down what each of these methods and members do:
Private Members | |
---|---|
ui | This will hold the elements that we laid out in the .ui file |
m_chatClient | The client object we developed above |
m_chatModel | This model will be displayed in the main list view and will hold the chat messages |
m_lastUserName | The username of the last person that spoke in the chat, we use this only for aesthetic purposes |
Private Slots | |
attemptConnection() | Asks the user to enter the server address and attempts a connection to it |
connectedToServer() | This slot is executed once the client signals it successfully connected to the server |
attemptLogin() | Tells the client to attempt a login with a given username |
loggedIn() | This slot is executed once the client signals it successfully logged in |
loginFailed() | This slot is executed once the client signals it failed in its log in attempt |
messageReceived() | This slot is executed every time a new message is received from the server |
sendMessage() | Sends the message in the line edit to the server |
disconnectedFromServer() | This slot is executed when the clients loses connection with the server |
userJoined() | This slot is executed when a new user logs in the chat |
userLeft() | This slot is executed when a user leaves the chat |
error() | This slot is executed when the client detects an error |
And now the implementations:
ChatWindow::ChatWindow(QWidget *parent) : QWidget(parent) , ui(new Ui::ChatWindow) // create the elements defined in the .ui file , m_chatClient(new ChatClient(this)) // create the chat client , m_chatModel(new QStandardItemModel(this)) // create the model to hold the messages { // set up of the .ui file ui->setupUi(this); // the model for the messages will have 1 column m_chatModel->insertColumn(0); // set the model as the data source vor the list view ui->chatView->setModel(m_chatModel); // connect the signals from the chat client to the slots in this ui connect(m_chatClient, &ChatClient::connected, this, &ChatWindow::connectedToServer); connect(m_chatClient, &ChatClient::loggedIn, this, &ChatWindow::loggedIn); connect(m_chatClient, &ChatClient::loginError, this, &ChatWindow::loginFailed); connect(m_chatClient, &ChatClient::messageReceived, this, &ChatWindow::messageReceived); connect(m_chatClient, &ChatClient::disconnected, this, &ChatWindow::disconnectedFromServer); connect(m_chatClient, &ChatClient::error, this, &ChatWindow::error); connect(m_chatClient, &ChatClient::userJoined, this, &ChatWindow::userJoined); connect(m_chatClient, &ChatClient::userLeft, this, &ChatWindow::userLeft); // connect the connect button to a slot that will attempt the connection connect(ui->connectButton, &QPushButton::clicked, this, &ChatWindow::attemptConnection); // connect the click of the "send" button and the press of the enter while typing to the slot that sends the message connect(ui->sendButton, &QPushButton::clicked, this, &ChatWindow::sendMessage); connect(ui->messageEdit, &QLineEdit::returnPressed, this, &ChatWindow::sendMessage); }
The constructor creates the client and connects slots to the signals it sends. It also sets up the ui and connect a few slots to implement functionalities on button clicks
ChatWindow::~ChatWindow() { // delete the elements created from the .ui file delete ui; }
void ChatWindow::attemptConnection() { // We ask the user for the address of the server, we use 127.0.0.1 (aka localhost) as default const QString hostAddress = QInputDialog::getText( this , tr("Chose Server") , tr("Server Address") , QLineEdit::Normal , QStringLiteral("127.0.0.1") ); if (hostAddress.isEmpty()) return; // the user pressed cancel or typed nothing // disable the connect button to prevent the user clicking it again ui->connectButton->setEnabled(false); // tell the client to connect to the host using the port 1967 m_chatClient->connectToServer(QHostAddress(hostAddress), 1967); }
In attemptConnection we ask the user to input the IP address of the server. We decide to use port 1967 but you are free to use whatever port you prefer or even ask the port as an input from the user. This slots will be executed when the user clicks the "Connect" button.
void ChatWindow::connectedToServer() { // once we connected to the server we ask the user for what username they would like to use const QString newUsername = QInputDialog::getText(this, tr("Chose Username"), tr("Username")); if (newUsername.isEmpty()){ // if the user clicked cancel or typed nothing, we just disconnect from the server return m_chatClient->disconnectFromHost(); } // try to login with the given username attemptLogin(newUsername); }
Once we establish a connection with the server we ask the user to select a username.
void ChatWindow::attemptLogin(const QString &userName) { // use the client to attempt a log in with the given username m_chatClient->login(userName); }
The client will negotiate the username with the server
void ChatWindow::loggedIn() { // once we successfully log in we enable the ui to display and send messages ui->sendButton->setEnabled(true); ui->messageEdit->setEnabled(true); ui->chatView->setEnabled(true); // clear the user name record m_lastUserName.clear(); } void ChatWindow::loginFailed(const QString &reason) { // the server rejected the login attempt // display the reason for the rejection in a message box QMessageBox::critical(this, tr("Error"), reason); // allow the user to retry, execute the same slot as when just connected connectedToServer(); } void ChatWindow::messageReceived(const QString &sender, const QString &text) { // store the index of the new row to append to the model containing the messages int newRow = m_chatModel->rowCount(); // we display a line containing the username only if it's different from the last username we displayed if (m_lastUserName != sender) { // store the last displayed username m_lastUserName = sender; // create a bold default font QFont boldFont; boldFont.setBold(true); // insert 2 row, one for the message and one for the username m_chatModel->insertRows(newRow, 2); // store the username in the model m_chatModel->setData(m_chatModel->index(newRow, 0), sender + ':'); // set the alignment for the username m_chatModel->setData(m_chatModel->index(newRow, 0), int(Qt::AlignLeft | Qt::AlignVCenter), Qt::TextAlignmentRole); // set the for the username m_chatModel->setData(m_chatModel->index(newRow, 0), boldFont, Qt::FontRole); ++newRow; } else { // insert a row for the message m_chatModel->insertRow(newRow); } // store the message in the model m_chatModel->setData(m_chatModel->index(newRow, 0), text); // set the alignment for the message m_chatModel->setData(m_chatModel->index(newRow, 0), int(Qt::AlignLeft | Qt::AlignVCenter), Qt::TextAlignmentRole); // scroll the view to display the new message ui->chatView->scrollToBottom(); }
This slot will be called once a message is received from the server. It adds the message to the model aligned to the left. It also adds the username of the sender only if it's different from the previous sender, this avoids clogging the interface repeating the username of the sender for every message.
void ChatWindow::sendMessage() { // we use the client to send the message that the user typed m_chatClient->sendMessage(ui->messageEdit->text()); // now we add the message to the list // store the index of the new row to append to the model containing the messages const int newRow = m_chatModel->rowCount(); // insert a row for the message m_chatModel->insertRow(newRow); // store the message in the model m_chatModel->setData(m_chatModel->index(newRow, 0), ui->messageEdit->text()); // set the alignment for the message m_chatModel->setData(m_chatModel->index(newRow, 0), int(Qt::AlignRight | Qt::AlignVCenter), Qt::TextAlignmentRole); // clear the content of the message editor ui->messageEdit->clear(); // scroll the view to display the new message ui->chatView->scrollToBottom(); // reset the last printed username m_lastUserName.clear(); }
This method sends the message to the server and adds it to the model aligned to the right
void ChatWindow::disconnectedFromServer() { // if the client loses connection to the server // communicate the event to the user via a message box QMessageBox::warning(this, tr("Disconnected"), tr("The host terminated the connection")); // disable the ui to send and display messages ui->sendButton->setEnabled(false); ui->messageEdit->setEnabled(false); ui->chatView->setEnabled(false); // enable the button to connect to the server again ui->connectButton->setEnabled(true); // reset the last printed username m_lastUserName.clear(); } void ChatWindow::userJoined(const QString &username) { // store the index of the new row to append to the model containing the messages const int newRow = m_chatModel->rowCount(); // insert a row m_chatModel->insertRow(newRow); // store in the model the message to communicate a user joined m_chatModel->setData(m_chatModel->index(newRow, 0), tr("%1 Joined the Chat").arg(username)); // set the alignment for the text m_chatModel->setData(m_chatModel->index(newRow, 0), Qt::AlignCenter, Qt::TextAlignmentRole); // set the color for the text m_chatModel->setData(m_chatModel->index(newRow, 0), QBrush(Qt::blue), Qt::ForegroundRole); // scroll the view to display the new message ui->chatView->scrollToBottom(); // reset the last printed username m_lastUserName.clear(); } void ChatWindow::userLeft(const QString &username) { // store the index of the new row to append to the model containing the messages const int newRow = m_chatModel->rowCount(); // insert a row m_chatModel->insertRow(newRow); // store in the model the message to communicate a user left m_chatModel->setData(m_chatModel->index(newRow, 0), tr("%1 Left the Chat").arg(username)); // set the alignment for the text m_chatModel->setData(m_chatModel->index(newRow, 0), Qt::AlignCenter, Qt::TextAlignmentRole); // set the color for the text m_chatModel->setData(m_chatModel->index(newRow, 0), QBrush(Qt::red), Qt::ForegroundRole); // scroll the view to display the new message ui->chatView->scrollToBottom(); // reset the last printed username m_lastUserName.clear(); }
These 2 methods are almost identical, all they do is adding a header in the chat window to warn about users joining/leaving the chat
void ChatWindow::error(QAbstractSocket::SocketError socketError) { // show a message to the user that informs of what kind of error occurred switch (socketError) { case QAbstractSocket::RemoteHostClosedError: case QAbstractSocket::ProxyConnectionClosedError: return; // handled by disconnectedFromServer case QAbstractSocket::ConnectionRefusedError: QMessageBox::critical(this, tr("Error"), tr("The host refused the connection")); break; case QAbstractSocket::ProxyConnectionRefusedError: QMessageBox::critical(this, tr("Error"), tr("The proxy refused the connection")); break; case QAbstractSocket::ProxyNotFoundError: QMessageBox::critical(this, tr("Error"), tr("Could not find the proxy")); break; case QAbstractSocket::HostNotFoundError: QMessageBox::critical(this, tr("Error"), tr("Could not find the server")); break; case QAbstractSocket::SocketAccessError: QMessageBox::critical(this, tr("Error"), tr("You don't have permissions to execute this operation")); break; case QAbstractSocket::SocketResourceError: QMessageBox::critical(this, tr("Error"), tr("Too many connections opened")); break; case QAbstractSocket::SocketTimeoutError: QMessageBox::warning(this, tr("Error"), tr("Operation timed out")); return; case QAbstractSocket::ProxyConnectionTimeoutError: QMessageBox::critical(this, tr("Error"), tr("Proxy timed out")); break; case QAbstractSocket::NetworkError: QMessageBox::critical(this, tr("Error"), tr("Unable to reach the network")); break; case QAbstractSocket::UnknownSocketError: QMessageBox::critical(this, tr("Error"), tr("An unknown error occurred")); break; case QAbstractSocket::UnsupportedSocketOperationError: QMessageBox::critical(this, tr("Error"), tr("Operation not supported")); break; case QAbstractSocket::ProxyAuthenticationRequiredError: QMessageBox::critical(this, tr("Error"), tr("Your proxy requires authentication")); break; case QAbstractSocket::ProxyProtocolError: QMessageBox::critical(this, tr("Error"), tr("Proxy communication failed")); break; case QAbstractSocket::TemporaryError: case QAbstractSocket::OperationError: QMessageBox::warning(this, tr("Error"), tr("Operation failed, please try again")); return; default: Q_UNREACHABLE(); } // enable the button to connect to the server again ui->connectButton->setEnabled(true); // disable the ui to send and display messages ui->sendButton->setEnabled(false); ui->messageEdit->setEnabled(false); ui->chatView->setEnabled(false); // reset the last printed username m_lastUserName.clear(); }
Finally we take care of any error that could happen on the client. For irrecoverable errors we disable the ui to send and display messages.
int main(int argc, char *argv[]) { QApplication a(argc, argv); ChatWindow chatWin; chatWin.show(); return a.exec(); }
The main function is as standard as they get. We create the application and the chat window. We show the window and start the main event loop.
The Single Thread Server
One common misconception is that, to handle multiple clients, the server needs to implement a multi-thread infrastructure, this is not the case as we'll see in this section.
We'll start by devising an object that will take care of communicating with a single client and manage the socket on the server side.
class ServerWorker : public QObject { Q_OBJECT Q_DISABLE_COPY(ServerWorker) public: explicit ServerWorker(QObject *parent = nullptr); virtual bool setSocketDescriptor(qintptr socketDescriptor); QString userName() const; void setUserName(const QString &userName); void sendJson(const QJsonObject &jsonData); signals: void jsonReceived(const QJsonObject &jsonDoc); void disconnectedFromClient(); void error(); void logMessage(const QString &msg); public slots: void disconnectFromClient(); private slots: void receiveJson(); private: QTcpSocket *m_serverSocket; QString m_userName; };
Let's break down what each of these methods and members do:
Private Members | |
---|---|
m_serverSocket | This will hold the socket that will communicate with the client |
m_userName | The username of the client connected to this object. |
Private Slots | |
receiveJson() | This method will take care of reading the incoming messages from the network and send them to the main server object. |
Public Slots | |
disconnectFromClient() | This method will close the connection with the client. |
Public Methods | |
userName() / setUserName() | Getter and setter for the username of the client. |
setSocketDescriptor() | Used by the server to direct the object toward what client it should listen to. |
Signals | |
disconnectedFromClient() | Emitted when the client closes the connection. |
jsonReceived() | Used to send to the central server a message that was received. |
logMessage() | Used to send to notify the central server of internal events that should be logged. |
error() | Used to send to notify the central server of an error. |
The implementation is pretty simple
ServerWorker::ServerWorker(QObject *parent) : QObject(parent) , m_serverSocket(new QTcpSocket(this)) { // connect readyRead() to the slot that will take care of reading the data in connect(m_serverSocket, &QTcpSocket::readyRead, this, &ServerWorker::receiveJson); // forward the disconnected and error signals coming from the socket connect(m_serverSocket, &QTcpSocket::disconnected, this, &ServerWorker::disconnectedFromClient); connect(m_serverSocket, QOverload<QAbstractSocket::SocketError>::of(&QAbstractSocket::error), this, &ServerWorker::error); }
The constructor creates the socket and connects the signals coming from it
bool ServerWorker::setSocketDescriptor(qintptr socketDescriptor) { return m_serverSocket->setSocketDescriptor(socketDescriptor); } void ServerWorker::disconnectFromClient() { m_serverSocket->disconnectFromHost(); } QString ServerWorker::userName() const { return m_userName; } void ServerWorker::setUserName(const QString &userName) { m_userName = userName; }
The methods above are trivial, they either just call the corresponding method in the socket or read/write the username
void ServerWorker::receiveJson() { // prepare a container to hold the UTF-8 encoded JSON we receive from the socket QByteArray jsonData; // create a QDataStream operating on the socket QDataStream socketStream(m_serverSocket); // set the version so that programs compiled with different versions of Qt can agree on how to serialise socketStream.setVersion(QDataStream::Qt_5_7); // start an infinite loop for (;;) { // we start a transaction so we can revert to the previous state in case we try to read more data than is available on the socket socketStream.startTransaction(); // we try to read the JSON data socketStream >> jsonData; if (socketStream.commitTransaction()) { // we successfully read some data // we now need to make sure it's in fact a valid JSON QJsonParseError parseError; // we try to create a json document with the data we received const QJsonDocument jsonDoc = QJsonDocument::fromJson(jsonData, &parseError); if (parseError.error == QJsonParseError::NoError) { // if the data was indeed valid JSON if (jsonDoc.isObject()) // and is a JSON object emit jsonReceived(jsonDoc.object()); // send the message to the central server else emit logMessage("Invalid message: " + QString::fromUtf8(jsonData)); //notify the server of invalid data } else { emit logMessage("Invalid message: " + QString::fromUtf8(jsonData)); //notify the server of invalid data } // loop and try to read more JSONs if they are available } else { // the read failed, the socket goes automatically back to the state it was in before the transaction started // we just exit the loop and wait for more data to become available break; } } }
This method is almost identical to ChatClient::onReadyRead(), the only difference is that in case an ill-formatted message is received the central server gets notified via the logMessage() signal
void ServerWorker::sendJson(const QJsonObject &json) { // we crate a temporary QJsonDocument forom the object and then convert it // to its UTF-8 encoded version. We use QJsonDocument::Compact to save bandwidth const QByteArray jsonData = QJsonDocument(json).toJson(QJsonDocument::Compact); // we notify the central server we are about to send the message emit logMessage("Sending to " + userName() + " - " + QString::fromUtf8(jsonData)); // we send the message to the socket in the exact same way we did in the client QDataStream socketStream(m_serverSocket); socketStream.setVersion(QDataStream::Qt_5_7); socketStream << jsonData; }
Again nothing new here, all we do is write the message to the socket using QDataStream
Let's now look at the central server object.
class ChatServer : public QTcpServer { Q_OBJECT Q_DISABLE_COPY(ChatServer) public: explicit ChatServer(QObject *parent = nullptr); protected: void incomingConnection(qintptr socketDescriptor) override; signals: void logMessage(const QString &msg); public slots: void stopServer(); private slots: void broadcast(const QJsonObject &message, ServerWorker *exclude); void jsonReceived(ServerWorker *sender, const QJsonObject &doc); void userDisconnected(ServerWorker *sender); void userError(ServerWorker *sender); private: void jsonFromLoggedOut(ServerWorker *sender, const QJsonObject &doc); void jsonFromLoggedIn(ServerWorker *sender, const QJsonObject &doc); void sendJson(ServerWorker *destination, const QJsonObject &message); QVector<ServerWorker *> m_clients; };
We inherit QTcpServer just for convenience so we don't have to forward the interface in this object. Let's break down what each of these methods and members do:
Private Members | |
---|---|
m_clients | This will hold a list of the objects communicating with the clients |
Private Methods | |
jsonFromLoggedOut() | This method is executed when we receive a message from a client that did not negotiated a username yet |
jsonFromLoggedIn() | This method is executed when we receive a message from a client that already negotiated a username |
sendJson() | This method sends a specific message to a single client |
Protected Members | |
incomingConnection | Override from QTcpServer. This gets executed every time a client attempts a connection with the server |
Public Slots | |
stopServer() | This slots stops the server performing any functionality. |
Private Slots | |
broadcast() | Sends a message to all connected clients with an option to exclude a specific one. |
jsonReceived() | This slot is executed every time a message is received from a client. |
userDisconnected() | This slot is executed every time client disconnects. |
userError() | This slot is executed every time a socket connected to a client encounters an error. |
Signals | |
logMessage() | Emitted when the server encounters any event that should be logged. |
Let's now look at the implementation:
void ChatServer::incomingConnection(qintptr socketDescriptor) { // This method will get called every time a client tries to connect. // We create an object that will take care of the communication with this client ServerWorker *worker = new ServerWorker(this); // we attempt to bind the worker to the client if (!worker->setSocketDescriptor(socketDescriptor)) { // if we fail we clean up worker->deleteLater(); return; } // connect the signals coming from the object that will take care of the // communication with this client to the slots in the central server connect(worker, &ServerWorker::disconnectedFromClient, this, std::bind(&ChatServer::userDisconnected, this, worker)); connect(worker, &ServerWorker::error, this, std::bind(&ChatServer::userError, this, worker)); connect(worker, &ServerWorker::jsonReceived, this, std::bind(&ChatServer::jsonReceived, this, worker, std::placeholders::_1)); connect(worker, &ServerWorker::logMessage, this, &ChatServer::logMessage); // we append the new worker to a list of all the objects that communicate to a single client m_clients.append(worker); // we log the event emit logMessage(QStringLiteral("New client Connected")); }
The first method we implement is the override from QTcpSocket. For readers less familiar with the functional header of the C++11 standard, std::bind creates a functor with some fixed arguments. For example connect(worker, &ServerWorker::error, this, std::bind(&ChatServer::userError, this, worker)); will result in this->userError(worker); to be called every time the worker emits the error signal.
void ChatServer::sendJson(ServerWorker *destination, const QJsonObject &message) { Q_ASSERT(destination); // make sure destination is not null destination->sendJson(message); // call directly the worker method } void ChatServer::broadcast(const QJsonObject &message, ServerWorker *exclude) { // iterate over all the workers that interact with the clients for (ServerWorker *worker : m_clients) { if (worker == exclude) continue; // skip the worker that should be excluded sendJson(worker, message); //send the message to the worker } }
These 2 methods are straightforward, they just call ServerWorker::sendJson for a specific or multiple workers.
Part 2: Redesigning the TCP client-server for profit and glory
Prerequisites
- Qt 5.7 or later
- An intermediate knowledge of C++, familiarity with C++11 concepts, especially lambdas and std::bind
- An intermediate level of knowledge of Qt5
- Basic knowledge about software design principles
- Basic knowledge about binary layout and creating libraries, familiarity with qmake
Motivation
One of the weak points that can be seen in the first part of this tutorial is that it is quite verbose. There is quite a lot of code duplication, which makes future maintainability and the possibilities for extension rather limited.
So to improve in that regard the following was done:
- Separate the data reception from the data processing by introducing a session class to handle the TCP communication.
- Introduce messages as a type and encapsulate the JSON parsing into that class.
- Create a common library that will hold the code to be reused. This includes:
- The generic TCP server code.
- The TCP server main window and form, as the interface is reused between the threaded and non-threaded example.
- The message class, which provides the JSON parsing and data conversion.
- The session class, which provides the generic code for the TCP data exchange.
- And finally a tiny utility class to facilitate the threading management for the multithreaded TCP server.
Project structure
Beside a common project file, each of the subprojects in this example lives in its own subdirectory with its own qmake project file.
The QtSimpleChat.pro in the top-level directory makes use of the subdirs template to build all the subprojects and to set the dependencies between them, as the actual library has to be built before the applications:
TEMPLATE = subdirs
SUBDIRS = QtSimpleChat QtSimpleChatClient QtSimpleChatServer QtSimpleChatServerThreaded
QtSimpleChatClient.depends = QtSimpleChat
QtSimpleChatServer.depends = QtSimpleChat
QtSimpleChatServerThreaded.depends = QtSimpleChat
Here QtSimpleChat refers to the common library while the rest of the subprojects are straightforwardly self-explanatory. The common project configurations were put, as usually done, in a project include file QtSimpleChat.pri that is included by each of the subprojects. The common configuration just facilitates shadow building for each of the projects by setting up the proper output directories:
CONFIG(release, debug|release): DESTDIR = $$PWD/release
CONFIG(debug, debug|release): DESTDIR = $$PWD/debug
OBJECTS_DIR = $$DESTDIR/.obj/$$TARGET
MOC_DIR = $$DESTDIR/.moc/$$TARGET
RCC_DIR = $$DESTDIR/.rcc/$$TARGET
UI_DIR = $$DESTDIR/.ui/$$TARGET
Without getting in too much details, the aforementioned files ensure that the binaries are put in separate folders - release and debug - respective of the build configuration; and that the intermediate sources/headers generated by Qt's moc, rcc and uic, and the compiled object files are all put together in their own sub-directories named after the project's name. Additional information on the variables and qmake syntax can be found at the relevant documentation pages.
The common library
The project file for the common library is fairly standard. The important points are the include at the top of the file:
include(../QtSimpleChat.pri)
which allows to make use of the already discussed common configuration file, and the definition of the
QTSIMPLECHAT_LIBRARY
macro:
DEFINES += QTSIMPLECHAT_LIBRARY QT_DEPRECATED_WARNINGS
which is necessary to be defined whenever the library itself is built and must not be defined whenever a project is built against the library. It is a common pattern to allow for proper handling exported symbols from within a shared library, and can be seen in the library's global include:
#if defined(QTSIMPLECHAT_LIBRARY)
# define QTSIMPLECHAT_EXPORT Q_DECL_EXPORT
#else
# define QTSIMPLECHAT_EXPORT Q_DECL_IMPORT
#endif
Beside the above the global include contains a couple of forward declarations that are needed throughout the code:
template <class T>
class QSharedDataPointer;
class ChatMessage;
typedef QSharedDataPointer<ChatMessage> ChatMessagePointer;
The chat message class
The chat messages marshaled between the client and the server are self contained in their own type(s) (header file), which allows for the low-level JSON parsing to be separated out from the application logic. Additionally, different message types are supported through the abstract base class ChatMessage, which is also making use of implicit sharing as many Qt data types do:
class QTSIMPLECHAT_EXPORT ChatMessage : public QSharedData { friend ChatMessagePointer;
Here ChatMessagePointer is a friend class so as to hide the virtual copy constructor from the user. The ChatMessage abstract base class defines the interface for all message types and the common data. Each supported message type gets an enumeration value:
public: enum Type { UnknownType = -1, LoginType = 0, LoginStatusType, LogoutType, TextType }; </code>and disallows explicit copying of the object:<code> public: ChatMessage() = default; virtual ~ChatMessage(); protected: ChatMessage(const ChatMessage &) = default; ChatMessage & operator = (const ChatMessage &) = default;
The class also includes a utility to distinguish the message type out from a QJsonObject:
public: static Type type(const QJsonObject &);
Each concrete implementation is to support at least querying for the message type:
virtual Type type() const = 0;
a pair of a setter and a getter for the name of the user that sent the message:
void setUsername(const QString &); QString username() const;
a pair of serialization to JSON and deserialization from JSON routines:
virtual bool fromJson(const QJsonObject &); virtual QJsonObject toJson() const;
and a virtual copy constructor (copying the object through a base pointer):
protected: virtual ChatMessage * clone() const = 0;
Accounting for the variable that stores the peer's username concludes the class declaration:
protected: QString user; };
One additional function is required, however, to allow the types that derive from the ChatMessage base to be passed around through the ChatMessagePointer properly, namely a T *QSharedDataPointer::clone() specialization for the ChatMessage type. It is just a trivial template specialization that calls the ChatMessage::clone() virtual method:
template <> inline ChatMessage * ChatMessagePointer::clone() { return d->clone(); }