刚接触Qt不久的练手项目,Github链接在这里,release目录下面是软件发布,如果无法打开软件可以先安装vc_redist(VS运行环境)。
已实现的feature包括:
1.指定tag从https://konachan.com下载所有匹配的图片;
2.自定义爬取线程池最大线程数量<30(根据实验,K站在同一IP并发请求数量高于30时会ban IP,本来打算爬西刺代理的高匿IP来解决这个问题来着,但是基于几个方面的考虑最终就没做,一是免费IP的速度实在是慢、不稳定,二是30个线程在不打算作为一个pure spider爬人家全站的情况下已经满足需求了,三是除了多调一个QNetworkProxy的api外也没什么新的技术含量和练习价值又浪费时间);
3.网络请求重发和本地图片去重,网络请求失败重发重发最多三次,要下载的图片先检测本地是否存在(重复),存在则不再下载,在更新本地图片的时候好用省时。
界面如下图所示:
下面记录和总结一下开发和学习过程中的思路、问题和收获。
〇、关于IDE(VS2015)和框架(Qt5.6.2)本身的一些问题
1. Qt designer打不开
原因是Qt5WebEngineWidgets.dll加载出问题,更名该动态库可解决。
2. 缺少ui_xxx.h头文件
VS2015集成Qt5有时会出现无法生成ui头文件(ui_xxx.h)的情况,需要对.ui文件单独编译。
3. 生成moc_xxx.cpp文件失败
VS2015集成Qt5有时会出现moc文件(moc_xxx.cpp)在解决方案中存在但实际上并没有成功生成的情况,需要把解决方案中的moc文件移除,然后重新编译
4. 如何切换当前项目的Qt版本
首先安装新的Qt版本,并且环境变量的QTDIR值也要设置为要切换的Qt版本路径,然后在Qt5插件的Qt Options选项中添加目标Qt版本并设为default,最后在当前解决方案上右键选择Change Solution's Qt Version即可,当然源码的兼容性就得靠自己来修改了。
一、需求分析与架构设计
从根据输入tag拼接出匹配结果页面(根页面)的url到下载到所有匹配图片要经过四个步骤:
- 请求根页面,并通过正则表达式分析根页面得出分页数量,获取所有分页页面的url;
- 请求分页页面,对每个分页页面分析得出该页所有图片的概览页面url;
- 请求概览页面,对每张图片的概览页面分析得出下载页面url;
- 请求下载页面,并保存到本地(下载图片)。
频繁发起的https请求毫无疑问要用线程池来处理,而对于不同步骤而言,请求过程是相同的,结果处理因步骤(url)而异,为了学习练手和有可能的图站扩展(并没有),实现的时候有意多态思想,把请求过程放在基类中,结果处理的部分抽象为纯虚函数,在四个派生类中各自实现,不过这样做还要根据不同url创建不同的类,可以矫情地套用一下工厂模式(并没有)。
对于线程间通信,即每次的进度更新(界面显示)和任务添加(产生的新url),第一个发布的版本用了任务队列,但是要考虑线程安全(加锁),还要用非成员变量,重构后的版本全部采用了事件循环的方式,win32下采用xxxEvent和WaitForxxxObject的一系列api,而Qt的则是信号槽,信号槽(signals & slots)是Qt的核心机制之一,基于其元对象系统(The Meta-Object System),具体内容留待后续再叙。
二、详细设计与相关问题
1. 线程池管理线程
用于给线程池添加新任务和通知主线程进度更新,采用任务队列的方式需要重写QThread类的run函数来实现,采用信号槽的方式需要继承QObject类然后movetothread()。
在Qt中创建线程有两种方式:
一是继承QThread类并重载run函数,开启线程后会在新线程中执行run函数,但是需要注意的是它的槽函数被触发时会执行在主线程;
二是继承QObject类,在其中定义信号和槽,然后调用movetothread移入QThread对象,这样它的槽函数被触发时会执行在新线程中。
关于Qt的信号槽也有几点注意事项:
一是在类中使用signals和slots需要继承QObject类并加上Q_OBJECT宏;
二是信号槽中对象不能是不完整类型;
三是信号参数要不少于槽参数;
四是信号和槽同时也是普通函数,只是信号函数不能由用户实现,调用时都一样。
class PoolManager :public QObject
{
Q_OBJECT
public:
PoolManager(QString path,int max);
~PoolManager();
public slots:
void add_new_task(QString task);
signals:
void new_progress(QString progress);
private:
QString path_;
QThreadPool thread_pool_;
};
- 使用QThreadPool来创建线程池,QThreadPool开启线程需要传入继承QRunnable类的对象,创建一个继承自QRunnable的基类,在重写的run函数中实现https请求过程,纯虚函数
virtual void handle_page() = 0;
留待派生类实现。
class TaskWorker :public QObject ,public QRunnable
{
Q_OBJECT
public:
void run();
TaskWorker* set_url(QString url);
TaskWorker* set_path(QString path);
virtual void handle_page() = 0;
signals:
void new_task(QString task);
protected:
QString url_;
QString path_;
QByteArray page_;
};
- https请求的实现可以用Qt提供的QNetworkAccessManager。
但是Qt的网络库不太好用,有几个问题需要注意:
一是每个QNetworkAccessManager对象可以发起多个连接,但是并发连接数量有上限且无法配置,官方文档说明对于http连接最多支持同时6个请求(QNetworkAccessManager queues the requests it receives. The number of requests executed in parallel is dependent on the protocol. Currently, for the HTTP protocol on desktop platforms, 6 requests are executed in parallel for one host/port combination.),一个项目只需要一个manager(One QNetworkAccessManager should be enough for the whole Qt application.),然而这显然无法满足需求,对于这个项目就不能墨守成规了,需要每个线程单独创建一个manager;
二是QNetworkAccessManager对象在发起连接时是异步的,也就是会开一个子线程进行请求,可以通过信号槽的方式来异步处理请求结果,但需要同步处理时可以用QEventLoop来阻塞,另外QNetworkAccessManager对象在主线程退出前不会主动释放,其子线程也不会主动退出,因此完成后要主动释放掉QNetworkAccessManager对象来关闭子线程,不然会造成隐式内存泄露;
三是关于请求返回的QNetworkReply*对象,它需要用户去主动释放(After the request has finished, it is the responsibility of the user to delete the QNetworkReply object at an appropriate time. Do not directly delete it inside the slot connected to finished(). You can use the deleteLater() function.),并且在调用readAll()方法时会清空缓冲区,需要多次使用请求结果时注意保存;
四是关于SSL的支持需要把对应版本(32/64位)的openssl动态库(ssleay32.dll和libeay32.dll)和Qt5Network.dll放在一起,Qt5.6.2是在
Qt5.6.2\Tools\QtCreator\bin
目录下;
void TaskWorker::run()
{
QSslConfiguration ssl_config;
ssl_config.setPeerVerifyMode(QSslSocket::VerifyNone);
ssl_config.setProtocol(QSsl::TlsV1_2);
QNetworkRequest request;
request.setSslConfiguration(ssl_config);
request.setUrl(QUrl(url_));
QEventLoop loop;
QNetworkAccessManager* manager = new QNetworkAccessManager;
QNetworkReply* reply;
bool suc = false;
for (int i = 0;i < 3 && !suc;i++)
{// resend when error
reply = manager->get(request);
connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
loop.exec();
if (!reply->error()) {
suc = true;// quit loop when success
page_ = reply->readAll();
}
else qInfo("Network Error(%d): %s![%s]", reply->error(), qPrintable(reply->errorString()), qPrintable(reply->url().toString()));
delete reply; reply = nullptr;// mem release
}
manager->deleteLater();
suc ? handle_page() : qInfo("Request failed: %s", qPrintable(url_));
}
- 对于页面分析,Qt对正则表达式的支持有QRegExp和QRegularExpression可供选择,本项目采用前者,另外值得一提的是QRegExp支持同一对象存放多个pattern,匹配时可以通过cap()的参数来选择采用哪个pattern的匹配结果。然后正则表达式这个东西用的时候经常需要查资料,不太好记,贴一下我觉得还行的正则表达式参考文档正则表达式30分钟入门教程和正则表达式测试网站Regexper。
- 最后提一下Qt的消息处理自定义方法,可以用来写日志,很方便,其中QMessageLogContext是消息发出时的附加信息如文件名、行号、函数名等等,当然除了qDebug()和qInfo()之外还有对qWarning()、qCritcal()和qFatal()消息的处理,main函数中QApplication对象的定义是Qt Widgets应用所必不可少的,相关内容留待后叙。
int main(int argc, char *argv[])
{
QApplication a(argc, argv);
KonachanCollecter w;
w.show();
// change qDebug&qInfo to logger
qInstallMessageHandler([](QtMsgType type, const QMessageLogContext &context, const QString &msg) {
QString time = QDateTime::currentDateTime().toString("yyyy-MM-dd hh:mm:ss");
QFile file("kc_log.txt");
file.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Append);
#ifdef _DEBUG
if (type == QtDebugMsg) file.write(QString("%1: %2 (%3:%4, %5)\n").arg(time).arg(msg).arg(context.file).arg(context.line).arg(context.function).toLocal8Bit());
#endif // _DEBUG
if (type == QtInfoMsg) file.write(QString("%1: %2\n").arg(time).arg(msg).toLocal8Bit());
file.close();
});
return a.exec();
}
抛去网络库不谈,Qt的确是一个十分优秀的框架,不但功能完善易用,还拥有丰富的帮助文档利于学习、进步和参考。本来想在这篇梳理一下这段时间接触Qt的收获和理解,但是内容较多,也还不够深入,决定后续再另起博文记录学习关于Qt的核心元对象系统、Qt Widgets界面设计以及尚未接触但久闻大名的QML。