“李杜文章在,光焰万丈长”,唐诗无疑是中国古代文学最灿烂的篇章之一。现代人发表论文,会互相引用,喝酒吃饭,也经常会谈及谁谁谁是我哥们。作为当时最重要的文学形式,唐代的诗人也经常会在诗文中提及自己的好朋友。杜甫比李白小十一岁,二者相识于杜甫父亲杜闲家中,彼时正是李白因触怒权贵放归山林之时。两人一见,杜秒变小迷弟。杜在《与李十二白同寻范十隐居》中描绘了两人的亲密关系:”余亦东蒙客,怜君如兄弟。醉眠秋共被,携手日同行”。不仅如此,在两人各奔东西后,杜甫压抑不住对李白的思念,写了多首提及李白的诗。例如《梦李白》中云:”三夜频梦君,情亲见君意”。能连续三个晚上做梦都梦到李白,可见交情不浅。

  通过分析全唐诗中各位诗人之间的“引用”关系,可以描绘出当时诗坛的大致朋友圈图景:谁跟谁熟? 谁是圈子里的带头大哥? 全唐诗有4万多首,人工一首一首地筛查费时费力,这种重复的统计性质的工作正是计算机最擅长的。

版权声明

本文可以在互联网上自由转载,但必须:注明出处(作者:海洋饼干叔叔)并包含指向本页面的链接。

本文不可以以纸质出版为目的进行改编、摘抄。

23. 数据分析 - 唐代诗人的朋友圈

同类相比,同声相应,固天理也。 ——庄子


  本章的代码和数据整合在一个名为C23_PoetsNetwork的文件夹中。注意,本章节所依赖的全唐诗文本以及《中国历代人物传记资料库》使用了繁体中文,所以读者在运行代码查询时,如果使用简体中文输入诗人姓名,结果将与预期不符。

  本章内容受开源项目poetry_analyzer的启发。为方便读者理解,作者整理了相关数据并重写了代码。

  本章的代码实现依赖于Qt平台的专有特性,只能通过Qt Creator集成开发环境来编写和构建。

23.1 创建程序框架

  请读者按照22.1及22.3节所介绍的方法,在Qt Creator中创建一个名为PoetsNetwork的项目,该项目的主窗口如图23-1所示,作者在该图上人工标注了各关键部件的名称。表23-1列出了该项目创建过程中的一些注意事项,以及其主窗口中部分部件的用途。

mw

图23-1 唐代诗人的朋友圈主窗口
表23-1 创建PoetsNetwork项目的注意事项及主窗口的主要部件
名目 说明
项目模板 Application(Qt)/Qt Widgets Application (Qt窗体应用程序)。
主窗口 类名:MainWidget;基类:QWidget。
pbParseQTS 类型:QPushButton;文字:全唐诗数据整理;用途:将全唐诗文本数据整理并写入到数据库。
pbConstructNetwork 类型:QPushButton;文字:构建关系网络;用途:对数据库中的全唐诗文本正行分析,找出诗人与诗人之间的引用关系并写入数据库。
leRefPoet1,leRefPoet2 类型:QLineEdit,单行输入框;用途:“引用查询”时输入第一/二个诗人的姓名。
pbQueryReference 类型:QPushButton;文字:引用查询;用途:查询两个诗人之间的引用数量以及相互之间的引用诗文。
textBrowser 类型:QTextBrowser,简单的文字型HTML浏览器;用途:显示程序的部分执行结果。
lePoetFriendCycle 类型:QLineEdit;用途:查询“朋友圈”时输入诗人的姓名。
pbFriendCycle 类型:QPushButton;用途:查询单个诗人的朋友圈,即找出与指定诗人存在引用关系的全部诗人,生成关系网络图,并用浏览器显示。
pbNetWork50/100/200/500 类型:QPushButton;用途:导出引用数量的前50/100/200/500行引用关系,将由相关诗人姓名及引用箭头组成的关系网络生成出来,并用浏览器显示。

  图23-2则展示MainWidget主窗口的对象结构以及窗口组件之间的布局关系。如图所示,主窗口自身呈现竖向布局,其内包含4个横向布局以及textBrowser。为了构造出期望的界面效果,读者可能需要:(1) 调整布局的layoutSpacing(布局间隔);(2) 修改按钮、单行输入框的minimumSize(最小尺寸)。此外,在图23-2中,我们还看到了类型为Spacer的部件,这种类型的部件仅用于占据布局空间,其在最终的结果页面上不会有任何显示,它可以把别的部件“挤”到期望的位置。

objstructure

图23-2 MainWidget主窗口的对象结构

23.2 数据整理与准备

23.2.1 sqlite数据库

  为了便于统计分析以及向读者简单介绍数据库的入门知识和C++访问数据库的方法,本章使用sqlite数据库来存储相关数据。

  对于结构化的数据,如个人的身份信息、银行的交易流水、图书馆的借还记录等,通常都存储在数据库系统中。数据库系统通常运行在一个服务器或者由多个服务器构成的集群中,软件使用者的计算机或者终端直接或者间接地透过TCP/IP访问数据库、查询或存储数据。大型的数据库系统软件有阿里蚂蚁金服的OceanBase、华为的GaussDB、开源的MySql以及私有的Oracle。

  本章使用的sqlite是一个超级mini版的数据库系统,它本质上是一个运行于软件内部的C语言包。在本章的代码中,数据库的存储文件为C22_PoetsNetwork/data子目录下的data.db。

📍 注意
  本章只能概要地介绍数据库系统的相关知识。对数据库系统的全面讨论是数据库系统课程的任务,读者如果对本实践所应用到的数据库技术感到疑惑,请查阅数据库系统课程的教材。
📕 操作指南 SQLiteStudio的下载与安装
http://codelearn.club/2022/04/installsqlitestudio/

  为了便于查询数据库中的数据,请读者按照二维码链接所提供的方法,下载并安装一个名为SQLiteStudio的软件,SQLiteStudio是遵从GPL协议的开源软件,它可以帮助我们创建、编辑和查询sqlite数据库。作者安装时,其版本号为3.3.3。

  如图23-3所示,运行SQLiteStudio,选择Database/Add a database(数据库/添加一个数据库)菜单项,将得到如图23-4所示的对话框。在该对话框中,将数据类型选择为SQLite 3,点击浏览按钮( navigate )定位到项目目录中已存在的data.db文件,此时,name(名称)被自动调整为data,点击“测试连接”,在测试通过后点击“OK”按钮即可打开本实践的数据库文件(data.db)。

adddb

图23-3 添加数据库

choosedata

图23-4 选择data.db

  数据库文件打开后,名为data数据库将显示在软件的左侧列表中,如图23-5所示。双击data数据库将其逐步展开,可以看到该数据库有4个表格,名称分别是altname(别名)、peom(诗)、poet(诗人)和reference(引用)。

  选择peom表,可以看到表格中的数据,共有42948行,每一行存储了一首唐诗。这些数据来自于“全唐诗”,可以看到,排在最前面的是唐太宗李世民的诗。

table

图23-5 peom表格中的数据

  数据库中的表(Table)都是二维的,每一行称为一条记录(Record), 在poem表中,一条记录存储一首唐诗。每一行又可以分为多列(Column),在数据库中,列也称为字段(Field)。peom表的结构如表23-2所示。

表23-2 诗人表 - poem
字段名 类型 用途
id int 用一个数字来表示每首唐诗在表中的唯一编号,该字段不可重复
title text 题名字符串
author text 作者姓名
content text 全文

  text是sqlite数据库中使用的字符串类型名,读者可以认为它就是Qt中的QString字符串类型。

23.2.2 数据库连接

  应用程序与数据库之间的关系通常是客户机/服务器模式,读者可以把数据库想象成一个服务器,而应用程序则是客户机,数据库服务器通过网络向客户机提供数据存储和查询服务。当然,对于sqlite这种嵌入式数据库而言,数据库服务器并不真正存在,通信也不依赖于网络。

📕 操作指南 创建并添加新类
http://codelearn.club/2021/03/qtnewclass/

  为了访问数据库,需要建立数据库连接。按照上述二维码链接提供的方法,创建一个名为DBHelper的类并加入到项目PoetsNetwork中。

  其中,头文件dbhelper.h的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//dbhelper.h
#ifndef DBHELPER_H
#define DBHELPER_H
#include <QSqlDatabase>

class DBHelper {
public:
static QSqlDatabase db;
static bool openDatabase();
static void closeDatabase();
static QString sProjectPath;
};

#endif // DBHELPER_H

  类DBHelper是一个所谓的“帮助类”,其数据成员和方法都是静态的,这意味着我们可以在不实例化DBHelper对象的前提下使用这些属性和方法。

🚩第8行:QSqlDatabase类型的对象db用于保存“数据库连接”。

🚩第9行:openDatabase()用于“打开”/建立数据库连接,如果打开失败,则返回假。

🚩第10行:closeDatabase()用于关闭数据库连接。

🚩第11行:sProjectPath用于存储程序的“工作目录”,整个应用程序依赖于该目录定位所有的数据文件,包括sqlite数据库文件。

  代码文件dbhelper.cpp的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//dbhelper.cpp
#include "dbhelper.h"
#include <QFile>
#include <QDebug>

QSqlDatabase DBHelper::db;

bool DBHelper::openDatabase() {
QString sDBFile = sProjectPath + "/data/data.db";
if (!(QFile::exists(sDBFile))) {
qDebug() << "Error: missing database file " << sDBFile;
return false;
}

db = QSqlDatabase::addDatabase("QSQLITE");
db.setDatabaseName(sDBFile);
if (db.open())
qDebug() << "Database opened successfully:" << sDBFile;
else
qDebug() << "Database open failed:" << sDBFile;

return db.isOpen();
}

void DBHelper::closeDatabase(){
if (db.isOpen())
db.close();
}

QString DBHelper::sProjectPath = "d:/C2Cpp/C23_PoetsNetwork";

🚩第30行:如本书14.5节所述,类的静态数据成员必须在cpp文件中定义和初始化。这里的“d:/C2Cpp/C23_PoetsNetwork”是作者计算机上的项目工作目录,读者应根据自己计算机上的情况对该目录进行修改。在这个目录下,应有data和html两个子目录,其中,data子目录下存储了全唐诗文本文件及sqlite数据库文件,html子目录内则储存了后期用于关系网络可视化用的JavaScript及html文本文件。

🚩第6行:对db静态数据成员进行定义。

🚩第8 ~ 23行:openDatabase()的函数定义。其中,第9行借助于sProjectPath生成了数据库文件data.db的绝对路径,第10行则通过QFile::exists()函数判定数据库文件是否存在,如果不存在,在向调试控制台输出错误信息后,返回false。

🚩第25 ~ 28行:closeDataBase()函数定义。如果当前数据库db处于打开状态,通过close成员函数关闭它。

💥 警告
  本实践的程序运行时会打开和访问sqlite数据库文件,前一小节中提到的SQLiteStudio软件也需要打开和访问同一个数据库文件。读者必须避免两者同时运行,即读者试图构建并运行PoetsNetwork项目时,需要先行退出SQLiteStudio软件,否则程序运行可能出错。
23.2.3 全唐诗数据整理

  在子目录data下有一个名为qts_zht.txt的文本文件,共收录唐诗4万多首。格式如下:

1
2
3
...
128_65 哭孟浩然 王維 故人不可見,漢水日東流。借問襄陽老,江山空蔡州。
...

  可见,文本文件的每一行是一首唐诗,用空格(部分为\t制表符)可分为四个部分,从前至后分别是编号、题名、作者和全文。为了便于后续的数据分析,作者使用下述程序将全唐诗导入sqlite数据库中的poem表 。

1
2
3
//mainwidget.h...
private:
int parsePoemsIntoDatabase(); //将全唐诗整理入库,返回成功整理入库的唐诗的数量

🚩第3行:在mainwidget.h中,parsePoemsIntoDatabase()函数被定义为私有成员,因为该函数预期仅用于MainWidget内部。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
//mainwidget.cpp ... 
int MainWidget::parsePoemsIntoDatabase() {
QString sFile = DBHelper::sProjectPath + "/data/qts_zht.txt";
if (!(QFile::exists(sFile))) {
qDebug() << "Error: missing QTS file " << sFile;
return -1;
}

auto q = QSqlQuery();
q.prepare("DROP TABLE IF EXISTS poem; ");
if (!q.exec()){
qDebug() << q.lastError();
return -1;
}

q.clear();
q.prepare("CREATE TABLE poem(id INT PRIMARY KEY ASC, "
"title TEXT, author TEXT, content TEXT);");
if (!q.exec()){
qDebug() << q.lastError();
return -1;
}

auto idx = 0;
QVariantList ids, titles, authors, contents;

QFile f(sFile);
Q_ASSERT(f.open(QIODevice::ReadOnly));
QTextStream fs(&f);
fs.setEncoding(QStringConverter::Utf8);
while (!fs.atEnd()){
auto line = fs.readLine();
line.replace("\t"," ");
auto r = line.split(" ");
if (r.size()==4){
ids << idx++; titles << r[1]; authors << r[2]; contents << r[3];
}
}
f.close();

q.clear();
q.prepare("INSERT INTO poem VALUES (?,?,?,?)");
q.addBindValue(ids); q.addBindValue(titles);
q.addBindValue(authors); q.addBindValue(contents);

DBHelper::db.transaction(); //开始数据库事务
if (!q.execBatch()){
qDebug() << q.lastError();
DBHelper::db.rollback(); //回滚数据库事务
return -1;
}
DBHelper::db.commit(); //提交数据库事务

return int(ids.size());
}

  这很可能是读者第一次接触数据库的相关应用,我们先解释代码中的SQL语名。SQL是Structured Query Language的首字母拼写,是专门应用于关系数据库数据查询和操纵的“语言”。与函数parsePoemsIntoDatabase()相关的SQL语句解析请见表23-3。

表23-3 SQL语句解析1
语句 DROP TABLE IF EXISTS poem;
说明 如果poem表存在,将其从数据库中删除(包括结构和数据)。当一个字符串中存在多条SQL语句时,语句之间使用分号作分隔。
语句 CREATE TABLE poem(id INT PRIMARY KEY ASC, title TEXT, author TEXT, content TEXT);
说明 创建poem表,id字段的类型为int,title、author、content字段的类型为text。请注意,id字段同时也被指定为表的主键(PRIMARY KEY),这意味着在这个表里面,每一行的id字段的值不可以重复。对于关系数据库而言,为每个表指定一个主键通常是必要的。ASC是ASCENDING的缩写,表示升序排列,这里就是指poem表内的各行依其主键键值在表中升序排列。
语句 INSERT INTO poem VALUES (?,?,?,?)
说明 用于向表格poem中插入一行数据,即一首唐诗。?在这里的作用类似于替换符,相关代码会将?号用实际数据替换,形成下述完整的SQL语句: INSERT INTO poem VALUES (0, ‘峨眉山月歌’,’李白’,’峨眉山月半轮秋,影入平羌江水流。…’)

🚩第3 ~ 7行:通过DBHelper::sProjectPath生成全唐诗文件文件的绝对路径sFile。再通过QFile::exists()函数判断该文件是否存在,如不存在,报错后返回-1。

🚩第9 ~ 14行:执行SQL语句删除poem表(如果存在)。QSqlQuery是Qt中的数据库查询对象,专门用于执行SQL语句。如果q.exec()返回错误值,则报错并返回-1。在QSqlQuery类型的构造函数中,可以指定该查询对象所使用的数据库连接,如果没有指定,则使用默认数据库连接。由于本应用程序仅连接一个数据库,故总是使用默认数据库连接。

🚩第16 ~ 22行:使用类似于第9 ~ 14行的代码新建poem表格。读者可能会好奇为什么要重建poem表,而不是简单清空其表内数据,作者经过测试,确认相较于删除表内数据,直接重建表的速度更快。

🚩第24 ~ 39行:使用QFile打开全唐诗文本文件,逐行读取其中的唐诗,将题名、作者、内容分拆后,连同顺序号,按顺序装入QVariantList类型的容器ids、titles、authors和contents,为一次性地向数据库写入全部唐诗数据做好准备。

🚩第25行:QVariantList是用于存储QVariant类型的容器。在Qt中,QVariant代表“不确定类型”的对象,该型对象可以存储int、QString以及任意其它类型的对象。

🚩第30行:我们使用utf-8格式来解析文件文件,utf-8是一种支持多国文字共存的文字编码方案。读者应该知道,任何一个文件本质上都是字节流,而文本文件中的字节流,预期应该被“解读”为文字,应该有确定的编码方案将其中的字节流与文字字符进行相互映射。

🚩第33 行:将行字符串line中的制表符\t替换成空格。

🚩第34行:line.split(“ “)函数以空格作为分隔符,将字符串分成多个子串,r的类型为QStringList,是一个存储QString对象的容器。

🚩第41 ~ 52行:通过批量执行SQL语句将准备好的唐诗数据插入数据库的poem表。

🚩第43 ~ 44行:将ids、titles等准备好的QVariantList绑定到SQL查询对象q上。

🚩第47行:q.execBatch()按照第42行所设定的SQL语句模板,结合存储于ids、titles、authors及contents容量内的数据,批量生成并执行SQL语句,将所有数据一次性地写入数据库。

🚩第46、52行:为了提交数据写入的效率,我们采用了数据库的事务(transaction)管理技术。第46行开始数据库事务,第52行提交数据库事务。只有事务提交后,相关的数据库修改才会被确认并写入硬盘文件。想象一笔银行转账交易,如果细分下来,其实包含了多处数据修改:包括减少转出账户的余额、增加转入账户的余额、添加流水日志等。上述多处修改,如果部分成功,部分失败(因断电或系统故障等导致),数据库里的数据就会出现不一致的情况。为了避免一个完整操作的细分动作部分成功,部分失败,数据库系统通常提供事务管理功能,只有在事务提交时,之前的一系列数据修改动作才被确认。在本例当中,应用事务管理技术的目的更多是为了避免数据库文件的多次重写。如果不使用事务管理,每执行一条SQL语句,数据库文件就可能需要写入一次,效率十分低下。

🚩第49行:如果SQL语句的执行发生错误,回滚数据库事务。所谓回滚,就是把事务开始之后的全部操作作废,以避免一系列完整的数据库操作部分成功,部分失败。

  按照22.3.6节所介绍的方法,我们为MainWidget里的pbParseQTS按钮的“released()”信号添加如下内容的槽函数。

1
2
3
4
5
6
&#8195;//mainwidget.cpp
void MainWidget::on_pbParseQTS_released(){
auto r = parsePoemsIntoDatabase();
ui->textBrowser->setHtml(r<0?QString("解析全唐诗文本并导入数据库失败!")
:QString("%1首全唐诗被解析并存入数据库peom表。").arg(r));
}

🚩第3行:调用执行parsePoemsIntoDatabase()函数。

🚩第4行:如果返回的r小于0,说明操作失败,大于等于0则表示解析成功的唐诗数量。textBrowser是简单的HTML文本浏览器,在本例中用于显示部分程序执行结果。

🚩第5行:arg()是QString对象的成员函数,本例中,它用参数值替换掉字符串中的占位符1%,返回一个完成格式化的新字符串。

  构建并执行PoemsNetwork程序,并点击“全唐诗整理”按钮,一切无误的话,将得到如图23-6的执行结果。该执行结果显示,共有42948首唐诗被整理入库。再次提醒,执行PoemsNetwork程序前,应退出SQLiteStudio,以避免两个程序同时访问数据库文件data.db。

parsedata

图23-6 全唐诗数据整理执行结果

  在退出PoetsNetwork程序的执行后,读者可以使用SQLiteStudio查看全唐诗数据整理的成果。如图23-7所示,在左侧的数据库浏览框中,右键单击poem表,并在弹出的菜单中选择Generate query for table(生成表查询)/SELECT(选择)。

tablequery

图23-7 创建表查询

  接着,在SQL语句输入框中录入如图23-8所示的SQL语句,然后单击执行语句按钮(蓝色三角形)。语句成功执行后,下方的结果框显示poem表的数据总行数为42948。

record

图23-8 查询poem表的总记录数

  如果把SQL语句修改成如图23-9所示,并再次执行,则可以查询并显示poem表数据的前10行。

top10

图23-9 查询poem表前10行

  此处涉及的两条SQL语句的语义解释请见表22-4。SQL语言本身是大小写不敏感的,即SELECT与select不作区分,等同使用。

表23-4 SQL语句解析2
语句 SELECT count(*) FROM poem;
说明 从poem表统计并返回数据总行数。该语句的执行结果数据集包含一行数据且仅有一个名为count(*)的字段,该字段预期为一个整数。
语句 SELECT id,title,author,content FROM poem LIMIT 10;
说明 从poem表中查询并返回前10行记录,包括id、title、author及content共4个字段。该语句的执行结果数据集包含10行数据,4个字段。

练习巩固 👣

23-1 (李白的诗)在SQLiteStudio中执行下述SQL语句,查询全部作者为李白的诗。

1
SELECT * FROM POEM WHERE author = '李白';

23-2 (提到孟浩然的诗)在SQLiteStudio中执行下述SQL语句,查询标题中包含“孟浩然”的诗。

1
SELECT * FROM POEM WHERE title LIKE '%孟浩然%';
23.2.4 诗人名录及别名

  要通过对唐诗的检索确定诗人之间的引用关系,并不容易。最大的困难在于古代中国人的别名太多。比如,杜甫,按字称子美,按排行称杜二,按官职称杜工部,有时还甚至被称为老杜。为了解决上述问题,我们下载了哈佛大学编撰的《中国历代人物传记资料库》,关于这个资料库的信息,请访问下述二维链接。

📕 资源下载 中国历代人物传记资料库
http://codelearn.club/2022/02/figurelib/

  这个人物资料库中包含中国历代人物,并非特指唐代诗人,因此重名太多,例如可能存在多个王维、李良的情况。如果仅凭全唐诗的作者名,很难在人物资料库中准确定位那个作为诗人的王维以及他的别名王右丞。还好,我们还有生卒年可以用。唐朝建立于618年,灭亡于907年。我们删除了那些生卒年明确且与唐朝没有交集的全部人名记录,也删除了仅记录有生年或卒年,但从生年或卒年看明显跟唐朝没关系的人名记录。顺便,我们也删除了全部生卒年均不明确的人名记录。经过整理,我们得到了两个数据表,诗人(poet)表(23-5)和别名表(altname)(23-6)。

表23-5 诗人(poet)表字段清单
字段名 类型 用途
id int 用作主键,表示一个诗人的唯一编号
name text 诗人的姓名, 比如“李白”、“刘禹锡”
birthyear int 诗人的出生年份,如果为0,表示生年不详
deathyear int 诗人的死亡年份,如果为0,表示卒年不详
表23-6 别名(altname)表字段清单
字段名 类型 用途
id int 人物在poet表中的id号,由于一个诗人可能拥有多个别名,因此,altname表中的id字段是允许重复的
name text 人物的别名

  接下来,我们在SQLiteStudio中通过SQL语句来查询一下杜甫的别名。第一步,执行下述SQL语句:SELECT * FROM poet WHERE name = ‘杜甫’,得到如图23-10所示的查询结果。请读者注意,与C/C++语言不同,SQL语言中的字符串使用单引号包裹。

du

图23-10 poet表中的杜甫

  在上述结果中可见,杜甫在poet表中的id号为3915,他生于公元712年,卒于公元770年。第二步,我们使用3915这个id去altname表中查询他的别名:SELECT * FROM altname WHERE id = 3915,查询结果如图23-11所示。杜甫的别名子美、工部都是我们所熟悉的,老杜这个别名有点出人意料。

altname

图23-11 altname表中的杜甫别名

  前述两行SQL语句的解释详见表22-7。

表23-7 SQL语句解析3
语句 SELECT * FROM poet WHERE name = ‘杜甫’
说明 对poet表进行筛选,返回姓名(name)字段等于’杜甫’的所有行。*号代码返回行中应包括表格的全部字段(列),对于poet表,这些字段应为id、name、birthyear和deathyear。
语句 SELECT * FROM altname WHERE id = 3915
说明 对altname表进行筛选,返回诗人id等于3915的所有行。同样地,*号代表结果数据集应包括altname表的全部字段,也就是id和name。

练习巩固 👣

23-3 (白居易的别名)执行下述复合SQL语句,查询白居易的别名。

1
2
SELECT * FROM altname WHERE id in 
(SELECT id FROM poet WHERE name = '白居易')
23.2.5 引用关系

  为了保存诗人之间的引用关系,我们还创建了一个名为 reference的表(23-8)。

表23-8 引用(reference)表字段清单
字段名 类型 作用
authorid int 诗的作者在poet表中的id
refid int 被引用的诗人在poet表中的id
poemid int 诗在poem表中的id

  李白在《黄鹤楼送孟浩然之广陵》一诗中引用了孟浩然,李白在poet表中的id号为32 540,孟浩然在poet表中的id号为93 956,《黄鹤楼送孟浩然之广陵》在poem中的id号为7 228,故reference表中存在一行记录,其值如图23-12所示。

ref

图23-12 “黄鹤楼送孟浩然之广陵”所对应的李白对孟浩然的引用记录

  上述李白、孟浩然和诗的id号可以通过下述SQL语句查询而得。其中,第三行的%表示通配符,它表示0到多个任意字符。注意,如果读者进行实际查询,SQL语句中的第三行中的诗名应使用繁体中文。

1
2
3
select * from poet where name = '李白';
select * from poet where name = '孟浩然';
select * from poem where title like '%黄鹤楼送孟浩然之广陵%';

练习巩固 👣

23-4 (李白引用孟浩然的诗)执行下述复合SQL语句,查询李白引用孟浩然的所有诗。

1
2
3
4
5
6
7
8
SELECT * FROM poem
WHERE id IN
(
SELECT poemid FROM reference
WHERE authorid IN
(SELECT id FROM poet WHERE name = '李白') AND
refid IN (SELECT id FROM poet WHERE name = '孟浩然')
);

23.3 构建诗人关系网络

  构建诗人关系网络,即是对poem表中的唐诗题名和内容进行分析,对照poet表和altname表,找出诗人之间的引用关系,并将引用关系记录存入reference。对于每一首唐诗的题名和目录,我们均需要使用字符串匹配方法逐一检查所有诗人的姓名和别名,因此,诗人关系网络的构造运算量大,花费时间较长。为了设计出友好的用户界面,我们需要:(1) 当操作者点击主窗口中的“构建关系网络”按钮后,需要显示一个进度条对话框以避免操作者的焦虑,如图23-13所示;(2) 创建一个专门的线程(thread)来执行诗人关系网络的构建工作。如果我们在主线程也就是消息循环中进行耗时过长的操作,主线程将没有机会收取和处理来自操作者的操作信息,软件界面将“长时间”表现为“卡”住,即不回应操作者的任意操作。软件是否崩溃了?操作还要多久才能完成?没有预期的等待是对用户心理的极大折磨。

construct

图23-13 构建诗人关系网络进度对话框
23.3.1 创建Poem及Poet类

  为了给后续工作做好准备,请读者新创Poem类并加入PoetsNetwork项目。其中, poem.cpp中没有代码,头文件poem.h的内容如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#ifndef POEM_H
#define POEM_H
#include <QString>

class Poem {
public:
Poem(int id, const QString& title,
const QString& author, const QString& content):
iId(id),sTitle(title),sAuthor(author),sContent(content){ }

int iId = 0;
QString sTitle;
QString sAuthor;
QString sContent;
};

#endif // POEM_H

  一个Poem对象存储一首诗,其数据成员的名称iId、sTitle、sAuthor和sContent分别与数据库poem表中的字段名id、title、author和content对应。出于简便,作者把Poem的构造函数的实现直接写在了头文件中,按照本书稍早的描述,这也是一种函数内联的方式。在构造函数初始化列表(第9行)中,构造函数对数据成员进行了初始化。

  请读者新建Poet类并加入PoetsNetwork项目。头文件poet.h的内容如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#ifndef POET_H
#define POET_H
#include <QString>
#include <QVector>

class Poet {
public:
Poet(){}
Poet(int id, const QString& name, int birthyear, int deathyear);
int iId = 0;
QString sName;
int iBirthYear = 0;
int iDeathYear = 0;
QVector<QString> altNames;

static bool getPoetByName(const QString& name, Poet& poet);
static QVector<QString> getAltNamesById(int id);
static QString getPoetNameById(int id);
};

#endif // POET_H

  一个Poet对象存储一位诗人,其数据成员iId、sName、iBirthYear和iDeathYear分别对应数据库poet表中的id、name、birthyear和deathyear字段;字符串向量altNames则存储诗人对象的全部别名,其数据预期来源于altname表。

🚩第16行:静态成员函数getPoetByName()从poet表中查询指定姓名的唐代诗人信息,如果找到“确定”的唐代诗人,将信息填入poet引用,并返回真。

🚩第17行:静态成员函数getAltNamesById()使用诗人id号从altname表中查询该诗人id所对应的全部别名。

🚩第18行:静态成员函数getPoetNameById()使用诗人id号从poet表中查询并返回诗人姓名。

  poet.cpp中的代码分段解读如下。

1
2
3
4
5
6
7
8
#include "poet.h"
#include <QSqlQuery>
#include <QSqlRecord>
#include <QSqlField>

Poet::Poet(int id, const QString& name, int birthyear, int deathyear){
iId = id; sName = name; iBirthYear = birthyear; iDeathYear = deathyear;
}

🚩第2 ~ 4行:导入QSqlQuery、QSqlRecord及QSqlField头文件,这些类型是使用SQL进行数据库查询所必需的。

🚩第6 ~ 8行:构造函数使用参数值初始化数据成员。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
10 	bool Poet::getPoetByName(const QString& name, Poet& poet){
11 int iTangStart = 618, iTangEnd = 907; //唐建立及灭亡年份
12 QVector<Poet> candidates;
13
14 QSqlQuery q;
15 auto sSql = QString("SELECT id, name, birthyear, deathyear "
16 "FROM poet WHERE name like '%%1%'").arg(name);
17 Q_ASSERT(q.exec(sSql));
18 while (q.next()){
19 auto r = q.record();
20 auto id = r.field(0).value().toInt();
21 auto name = r.field(1).value().toString();
22 auto birthyear = r.field(2).value().toInt();
23 auto deathyear = r.field(3).value().toInt();
24 if (birthyear && deathyear){
25 if (birthyear < deathyear and deathyear > iTangStart){
26 poet = Poet(id,name,birthyear,deathyear);
27 poet.altNames = getAltNamesById(id);
28 return true;
29 }
30 }
31 else if (birthyear || deathyear){
32 auto year = birthyear?birthyear:deathyear;
33 if (year>iTangStart && year<iTangEnd)
34 candidates.emplace_back(id,name,birthyear,deathyear);
35 }
36 }
37
38 if (candidates.size()!=1)
39 return false;
40
41 poet = candidates[0];
42 poet.altNames = getAltNamesById(poet.iId);
43 return true;
44 }

  getPoetByName()函数用于从poet表中找出指定姓名的唐代诗人,如果成功找到,将信息填入poet引用,将返回真,否则返回假。考虑到poet表中同时存在中国历朝历代的历史人物,重名情况较多,需要结合生卒年加以鉴别。

🚩第15 ~ 16行:假设name参数的值为“王维”,将产生如图23-14所示的SQL语句,其中的%在SQL语言里被用作通配符,代表0到多个任意字符。这里之所以采用模糊匹配,是因为在poet表,即《中国历代人物传记资料库》中,姓名后面常常包括备注,如李隆基,在poet表中其name字段内容实为:李隆基(唐玄宗)。如图23-14所示的查询结果,poet表中存在两个王维,需要根据生卒年进行鉴别以返回正确的王维。

mh

图23-14 诗人姓名的模糊查询

🚩第17行:通过SQL查询对象q执行SQL语句sSql。该语句执行后,q内将“包含”查询的结果数据集。

🚩第18 ~ 36行:使用while循环对q中的结果数据集进行逐行遍历。

🚩第24 ~ 30行:如果卒年明确且于唐代有交集,将信息填入poet引用,并通过getAltNamesById()函数获取诗人的别名清单,然后返回真。第24行的条件判断使用了非零即真的原则,只要birthyear和deathyear的值不为0,就表示生年或卒年是明确可考的。

🚩第31 ~ 35行:如果生年和卒年之一是明确可考的,且生年或卒年与唐代有交集,将信息加入“候选人”(candidates)向量。

🚩第38 ~ 39行:如果“候选人”数量不等于1,表明没有到适配的诗人或者潜在的候选人多于1个,无法确定,返回false代表查询失败。

🚩第41 ~ 43行:将唯一的“候选人”信息填入poet引用,并通过getAltNamesById()函数获取其别名清单,然后返回真。

1
2
3
4
5
6
7
8
9
10
11
12
13
46 	QVector<QString> Poet::getAltNamesById(int id) {
47 QSqlQuery q;
48 Q_ASSERT(q.exec(QString("SELECT name FROM altname WHERE id = %1").arg(id)));
49
50 QVector<QString> r;
51 while (q.next()) {
52 auto n = q.record().field(0).value().toString();
53 if (n.size()>1) //别名至少要有两个字,单字别名为 刺,德
54 r << n;
55 }
56
57 return r;
58 }

  getAltNamesById()根据诗人id号查询并返回对应诗人的全部别名,返回值类型为一个字符串向量。

🚩第53行:对别名的长度进行了限制,不允许单字别名被使用。如果允许象“刺”、“德”这样的单字别名被使用,最终程序分析得到的引用关系将是不可靠的,因为“德”字出现在诗的题名或者内容中,多数情况下并不代表人名。

🚩第54行:r << n执行的是向量r被重载的操作符函数<<,其用途就将字符串n添加至向量的尾部。

1
2
3
4
5
6
7
8
60 	QString Poet::getPoetNameById(int id) {
61 QSqlQuery q;
62 Q_ASSERT(q.exec(QString("SELECT name FROM poet WHERE id = %1").arg(id)));
63 if (q.next())
64 return q.record().field(0).value().toString();
65 else
66 return "N/A";
67 }

  getPoetNameById()根据诗人的id号查询并返回诗人的姓名,如果没找到,返回“N/A”。

23.3.2 进度条对话框

  请读者按照本书22.7.1节所示的方法为项目PoetsNetwork创建BuildNetwork对话框,该对话框添加完成后,项目里将增加三个文件:buildnetwork.ui、buildnetwork.h以及buildnetwork.cpp。该对话框的运行效果如图23-13所示。

dlgstructure

图23-15 BuildNetwork对话框的对象结构

  图23-15展示了该对话框的对象结构。BuildNetwork的父类是QDialog,其自身是一个竖向布局。在该对话框内部,包括一个标签(QLabel)和一个横向布局,在横向布局内,包含进度条(QProgressBar)类型的部件progressBar以及按钮pbAbort。显而易见,在诗人关系网络的“漫长”的构建过程中,如果点击放弃按钮,意味着要中断关系网络的构建过程。

  接下来,我们在主窗口中为pbConstructNetwork(“构建关系网络”)按钮的“released()”信号添加如下的槽函数。请读者注意,在当前阶段,暂时还不能录入下述代码的第5 ~ 7行,因为BuildNetwork对话框目前仅有一个框架,其并不存在名为iReferenceCount的公有数据成员。该成员预期用于返回成功发现并写入数据库的引用记录的总数量。

1
2
3
4
5
6
7
8
//mainwidget.cpp
void MainWidget::on_pbConstructNetwork_released() {
auto dlg = QSharedPointer<BuildNetwork>(new BuildNetwork(this));
dlg->exec();
auto r = dlg->iReferenceCount;
ui->textBrowser->setHtml(r<=0?QString("关系网络构建过程中途中断。")
:QString("关系网络构建完成,共发现%1条引用关系。").arg(r));
}

🚩第6 ~ 7行:根据对话框所返回的引用记录数判断关系网络的构建过程是否成功完成,并将相应结果信号填入textBrower。

🚩第3行:QSharedPointer是Qt版本的共享智能指针(shared_ptr)。智能指针的采用,使得我们不必担心dlg对象的回收问题。除非智能指针的使用将严重影响程序的执行效率,作者尽可能多地使用智能指针以避免内存泄漏。

  当前阶段,请读者仅录入上述槽函数的前两行(即第3 ~ 4行),确保对话框可以在点击“构建关系网络”按钮后被正常打开。此时,点击对话框右上角的×按钮,可结束对话框。程序设计不是一蹴而就的,程序员正常先搭出一个简易的可运行可验证的程序框架,然后再逐步添加程序的血和肉。

23.3.3 关系网络构建线程

  如前所述,为了避免软件界面长时间“卡”住,我们不能在主线程的消息循环中去执行耗时的关系网络构建工作,也就是说,关系网络的构建工作不能放在槽函数中进行,应该建立一个专门的线程来构建关系网络。

  请读者在PoetsNetwork中新建一个名为ThreadNetworkBuilder的新类。头文件threadnetworkbuilder.h的内容如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#ifndef THREADNETWORKBUILDER_H
#define THREADNETWORKBUILDER_H
#include <QThread>
#include <QVector>
#include <QMap>
#include "poem.h"
#include "poet.h"

class ThreadNetworkBuilder:public QThread {
Q_OBJECT
public:
ThreadNetworkBuilder();
void run() override;
volatile int iProgress {0}; //进度,0-100
void requestTerminate(); //主线程要求线程终止
volatile int iReferenceCount = 0; //成功写入数据库的引用记录数

signals:
void progressUpdate();

private:
QVector<Poem> poems;
QMap<QString,Poet> mapPoets;
void loadPoemsPoets();
void buildPoetsNetwork();
volatile bool bRequestTerminated {false};
};

#endif // THREADNETWORKBUILDER_H

🚩第5行:QMap是Qt版本的有序关联容器,其内存储着“键值对”,它可以帮助我们完成从键到值的快速映射。相关细节请回顾本书19.6.1节。

🚩第9行:ThreadNetworkBuilder的父类是QThread,QThread是Qt中的线程类型。

🚩第10行:为了让ThreaadNetworkBuilder支持信号与槽机制,加入Q_OBJECT宏。

🚩第13行:公用函数run()是线程对象的运行实体,该函数由线程来执行。当run()函数return时,即意味着线程执行的终结,线程对象的“finished()”信号随即被触发。

🚩第14行:主线程可以随时访问线程对象的iProgress属性来获取关系网络构建的进度,其取值范围为0 ~ 100。请读者注意,iProgress的类型是volatile int。关于volatile,请读者留意后续说明。

🚩第15行:主线程可以执行线程对象的requestTerminate()函数要求线程提前终止。事实上,该函数的执行并不能直接终止run()函数运行,它只是将第26行的bRequestTerminated属性设为真。在run()函数内部,我们会经常检查bRequestTerminated属性的值,如果为真,说明主线程已发出了终止执行的要求,run()函数主动return以结束线程。请读者注意,bRequestTerminated属性也是volatible的。

🚩第16行:线程执行结束后,主线程可从线程对象的iReferenceCount属性获取关系网络构建的结果:成功发现并写入数据库的引用记录的总数量。如果该值为-1,表示关系网络构建工作被中途中断。

🚩第18 ~ 19行:progressUpdate()是ThreadNetworkBuilder类型的一个信号,当run()函数认为关系网络构建工作获得了进展时,将会主动发送一个信号。该信号会进入主线程的消息循环,主线程将会调用与该信号相关联的槽函数,以更新对话框中的进度条显示。请读者注意,在Qt中,信号的声明格式类似于函数,但它本身并不是函数。在threadnetworkbuilder.cpp中,并不存在progressUpdate()函数的定义。第18行的signals是一个宏,它是Qt对C++语言的扩展,表示其之后的“函数声明”都是信号。

🚩第22行:私有的poems向量用于存储读入内存中的唐诗。

🚩第23行:私有的mapPoets是从诗人姓名到诗人对象的映射。在关系网络构建的过程中,它可以帮助我们快速地从诗人姓名映射至诗人对象,进而得到诗人id、生卒年等信息,避免对数据库进行不必要的频繁访问。

🚩第24行:私有函数loadPoemsPoets()对唐诗和诗人进行筛选,符合要求的唐诗被装入poems向量,符合要求的诗人对象(作为值)连同其姓名(作为键)则被装入mapPoets映射。

🚩第25行:私有函数buildPoetsNetwork()对poems向量中的唐诗和mapPoets中的诗人对象进行匹配处理,发现诗人之间的引用关系,并存入数据库。该函数是关系网络构建工作的主体,稍后我们会看到,run()是通过调用该函数来完成关系网络的构建的。

要点🎯


  当一个对象被多个线程共享时,需要将其定义为volatile。volatile在英文中的意思为易变的,不稳定的,在C/C++中,它是一个类型修饰符(type specifier),它要求编译器在生成机器指令时总是从内存中获其值,而不能使用位于寄存器中的副本。

optimize

图23-16 内存访问优化示例

  我们结合图23-16来说明将多线程共享的对象设置为volatile的必要性。线程1在时间t1将内存对象a读入到寄存器X,从时间t1到t3,线程1既没有修改a的值,也没有修改X的值,从线程1的角度看,a和X相等。当时间t3程序再次需要使用到对象a时,编译器会认为直接从寄存器X取值更为划算:因为寄存器的访问速度远高于内存。事实上,编译器确实会按照这一逻辑将那些不必要内存访问优化掉。

  对于单个线程的程序,这样做是安全的。但对于多线程共享的对象,上述内存访问优化会有风险。如图23-16所示,线程2在t1和t3之间的中间时刻t2修改了内存对象a的值,线程1在时间t3从寄存器X所得到的,并不是对象a的最新值,寄存器X中的值是“脏”的。

  通过将对象a设为volatile,可以禁止编译器对a对象的内存访问进行优化,从而避免上述问题的发生。


  接下来,分段讨论threadnetworkbuilder.cpp中的代码。

1
2
3
4
5
6
7
8
9
10
11
12
#include "threadnetworkbuilder.h"
#include <QSqlQuery>
#include <QSqlRecord>
#include <QSqlField>
#include <QSqlError>
#include "dbhelper.h"
#include "poet.h"

ThreadNetworkBuilder::ThreadNetworkBuilder() {
//按Qt文档,禁止通过terminate()函数终止线程
setTerminationEnabled(false);
}

🚩第9 ~ 12行:在构造函数中,我们执行setTerminationEnabled(false)禁止其它线程通过QThread::terminate()函数强行中止线程的执行。按照Qt文档的说法,这样做是有风险的,所以我们自定义了requestTerminate()函数来达成类似目的。请读者注意,在本项目中,线程对象是由主线程创建的,这个构造函数本身也是在主线程中执行的。

1
2
3
4
5
14 	void ThreadNetworkBuilder::run() {
15 DBHelper::openDatabase(); //打开线程数据库连接
16 buildPoetsNetwork();
17 DBHelper::closeDatabase(); //关闭线程数据库连接
18 }

🚩第14 ~ 18行:run()函数是线程的执行实体,该函数由线程执行。run()函数return即表示线程的执行结束,线程结束时,其“finished()”信号会被发射。

🚩第15行:Qt中的QSqlDatabase数据库连接对象并不是“线程安全”(thread safe)的,这意味着该对象不允许跨线程访问。因此,线程需要建立自己的数据库连接,通过执行DBHelper的静态成员函数openDatabase()打开数据库连接。稍后我们还会看到,在线程对象开始运行前,主线程会主动关闭数据库连接,以避免冲突。

🚩第16行:执行buildPoetsNetwork()函数构建诗人关系网络。

🚩第17行:关闭数据库连接。

1
2
3
4
5
20 	void ThreadNetworkBuilder::requestTerminate() {
21 //主线程要求线程终止
22 bRequestTerminated = true;
23 qDebug() << "ThreadNetworkBuilder - requestTerminate.";
24 }

🚩第20 ~ 24行:定义公有的requestTerminate()函数。主线程通过执行线程对象的requestTerminate()函数来“中断”线程的执行。

🚩第22行:将volatile的私有属性bRequestTerminated设为真。该标志表明线程对象被主线程要求中止执行。后面我们会看到,run()函数内的buildPoetsNetwork()函数会经常检查该标志,发现其为真,便返回以结束线程的运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
84 	void ThreadNetworkBuilder::loadPoemsPoets() {
85 QSqlQuery q;
86 Q_ASSERT(q.exec("SELECT count(*) FROM poem"));
87 Q_ASSERT(q.next());
88 auto size = q.record().field(0).value().toInt();
89 Q_ASSERT(size>0);
90
91 int idx = 0;
92 q.clear();
93 Q_ASSERT(q.exec("SELECT id, title, author, content FROM poem"));
94 while (q.next()) {
95 if (bRequestTerminated)
96 return;
97
98 int t = 30 * (idx++) / size;
99 if (t>iProgress){
100 iProgress = t;
101 emit progressUpdate();
102 }
103
104 auto r = q.record();
105 auto id = r.field(0).value().toInt();
106 auto title = r.field(1).value().toString();
107 auto author = r.field(2).value().toString();
108 auto content = r.field(3).value().toString();
109
110 Poem m(id,title,author,content);
111 if (mapPoets.contains(author))
112 poems.append(m); //诗人author已存在于映射mapPoets中
113 else {
114 Poet t;
115 if (Poet::getPoetByName(author,t)){
116 //在poet表中找到诗人author
117 poems.append(m);
118 mapPoets[author] = t;
119 }
120 }
121 }
122 }

🚩第84 ~ 122行:在进行关系网络构建之前,需要将符合要求的唐诗和诗人从数据库调入内存,这就是loadPoemsPoets()函数要完成的任务。稍后我们会看到,buildPoetsNetwork()函数执行的第一步便是调用loadPoemsPoets()。本函数将符合要求的唐诗装入poems向量,把符合要求的诗人对象装入mapPoets映射。

🚩第85 ~ 90行:使用SQL语句查询并统计poem表中待筛选的唐诗的总数量(size)。size变量在稍后被我们用于计算工作进度。

🚩第91行:idx变量表示当前正在处理的唐诗的编号,从0开始计数。

🚩第93行:执行SELECT语句从poem表查询全部唐诗数据。

🚩第94 ~ 121行:使用while循环逐行处理唐诗。

🚩第95 ~ 96行:在循环中对bRequestTerminated标志进行检查,如果该标志为真,说明主线程要求线程中止执行,直接返回。

🚩第98行:按作者的估计,唐诗和诗人的读取和筛选占总工作量的30%,结合idx及size变量计算得到当前进度t。

🚩第99 ~ 102行:如果当前进度t大于iProgress,发射“progressUpdate()”信号。主线程的消息循环在收到该信号后会调用对应的槽函数,刷新进度条对话框中的进度显示。

🚩第104 ~ 108行:从数据库记录中获取id、title、author和content字段的值。

🚩第110行:构建Poem对象m。

🚩第111 ~ 120行:如果诗人的姓名已存在于映射mapPoets中,说明这首唐诗的作者明确且可以对应到《中国历代人物传记资料库》中的某唐代人物,将该唐诗加入poems向量。否则,通过Poet::getPoetByName()函数从poet表中筛查,如果能够对应到符合要求的唐代人物,该函数返回真,程序将唐诗装入poems向量,将诗人对象t加入映射mapPoets。如果一首唐诗的作者无法对应到符合要求的唐代人物,程序会忽略这首唐诗,将其排除在统计范围之外。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
26 	void ThreadNetworkBuilder::buildPoetsNetwork() {
27 loadPoemsPoets();
28
29 int idx = 0;
30 QVariantList authorids, refids, poemids;
31 for (auto& m:poems){
32 if (bRequestTerminated)
33 return;
34
35 int t = 30 + 65 *(idx++)/poems.size();
36 if (t>iProgress){
37 iProgress = t;
38 emit progressUpdate();
39 }
40
41 auto authorid = mapPoets[m.sAuthor].iId;
42 for (auto& t:mapPoets.values()) {
43 if (m.sTitle.contains(t.sName) || m.sContent.contains(t.sName)){
44 //直接引用了诗人的本名
45 authorids << authorid;
46 refids << t.iId;
47 poemids << m.iId;
48 continue;
49 }
50
51 //尝试别名
52 for (auto& altName:t.altNames){
53 if (m.sTitle.contains(altName) || m.sContent.contains(altName)){
54 authorids << authorid;
55 refids << t.iId;
56 poemids << m.iId;
57 break;
58 }
59 }
60 }
61 }
62
63 QSqlQuery q;
64 Q_ASSERT(q.exec("DELETE FROM reference"));
65
66 q.clear();
67 q.prepare("INSERT INTO reference VALUES (?,?,?)");
68 q.addBindValue(authorids); q.addBindValue(refids);
69 q.addBindValue(poemids);
70
71 DBHelper::db.transaction(); //开始数据库事务
72 if (!q.execBatch()){
73 qDebug() << q.lastError();
74 DBHelper::db.rollback(); //回滚数据库事务
75 return;
76 }
77 DBHelper::db.commit(); //提交数据库事务
78
79 iReferenceCount = int(refids.size());
80 iProgress = 100;
81 emit progressUpdate();
82 }

  buildPoetsNetwork()函数对poems向量中的唐诗和mapPoets中的诗人对象进行匹配处理,发现诗人之间的引用关系,并存入数据库。

🚩第27行:读入并筛选唐诗和诗人,该函数的执行成果是poems向量和mapPoets映射。

🚩第29行:稍后第31行的for循环将逐行分析poems向量中的唐诗。变量idx表示当前正在处理的唐诗的序号。

🚩第30行:authorids、refids和poemsids是三个元素类型为QVariant的容器,其内分别存储23.2.5节所述的reference表的对应字段的值。第31行的for循环每发现一个引用关系,就会将作者id、被引用人id、相关唐诗的id依序存入这三个容器。

🚩第31 ~ 61行:通过for循环逐一分析poems向量中的唐诗m。

🚩第32 ~ 33行:在循环内检查bRequestTerminated属性的值,真则表示主线程提出了中止执行的要求,return以结束函数执行。

🚩第35 ~ 39行:计算工作进度,必要则发射“progressUpdate()”信号。根据作者的估计,loadPoemsPoets()工作量占比总工作量的30%, 引用关系分析占65%,将引用关系写入数据库占5%。

🚩第41行:通过mapPoets映射找到m.sAuthor对应的诗人对象并得到作者id。

🚩第42 ~ 60行:mapPoets.values()函数返回映射内的全部诗人对象。逐一遍历这些诗人,分析这些诗人的本名或者别是否出现在诗的题名或者内容里,如果出现,则意味着发现了一条引用关系。

🚩第43 ~ 49行:检查候选诗人t的本名是否出现在唐诗的题名或者内容里,如果出现,说明发现了一条引用关系,将authorid(作者id)、t.iId(被引用者id)、m.iId(唐诗id)存入相应的容器以备后续数据库写入之用。

🚩第52 ~ 59行:逐一检查候选诗人t的别名是否出现在唐诗的题名或者内容里,如果出现,也认为是一条新的引用关系。

🚩第63 ~ 64行:执行SQL语句,删除数据库reference表中的全部数据。SQL语句“DELETE FROM reference”没有通过WHERE子句对reference表中拟删除的记录进行限制,这意味着其执行将清空reference表。

🚩第66 ~ 77行:通过批量执行SQL语句将位于authorids、refids和poemids容器内的引用关系写入数据库的reference表。写入过程和方法同mainwidget.cpp中的parsePoemsIntoDatabase()函数,请参考该函数的代码说明。关于INSERT SQL语句的语义,也请参阅parsePoemsIntoDatabase()函数的相关解释。同样地,我们采用了数据库的事务管理技术来避免data.db文件的多次写入。

🚩第79行:修改iReferenceCount属性的值,BuildNetwork对话框将通过线程对象的这个属性来获取线程执行结果,该值代表了成功发现并写入数据库的引用关系的总数量。

🚩第80 ~ 81行:关系网络构建完成,更新进度为100%,并发射“progressUpdate()”信号。

23.3.4 创建线程对象并执行

  接下来,我们需要在BuildNetwork对话框中创建ThreadNetworkBuilder线程对象并启动执行。头文件buildnetwork.h中的代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#ifndef BUILDNETWORK_H
#define BUILDNETWORK_H
#include <QDialog>
#include <QSharedPointer>
#include "threadnetworkbuilder.h"

namespace Ui {
class BuildNetwork;
}

class BuildNetwork : public QDialog {
Q_OBJECT
public:
explicit BuildNetwork(QWidget *parent = nullptr);
~BuildNetwork();
int iReferenceCount = 0;

protected:
void reject();

private slots:
void progressUpdate(); //进度更新槽函数
void builderFinished(); //线程结束槽函数
void on_pbAbort_released();

private:
Ui::BuildNetwork *ui;
QSharedPointer<ThreadNetworkBuilder> builder = nullptr;
bool bCloseEnabled {false};
};

#endif // BUILDNETWORK_H

🚩第16行:公有属性iReferenceCount表示成功发现并写入数据库的引用关系的总数。主窗口MainWidget.cpp通过该属性获取关系网络构建的结果。

🚩第19行:重写QDialog的reject()槽函数以禁止操作者通过对话框右上角的×按钮结束对话框。

🚩第22行:进度更新槽函数。BuildNetwork的构造函数会将该槽函数与线程对象的“progressUpdate()”信号相关联。

🚩第23行:线程结束槽函数。BuildNetwork的构造函数会将该槽函数与线程的“finished()”信号相关联,当线程执行结束后,该槽函数会被主线程的消息循环调用。

🚩第24行:对话框内pbAbort按钮的槽函数。操作者点击并释放“放弃”按钮后,该槽函数会执行线程对象的requestTerminate()成员函数要求中止线程执行。

🚩第28行:指向线程对象的智能指针。

🚩第29行:bCloseEnabled成员表示程序是否允许对话框关闭,其默认值为false。

  对于buildnetwork.cpp,我们分段解释如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include "buildnetwork.h"
#include "ui_buildnetwork.h"
#include "dbhelper.h"

BuildNetwork::BuildNetwork(QWidget *parent) :
QDialog(parent),
ui(new Ui::BuildNetwork)
{
ui->setupUi(this);

ui->progressBar->setRange(0, 100);
ui->progressBar->setValue(0);
setFixedSize(width(), height());

DBHelper::closeDatabase(); //关闭主线程中的数据库连接

builder = QSharedPointer<ThreadNetworkBuilder>(new ThreadNetworkBuilder());
connect(builder.get(), &ThreadNetworkBuilder::progressUpdate,
this, &BuildNetwork::progressUpdate);
connect(builder.get(), &ThreadNetworkBuilder::finished,
this, &BuildNetwork::builderFinished);
builder->start();
}

  BuildNetwork对话框对象是在mainwidget.cpp的pbConstructNetwork按钮的槽函数中实例化的,这意味着第5 ~ 23行的构造函数是在主线程中执行的。事实上,本实践中只有ThreadNetworkBuilder的run()函数是在关系网络构建线程而不是主线程中执行的。

🚩第11 ~ 12行:设置进度条的值范围以及当前值。

🚩第13行:固定对话框大小,不允许操作者通过鼠标拖动改变对话框尺寸。

🚩第15行:为了让关系网络构建线程建立自己的数据库连接,关闭主线程中的数据库连接。

🚩第17行:创建ThreadNetworkBuilder线程对象。智能指针的使用可以确保该对象在BuildNetwork对话框被析构时回收。

🚩第18 ~ 19行:连接线程对象的“progressUpdate()”信号与对话框对象的槽函数“progressUpdate()”。

🚩第20 ~ 21行:连接线程对象的“finished()”信号与对话框对象的槽函数“builderFinished()”。线程对象的“finished()”信号来自于其父类QThread。

🚩第22行:执行builder的start()成员函数启动线程执行。通过该函数,程序将会通过操作系统API创建一个单独的线程,该线程的执行主体即为builder的run()成员函数。

1
2
3
4
25 	BuildNetwork::~BuildNetwork() {
26 builder = nullptr;
27 delete ui;
28 }

🚩第25 ~ 28行:进度对话框的析构函数。在第26行中,我们把智能指针builder主动置为空,这将导致线程对象的析构。事实上,第26行可以没有,智能指针对象builder作为BuildNetwork对话框的成员,其析构会自动销毁线程对象。

1
2
3
4
30 	void BuildNetwork::progressUpdate() {
31 if (builder)
32 ui->progressBar->setValue(builder->iProgress);
33 }

🚩第30 ~ 33行:进度更新槽函数。当关系网络构建线程发射相关信号后,主线程的消息循环将调用这个函数。如第32行所示,函数简单地将进度条的值设定为builder->iProgress。事实上,通过线程对象的成员来获取其执行进度并不是一个好主意,这导致对话框与线程之间的“接口”变得复杂。在Qt中,信号是可以附带参数的,读者可以对相关代码进行改行,使得上述槽函数可以通过参数获得当前执行进度,而不是访问builder对象的属性。

1
2
3
4
5
6
7
8
35 	void BuildNetwork::builderFinished() {
36 if (builder)
37 iReferenceCount = builder->iReferenceCount;
38 bCloseEnabled = true;
39
40 DBHelper::openDatabase(); //重新打开主线程的数据库连接
41 close();
42 }

🚩第35 ~ 42行:线程结束槽函数。在线程结束运行后,该槽函数会被主线程的消息循环所调用。

🚩第37行:从线程对象获取执行结果,即成功发现并写入数据库的引用关系的总数量。

🚩第38行:打开bCloseEnabled开关,允许对话框关闭。稍后进一步解释其工作机制。

🚩第40行:线程结束运行后,重新建立主线程的数据库连接。

🚩第41行:执行QDialog的close()函数以关闭对话框。

1
2
3
4
44 	void BuildNetwork::on_pbAbort_released() {
45 if (builder)
46 builder->requestTerminate();
47 }

🚩第44 ~ 47行:“放弃”按钮的框函数。如第46行所示,通过执行builder->requestTerminate(),主线程要求关系网络构建线程中止。

1
2
3
4
5
49 	void BuildNetwork::reject() {
50 if (bCloseEnabled){
51 QDialog::reject();
52 }
53 }

🚩第49 ~ 53行:无论是操作者按下了对话框右上角的×按钮,还是程序主动执行了对话框close()函数,Qt都会通过QDialog::reject()来完成对话框的真正关闭,并将对话框的执行结果设为Rejected(已拒绝)。如上述代码所示,只有当bCloseEnabled标志为真时,QDialog::reject()才会被执行。这确保了操作者无法通过对话框的右上角关闭按钮关闭对话框。

  重新构建程序并运行,如果代码无误,读者将得到如图23-13所示的执行界面,点击“构建关系网络”按钮后,将看到BuildNetwork进度条对话框被显示出来,其中包含一个进度条以及一个“放弃”按钮。在作者的计算机上,整个关闭网络构建过程大约耗时20秒。执行结束后,进度条对话框会自动关闭,如图23-17所示,主窗口的textBrower显示了执行结果:发现了4729条引用关系。

netrst

图23-17 构建关系网络执行结果

  读者会发现,关系网络的构建线程虽然在“后台”执行,但操作者仍然可以很顺畅地操作进度条对话框。如果读者点击“放弃”按钮,构建线程很快终结,textBrower则显示构建被放弃的信息。

练习巩固 👣


23-5 (繁忙的主线程)修改示例代码,去除关系网络构建线程,在“构建关系网络”的槽函数中进行关系网络构建。


23.4 引用查询

  运行作者编写好的成品程序,在行编辑框leRefPoet1和leRefPoet2中分别输入李白和杜甫,然后单击“引用查询”按钮,可得如图23-18所示的执行结果。如图中所示,textBrower显示了两位诗的姓名以及别名、生卒年等信息,还列出了两者相互引用的全部唐诗的原文。本节讨论“引用查询”功能的代码实现。

ld

图23-18 李白与杜甫的相互引用
23.4.1 创建Reference类

  为了实现“引用查询”、“朋友圈”、“可视化关系网络”等功能,我们创建一个名为Reference的类型,其头文件reference.h的内容如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#ifndef REFERENCE_H
#define REFERENCE_H
#include <QVector>
#include "poem.h"

class Reference {
public:
int iAuthorId; //引用人id
int iRefId; //被引用人id
int iCount; //引用数量
Reference(int authorid, int refid, int cnt):
iAuthorId(authorid),iRefId(refid),iCount(cnt){ }
static QVector<Poem> getReferencePoems(int authorid, int refid);
static QVector<Reference> getFriendCycle(int authorid);
static QVector<Reference> getReferences(int iLimit);
};

#endif // REFERENCE_H

🚩第8 ~ 10行:假设一个Reference对象表示李白在诗作中提到杜甫3次,那么李白就是引用人, 杜甫是被引用人,引用数量则为3;iAuthorId保存李白在poet表中的id,iRefId保存杜甫在poet表中的id,cnt则保存引用数量3。

🚩第11 ~ 12行:Reference对象的构造函数,其通过构造函数初始化列表初始化3个数据成员。

🚩第13行:静态成员函数getReferencePoems(int authorid, int refid)查询并返回某位指定的诗人引用另一位指定诗人的全部诗作。其中,authorid是引用人的id,refid是被引用人的id,函数的返回值类型为QVector

🚩第14行:静态成员函数getFriendCycle(int authorid)查询并返回某位指定诗人的全部相关引用关系,其返回值类型为QVector。在这些引用关系中,这位诗人既可以是引用人,也可以是被引用人。在稍后的23.5节,该函数被用于显示一个诗人的朋友圈。

🚩第15行:静态成员函数getReferences(int iLimit)查询将返回所有唐代诗人相互引用关系的前iLimit行,该函数的返回值类型为QVector。在稍后的23.6节,该函数被用于可视化诗人关系网络。

  接下来分段讨论reference.cpp中的代码实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include "reference.h"
#include <QSqlQuery>
#include <QSqlRecord>
#include <QSqlField>

QVector<Poem> Reference::getReferencePoems(int authorid, int refid){
QSqlQuery q;
QString sSql =
QString("SELECT id, title, author, content FROM poem WHERE id IN "
"(SELECT poemid FROM reference WHERE authorid = %1 and refid = %2)")
.arg(authorid).arg(refid);
Q_ASSERT(q.exec(sSql));

QVector<Poem> t;
while (q.next()){
auto r = q.record(); //取得QsqlRecord记录
t << Poem(r.field(0).value().toInt(), //字段0是id
r.field(1).value().toString(), //字段1是title
r.field(2).value().toString(), //字段2是author
r.field(3).value().toString()); //字段3是content
}

return t;
}

  如前所述,该函数用于查询并返回id为authorid的引用人引用id为refid的被引用人的全部唐诗。如代码所见,整个函数的执行过程分为两步。首先通过SQL查询获得结果数据集,然后遍历结果数据集,并逐行转换成Poem对象,加入向量t然后返回。我们重点解释相关的SQL语句。

🚩第9 ~ 11行:通过连续两次的arg()函数调用完成SQL语句字符串的格式化。本例中,authorid替代了占位符1%,refid替代了占位符2%。

  对照前文,我们已知李白的id为32 540,杜甫的id的是3 915。如果要查询李白引用杜甫的所有唐诗,则经格式化后的上述SQL语句如下所示。为了便于阅读,作者把格式稍作了整理。

1
2
3
4
5
6
7
SELECT id, title, author, content 
FROM poem
WHERE id IN (
SELECT poemid
FROM reference
WHERE authorid = 32540 and refid = 3915
)

  为了便于读者理解,作者把上述SQL语句分成两部分在SQLiteStudio中执行。图23-19展示了后一个SELECT子句的执行结果,该执行结果只有1个字段3行,其数据来源为reference表,引用人限定为李白,被引用人限定为杜甫。显然,执行结果中的3个整数就是李白引用杜甫的3首诗在poem表中的id。

id

图23-19 查询指定引用人和被引用人的引用关系中的唐诗id

  接下来,我们用查得的3首唐诗的id替换掉完整SQL语句中的第2个SELECT子句。查询结果如图23-20所示。这个SQL语句比较容易理解,它从poem表中进行记录筛选,要求id值必须是7166、7261和7655之一,最终的结果数据集包括id、title、author和content共4个字段。综上,前面所示的完整SQL语句由两个SELECT子句构成,其中,可以认为后面一个SELECT子句先执行,其执行结果构成了前一个SELECT子句的筛选条件。

lyd

图23-20 李白引用杜甫的全部唐诗

  接下来讨论reference.cpp中的getFriendCycle()函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
26 	QVector<Reference> Reference::getFriendCycle(int authorid) {
27 QSqlQuery q;
28 QString sSql = QString(
29 "SELECT authorid,refid,count(*) as cnt FROM reference "
30 "WHERE authorid=%1 or refid=%2 GROUP BY authorid, refid")
31 .arg(authorid).arg(authorid);
32
33 Q_ASSERT(q.exec(sSql));
34
35 QVector<Reference> t;
36 while (q.next()){
37 auto r = q.record();
38 t << Reference(r.field(0).value().toInt(),
39 r.field(1).value().toInt(),
40 r.field(2).value().toInt());
41 }
42
43 return t;
44 }

  如前所述,getFriendCycle()函数用于查询与指定id的诗人相关的全部引用关系。同样地,函数先进行SQL查询,然后遍历SQL查询结果,逐行转换成Reference对象,再置入向量后返回。

  假设我们查询的是与杜甫有关的全部引用关系,在经过占位符替换后,代码第29 ~ 31行生成的SQL语句如下所示(格式有调整)。

1
2
3
4
SELECT authorid,refid,count(*) as cnt 
FROM reference
WHERE authorid=3915 or refid=3915
GROUP BY authorid, refid

  在上述SQL语句中,被查询表格为reference(第2行);筛选条件为引用人或者被引用人id为3915(第3行);筛选而得的全部记录按引用人id和被引用人id进行分组(第4行),即引用人id和被引用人id相同的记录分在同一组;分组之后的数据取authorid、refid和cnt三个字段,其中,cnt的值为count(*),表示对应分组的记录数。

  相关SQL语句在SQLiteStudio中的局部执行结果如图23-21所示。执行结果的第2行表示id为3 702的诗人引用了id为3 915的诗人(即杜甫)2次;执行结果的第8行表示id为3 915的诗人(即杜甫)引用了id为13 060的诗人8次。带着这两个id去poet表中查询可得,皮日休(3 702)引用了杜甫2次,杜甫引用了李世民(13 060)8次。

durelative

图23-21 与杜甫相关的引用关系的部分查询结果

  最后讨论reference.cpp中的最后一个函数getReferences()。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
46 	QVector<Reference> Reference::getReferences(int iLimit) {
47 QSqlQuery q;
48 QString sSql = QString(
49 "SELECT authorid,refid,count(*) as cnt FROM reference "
50 "GROUP BY authorid, refid ORDER BY cnt DESC LIMIT %1").arg(iLimit);
51
52 Q_ASSERT(q.exec(sSql));
53
54 QVector<Reference> t;
55 while (q.next()){
56 auto r = q.record();
57 t << Reference(r.field(0).value().toInt(),
58 r.field(1).value().toInt(),
59 r.field(2).value().toInt());
60 }
61
62 return t;
63 }

  如前所述,getReference()函数用于查询唐代诗人引用数量最多的前iLimit对。函数的执行过程和原理同前两个函数类似,我们重点讨论SQL语句。假设参数iLimit为50,则相关SQL语句及其在SQLiteStudio中的执行结果如图23-22所示。

  相关SQL语句中,被查询的表格为reference;筛选条件无;全部记录按authorid以及refid进行分组;字段cnt值为count(*),表示每组中的记录条数;分组统计数据按照cnt降序排序(第4行);只取全部查询结果的前50行(第5行)。

top50

图23-22 唐代诗人引用关系前50强

   从图23-22可以看到,编号为33 753的诗人引用编号3 702的诗人199次,按SQL语句“SELECT * FROM poet WHERE id in (3702,33753)”进行查询可知,这对好朋友是陆龟蒙(~881)和皮日休(834 ~ 883)。

23.4.2 引用关系的超文本展现

  我们对主窗口中的pbQueryReference按钮添加了如下的槽函数,该函数在操作者单击并释放“引用查询”按钮后被调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
void MainWidget::on_pbQueryReference_released() {
Poet poet1;
if (!Poet::getPoetByName(ui->leRefPoet1->text().trimmed(),poet1))
return;
Poet poet2;
if (!Poet::getPoetByName(ui->leRefPoet2->text().trimmed(),poet2))
return;

QString sHtml;
auto poems1 = Reference::getReferencePoems(poet1.iId,poet2.iId);
sHtml += "<table><tr><td bgcolor='#e8f4fe'>";
sHtml += toHtml(poet1);
sHtml += QString("<font size='4' color=#369ff3><center><br>"
"%1在%2首诗中提到了%3<br></center></font>")
.arg(poet1.sName).arg(poems1.size()).arg(poet2.sName);
for (auto& m:poems1)
sHtml += toHtml(m);
sHtml += "</td><td bgcolor='#fcfdf8'>";

auto poems2 = Reference::getReferencePoems(poet2.iId,poet1.iId);
sHtml += toHtml(poet2);
sHtml += QString("<font size='4' color=#369ff3><center><br>"
"%1在%2首诗中提到了%3<br></center></font>")
.arg(poet2.sName).arg(poems2.size()).arg(poet1.sName);
for (auto& m:poems2)
sHtml += toHtml(m);
sHtml += "</td></tr></table>";

ui->textBrowser->setHtml(sHtml);
}

🚩第2 ~ 4行:从单行输入框leRefPoet1获取诗人1的姓名,并通过Poet::getPoetByName()函数在《中国历代人物传记》中进行比对,如果无法准确定位到某位唐代诗人,放弃并返回。

🚩第5 ~ 7行:使用相同方法比对诗人2。

🚩第10行:查询并得到诗人1引用诗人2的唐诗向量。

🚩第11行:为sHtml字符串添加标记,这些标记属于超文本标记语言(Hyper Text Markup Language)的范畴,其简单解释见表22-9。

🚩第12行:toHtml()函数将诗人poet1转换成HTML格式的字符串,然后附加到sHtml。

🚩第13 ~ 15行:向sHtml字符串附加诗人1引用诗人2的摘要信息。

🚩第16 ~ 17行:对诗人1引用诗人2的唐诗向量poems1进行遍历,使用toHtml(m)函数逐一将唐诗对象转换成HTML格式的字符串,然后附加到sHtml。

🚩第18行:向sHtml字符串添加标记。

🚩第20 ~ 27行:重复上述过程,向sHtml添加诗人2引用诗人1的唐诗及相关信息。

🚩第29行:将字符串sHtml填入textBrower。如前所述,textBrower是文本浏览器,具备一定的超本文解析和展示能力。

  前述代码中用到和toHtml()实际上是两个函数名重载的同名函数,其分别完成Poet和Poem对象的HTML字符串转换工作。两个函数的代码如下,请读者结合表23-9列出的HTML标记综合理解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
QString MainWidget::toHtml(const Poet& t) {
QString s = t.sName + "( ";
for (auto& x:t.altNames)
s = s + x + " ";
s += ")";
return QString("<center><font size='4'>%1</font><br>"
"<font size='2'>%2 ~ %3</font></center><hr>")
.arg(s).arg(t.iBirthYear).arg(t.iDeathYear);
}

QString MainWidget::toHtml(const Poem& m) {
QString t;
t += QString("<center><h3>%1</h3></center>").arg(m.sTitle);
t += QString("<center>%1</center>").arg(m.sAuthor);

QString c = m.sContent;
c.replace("。","。<br>");
t += QString("<center>%1</center>").arg(c);
return t;
}
表23-9 案例中使用到的HTML标记说明
标记 说明
<table></table> 成对标记,中间的内容为一个表格。
<tr></tr> 成对标记,中间的内容为表格中的一行,tr是table row的首字母简写。
<td></td> 成对标记,中间的内容为一个表格行中的一个单元格。
<br> 单一标记,表示内容换行。
<center></center> 成对标记,中间的内容居中显示。
<font></font> 成对标记,改变中间内容的字体。
<hr> 单一标记,显示一根分隔横线。
<h3></h3> 成对标记,中间的内容按3号标题显示。

  至此,引用查询功能完成。执行效果请回顾图23-18。

练习巩固 👣


23-6 (修改HTML标记)尝试修改示例代码中的HTML标记,比如调整字号、颜色等内容,再次运行并对照图23-18,观察相关标记对界面展示效果的影响。


23.5 朋友圈

cyc

图23-23 单位诗人的朋友圈

  如图23-23所示,在主窗口的lePoetFriendCycle单行编辑框中输入杜甫,然后单击“朋友圈”按钮,程序会打开浏览器,显示出如图23-24所示以杜甫为中心的朋友圈,看起来,杜圣是还是位社交达人。请读者注意图中顶部的文件名,该扩展名为html的超文本标记语言文件,就是俗称的网页文件,是由程序生成并保存至硬盘的。

ducycle

图23-24 杜甫的朋友圈

  在mainwidget.cpp中,“朋友圈”按钮的槽函数如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void MainWidget::on_pbFriendCycle_released() {
Poet m;
if (!Poet::getPoetByName(ui->lePoetFriendCycle->text().trimmed(),m)){
ui->textBrowser->setHtml(
"<h3>诗人姓名不存在,注意数据库存储的姓名为繁体中文.</h3>");
return;
}

QVector<Reference> r = Reference::getFriendCycle(m.iId);
QString sFileName = DBHelper::sProjectPath + "/html/friendcycle.html";
writeHtmlFile(sFileName,toHtml(r));

QStringList t;
t << sFileName.replace("/","\\");
QProcess::startDetached("explorer",t);
}

🚩第2 ~ 7行:从单行输入框lePoetFriendCycle读取操作者输入的诗人姓名,然后使用Poet::getPoetByName()进行唐代诗人比对,如果比对失败,在textBrower中显示错误信息并返回。

🚩第9行:使用Reference::getFriendCycle()获取该诗人的全部引用关系,该函数代码细节见23.4节。

🚩第10行:朋友圈的显示是通过浏览器实现的,程序需要往硬盘写入一个html格式的文件,本行代码生成该文件的完整路径。

🚩第11行:调用toHtml(r)将引用关系转换成HTML字符串,再经由writeHtmlFile()将其写入到指定的文件。

🚩第13 ~ 16行:使用QProcess::startDetached()函数开始一个分离的外部进程,打开浏览器。“explorer”是浏览器的可执行文件名,QStringList类型的变量t是命令行参数,它是一个容器,其包含了拟浏览的HTML文件在硬盘上的路径。

🚩第14行:将文件路径放入容器t之前,提前将其中的“/”修改为“\”。Windows操作系统使用“\”做为目录分隔符,如果读者使用的是Linux系统,则不应做这种替换。

  接下来我们讨论上述代码中用到的toHtml()函数的下述函数名重载版本,该函然将引用关系向量转换一个HTML字符串。这个生成出来的字符串将由writeHtmlFile()函数写入文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
QString MainWidget::toHtml(const QVector<Reference>& n) {
auto sHtmlHead = readFile(DBHelper::sProjectPath + "/html/html_head.txt");
auto sHtmlTail = readFile(DBHelper::sProjectPath + "/html/html_tail.txt");

QString sLinks = "links: [\n";
QString sLinkFormat =
"{source:'%1',target:'%2',lineStyle:{normal:{width: %3}}},";
QSet<QString> authorNames;
for (auto& x:n){
auto sAuthorName = Poet::getPoetNameById(x.iAuthorId);
auto sRefName = Poet::getPoetNameById(x.iRefId);
sLinks += sLinkFormat.arg(sAuthorName).arg(sRefName).arg(sqrt(x.iCount));
authorNames << sAuthorName << sRefName;
}
sLinks += "],\n";

QString sNodes = "data:[\n";
QString sItemFormat = "{name: '%1'},\n";
for (auto& x:authorNames)
sNodes += sItemFormat.arg(x);
sNodes += "],\n";

return sHtmlHead + sNodes + sLinks + sHtmlTail;
}

  可以看到,整个HTML文件的内容由文件头(sHtmlHead)、文件尾(sHtmlTail)、链接(sLinks)和节点(sNodes)四个部分组成。该HTML文件使用了一个由Java Script编写的第三方库来渲染关系网络,字符串sNodes用于描述网络的节点构成,字符串sLinks则用于描述网络中节点间的连接。文件头和文件尾则包含了HTML文件的一些必要的语法构成部分,且提供了对第三方Java Script库的引用,它们都是从文件中读取出来的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
QString MainWidget::readFile(const QString filename) {
if (!(QFile::exists(filename))) {
qDebug() << "Error: missing file... " << filename;
return "";
}

QFile f(filename);
Q_ASSERT(f.open(QIODevice::ReadOnly));
QTextStream fs(&f);
fs.setEncoding(QStringConverter::Utf8);
auto s = fs.readAll();
f.close();
return s;
}

  readFile()函数则用于读取指定文件的全部内容,返回值类型为字符串。

1
2
3
4
5
6
7
void MainWidget::writeHtmlFile(const QString& filename,
const QString& sContent) {
QFile f(filename);
Q_ASSERT(f.open(QIODevice::WriteOnly));
f.write(sContent.toUtf8());
f.close();
}

  writeHtmlFile()将字符串sContent写入文件filename。请读者注意,无论是读文件文件还是写文本文件,我们都使用了UTF-8编码。

23.6 可视化关系网络

  在主窗口的下方,还有四个分别名为“关系网络(50)”、“关系网络(100)”、“关系网络(200)”、“关系网络(500)”的按钮,其预期用于浏览诗人关系网络。由于唐代诗人数量多,引用关系复杂,括号内的数字代表了对要浏览的关系网络的限制,比如100表示在该关系网络中,仅包含数量前100名的引用及其相关作者。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void MainWidget::on_pbNetwork50_released() {
exploreNetwork(50);
}

void MainWidget::on_pbNetwork100_released() {
exploreNetwork(100);
}

void MainWidget::on_pbNetwork200_released() {
exploreNetwork(200);
}

void MainWidget::on_pbNetwork500_released() {
exploreNetwork(500);
}

  如上述代码所示,我们给这四个按钮添加了几乎相同的槽函数,这些槽函数都调用执行了exploreNetowrk()。

1
2
3
4
5
6
7
8
9
10
void MainWidget::exploreNetwork(int iLimit) {
QVector<Reference> r = Reference::getReferences(iLimit);
QString sFileName = DBHelper::sProjectPath +
QString("/html/network%1.html").arg(iLimit);
writeHtmlFile(sFileName,toHtml(r));

QStringList t;
t << sFileName.replace("/","\\");
QProcess::startDetached("explorer",t);
}

🚩第1行:执行Reference::getReferences()获取数量前iLimit的引用关系向量。相关代码细节请见23.4节。

🚩第3 ~ 5行:将引用关系转换成HTML,并写入文件。

🚩第7 ~ 9行:打开外部浏览器渲染关系网络。

  至此,本实践任务全部完成。图23-25展示了包含200行引用关系的唐代诗人关系网络的局部。在浏览器里,读者可以用鼠标按住诗人的姓名,然后拖动观察。在该网络中,诗人与诗人之间的连线的粗细表示引用的次数。可见,白居易与元稹,白居易与刘禹锡过从甚密。陆龟蒙和皮日休两位相互疯狂引用,但与其他诗人联络却相对较少。读者如果拖一下白居易和刘禹锡,可以看到他们两位以及元稹,几乎在中唐诗人中处于核心地位,属于带头大哥级别。熟悉唐诗的朋友可能知道,李杜时期的盛唐诗人以浪漫主义为主,而白居易、元稹为代表的中唐诗人逐渐转向现实主义。

network

图23-25 可视化诗人关系网络局部

源代码及数据下载

http://codelearn.club/download/C23_PoetsNetwork.zip