Получение данных с сайта. Шаблон Producer/Consumer [Qt, C++]

В последнее время часто встречал вопросы о получении данных с сайта на С++, поэтому решил написать статью, посвященную этой теме.

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

В статье описаны:

  • шаблон проектирования «поставщик/потребитель» (producer/consumer pattern);
  • средства библиотеки Qt5, позволяющие получать и обрабатывать информацию, размещенную на сайтах (QNetworkAccessManager);
  • создание потоков при помощи QThread;
  • реализация шаблона producer/consumer в задаче получения и обработки веб-страниц.

Шаблон проектирования «поставщик/потребитель»

Шаблон «поставщик/потребитель» — шаблон параллельного программирования, нем принимают участие:

  • поставщик — объект, генерирующий, поставляющий данные (задачи) для обработки;
  • потребитель — объект, обрабатывающий данные, полученные от поставщика;
  • задача — данные, передаваемые между поставщиком и потребителем;
  • очередь задач — буфер, через который взаимодействуют поставщики и потребители.

На каждую задачу можно было бы создать по отдельному потребителю (потоку), однако, это эффективное решение — лучше, количество потоков должно совпадать с числом ядер процессора. Эту проблему и решает шаблон producer/consumer.

Суть шаблона заключается в том, что каждый поставщик и потребитель работает в отдельном потоке. Поставщики помещают задачи в очередь, а не занятые потребители вытаскивают их и выполняют. Очень схематично это показано на рис.1.

producer-consumer-pattern

Шаблон проектирования Producer-Consumer

Реализация может быть самой различной, например, может не выделяться явно класс «задача». Возможны вариации на тему «очереди» — это может быть как объект, работающий в отдельном потоке, так и очередь в разделяемой памяти, охраняемая мьютексом.

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

Родственные паттерны. Если мы перестанем задумываться откуда поступают данные (забудем о поставщиках) — то придем к шаблону Thread Pool (пул потоков). Если в пуле потоков будет лишь один поток — шаблон будет называться Worker Thread (рабочий поток).

Загрузка страницы

Для загрузки страницы в Qt4 использовался класс QHttp, однако, он готовится к удалению и в Qt5 оставлен для совместимости в отдельном модуле — его использовать сейчас не стоит и мы его не коснёмся.

Загрузку страниц в Qt, в принципе, можно производить с помощью классов QWebView и QWebPage. Оба класса очень объёмны и предназначены для отрисовки содержимого страницы — первый из них представляет собой почти полноценный веб-браузер. Эти классы могут быть полезны и в других случаях (когда отрисовка не требуется), т.к. представляют страницу в виде DOM (Document Object Model), позволяет выполнить JavaScript, размещенный на странице и еще много всего. В связи с тем, что их основной функционал в нашем случае не нужен — не будем ими пользоваться (тем более эти классы связаны с GUI, поэтому не могут работать в отдельном потоке).

Для загрузки страниц в Qt5 используется класс QNetworkAccessManager, в частности, он позволяет отправлять POST- и GET-запросы. Помимо QNetworkAccessManager используются классы QNetworkRequest (запрос к сайту) и QNetworkReply (ответ сервера).

Пример запроса страницы приведен на листинг 1.

class PageHandler : public QObject {
Q_OBJECT
public:
explicit PageHandler(QString host, QObject *parent = 0);
public slots:
void on_page(QObject*, TaskType, QString);
//!< страница запрошена
private slots:
void on_load(QNetworkReply*);
//!< обработка сигнала finished QNetworkAccessManager
signals:
void pageHandled(TaskType type, QString url);
//!< \brief завершена обработка страницы
//!< менеджер удаляет страницу из очереди
void finished();
//!< обработчик готов к приему следующей задачи
void task(TaskType type, QString url);
//!< сигнал с информацией о новой странице для обработки
void project(QString result);
//!< сигнал с резульутатом обработки проекта
protected:
QNetworkAccessManager *m_nam;
//!< менеджер загрузки страниц
QString m_host;
//!< хост обрабатываемой страницы
TaskType m_pageType;
//!< тип обрабатываемой страницы
};
PageHandler::PageHandler(QString host, QObject *parent) :
QObject(parent), m_host(host), m_nam(new QNetworkAccessManager(this)) {
connect(m_nam, SIGNAL(finished(QNetworkReply*)), SLOT(on_load(QNetworkReply*)));
}
void PageHandler::on_page(QObject* obj, TaskType type, QString url) {
if (this != obj) // сообщение адресовано другому объекту
return;
m_pageType = type;
QNetworkRequest req(url);
req.setHeader(QNetworkRequest::ContentTypeHeader, QVariant("text/plain"));
m_nam->post(req, "");
}

В 41 строке создается экземпляр класса QNetworkRequest, в 43 — отправляется POST-запрос. Метод post() возвращает указатель на QNetworkReply, который будет содержать ответ сервера после того, как QNetworkAccessManager выработает сигнал finished(QNetworkReply*). Результат вызова post() можно не сохранять, т.к. сигнал finished(QNetworkReply*) несет тот же указатель. Обработка страницы в примере осуществляется в слоте on_load(QNetworkReply *reply), с которым в 34 строке соединен соответствующий сигнал.

При обработке страницы удобно использовать регулярные выражения (QRegExp), но в нашей простой задаче это будет излишним. QNetworkReply::readAll() возвращает содержимое страницы в виде QByteArray.

QByteArray позволяет найти первое вхождение подстроки при помощи метода indexOf(), который гораздо проще (в реализации) регулярных выражений и в нашем случае гораздо эффективнее.

На листинг 2 приведен фрагмент слота on_load, в котором осуществляется поиск ссылок на бесплатные проекты. В методе on_load() намеренно написан некрасивый switch — на этом примере я надеюсь в будущем описать делегирование в C++, а именно, шаблон проектирования «Состояние» (State).

void PageHandler::on_load(QNetworkReply *reply) {
QByteArray buff = reply->readAll();
qint16 idx = 0, start = -1, finish = -1;
switch (m_pageType) {
case TaskType::External: {
QString
patProjStart = "<div class=\"proj public\" >",
patAddrStart = "<a class=\"ptitle\" href=\"",
patAddrFinish = "\"";
for (;;) {
idx = buff.indexOf(patProjStart, idx);
if (idx < 0) break;
idx = idx + patProjStart.length();
idx = buff.indexOf(patAddrStart, idx);
Q_ASSERT(idx > 0);
idx = start = idx + patAddrStart.length();
idx = buff.indexOf(patAddrFinish, idx);
Q_ASSERT(idx > 0);
finish = idx;
//qDebug() << m_host + buff.mid(start, finish - start);
}
} break;
// ...
}
//...
delete reply;
}

Обратите внимание на 31 строку — если из под ответа сервера не освободить память в обработчике сигнала finished(QNetworkReply*) — произойдет утечка (ведь указатель мы нигде не сохраняли).

Потоки в Qt

Для работы с потоками в Qt применяется класс QThread, есть 2 варианта порождения потоков с его помощью:

  1. порождение наследника от QThread, содержащего слот run(). При запуске потока методом QThread::start() начнется выполнение кода слота run();
  2. создание экземпляра класса QThread и «перемещение» в него объекта, который должен работать в отдельном потоке. Перемещение осуществляется методом QThread::moveToThread(), а запуск потока — методом QThread::start().

В статье используется второй подход.

Отмечу, что в Qt потоки можно создавать и без использования QThread напрямую — для этого существуют классы QtConcurrent и QThreadPool, описание которых не вошло в статью.

На листинг 3 приведена главная функция программы, порождающая 5 потоков с экземплярами класса PageHandler — обработчиками страниц.

int main(int argc, char **argv) {
QApplication a(argc, argv);
qRegisterMetaType<TaskType>("TaskType");
ResultDriver driver("results.txt");
TaskManager manager;
for (int i = 0; i < 5; ++i) {
QThread *handlerThread = new QThread(&manager);
PageHandler *handler = new PageHandler("http://freelance.ru");
QObject::connect(handlerThread, SIGNAL(finished()),
handler, SLOT(deleteLater()));
QObject::connect(handler, SIGNAL(project(QString)),
&driver, SLOT(on_project(QString)));
handler->moveToThread(handlerThread);
handlerThread->start();
manager.addHandler(handler, handlerThread);
}
QObject::connect(&manager, SIGNAL(finished()), &driver, SLOT(saveAll()));
QObject::connect(&driver, SIGNAL(finished()), &a, SLOT(quit()));
manager.addTask(TaskType::External, "http://freelance.ru/projects/?cat=4&spec=108");
return a.exec();
}

Я пока что не описывал из каких частей состоит наша программа, а теперь должен пояснить. TaskManager отвечает за хранение списка задач (Task) по обработчикам (PageHandler). Каждый обработчик работает в отдельном потоке.

Потоки порождаются в 10 строке, при этом в качестве родительского объекта у них указывается менеджер задач (при уничтожении менеджера будут убиты все потоки). Каждый объект соединяется с менеджером соответствующими сигналами и слотами (строки 13-16). В 18 строке обработчик перемещается в отдельный поток, а в 19 строке поток запускается.

При взаимодействии обработчиков с менеджером через механизм сигналов и слотов передаются задачи, т.к. они не являются стандартным типом Qt — должны быть зарегистрированы вызовом qRegisterMetaType (4 строка).

Для того, чтобы потоки корректно завершили работу необходим последовательный вызов методов QThread::quit() и QThread::wait() — первый просит поток завершиться, а второй — ждет завершения. Остановкой потоков занимается TaskManager, поэтому помимо списка обработчиков он должен хранить список потоков. В 27 строке метод TaskManager::addtask()) принимает соответствующие указатели. На листинг 4 приведен код остановки потоков.

TaskManager::~TaskManager() {
for (QThread *thread : m_threads) {
thread->quit();
thread->wait();
}
}

Реализация

Достаточно грубо архитектура приложения показана на рис. 2. Стрелками показаны потоки данных, передаваемые между объектами программы. Ключевое отличие от рис. 1 заключается в том, что поставщики данных, являются и потребителями.

architecture

Архитектура приложения

Поставщики и потребители, вообще не являются экземплярами разных классов, они получают задачу и в зависимости от ее типа выполняют те или иные действия (в одной из следующих статей я покажу как сделать эту систему более гибкой и красивой за счет применения шаблона проектирования «состояние» (State)).

Конфликтов при обращении исполнителей к очереди не возникает, т.к. очередь обрабатывается в отдельном потоке. Потребители и поставщики могут посылать очереди сигналы, которые по умолчанию являются синхронными (обработка синхронных сигналов в Qt такова, что каждый объект имеет очередь сигналов, которые обрабатываются последовательно — гарантируется, что сигнал, принятый раньше будет обработан первым).

Аналогичным образом происходит работа с файлом — писать в файл может лишь один объект — «обработчик результатов» (рис.2). Если бы мы не ввели такой объект — потоки начали бы конкурировать за файл, пришлось бы вводить мьютексы (или что-то подобное).

Я не буду приводить описания классов задачи, менеджера и обработчика результатов, т.к. они весьма тривиальны. Вместо этого прикреплю файл с проектом: Парсер freelance[Qt] (исходный код)

Отмечу лишь то, что менеджер помимо указателя на поставщика/потребителя должен хранить его состояние — поэтому введен дополнительный класс TaskManagerHandler (состояние обработчика с точки зрения менеджера). Дело в том, что состояние обработчика может измениться, но менеджер не узнает об этом мгновенно — он изменит состояние лишь после получения соответствующего сигнала (и лишь после этого будет принимать решение о загрузке потока новыми задачами).

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

  1. сайт, проекты с которого парсим в этой статье / http://freelance.ru/;
  2. описание шаблона «Поставщик/Потребитель» на сайте Intel / https://software.intel.com/ru-ru/articles/producer-consumer;
  3. Короткое описание шаблона проектирования «Thread Pool» на сайте Microsoft / https://social.technet.microsoft.com/wiki/contents/articles/13245.thread-pool-design-pattern.aspx;
  4. Описание шаблонов проектирования «Пул потоков» и «Поставщик/Потребитель» на сайте Корнелльского университета (престижный американский университет) / http://www.cs.cornell.edu/courses/cs3110/2010fa/lectures/lec18.html;
  5. Одна из возможных реализаций шаблона «Producer/Consumer» с описанием / http://hashcode.ru/research/221526/многопоточность-имплементация-producer-consumer-pattern;
  6. Книги раздела «проектирование».

2 thoughts on “Получение данных с сайта. Шаблон Producer/Consumer [Qt, C++]

  1. Reef
    PageHandler::PageHandler(QObject *parent) :
    QObject(parent), m_nam(new QNetworkAccessManager(this)) {
    connect(m_nam, SIGNAL(finished(QNetworkReply*)), SLOT(on_load(QNetworkReply*)));
    }
    void PageHandler::on_page(QString url) {
    QNetworkRequest req(url);
    req.setHeader(QNetworkRequest::ContentTypeHeader, QVariant("text/plain"));
    m_nam->post(req, "");
    }
    

    Почему бы этот код не заменить на?

    QString Url="Http://site";
    QNetworkAccessManager *m_nam= new QNetworkAccessManager(this);
    connect(pManager, SIGNAL(finished(QNetworkReply*)), this, SLOT(replyFinish(QNetworkReply*)));
    m_nam->get(QNetworkRequest(QUrl(Url))); //post(QNetworkRequest(QUrl(Url)), postRequest.toUtf8());
    
    1. admin Post author

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

      Делать как Вы описали можно, но наш обработчик страниц хранит состояние (тип текущей страницы) для того, чтобы корректно обработать страницу в методе on_load. Поэтому и пришлось создать еще один класс (PageHandler), включающий в себя тип страницы и QNetworkAccessManager.

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