Клиент-серверный чат, используя сокеты Qt/C++

Предисловие


Статья ориентирована в основном на новичков. Целью ее написания является быстрое и максимально подробное описание сокетов, для начального понимания сети и сокетов. В свое время искал подобную, но нужны были подробные примеры. В стандартном примере fortune server/client, который идет с qt очень плохо показывают возможности сокетов.

Итак, сервер умеет:
  • «Слушать» произвольный адрес, порт
  • Авторизовать клиента по имени
  • Отправлять общие, приватные, серверные сообщения
  • Отправлять список пользователей
Клиент умеет подавать соответствующие запросы серверу.

Для понимания это будут Гуи-приложения:

image

В qt существуют классы QTcpSocket и QTcpServer для работы с сокетами. Используя сигналы и слоты, с ними можно работать в неблокирующем (асинхронном режиме). Это значит, если подключение к серверу занимает заметное количество времени, гуи не блокируется, а продолжает обрабатывать события, а когда произойдет подключение (либо ошибка), вызовется определенный слот (в текущем случае подключенный к сигналу connected()).

Клиент


Начнем с простого — не будем наследовать классы, а воспользуемся QTcpSocket:
//dialog.h
class Dialog : public QDialog
{
private slots:
//определим слоты для обработки сигналов сокета
    void onSokConnected();
    void onSokDisconnected();
//сигнал readyRead вызывается, когда сокет получает пакет (который может быть лишь частью отправленых данных) байтов
    void onSokReadyRead();
    void onSokDisplayError(QAbstractSocket::SocketError socketError);
private:
    QTcpSocket *_sok; //сокет
    quint16 _blockSize;//текущий размер блока данных
    QString _name;//имя клиента
};
Вообще при работе с сокетами нужно смотреть на данные как набор байтов, иначе могут быть проблемы с отображением информации лишь частично (пришло не полное сообщение, а следующее отображается с куском предыдущего). Чтобы избежать этих неприятностей будем использовать потоки данных (QDataStream) и предавать между сокетами блоки, в которых первые 2 байта это размер текущего блока, 3-й байт это команда клиента серверу (или ответ сервера), а остальное — данные в зависимости от команды. Стоит сказать, что протокол tcp гарантирует доставку всех пакетов, поэтому можно смело ждать полный размер блока, прежде чем его обрабатывать.

//dialog.cpp
Dialog::Dialog(QWidget *parent) :QDialog(parent),ui(new Ui::Dialog)
{
//создаем сокет
    _sok = new QTcpSocket(this);
//подключаем сигналы
    connect(_sok, SIGNAL(readyRead()), this, SLOT(onSokReadyRead()));
    connect(_sok, SIGNAL(connected()), this, SLOT(onSokConnected()));
    connect(_sok, SIGNAL(disconnected()), this, SLOT(onSokDisconnected()));
    connect(_sok, SIGNAL(error(QAbstractSocket::SocketError)),this, SLOT(onSokDisplayError(QAbstractSocket::SocketError)));
}

//по нажатию кнопки подключаемся к северу, отметим, что connectToHost() возвращает тип void, потому, что это асинхронный вызов и в случае ошибки будет вызван слот onSokDisplayError
void Dialog::on_pbConnect_clicked()
{
    _sok->connectToHost(ui->leHost->text(), ui->sbPort->value());
}

void Dialog::onSokConnected()
{
//после подключения следует отправить запрос на авторизацию
    QByteArray block;
    QDataStream out(&block, QIODevice::WriteOnly);
//резервируем 2 байта для размера блока. Класс MyClient используется в реализации сервера, но тут используем статические члены этого класса - константы команд
//третий байт - команда
    out << (quint16)0 << (quint8)MyClient::comAutchReq << ui->leName->text();
    _name = ui->leName->text();
//возваращаемся в начало
    out.device()->seek(0);
//вписываем размер блока на зарезервированное место
    out << (quint16)(block.size() - sizeof(quint16));
    _sok->write(block);
}

void Dialog::onSokReadyRead()
{
//тут обрабатываются данные от сервера
    QDataStream in(_sok);
//если считываем новый блок первые 2 байта это его размер
    if (_blockSize == 0) {
//если пришло меньше 2 байт ждем пока будет 2 байта
        if (_sok->bytesAvailable() < (int)sizeof(quint16))
            return;
//считываем размер (2 байта)
        in >> _blockSize;
    }
//ждем пока блок прийдет полностью
    if (_sok->bytesAvailable() < _blockSize)
        return;
    else
//можно принимать новый блок
        _blockSize = 0;
//3 байт - команда серверу
    quint8 command;
    in >> command;

    switch (command)
    {
//сервер отправит список пользователей, если авторизация пройдена, в таком случае третий байт равен константе MyClient::comUsersOnline
        case MyClient::comUsersOnline:
        {
            QString users;
            in >> users;
            if (users == "")
                return;
//сервер передает имена через запятую, как строку (хотя можно писать в поток и объект QStringList)
            QStringList l =  users.split(",");
//обновляем гуи
            ui->lwUsers->addItems(l);
        }
        break;
//общее сообщение от сервера
        case MyClient::comPublicServerMessage:
        {
//считываем и добавляем в лог
            QString message;
            in >> message;
            AddToLog("[PublicServerMessage]: "+message, Qt::red);
        }
        ...
    }
}
На этом с клиентом все — основные моменты описаны, остальное все довольно просто.

Сервер


В сервере будет все посложнее — отделим гуи (dialog), сервер (myserver) и клиент (myclient). Кто малознаком с сокетами может не понять какой клиент может быть в сервере? Так вот, при подключении сокета к серверу, на сервере, как привило, создается «клиентский» сокет, который добавляется в массив (напрашивается мысль использовать ассоциативный массив, но для простоты возьмем QList).

//dialog.cpp
Dialog::Dialog(QWidget *parent) :QDialog(parent), ui(new Ui::Dialog)
{
//создаем сервер. первый параметр стандартный - parent, второй - передадим ссылку на объект виджета, для подключения сигналов от myclient к нему
    _serv = new MyServer(this, this);
//подключаем сигналы от виджета к серверу
    connect(this, SIGNAL(messageFromGui(QString,QStringList)), _serv, SLOT(onMessageFromGui(QString,QStringList)));
    ...
    //по умолчанию запускаем сервер на 127.0.0.1:1234
    if (_serv->doStartServer(QHostAddress::LocalHost, 1234))
    {...}
    else
    {...}
}


Унаследуем класс от QTcpServer, это нужно для переопределения виртуальной функции incomingConnection, в которой перехватывается входящее соединение (входной параметр — дескриптор сокета)

//myserver.h
class MyServer : public QTcpServer
{
public:
    bool doStartServer(QHostAddress addr, qint16 port);
    void doSendToAllUserJoin(QString name); //уведомить о новом пользователе
    void doSendToAllUserLeft(QString name);
    void doSendToAllMessage(QString message, QString fromUsername); //разослать сообщение
    void doSendToAllServerMessage(QString message);//серверное сообщение
    void doSendServerMessageToUsers(QString message, const QStringList &users); //приватное серверное сообщение
    void doSendMessageToUsers(QString message, const QStringList &users, QString fromUsername);
    QStringList getUsersOnline() const; //узнать список пользователей
    bool isNameValid(QString name) const; //проверить имя
    bool isNameUsed(QString name) const; //проверить используется ли имя
protected:
    void incomingConnection(int handle);
private:
    QList<MyClient *> _clients; //список пользователей
    QWidget *_widget; //ссылка на виджет для подключения к нему сигналов от myclient
};

//myserver.cpp
void MyServer::incomingConnection(int handle)
{
//передаем дескрпитор сокета, указатель на сервер (для вызова его методов), и стандартный параметр - parent
    MyClient *client = new MyClient(handle, this, this);
//подключаем сигналы напрямую к виджету, если его передали в конструктор сервера
    if (_widget != 0)
    {
        connect(client, SIGNAL(addUserToGui(QString)), _widget, SLOT(onAddUserToGui(QString)));
        connect(client, SIGNAL(removeUserFromGui(QString)), _widget, SLOT(onRemoveUserFromGui(QString)));
        ...
    }
    _clients.append(client);
}
/*
При рассылке сообщения всем нужно делать проверку авторизован ли текущий пользователь, ибо в массиве _clients, возможно, находятся не авторизованные
*/
void MyServer::doSendServerMessageToUsers(QString message, const QStringList &users)
{
//знакомые по клиенту действия
    QByteArray block;
    QDataStream out(&block, QIODevice::WriteOnly);
    out << (quint16)0 << MyClient::comPrivateServerMessage << message;
    out.device()->seek(0);
    out << (quint16)(block.size() - sizeof(quint16));
//отправка сообщения всем (тут отсутствует проверка, ибо все пользователи в users гарантированно авторизованы)
    for (int j = 0; j < _clients.length(); ++j)
        if (users.contains(_clients.at(j)->getName()))
            _clients.at(j)->_sok->write(block);
}


Для класса-клиента, пожалуй, стоит описать только интерфейс, тут все понятно по аналогии. MyClient не обязательно наследовать от QTcpSocket, можно сделать иначе:

//myclient.h
class MyClient : public QObject
{
//откроем доступ классу MyServer к приватному члену _sok 
    friend class MyServer;
public:
    static const QString constNameUnknown;
    static const quint8 comAutchReq = 1;
    static const quint8 comUsersOnline = 2;
    ...
    static const quint8 comErrNameUsed = 202;

    void setName(QString name) {_name = name;}
    QString getName() const {return _name;}
    bool getAutched() const {return _isAutched;}
    void doSendCommand(quint8 comm) const;
    void doSendUsersOnline() const;
signals:
//сигналы для обновления гуи
    void addUserToGui(QString name);
    void removeUserFromGui(QString name);
    void messageToGui(QString message, QString from, const QStringList &users);
//сигнал удаления пользователя из QList
    void removeUser(MyClient *client);
//знакомые слоты для работы с сокетом
private slots:
    void onConnect();
    void onDisconnect();
    void onReadyRead();
    void onError(QAbstractSocket::SocketError socketError) const;
private:
    QTcpSocket *_sok; //сокет
    MyServer *_serv; //ссылка на сервер
    quint16 _blockSize; //текущий размер блока
    QString _name; //имя
    bool _isAutched; //флаг авторизации
};


Upd 2017:
Увы, но исходники уже утеряны навсегда
Upd 2018:
Как известно, все что когда либо было загружено в интернет остается там навсегда. Благодаря vladpower исходники снова доступны
Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 13

    +1
    Что ж, гораздо лучше, чем предыдущий «сервер на Qt»!
    Но, опять таки, где же потоки? Тут достаточно было бы MyClient унаследовать от QThread и всю работу поместить в run().

    Из замечаний не по серверу: строки для экономии лучше передавать как const QString & — для избежания лишнего копирования. Если сервер будет хорошо нагружен, то это может оказаться важным.
      +1
      (да, я знаю, что строки в Qt копируются только при необходимости и обычный copy-ctor работает за константное время, но это время на сервере тоже иногда имеет смысл экономить)
        0
        На счет потоков — стоит ли так делать для чата даже не знаю, в таком случае в памяти будет висеть (даже не просто висеть, а кушать время процессора?) столько потоков, сколько есть активных подключений, даже если нету активности (никто ни кому ничего не пишет).
          0
          Это если реализовывать поток как конечный автомат — но ведь можно и иначе, поток запускать только при активности, если активность высокая, то можно его и всё время в памяти держать.

          Есть и другой вариант (самый, на мой взгляд, интересный) — создать столько потоков, сколько ядер в системе, и перераспределять между ними нагрузку.

          Без потоков, мне кажется, не обойтись, если хочешь сделать высоконагруженный сервер.
            0
            Если дефицит — процессор, да нужны потоки. Иначе не нужны. Это чат, нагрузка на проц отстуствует. Вывод — потоки не нужны.
          0
          Потоки там не нужны, не такая большая нагрузка. В свое время делал лабу (тоже клиент-сервер чат), чистое WinAPI, вместо GUI использовал CUI, где-то даже еще хранится. Для обработки подключений использовал динамический pool потоков. В итоге максимум было 2 активных потока.
          0
          Везет Вам с лабами по Qt…
            0
            Вообще-то лабы не по Qt, у нас в универе 70% всего ориентируется на Delphi, но я с ним не хочу дружить…
            +13
            СДЕЛАЛ ЛАБУ @ ВЫЛОЖИ НА ХАБР
              +1
              может стоить завести блог «о Qt для новичков»?

              P.S.
              > QTcpSocket *_sok; //сокет
              > //создаем сокет
              > _sok = new QTcpSocket(this);
              

              Капитан очевидность одобряет.
                0
                Копипаста — это плохо. У вас например doSendToAllServerMessage отличается от doSendToAllMessage менее чем одной строчкой. Как и doSendToAllUserJoin от doSendToAllUserLeft. Ну и первые два от вторых двух тоже отличаются минимально.

                Чем добавление сервера в друзья к клиенту лучше добавления одного простенького метода MyClient::write(const QByteArray&) — тоже не очень понятно.
                  0
                  Где вы были пол года назад когда я мучался с сокетами для передачи даных.
                  Првда за пример взял другой чат thesmithfam.org/blog/2009/07/09/example-qt-chat-program/ вот отсюда
                    0
                    Кстати, чтобы код нормально работал под qt >= 5.0.1, нужно в myserver.h и myserver.cpp заменить сигнатуру
                    void incomingConnection(int handle);

                    на сигнатуру
                    void incomingConnection(qintptr handle);

                    Описание проблемы есть вот тут.

                    Only users with full accounts can post comments. Log in, please.