13.3. Класс QSocket.

Класс QSocket может использоваться при разработке приложений серверов и клиентов, работающих по протоколу TCP. TCP -- это протокол транспортного уровня, который является базой для множества других протоколов Интернет, включая FTP и HTTP, а так же может служить основой для разработки нестандартных протоколов обмена данными.

Протокол TCP ориентирован на потоки. Протоколы более высокого уровня, работающие поверх TCP, обычно подразделяются на строко-ориентированные и блочно-ориентированные:

Класс QSocket порожден от класса QIODevice, поэтому он в состоянии читать и писать данные из/в экземпляры классов QDataStream или QTextStream. Одно важное отличие чтения данных из сети от чтения данных из файла состои в том, что перед вызовом оператора ">>" необходимо убедиться в том, что от удаленного хоста получены все данные. В случае ошибки мы можем получить непредсказуемый результат.

В этом разделе мы рассмотрим исходный код клиентского и серверного приложений, которые используют собственный, блочно-ориентированный протокол обмена. Приложение-клиент называется Trip Planner. Оно позволяет пользователю планировать поездку по железной дороге. Приложение-сервер называется Trip Server. Оно предоставляет клиенту информацию о расписании движения поездов. Начнем с приложения Trip Planner.

Рисунок 13.1. Внешний вид приложения Trip Planner.


На форме приложения находятся поля ввода From (Из), To (В), Date (Дата), Approximate Time (Примерное время) и две кнопки с зависимой фиксацией (radio buttons), которые уточняют смысл поля pproximate Time -- время отправления или время прибытия. Когда пользователь нажимает кнопку Search, приложение передает запрос серверу и получает список поездов, которые отвечают заданным критериям. Этот список отображается в виджете QListView. В самом низу формы находятся QLabel, для отображения результатов выполнения последнего запроса, и QProgressBar.

Пользовательский интерфейс приложения был разработан в среде визуального построителя Qt Designer. Поэтому, все свое внимание мы сконцентрируем на содержимом файла .ui.h. Обратите внимание: следующие четыре переменные-члены были объявлены на вкладке Members, в построителе Qt Designer, как:

QSocket socket; QTimer connectionTimer; QTimer progressBarTimer; Q_UINT16 blockSize; Переменная socket отвечает за работу с TCP-соединением. Переменная connectionTimer используется для отслеживания тайм аута соединения. Переменная progressBarTimer предназначена для периодического обновления индикатора хода выполнения запроса. И наконец переменная blockSize используется при анализе блока данных, полученных от сервера. void TripPlanner::init() { connect(&socket, SIGNAL(connected()), this, SLOT(sendRequest())); connect(&socket, SIGNAL(connectionClosed()), this, SLOT(connectionClosedByServer())); connect(&socket, SIGNAL(readyRead()), this, SLOT(updateListView())); connect(&socket, SIGNAL(error(int)), this, SLOT(error(int))); connect(&connectionTimer, SIGNAL(timeout()), this, SLOT(connectionTimeout())); connect(&progressBarTimer, SIGNAL(timeout()), this, SLOT(advanceProgressBar())); QDateTime dateTime = QDateTime::currentDateTime(); dateEdit->setDate(dateTime.date()); timeEdit->setTime(QTime(dateTime.time().hour(), 0)); } Функция init() связывает сигналы объекта QSocket -- connected(), connectionClosed(), readyRead() и error(int), и сигналы timeout() от таймеров, с соответствующими слотами. Поля ввода Date и Approximate Time заполняются значениями по-умолчанию -- текущими датой и временем. void TripPlanner::advanceProgressBar() { progressBar->setProgress(progressBar->progress() + 2); } Слот advanceProgressBar() связан с сигналом timeout(), объекта progressBarTimer. void TripPlanner::connectToServer() { listView->clear(); socket.connectToHost("tripserver.zugbahn.de", 6178); searchButton->setEnabled(false); stopButton->setEnabled(true); statusLabel->setText(tr("Connecting to server...")); connectionTimer.start(30 * 1000, true); progressBarTimer.start(200, false); blockSize = 0; } Слот connectToServer() вызывается по нажатию на кнопку Search. Функция вызывает connectToHost() для установления соединения с мифическим сервером tripserver.zugbahn.de, который ожидает поступления запросов на порту с номером 6178. (Если вы планируете опробовать пример на своей машине, замените имя удаленного сервера на localhost.) Функция connectToHost() работает асинхронно -- она всегда сразу же возвращает управление вызывающей программе. Само соединение устанавливается немного позже, в этот момент QSocket выдает сигнал connected(). В случае возникновении ошибки, выдается сигнал error(int) (с кодом ошибки).

После этого обновляется интерфейсная часть приложения и запускаются два таймера. Первый из них, connectionTimer -- это таймер с однократным срабатыванием. Он выдает сигнал timeout() через 30 секунд после запуска. Второй таймер, progressBarTimer, отрабатывает через каждые 200 миллисекунд. С его помощью выполняется обновление индикатора хода процесса.

И в заключении в переменную blockSize записывается значение 0. Она хранит размер очередного блока данных, принятого от сервера.

void TripPlanner::sendRequest() { QByteArray block; QDataStream out(block, IO_WriteOnly); out.setVersion(5); out << (Q_UINT16)0 << (Q_UINT8)'S' << fromComboBox->currentText() << toComboBox->currentText() << dateEdit->date() << timeEdit->time(); if (departureRadioButton->isOn()) out << (Q_UINT8)'D'; else out << (Q_UINT8)'A'; out.device()->at(0); out << (Q_UINT16)(block.size() - sizeof(Q_UINT16)); socket.writeBlock(block.data(), block.size()); statusLabel->setText(tr("Sending request...")); } Слот sendRequest() связан с сигнвлом connected(), объекта QSocket. При появлении сигнала, слот генерирует запрос серверу, передавая информацию, введенную пользователем.

Блок запроса имеет следующую структуру:

Q_UINT16 Размер блока в байтах (исключая это поле)
Q_UINT8 Тип запроса (всегда 'S')
QString Пункт отправления
QString Пункт прибытия
QDate Дата
QTime Примерное время
Q_UINT8 Тип поля "Примерное время": 'D' -- отправление, 'A' -- прибытие.
Сначала данные записываются в объект QByteArray, который называется block. Записать данные напрямую в QSocket не представляется возможным, потому что размер блока заранее не известен.

Изначально, в поле size записывается число 0. Затем, после записи в блок всех данных, производится переход к началу блока, вызовом функции at(0) и записывается корректное значение размера передаваемого блока данных. После этого блок передается серверу, вызовом writeBlock().

void TripPlanner::updateListView() { connectionTimer.start(30 * 1000, true); QDataStream in(&socket); in.setVersion(5); for (;;) { if (blockSize == 0) { if (socket.bytesAvailable() < sizeof(Q_UINT16)) break; in >> blockSize; } if (blockSize == 0xFFFF) { closeConnection(); statusLabel->setText(tr("Found %1 trip(s)") .arg(listView->childCount())); break; } if (socket.bytesAvailable() < blockSize) break; QDate date; QTime departureTime; QTime arrivalTime; Q_UINT16 duration; Q_UINT8 changes; QString trainType; in >> date >> departureTime >> duration >> changes >> trainType; arrivalTime = departureTime.addSecs(duration * 60); new QListViewItem(listView, date.toString(LocalDate), departureTime.toString(tr("hh:mm")), arrivalTime.toString(tr("hh:mm")), tr("%1 hr %2 min").arg(duration / 60) .arg(duration % 60), QString::number(changes), trainType); blockSize = 0; } } Слот updateListView() реагирует на сигнал readyRead(), объекта QSocket, который выдается при получении новых данных от сервера. Первое, что необходимо сделать -- это перезапустить таймер с однократным срабатыванием, отслеживающий тайм аут соединения. Всякий раз, когда от сервера приходит очередная порция данных, необходимо продлить срок "жизни" соединения еще на 30 секунд.

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

Рисунок 13.2. Поток данных от Trip Server, разбитый на блоки.


Так как же работает цикл for? Если значение переменной blockSize равно 0, это означает, что размер очередного блока еще не прочитан. Значение 0xFFFF используется для индикации окончания передачи, поэтому, прочитав это значение, можно быть уверенным, что новых данных больше не поступит.

Если размер блока меньше 0xFFFF, то выполняется попытка прочитать блок, но прежде всего проверяется -- получен ли блок полностью. Если это не так, то цикл прерывается. При поступлении новой порции данных, снова будет выдан сигнал readyRead() и тогда можно будет повторить попытку.

После того как блок будет получен целиком, можно безопасно прочитать его оператором ">>", выделить нужную информацию и записать ее в объект класса QListViewItem. Блок, поступающий от сервера имеет следующую структуру:

Q_UINT16 Размер блока в байтах (исключая это поле)
QDate Дата отправления
QTime Время отправления
Q_UINT16 Время в пути (в минутах)
Q_UINT8 Количество остановок
QString Тип поезда
Завершив разбор блока данных, функция записывает значение 0 в переменную blockSize, говоря о том, что размер очередного блока данных неизвестен. void TripPlanner::closeConnection() { socket.close(); searchButton->setEnabled(true); stopButton->setEnabled(false); connectionTimer.stop(); progressBarTimer.stop(); progressBar->setProgress(0); } Функция closeConnection() закрывает соединение с сервером, обновляет интерфейс с пользователем и останавливает таймеры. Она вызывается из updateListView(), когда будет получен блок с размером 0xFFFF, и из некоторых других слотов, которые будут описаны чуть ниже. void TripPlanner::stopSearch() { statusLabel->setText(tr("Search stopped")); closeConnection(); } Слот stopSearch() реагирует на нажите кнопки Stop. Суть его состоит в закрытии соединения вызовом функции closeConnection(). void TripPlanner::connectionTimeout() { statusLabel->setText(tr("Error: Connection timed out")); closeConnection(); } Слот connectionTimeout() отрабатывает по истечении тайм аута соединения. void TripPlanner::connectionClosedByServer() { if (blockSize != 0xFFFF) statusLabel->setText(tr("Error: Connection closed by " "server")); closeConnection(); } Слот connectionClosedByServer() реагирует на сигнал connectionClosed(), объекта socket. Если сервер закрыл соединение до того, как был получен маркер конца передачи (0xFFFF), пользователю выводится сообщение об ошибке. Затем вызывается closeConnection(), чтобы обновить интерфейс и остановить таймеры. void TripPlanner::error(int code) { QString message; switch (code) { case QSocket::ErrConnectionRefused: message = tr("Error: Connection refused"); break; case QSocket::ErrHostNotFound: message = tr("Error: Server not found"); break; case QSocket::ErrSocketRead: default: message = tr("Error: Data transfer failed"); } statusLabel->setText(message); closeConnection(); } Слот error(int) связан с сигналом error(int) сокета. Он генерирует текст сообщения, соответствующий полученному коду ошибки.

Функция main() не содержит ничего нового:

int main(int argc, char *argv[]) { QApplication app(argc, argv); TripPlanner tripPlanner; app.setMainWidget(&tripPlanner); tripPlanner.show(); return app.exec(); } Перейдем к реализации приложения-сервера. Сервер состоит из двух классов: TripServer и ClientSocket. Первый порожден от QServerSocket и предназначен для приема входящих соединений. Второй -- наследник QSocket предназначен для обслуживания одиночного соединения с клиентом. В каждый конкретный момент времени, в памяти приложения будет находиться столько экземпляров ClientSocket, сколько клиентов подключено к серверу. class TripServer : public QServerSocket { public: TripServer(QObject *parent = 0, const char *name = 0); void newConnection(int socket); }; В классе TripServer перекрыт родительский метод newConnection(). Эта функция вызывается всякий раз, когда сервер обнаруживает попытку соединения с ним. TripServer::TripServer(QObject *parent, const char *name) : QServerSocket(6178, 1, parent, name) { } Здесь, родительскому конструктору передается номер порта (6178). Второй аргумент, 1, это количество подключений, ожидающих обработки. void TripServer::newConnection(int socketId) { ClientSocket *socket = new ClientSocket(this); socket->setSocket(socketId); } В функции newConnection() создается новый объект класса ClientSocket, которому присваивается заданный идентификационный номер. class ClientSocket : public QSocket { Q_OBJECT public: ClientSocket(QObject *parent = 0, const char *name = 0); private slots: void readClient(); private: void generateRandomTrip(const QString &from, const QString &to, const QDate &date, const QTime &time); Q_UINT16 blockSize; }; Класс ClientSocket порожден от класса QSocket и отвечает за обслуживание одиночного соединения с клиентом. ClientSocket::ClientSocket(QObject *parent, const char *name) : QSocket(parent, name) { connect(this, SIGNAL(readyRead()), this, SLOT(readClient())); connect(this, SIGNAL(connectionClosed()), this, SLOT(deleteLater())); connect(this, SIGNAL(delayedCloseFinished()), this, SLOT(deleteLater())); blockSize = 0; } В конструкторе устанавливаются все необходимые соединения между сигналами и слотами, и записывается значение 0 в переменную blockSize.

Сигналы connectionClosed() и delayedCloseFinished() соединены со слотом deleteLater(). Эта функция унаследована от QObject. Она удаляет объект, когда управление переходит в цикл обработки событий. Она обеспечивает удаление экземпляров ClientSocket при закрытии соединения.

void ClientSocket::readClient() { QDataStream in(this); in.setVersion(5); if (blockSize == 0) { if (bytesAvailable() < sizeof(Q_UINT16)) return; in >> blockSize; } if (bytesAvailable() < blockSize) return; Q_UINT8 requestType; QString from; QString to; QDate date; QTime time; Q_UINT8 flag; in >> requestType; if (requestType == 'S') { in >> from >> to >> date >> time >> flag; srand(time.hour() * 60 + time.minute()); int numTrips = rand() % 8; for (int i = 0; i < numTrips; ++i) generateRandomTrip(from, to, date, time); QDataStream out(this); out << (Q_UINT16)0xFFFF; } close(); if (state() == Idle) deleteLater(); } Слот readClient() связан с сигналом readyRead() сокета. Если переменная blockSize содержит 0, то выполняется попытка прочитать размер очередного блока данных, в противном случае предполагается, что размер уже прочитан и необходимо проверить -- поступил ли блок данных полностью. Если блок данных поступил целиком, то выполняется чтение блока. Чтение производится с помощью QDataStream напрямую из сокета (аргумент this).

После того как блок запроса прочитан, можно приступать к формированию ответа. Если бы это было реальное приложение, все необходимые сведения можно было бы брать из базы данных. Но здесь мы будем довольствоваться функцией generateRandomTrip(), которая генерирует расписание случайным образом. Функция будет вызываться случайное число раз и в конце передачи будет отправляться маркер конца передачи (0xFFFF).

В заключение -- соединение закрывается. Если выходной буфер сокета пуст, то соединение закрывается немедленно и можно вызвать deleteLater(), чтобы удалить сокет, когда управление попадет в цикл обработки событий. (Вполне безопасно было бы вызвать delete this.) В противном случае, сокет продолжит передачу данных и затем закроет соединение по сигналу delayedCloseFinished().

void ClientSocket::generateRandomTrip(const QString &, const QString &, const QDate &date, const QTime &time) { QByteArray block; QDataStream out(block, IO_WriteOnly); out.setVersion(5); Q_UINT16 duration = rand() % 200; out << (Q_UINT16)0 << date << time << duration << (Q_UINT8)1 << QString("InterCity"); out.device()->at(0); out << (Q_UINT16)(block.size() - sizeof(Q_UINT16)); writeBlock(block.data(), block.size()); } Функция generateRandomTrip() показывает, как можно отправить блок данных через TCP-соединение. Это очень похоже на то, что мы уже видели в клиентском приложении (функция sendRequest()). Опять же, чтобы определить размер блока, данные сначала записываются в QByteArray, а затем передаются сокету вызовом writeBlock(). int main(int argc, char *argv[]) { QApplication app(argc, argv); TripServer server; if (!server.ok()) { qWarning("Failed to bind to port"); return 1; } QPushButton quitButton(QObject::tr("&Quit"), 0); quitButton.setCaption(QObject::tr("Trip Server")); app.setMainWidget(&quitButton); QObject::connect(&quitButton, SIGNAL(clicked()), &app, SLOT(quit())); quitButton.show(); return app.exec(); } В функции main() создается экземпляр класса TripServer и кнопка QPushButton, с помощью которой пользователь может остановить сервер.

На этом мы завершаем рассмотрение примера построения клиентского и серверного приложений. В данном случае мы реализовали обмен по своему, блочно-ориентированному протоколу, что позволило нам использовать QDataStream для чтения и записи данных. Если бы мы занялись реализацией строково-ориентированного протокола, то в самом простейшем случае мы могли бы воспользоваться функциями класса QSocket -- canReadLine() и readLine(), при получении сигнала readyRead():

QStringList lines; while (socket.canReadLine()) lines.append(socket.readLine()); После этого можно былобы обработать каждую прочитанную строку. Передача текстовых строк могла бы быть выполнена с помощью QTextStream, связанного с QSocket.

Серверное приложение, в данной реализации, довольно плохо масштабируется, при наличии большого числа подключений. Проблема состоит в том, что когда обслуживается одно подключение, приложение не в состоянии обслужить другие соединения. Более масштабируемый подход заключается в создании отдельного потока для каждого соединения. Но экземпляры класса QSocket могут использоваться только в том потоке, который содержит цикл обработки событий (запускаемый вызовом QApplication::exec()), по причинам, которые более подробно будут описаны в Главе 17. Решение проблемы заключается в использовании низкоуровневого класса QSocketDevice, который работает независимо от цикла обработки событий.