Работа с сетью в Qt. Сокеты. Паттерн Adapter

В статье показана работа с сетью на примере очень простого сетевого чата, а также описан никак не связанный с сетью шаблон проектирования адаптер (adapter, wrapper, обертка).

Несмотря на то, что наш чат максимально прост (он не позволяет передавать файлы и оффлайн-сообщения, не хранит историю, передает сообщения не шифрованными и т.д.), мы все же отделим часть, ответственную за работу с сетью. Эта часть будет использовать класс QTcpSocket, интерфейс которого нас не устраивает, в связи с чем, мы применим шаблон проектирования Wrapper.

Сервер чата работает в одном потоке, т.к. целью статьи я ставил описание примера, наиболее просто и наглядно показывающего использование сокетов библиотеки Qt. В одной из следующих статей я возможно опишу многопоточный чат.

Содержание:

  1. шаблон проектирования Adapter;
  2. работа с сетью в Qt. Классы QTcpServer и QTcpSocket;
  3. пример использования паттерна Adapter;
  4. исходный код сетевого чата.

1. Шаблон проектирования Adapter

Адаптер относится к структурным шаблонам проектирования и применяется в случаях, когда появляется класс, интерфейс которого нам не подходит по каким-либо причинам. Очевидное решение проблемы – создать новый класс с “правильным” интерфейсом, который будет каким-либо образом делегировать запросы к классу с “плохим” интерфейсом – при этом не угодный нам интерфейс как бы “скрывается”. Поэтому шаблон проектирования адаптер также называют оберткой (wrapper).

рис. 1 шаблон проектирования адаптер. Проблема

рис. 1 шаблон проектирования адаптер. Проблема

На рисунке 1 показана типичная ситуация, в которой может быть уместным применение адаптера. Есть некоторый код (Client), который использует экземпляры классов A и B, реализующих общий интерфейс (Interface). В один прекрасный момент, нам потребовалось наравне с A и B использовать класс Adaptee, но он не реализует нужный нам интерфейс.

Вероятно, класс Adaptee достаточно сложен, чтобы нам не пришло в голову переписывать его с новым интерфейсом или лезть в него чтобы “подпилить” интерфейс.

Первый вариант решения такой проблемы заключается в наследовании классов Interface и Adaptee классом Adapter, как показано на рисунке 2. При этом в отношении класса Adaptee используется закрытое наследование, т.к. адаптер не должен предоставлять лишний интерфейс. Такой вариант решения проблемы называется адаптером классов, использует множественное наследование и все вытекающие из него проблемы.

рис. 2 адаптер классов

рис. 2 адаптер классов

Другим вариантом, является адаптер объектов, заключающийся в наследовании интерфейса и агрегации адаптируемого класса (рисунок 3). Такое решение является более гибким и не имеет недостатков от множественного наследования, однако, за счет того, что доступ к элементам адаптируемого класса осуществляется через указатель, снижается быстродействие.

рис. 3 адаптер объектов

рис. 3 адаптер объектов

Пример из диаграмм очень прост и абстрактен, в реальности все бывает совсем иначе – стоит задача не просто заменить функцию bar на foo, а проделать кучу работы по адаптации интерфейса.

В классической книге [1], приводится пример с графическими объектами – в качестве наших классов A и B выступают круги и квадраты, но нам приспичило кроме кругов выводить текст. Выводить текст не так просто, ведь нужна поддержка разных шрифтов и стилей, поэтому принимается решение взять готовое, проверенное решение (чей-нибудь код). Но вот беда – разработчик писал код не под наш редактор и не учел кучу его особенностей, проблему решает адаптер.

2. Работа с сетью в Qt. Классы QTcpServer и QTcpSocket

В библиотеке Qt есть модуль работы с сетью – Qt Network, для подключения которого в файле проекта достаточно добавить одну строку:

QT += network

Модуль предоставляет ряд полезных классов, среди которых рассмотренный ранее QHttp [2], QTcpSocket и QTcpServer.

Основная задача QTcpServer – отслеживать подключение клиентов. Сервер слушает определенный порт, задаваемый при вызове метода listen. При подключении клиента, вырабатывается сигнал newConnection и создается сокет (QTcpSocket) для обмена данными с клиентом. Получить указатель на сокет можно вызовом nextPendingConnection.

Обмен данными с клиентом осуществляется через сокет. При отключении клиента сокет вырабатывает сигнал disconnected, а при поступлении в сокет данных – сигнал readyRead. Сигнал readyRead вызывается всякий раз, когда новая порция данных поступает в сокет, узнать сколько именно данных доступно для чтения в сокете можно вызовом метода bytesAvailable.

Чтобы данные поступили в сокет, их нужно туда записать, для этого используется метод QTcpSocket::write. Мы можем олдскульно записывать в сокет последовательности байт:

int i = 123;
pSock->write((char*)&i, sizeof(int));

В приведенном прмере в сокет записывается столько байт, начиная с адреса переменной i, сколько достаточно для хранения целого числа. На другом конце провода можно будет считать эти байты методом read, но это очень не удобно, поэтому есть другое, более красивое решение с использованием QByteArray и QDataStream.

void SocketAdapter::sendString(const QString& str) {
  QByteArray block;
  QDataStream sendStream(&block, QIODevice::ReadWrite);

  sendStream << quint16(0) << str;
  sendStream.device()->seek(0);
  sendStream << (quint16)(block.size() - sizeof(quint16));
  m_ptcpSocket->write(block);
}

На листинг 1 приведен код записи строки в сокет. Если помимо строки потребуется записать еще что-нибудь – изменится только 5 строка. Мы пишем в сокет данные блоками, в начале блока идет количество байт. В 5 строке, мы резервируем в начале блока память для будующего размера, пишем в блок остальные данные, в 7 строке устанавливаем указатель записи на начало и в 8 строке помещаем в это начало получившийся размер блока.

При считывании данных с сокета нужно учитывать, что данные могут поступать частями. Никто не гарантирует, что в результате двух вызовов функции write, будет ровно два раза выслан сигнал readyRead – он может быть выслан любое количество раз (даже лишь один раз).

void SocketAdapter::on_readyRead() {
  QString buff;
  QDataStream stream(m_ptcpSocket);

  while(true) {
    if (m_msgSize < 0) {
       if (m_ptcpSocket->bytesAvailable() < sizeof(int))
         return;
       stream >> m_msgSize;
    }
    else {
      if (m_ptcpSocket->bytesAvailable() < m_msgSize)
         return;
      stream >> buff;
      emit message(buff);
      m_msgSize = -1;
    }
  }
}

В листинг 2 с сокета считывается ровно одна строка, но если бы считывалось больше данных – изменения коснулись бы только 14 строчки кода. Вечный цикл в этом коде нужен именно потому, что на несколько операций записи в сокет может прийти один сигнал readyRead – обработать же нужно все имеющиеся в сокете данные. При считывании строки, в приведенном примере выработается сигнал message.

3. Пример использования паттерна Adapter

Наш пример адаптера будет не совсем типичным, потому что у нас не будет классов A и B (рис. 3) – мы адаптируем класс не к существующей системе, а к проектируемой.

рис. 4 необычный адаптер

рис. 4 необычный адаптер

На рисунке 4 очень грубо показано что именно мы будем делать. В качестве Adaptee будет выступать сокет библиотеки Qt – класс QTcpSocket. Работать с этим сокетом не очень приятно – это все же весьма низкоуровневая штука. В качестве клиента будет выступать чат или сервер, при разработке которых нам бы хотелось иметь более удобный интерфейс – по этой причине мы добавим прослойку в виде адаптера. Внешне это не очень похоже на стандартный адаптер, но он решает те же задачи – интерфейс класса сокета приводится (адаптируется) к желаемому виду.

Хотелось бы, чтобы сокет сообщал о разрыве соединения и новых данных, когда они полностью получены, а также, позволял бы отправлять данные. Для этого интерфейс адаптера должен содержать 2 сигнала и метод (или слот) отправки. Такой интерфейс показан на листинг 3.

class ISocketAdapter : public QObject {
  Q_OBJECT
public:
  explicit ISocketAdapter(QObject *parent);
  virtual ~ISocketAdapter();
  virtual void sendString(const QString& str) = 0;
signals:
  void message(QString text);
  void disconnected();
};

Класс адаптера агрегирует сокет и наследует интерфейс. В результате все операции сокета оказываются скрыты за приятным интерфейсом.

Пример был бы более удачным, если чат мог бы получать данные из других источников (помимо TCP-сокета), которые реализовывали бы наш интерфейс. В качестве таких источников могли бы быть например обертки над QUdpSocket или вариации на тему передачи данных в защищенном виде. Наш же пример адаптера по результатам применения получился похожим на фасад.

4. Исходный код сетевого чата

Диаграмма классов (не совсем UML) наших клиента и сервера приведена на рисунке 5. Часть классов мы уже подробно рассмотрели.

рис. 5 диаграмма классов сетевого чата

рис. 5 диаграмма классов сетевого чата

MainWidget представляет собой главное окно чата, он агрегирует форму, созданную в Qt Designer [4]. Форма содержит 2 поля ввода и кнопку. При щелчке на кнопку вызывается функция sendString класса ClientSocketAdapter, а поле ввода очищается. При получении сигнала message от ClientSocketAdapter, второе поле ввода главной формы дополняется принятой от сервера строкой.

MainWidget::MainWidget(QWidget *parent) :
  QWidget(parent), m_pForm(new Ui::Form()),
  m_pSock(new ClientSocketAdapter(this)) {
  m_pForm->setupUi(this);
  connect(m_pSock, SIGNAL(message(QString)), SLOT(on_message(QString)));
  connect(m_pForm->send, SIGNAL(clicked()), SLOT(on_send()));
}

void MainWidget::on_message(QString text) {
  m_pForm->messages->setHtml(m_pForm->messages->toHtml() + text + " ");
}

void MainWidget::on_send() {
  m_pSock->sendString(m_pForm->message->text());
  m_pForm->message->clear();
}

Класс Server агрегирует QTcpServer, а также, список указателей на адаптеры сокетов. При подключении клиента наш сервер получает от QTcpServer указатель на созданный сокет, на основе которого создается адаптер серверного сокета (ServerSocketAdapter) и добавляется в список. Сервер хранит список подключенных клиентов чтобы пересылать между ними сообщения. При получении сообщения от любого клиента, сервер обходит список адаптеров и для каждого вызывает метод sendString. При отключении клиента, адаптер удаляется из списка. Деструктор адаптера обеспечивает корректное освобождение памяти из под сокета (QTcpSocket).

Server::Server(int nPort, QObject *parent) : QObject(parent),
  m_ptcpServer(new QTcpServer(this)) {

  connect(m_ptcpServer, SIGNAL(newConnection()), SLOT(on_newConnection()));

  if (false == m_ptcpServer->listen(QHostAddress::Any, nPort)) {
    m_ptcpServer->close();
    throw m_ptcpServer->errorString();
  }
}

void Server::on_newConnection() {
  QTextStream(stdout) << "new connection" << endl;
  QTcpSocket* pclientSock = m_ptcpServer->nextPendingConnection();
  ISocketAdapter *pSockHandle = new ServerSocketAdapter(pclientSock);

  m_clients.push_back(pSockHandle);

  pSockHandle->sendString("connect");

  connect(pSockHandle, SIGNAL(disconnected()), SLOT(on_disconnected()));
  connect(pSockHandle, SIGNAL(message(QString)), SLOT(on_message(QString)));
}

void Server::on_disconnected() {
  QTextStream(stdout) << "client disconnected" << endl;
  ISocketAdapter* client = static_cast<ServerSocketAdapter*>(sender());
  m_clients.removeOne(client);
  delete client;
}

void Server::on_message(QString msg) {
  foreach (ISocketAdapter *sock, m_clients)
    sock->sendString(msg);
}

В связи с тем, что клиент и сервер обменивается сообщения в одинаковом формате, адаптер сокета клиента почти не отличается от адаптера сокета сервера, поэтому основную часть кода можно перенести в класс SocketAdapter.

Первое различие между ними заключается в том, что адаптер серверного сокета создается на основе уже имеющегося объекта QTcpSocket, а адаптер сокета клиента должен создать такой объект. Эта разница учтена в конструкторе класса SocketAdapter.

SocketAdapter::SocketAdapter(QObject *parent, QTcpSocket *pSock)
  : ISocketAdapter(parent), m_msgSize(-1) {
  if (0 == pSock)
    m_ptcpSocket = new QTcpSocket(this);
  else
    m_ptcpSocket = pSock;
  connect(m_ptcpSocket, SIGNAL(readyRead()), this, SLOT(on_readyRead()));
  connect(m_ptcpSocket, SIGNAL(disconnected()), this, SLOT(on_disconnected()));
}

Кроме того, клиентский сокет должен выполнить метод connectToHost чтобы начать диалог с сервером. Сервер таких действий предпринимать не должен.

ClientSocketAdapter::ClientSocketAdapter(QObject *parent)
  : SocketAdapter(parent) {
  m_ptcpSocket->connectToHost("localhost", 1024);
}

ServerSocketAdapter::ServerSocketAdapter(QTcpSocket* pSock, QObject *parent) :
  SocketAdapter(parent, pSock) {
}

Полный исходный код клиента и сервера можно скачать бесплатно: клиент-серверный чат Qt.

В следующей статье мы подумаем над распараллеливанием сервера с использованием QThread.

Список полезных источников

  1. Э. Гамма «Приемы объектно-ориентированного проектирования. Паттерны проектирования» Э. Гамма, Р. Хелм, Р. Джонсон, Дж. Влиссидес — Питер, 2012
  2. Получение данных с сайта. Шаблон Producer/Consumer [Qt, C++] \ https://pro-prof.com/archives/1034
  3. Использование БД SQL. Шаблон проектирования «Фасад» (Facade) \ https://pro-prof.com/archives/882
  4. Собственные виджеты в Qt Designer [Qt, C++] \ https://pro-prof.com/archives/958
  5. Qt5 Tutorial QTcpSocket with Signals and Slots \ http://www.bogotobogo.com/Qt/Qt5_QTcpSocket_Signals_Slots.php
  6. Реализация сервера с помощью класса QTcpServer \ http://qt-doc.ru/realizacia-servera-s-pomoschu-klassa-qtcpserver.html

6 thoughts on “Работа с сетью в Qt. Сокеты. Паттерн Adapter

  1. Valery

    Фича в приложении.
    При закрытии всех клиентов сервер продолжает работу.
    Т.е. прекратить работу сервера получается только через диспетчер задач.

    Reply
    1. admin Post author

      Это не фича. Сервер как бы от клиентов не зависит, он может располагаться на другой машине (где-то в интернете).
      Выход клиентов из чата не должен приводить к закрытию сервера. Сейчас все вышли, но через минуту кто-то зайдет (попробует подключиться) и что? – сервер по вашему должен оказаться недоступен, т.к. до этого все вышли?

      Reply
  2. Andrey

    Владимир, добрый день.

    1. Почему вы не используете переопределенный метод incomingConnection(int)?
    2. Ну и почему вместо #include употребляется class QTcpSocket? – Просто как стиль написания?

    Reply
    1. admin Post author

      Почему вы не используете переопределенный метод incomingConnection(int)

      На стороне сервера (где появляются новые соединения) я использую сигнал QTcpServer::newConnection():
      connect(m_ptcpServer, SIGNAL(newConnection()), SLOT(on_newConnection()));
      Этот сигнал вырабатывается всякий раз, когда появляется новое соединение. С документации:

      This signal is emitted every time a new connection is available.

      QTcpServer::incomingConnection представляет собой виртуальную функцию, которая также вызывается при появлении нового соединения. В документации написано, что эта функция реализуется в QTcpServer так, что она добавляет сокет (соединение) в список и выполняет emit newConnection(). Изменять ее стоит лишь в случаях, если нам нужно другое поведение, если же нам нужен лишь сигнал о том, что появилось новое соединение – нужно всегда использовать сигнал QTcpServer::newConnection().

      Reply
      1. Andrey

        Изменять ее стоит лишь в случаях, если нам нужно другое поведение, если же нам нужен лишь сигнал о том, что появилось новое соединение — нужно всегда использовать сигнал QTcpServer::newConnection()

        А что подразумевается под другим поведением. Ну просто ведь сигнал пришел и дальше уже начали описывать то что нам нужно, например посчитали что-то на сервере и передали клиенту. Как бы все дейстия зависят от того есть сигнал или нет. Ну т.е. есть сигнал мы в любом случае что-то делаем.

        В каких случаях в нашем делании нужно применять incomingConnection(int)? Ну просто пример.

        Reply
    2. admin Post author

      В данном случае, запись class QTcpSocket является опережающим объявлением. Я описал максимально подробно зачем это может использоваться – ослабление и разрыв циклических зависимостей между файлами, сокращение времени компиляции в соответствующей теме на форуме: Опережающее объявление класса.

      Reply

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Вы не робот? *