WIP-How to create a simple chat application
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
Pre-Requisites
- Qt 5.7 or later
- An intermediate level of knowledge of Qt5
- Familiarity with C++11 concepts, especially lambdas and std::bind
The Logic
This application will use a central server that will manage the communication among clients via JSON messages. We'll implement 2 versions of the server, one that runs in a single server and one that distributes the sockets among multiple threads.
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 member does:
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 disconnec. 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();
}
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 login and sendMessage 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 succesfully 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;
}
}
}
Describe ReadyRead
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. It might be 100 JSON messages but it can also be just a partial of a 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. This is what we are using here to detect if what we read with socketStream >> jsonData; was actually a full JSON or just a partial.
Go On
Now that we have the chat client object let's build a UI for it.
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 member does:
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 successully 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
// comunicate 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 comunicate 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 comunicate 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 occured"));
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 comunication 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.