QT 开发基础知识(六)

原文:Foundations of Qt Development

协议:CC BY-NC-SA 4.0

十三、数据库

D 即使是最简单的现代应用程序,数据库也是不可或缺的一部分。虽然大多数读者可能倾向于将数据库与网站和大型企业解决方案联系起来,但您可能会惊讶地发现,您还可以使用数据库来存储 Qt 应用程序中管理的数据。

Qt 为关系数据库提供了一个叫做QtSql的模块。 SQL ,代表结构化查询语言,是一种用于操作关系数据库的语言。使用 SQL,您可以在不同的数据库引擎和应用程序之间进行通信。

Qt 支持许多最流行的数据库,包括 MySQL、Oracle、PostgreSQL、Sybase、DB2、SQLite、Interbase 和 ODBC。这些驱动可以作为插件构建,也可以集成在 Qt 中。

在这一章中,你将学习如何将 MySQL 和 SQLite 数据库与你的 Qt 应用程序集成。您可能会在更复杂的情况下使用 MySQL,当数据库很方便时使用 SQLite,但是功能齐全的数据库服务器可能会被认为是多余的。

SQL 快速介绍

在开始学习一些基本的 SQL 语句之前,您应该理解 SQL 是另一种编程语言。这本书不会教你用 SQL 开发;它只会显示最基本的信息。您还需要知道 Qt 支持的不同数据库服务器支持不同的 SQL 方言。这意味着,与 SQLite 连接相比,MySQL 连接的语句看起来可能略有不同。通过坚持最基本的原则,这些差异是可以避免的,但是请准备好阅读 SQL 手册中您选择使用的数据库引擎。

本节中使用和描述的语句已经过 MySQL 和 SQLite 的测试,所以不会有方言上的问题。


注意一条 SQL 语句也被称为查询,因为有些语句是用来查询数据库信息的。


什么是数据库?

本章的其余部分将讨论关系数据库,即表的集合。每个表都有一个名称和一组列和行。列定义了表格的结构,而行包含数据。然后,这些表通过关系连接在一起,在关系中,不同表中的列值相互链接。

每一列都有一个名称和一个类型,这使得控制内容的去向和按名称检索成为可能。您还可以控制允许的内容,以便用默认值替换NULL值,或者您可以完全禁止NULL值。

行包含由列定义的数据。当您使用数据库时,通常会搜索行、添加行、更新行或删除行。

您需要做的第一件事是创建一个实际的数据库,创建它的方式取决于您计划使用的数据库服务器。有关详细信息,请参阅服务器的用户手册。

在开始添加行之前,您需要使用CREATE TABLE命令创建一个表。我们把桌子叫做names。下面的语句创建一个表,该表有一个名为id的整数列和两个名为firstnamelastname的字符串:

CREATE TABLE names (

id INTEGER PRIMARY KEY,

firstname VARCHAR(30),

lastname VARCHAR(30)

)
在语句中,您将`id`指定为`PRIMARY KEY`,这意味着在同一个表中不能有两个相同的`id`值。您可以通过`id`列来识别每一行,这可以在搜索数据时使用。
使用的类型是用于整数值的`INTEGER`和用于字符串的`VARCHAR(30)`。`VARCHAR`类型是一个可变长度的字符串。括号内的值限制了字符串的长度,因此`firstname`和`lastname`字符串必须少于或等于 30 个字符。
该语句的一个问题是,如果该表已经存在,它将失败。您可以通过添加`IF NOT EXISTS`来创建以下语句来解决这个问题:
CREATE TABLE IF NOT EXISTS names (

id INTEGER PRIMARY KEY,

firstname VARCHAR(30),

lastname VARCHAR(30)

)
该语句添加该表,或者如果该表已经存在,则忽略它。
要删除一个表,使用`DROP TABLE`命令。要删除刚刚创建的`names`表,只需执行以下命令:
DROP TABLE names
插入、查看、修改和删除数据
与数据库交互所需的最基本操作是查看、添加、修改和删除存储在表中的数据。一旦你把桌子摆好,剩下的时间你就要做这个了。这四个操作组成了有时所谓的 *CRUD* 接口(代表*创建、读取、更新**删除*)。
执行这些任务的 SQL 命令包括用于添加数据的`INSERT`、用于查看的`SELECT`、用于修改的`UPDATE`和用于删除的`DELETE`。所有这四项任务都将在以下章节中介绍。
 **插入数据**
将一个名字插入到`names`表中很容易。使用`INSERT INTO`语句,您可以列出列名,后跟`VALUES`关键字和实际值:
INSERT INTO names (id, firstname, lastname) VALUES (1, 'John', 'Doe')
可以跳过列名,但这意味着您依赖于表中列的顺序 Murphy 告诉您,如果您依赖于它,这种情况一定会改变。虽然我将命令放在一行中,但是为了可读性,请随意将较大的查询分成多行,因为 SQL 对换行符不敏感。
当向`names`表中插入项目时,您可以让数据库自动生成`id`值,方法是告诉它在创建表时该列将`AUTOINCREMENT`。

**注意**这个特性被 SQLite 称为`AUTOINCREMENT`,被 MySQL 称为`AUTO_INCREMENT`,但是其他数据库可能根本不支持它。这意味着表创建语句可能不兼容。

**查看数据**
当您将数据放入数据库后,您需要能够检索并查看它。这是`SELECT`命令进入画面的地方。该命令可用于转储表的全部内容,但也可以指示它查找特定数据、对其排序、分组并执行计算。
让我们从询问`names`表的全部内容开始:
SELECT * FROM names
这一行返回整个`names`表,如下所示。我已经执行了额外的`INSERT`语句。`SELECT`和`FROM`之间的星号表示您对所有栏目都感兴趣。

| **id** | **名字** | **姓氏** | | --- | --- | --- | | `1` | `John` | `Doe` | | `2` | `Jane` | `Doe` | | `3` | `James` | `Doe` | | `4` | `Judy` | `Doe` | | `5` | `Richard` | `Roe` | | `6` | `Jane` | `Roe` | | `7` | `John` | `Noakes` | | `8` | `Donna` | `Doe` | | `9` | `Ralph` | `Roe` |

该表中显示了许多不同的姓氏,所以让我们查询数据库中所有姓氏为 Roe 的个人。为此,SELECT语句是由和一个WHERE子句组合而成的。id列并没有那么有趣,所以要求使用firstnamelastname列,而不是使用星号:

SELECT firstname, lastname FROM names WHERE lastname = 'Roe'

下表显示了查询的结果:

| 西方人名的第一个字 | 姓 | | --- | --- | | `Richard` | `Roe` | | `Jane` | `Roe` | | `Ralph` | `Roe` |

WHERE子句包含几个比较,可以使用ANDORNOT和括号组合起来,形成更复杂的过滤器。

请注意,上表中名字的顺序并不理想。您可以使用ORDER BY子句来指定排序顺序:

SELECT firstname, lastname FROM names WHERE lastname = 'Roe' ORDER BY firstname

该命令的结果如下表所示(顺序已固定):

| 西方人名的第一个字 | 姓 | | --- | --- | | `Jane` | `Roe` | | `Ralph` | `Roe` | | `Richard` | `Roe` |

另一个可以与SELECT语句一起使用的子句是GROUP BY,它将结果分组。它可以与COUNT(*)函数结合使用,后者表示找到的行数。如果你按姓氏分组,你可以计算每个家庭的成员人数:

SELECT lastname, COUNT(*) as 'members' FROM names GROUP BY lastname ORDER BY lastname

下表显示了该命令的结果。我使用关键字AS将计算列命名为members。我还对lastname一栏中的进行了排序,以便姓氏按字母顺序出现:

| 姓 | 成员 | | --- | --- | | `Doe` | `5` | | `Noakes` | `1` | | `Roe` | `3` |

修改数据

更改数据库表中存储的数据是通过UPDATE语句处理的。在与一个WHERE子句结合之后,现在可以控制这些更改了。因为id列对于每一行都是唯一的,所以它可以用来更改一个人的姓名。下面一行将约翰·诺克斯重命名为尼西·斯文森:

UPDATE names SET firstname = 'Nisse', lastname = 'Svensson' WHERE id = 7

在本例中,WHERE子句用于将更新限制在id值为7的行。更改用逗号分隔,您可以更改firstnamelastname字段。

您可以使用更开放的WHERE子句一次更新几行。下面一行更改所有行的lastname字段,其中firstname是 Jane 它将 Jane Doe 和 Jane Roe 重新命名为 Jane Johnson:

UPDATE names SET lastname = 'Johnson' WHERE firstname = 'Jane'


注意省略WHERE子句会将更改应用于表中的所有行。


删除数据

DELETE语句用于从数据库表中删除数据。它看起来非常像UPDATE语句——通过使用一个WHERE子句来指定要从哪个表中删除哪些行。

您可以从删除 Nisse Svensson(以前称为 John Noakes)行开始:

DELETE FROM names WHERE id = 7

就像更新一样,您可以使用不太具体的WHERE子句一次删除几行。以下语句删除了从两个 Janes 创建的两个 Johnsons:

DELETE FROM names WHERE lastname = 'Johnson'

更多的桌子意味着更多的权力

当您使用数据库时,您通常需要几个包含同一事物不同方面信息的表。通过将JOIN子句与SELECT一起使用,您仍然可以通过一个查询提取您需要的信息。

通过指定一个关系来连接表——您定义了什么将两个表联系在一起。

在这里使用的数据库中,有另一个名为salaries的薪水表。立柱为idannual,均为INTEGER型。id列用于将薪水与names表中的个人相关联(这是表之间的关系),而annual列保存每个个人的年收入。该表的内容如下所示(注意,表中缺少id的一些值):

| id | 年刊 | | --- | --- | | `1` | `1000` | | `2` | `900` | | `3` | `900` | | `5` | `1100` | | `6` | `1000` | | `8` | `1200` | | `9` | `1200` |

现在您可以从namesSELECT并请求数据库JOINnamessalaries ONid。这在 SQL 中表示如下:

SELECT names.firstname, names.lastname, salaries.annual FROM names JOIN salaries ON names.id = salaries.id

该语句的结果如下所示(未在两个表中显示的行被省略):

| 西方人名的第一个字 | 姓 | 年刊 | | --- | --- | --- | | `John` | `Doe` | `1000` | | `Jane` | `Doe` | `900` | | `James` | `Doe` | `900` | | `Richard` | `Roe` | `1100` | | `Jane` | `Roe` | `1000` | | `Donna` | `Doe` | `1200` | | `Ralph` | `Roe` | `1200` |

要从names表中获取所有行,用LEFT JOIN替换JOIN。所有行都从第一个表(语句中左边的那个表)返回。结果是这样的:

SELECT names.firstname, names.lastname, salaries.annual FROM names LEFT JOIN salaries ON names.id = salaries.id

salaries表中未显示的行获得值NULL。查询结果如下表所示:

| 西方人名的第一个字 | 姓 | 年刊 | | --- | --- | --- | | `John` | `Doe` | `1000` | | `Jane` | `Doe` | `900` | | `James` | `Doe` | `900` | | `Judy` | `Doe` | `NULL` | | `Richard` | `Roe` | `1100` | | `Jane` | `Roe` | `1000` | | `John` | `Noakes` | `NULL` | | `Donna` | `Doe` | `1200` | | `Ralph` | `Roe` | `1200` |

当处理包含多个表的数据库时,拥有一个规范化的结构是很重要的。正常情况下,任何信息都不应该出现一次以上。相反的一个例子是如果salaries表包含lastnameid。在这种情况下,改变lastname需要两次UPDATE调用。

到目前为止使用的表格都非常简单,但是要记住只将数据保存在一个地方(这有时可能需要额外的id列来将事情联系在一起)。这是一个值得花的时间,因为它使结构更容易工作。

对 SQL 的介绍仅仅触及了数据库设计和连接语句的皮毛。在实现一个复杂的数据库之前,还需要考虑更多的方面,还有许多其他的连接表和创建关系的方法。其中一些是标准化的,另一些则非常依赖于您正在使用的数据库服务器。在实现任何复杂的数据库设计之前,我建议您查阅数据库服务器的文档以及相关书籍。

计数和计算

查询数据时,数据库可以在返回数据之前对数据进行计算。在本章的前面,您已经看到了这样一个例子,使用COUNT(*)来计算每个lastname的家庭成员数量。

SQL 中有一系列可用的数学函数。一些最常见的包括SUMMINMAX,它们用于汇总一列的值或获得最小值或最大值。这些功能为您提供了一个强大的工具。在SELECT语句中使用时,可以将这些函数与GROUP BY子句结合起来,根据行组计算结果。

这些计算的结果可以使用普通的算术运算进行组合,例如+-*/。以下语句使用SUM函数、除法和COUNT(*)来计算每个家庭的平均年薪:

SELECT   names.lastname,   SUM(salaries.annual)/COUNT(*) AS 'Average',   MIN(salaries.annual) AS 'Minimum',   MAX(salaries.annual) AS 'Maximum' FROM names LEFT JOIN salaries ON names.id = salaries.id GROUP BY names.lastname

因为您执行了左连接,所以没有收入的家庭成员将包含在COUNT(*)中,但不包含在汇总和挑选最小值和最大值的函数中。这意味着那些被命名为 Doe 的人的最低工资保持在 900,但平均工资计算为 800。下表显示了该语句的完整结果:

| 姓 | 平均的 | 最低限度 | 最高的 | | --- | --- | --- | --- | | `Doe` | `800` | `900` | `1200` | | `Noakes` | `NULL` | `NULL` | `NULL` | | `Roe` | `1100` | `1000` | `1200` |

让数据库对您的数据执行许多有趣的功能是很容易的,这既有好处也有坏处。潜在的负面后果可能是中央服务器的工作负载更重。好处是通过网络发送的数据更少,客户端代码也不太复杂。

Qt 和数据库

Qt 处理和连接数据库的类可以分为三组。第一层基于一组数据库驱动程序,这使得使用 Qt 访问不同类型的数据库服务器成为可能。

第二层处理与数据库的连接、查询及其结果,以及来自数据库服务器的错误消息。这一层基于驱动程序层,因为连接到数据库需要驱动程序。

第三层称为用户界面层,它提供了一组与 Qt 的模型视图框架一起使用的模型。


注意建议您在开发新软件而非实时版本时使用测试数据库。SQL 语句中很容易出现错误,导致整个数据库的内容变得无用。使用开发数据库而不是生产数据库(用于真实的东西)可以为您省去很多麻烦。最好的情况是,您不必从备份中恢复数据库;在最坏的情况下,它可以挽救你的工作。


连接

每个数据库连接由一个QSqlDatabase对象表示,这些连接是通过一个驱动程序建立的。选好司机后,可以设置hostNamedatabaseNameuserNamepassword等相关属性。连接建立后,您必须先open它,然后才能使用它。

为了避免传递同一个QSqlDatabase对象,整个QtSql模块都有默认连接的概念。只要您一次连接到一个数据库,所有与数据库交互的类都已经知道使用哪个连接。

清单 13-1 显示了一个正在建立的到 MySQL 服务器的连接。这个过程很简单。首先,通过静态的 QSqlDatabase::addDatabase方法,使用QMYSQL驱动程序添加一个数据库连接。因为您只传递了一个驱动程序名,而没有传递连接名,所以它将是默认连接。

然后设置返回的QSqlDatabase对象。设置了hostNamedatabaseNameuserNamepassword的属性。然后使用open方法打开数据库连接。如果返回false,则连接未建立。失败的原因通过一个QSqlError对象返回,这个对象可以通过使用lastError方法获得。如果返回true,则连接已经成功建立。


注意连接数据库时可以使用的属性有hostNamedatabaseNameuserNamepasswordportconnectOptions。这些属性的内容取决于所使用的数据库驱动程序。


清单 13-1。 连接到 MySQL 服务器

`QSqlDatabase db = QSqlDatabase::addDatabase( “QMYSQL” );

db.setHostName( “localhost” );
db.setDatabaseName( “qtbook” );

db.setUserName( “user” );
db.setPassword( “password” );

if( !db.open() )
{
  qDebug() << db.lastError();
  qFatal( “Failed to connect.” );
}`

清单 13-2 展示了如何使用QSQLITE驱动程序连接到 SQLite 数据库。SQLite 数据库不同于 MySQL 数据库,因为它不基于服务器,所以您不需要使用用户名和密码登录数据库。相反,您只需通过databaseName属性指定一个文件名。该文件包含数据库,并在连接成功打开时打开或创建。

清单 13-2。 连接到 SQLite 文件

QSqlDatabase db = QSqlDatabase::addDatabase( "QSQLITE" );

db.setDatabaseName( "testdatabase.db" );

if( !db.open() )

{

  qDebug() << db.lastError();

  qFatal( "Failed to connect." );

}

SQLite 数据库引擎的一个很好的特性是可以在内存中创建数据库。这意味着执行速度非常快,因为不需要从磁盘加载和保存到磁盘。如果希望信息在应用程序终止后仍然存在,就必须将它显式地存储到一个文件或另一个数据库中。

通过指定文件名":memory: ",如下面的代码行所示,数据库将包含在内存中:

db.setDatabaseName( ":memory:" );
当一个`QSqlDatabase`对象代表一个不再使用的连接时,您可以使用`close`方法关闭它。任何打开的连接都会被`QSqlDatabase`析构函数自动关闭。
查询数据
当向数据库传递 SQL 查询时,使用一个`QSqlQuery`对象来表示查询和从数据库引擎返回的结果。让我们从一个简单的`SELECT`查询开始。
清单 13-3 显示了一个正在执行的查询。SQL 语句被简单地传递给一个`QSqlQuery`对象的`exec`方法。如果执行失败,`exec`方法返回`false`。失败后,查询对象的`lastError`方法包含更多关于出错的信息。因为您正在处理一个被客户端应用程序查询的服务器,所以不一定是 SQL 语句错了,也可能是连接失败、用户身份验证问题或许多其他原因。

**清单 13-3** *准备和执行 SQL 查询*
if( !qry.exec( "SELECT firstname, lastname FROM names "

               "WHERE lastname = 'Roe' ORDER BY firstname" ) )

  qDebug() << qry.lastError(); 
如果查询的执行顺利完成,就该查看结果了。清单 13-4 展示了这是如何做到的。首先检索一个`QSqlRecord`。记录代表结果中的一行,您可以使用`count`方法获得列的总数。从`fieldName(int)`方法中可以得到返回列的名称。使用这两种方法,在第一个`for`循环中创建一个包含列名的字符串。
在`while`循环中,通过使用`next`方法从`QSqlQuery`对象请求第一个结果行。当查询对象从成功的`exec`调用返回时,当前行是空的(即`NULL`)。这表示为`isValid`是`false`。当调用`next`时,返回结果中的下一行(如果可用)。第一次调用该方法时,调用第一行。当调用试图移动到最后一个可用行之外时,返回值是`false`。

**注意**`next`方法只对`SELECT`查询有效。您可以用`isSelect`方法查看一个`QSqlQuery`对象是否是一个`SELECT`查询。

对于每一行,使用`value(int)`方法收集列中的值。`value`方法返回一个`QVariant`,因此必须使用`toString`方法将其转换为`QString`。不同的列可以有不同的值,所以没有必要使用`toString`方法。`QVariant`类有将值转换成大多数类型的方法。最常见的有`toInt`、`toDouble`、`toBool`、`toString`。

**清单 13-4** *遍历列名和结果行*

QSqlRecord rec = qry.record();

int cols = rec.count();

QString temp;

for( int c=0; c<cols; c++ )

temp += rec.fieldName© + ((c<cols-1)?“\t”:“”);

qDebug() << temp;

while( qry.next() )

{

temp = “”;

for( int c=0; c<cols; c++ )

temp += qry.value©.toString() + ((c<cols-1)?“\t”:“”);

qDebug() << temp;

}


在前面的清单中,您将整个 SQL 查询作为整个字符串传递。这对于简单的查询可能有用,但是一旦开始向查询中添加用户输入,就可能会出现问题。例如,如果用户在清单 13-3 中提供了`lastname`字符串,那么如果该名称包含单引号`(')`,就会出现问题。处理浮点值也是一个问题,因为不同地区的十进制字符不同。
这些问题的解决方案是在执行查询之前的准备阶段*绑定*查询中使用的值。清单 13-5 展示了如何为一个`INSERT`查询做这件事。查询的准备是一个可选步骤,可能包括对一些数据库进行语法检查,而其他数据库将在执行时失败。如果语法检查失败,`prepare`调用将返回`false`。因为您之前已经测试过 SQL 语句,所以您不必检查它。然而,即使语句已经过测试,`exec`调用仍然可能由于数据库连接的问题而失败。
在清单 13-5 中,查询是用`prepare`方法准备的。查询中放置的不是实际值,而是占位符。占位符由以冒号(`:`)为前缀的名称组成。准备好查询后,使用`bindValue(QString,QVariant)`将一个值绑定到每个占位符。

**注意**您可以使用一个问号(`?`)作为占位符,然后使用`addBindValue(QVariant)`从左到右将值绑定到它。我建议不要使用这种方法,因为在使用带有命名占位符的代码时,这种方法更容易修改,也更不容易出错。

**清单 13-5** *将值绑定到一个包含* `INSERT` *的查询调用*

qry.prepare( "INSERT INTO names (id, firstname, lastname) "

“VALUES (:id, :firstname, :lastname)” );

qry.bindValue( “:id”, 9 );

qry.bindValue( “:firstname”, “Ralph” );

qry.bindValue( “:lastname”, “Roe” );

if( !qry.exec() )

qDebug() << qry.lastError();


建立多个连接
如果您需要一次使用几个数据库连接,您必须给它们命名。如果未指定连接名称,则始终使用默认连接。如果新连接是使用与以前连接相同的名称建立的,它将替换以前的连接。这也适用于默认连接。
当您使用`QSqlDatabase::addDatabase(QString,QString)`添加连接时,第一个参数是数据库驱动程序的名称(例如`QMYSQL`,而第二个可选参数是连接的名称。
当创建您的`QSqlQuery`对象时,如果您希望它使用特定的连接,您可以将一个数据库对象传递给构造器。如果需要检索连接名的`QSqlDatabase`对象,可以使用静态的`QSqlDatabase::database(QString)`方法。
把所有这些放在一起
要真正尝试使用数据库类,您将看到一个图像收集应用程序,它使您能够将标签应用于图像,然后显示带有所选标签的图像。图像和标签将存储在 SQLite 数据库中。因为数据库包含在一个文件中,所以可以认为它是应用程序的文件格式。
该应用程序由一个简单的对话框组成(见图 13-1 )。标签显示在右侧,带有任何选定标签的图像数量显示在列表下方的标签中。左半部分用于显示当前图像,以及用于在图像间移动、添加图像和添加标签的按钮。
从可用的按钮可以看出,应用程序没有实现完整的 CRUD 接口。它主要关注前两个部分:创建,比如添加标签和图像;和读取,如显示图像和标签。

**13-1** *图画书应用在行动*
应用程序中使用的数据库(如图图 13-2 所示)由两个表组成:一个用于标签,一个用于图像(分别称为`tags`和`images`)。`images`表每行保存一个图像。每一行都包含一个名为`id`的`INTEGER`,用于识别每一幅图像。这些图像存储在每个`id`旁边的`BLOB`列中,称为`data`。一个`BLOB`是一个二进制大对象,它几乎意味着任何东西。应用程序将 PNG 格式的图像存储在该列中。
`tags`表由一个名为`id`的`INTEGER`列和一个名为`tag`的`VARCHAR`列组成。`id`列将标签连接到不同的图像。请注意,每个图像可以有几个标签。

**13-2***`tags`*`images`**** **####  应用的结构

应用程序分为两个主要部分:用户接口类和数据库接口类。用户界面使用数据库界面来访问来自`QtSql`模块的类。用户界面包含在`ImageDialog`类中,数据库界面包含在`ImageCollection`类中。

通过将使用 SQL 的代码拆分到一个特定的类中,可以避免在整个源代码中使用 SQL 字符串。将包含 SQL 的代码从其余代码中分离出来有几个原因。首先,可以详细测试这部分代码,这很重要,因为 SQL 语句中的任何语法错误都会在运行时首先被检测出来。在数据库中使用的类型和 Qt 的类之间进行转换非常方便。当您更改数据库引擎时,可能有必要检查和更新所使用的一些 SQL 语句。

#### 用户界面

用户界面在`ImageDialog`类中实现。如清单 13-6 所示,类声明的公共部分由一个构造器和一组插槽组成,每个插槽代表一个用户动作。

用户能做什么?查看类声明和图 13-1 你可以看到一些可能的用户动作。下面列出了它们及其对应的插槽:** 

*** 在图像:nextClickedpreviousClicked之间移动* 更改标签列表中的选择:tagsChanged* 添加新图像:addImageClicked* 添加新标签:addTagClicked**

**将继承的任务添加到该列表中,例如能够关闭对话框以退出应用程序。

清单 13-6。 半个 ImageDialog 类声明

class ImageDialog : public QDialog

{

  Q_OBJECT

public:

  ImageDialog();

private slots:

  void nextClicked();

  void previousClicked();

  void tagsChanged();

  void addImageClicked();

  void addTagClicked();

...

};

类声明的另一半告诉你这个应用程序是如何工作的(源代码如清单 13-7 所示)。它以四种私人支持方式开始:selectedTagsupdateImagesupdateTagsupdateCurrentImage。你很快就会看到他们每一个人。

在这些方法之后,设计者生成的用户界面类作为ui包含在用于跟踪图像的成员变量之前。imageIds列表包含根据所选标签显示的图像的id值。currentImage是进入imageIds列表的索引,指示哪个图像是活动的。最后,images变量是处理数据库的ImageCollection类的一个实例。

清单 13-7。ImageDialog的私人半班宣言

class ImageDialog : public QDialog

{

...

private:

  QStringList selectedTags();

  void updateImages();

  void updateTags();

  void updateCurrentImage();

  Ui::ImageDialog ui;

  QList<int> imageIds;

  int currentImage;

  ImageCollection images;

};

插件和插槽

ImageDialog是用 Designer 创建的,所以你可以先看看它(图 13-3 显示了对话框的基本设计)。除了文本属性和不同小部件的名称,唯一改变的属性是QListWidgetSelectionMode;它被设置为MultiSelection

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13-3。 图像对话框的设计

图 13-4 显示了对话框的对象层次结构(你也可以看到不同部件的名称)。唯一不明显的是对话框本身的布局是网格布局。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13-4。 图像对话框的对象层次

现在让我们看看ImageDialog类的源代码,从构造器和用户动作开始。(对话框显示之前运行的代码,构造器,可以在清单 13-8 中看到。)

它从设置从设计器文件生成的用户界面开始。当小部件就位时,它将currentImage初始化为无效值,以确保在更新标签列表和要显示的图像之前没有图像可见。完成后,连接就完成了。每个按钮的点击信号都连接到相应的插槽。标签列表的itemSelectionChanged信号连接到tagsChanged插槽。

清单 13-8。ImageDialog的构造器

ImageDialog::ImageDialog()

{

  ui.setupUi( this );

  currentImage =1;

  updateTags();

  updateImages();

  connect( ui.previousButton, SIGNAL(clicked()), this, SLOT(previousClicked()) );

  connect( ui.nextButton, SIGNAL(clicked()), this, SLOT(nextClicked()) );

  connect( ui.addTagButton, SIGNAL(clicked()), this, SLOT(addTagClicked()) );

  connect( ui.addImageButton, SIGNAL(clicked()), this, SLOT(addImageClicked()) );

  connect( ui.tagList, SIGNAL(itemSelectionChanged()), this, SLOT(tagsChanged()) );

}

记住,updateCurrentImage方法禁用下一个、上一个和添加标签按钮。从构造器调用的updateImages中调用updateCurrentImage方法。这意味着,如果单击“下一个”、“上一个”或“添加标签”按钮,就会出现一个当前图像。

查看插槽,注意其中三个相当简单(见清单 13-9 中的实现)。首先出场的是一对nextClickedpreviousClicked。如前所述,currentImage变量充当id值的imageIds列表的索引。当用户点击下一步按钮时,currentImage值增加。如果值太大,它又从零开始。上一个按钮也是如此。该值会减少,并在需要时从列表的另一端开始。

最后一个简单的槽是tagsChanged槽,如果标签的选择改变了,就会到达这个槽。如果它们被改变了,你需要得到一个新的图像列表。调用updateImages会解决这个问题。

清单 13-9。 三个简单的插槽

void ImageDialog::nextClicked()

{

  currentImage = (currentImage+1) % imageIds.count();

  updateCurrentImage();

}

void ImageDialog::previousClicked()

{

  currentImage --;

  if( currentImage ==1 )

    currentImage = imageIds.count()-1;

  updateCurrentImage();

}

void ImageDialog::tagsChanged()

{

  updateImages();

}

下一个插槽addTagClicked,可以在清单 13-10 的中看到。当用户想要向当前图像添加标签时,调用该槽。

该插槽通过显示一个QInputDialog来要求用户输入一个标签。如果用户指定了一个字符串,那么输入的文本将被转换为小写,并被检查以符合标签的标准。在这种情况下,这意味着它只包含字符 a-z。没有空格,没有特殊字符,没有元音字母或其他本地字符;实际的检查是使用正则表达式来执行的。

如果发现文本是一个实际的标签,要求ImageCollection对象images将标签添加到当前图像中。添加标签后,您需要更新标签列表并调用updateTags

清单 13-10。 给当前图像添加标签

void ImageDialog::addTagClicked()

{

  bool ok;

  QString tag = QInputDialog::getText(

    this, tr("Image Book"), tr("Tag:"),

    QLineEdit::Normal, QString(), &ok );

  if( ok )

  {

    tag = tag.toLower();

    QRegExp re( "[a-z]+" );

    if( re.exactMatch(tag))

    {

      QMessageBox::warning( this, tr("Image Book"),

        tr("This is not a valid tag. "

           "Tags consists of lower case characters a-z.") );

      return;

    }

    images.addTag( imageIds[ currentImage ], tag );

    updateTags();

  }

}

剩余的槽位addImageClicked(如清单 13-11 中的所示),当用户想要向集合中添加新图像时使用。该插槽还将当前选定的标签应用于图像,以确保它保持可见。

该插槽做的第一件事是要求用户使用QFileDialog选择一个 PNG 图像。当图像被拾取时,它被加载。如果加载失败,插槽的剩余部分将被中止。

如果加载成功,图像将与当前选择的标签一起添加到ImageCollection。要获得标签,使用selectedTags方法。添加图像后,您需要更新图像列表中的id值。为了解决这个问题,调用updateImages方法。

清单 13-11。 用当前标签给收藏添加图片

void ImageDialog::addImageClicked()

{

  QString filename = QFileDialog::getOpenFileName(

    this, tr("Open file"), QString(), tr("PNG Images (*.png)") );

  if( !filename.isNull() )

  {

    QImage image( filename );

    if( image.isNull() )

    {

      QMessageBox::warning( this, tr("Image Book"),

        tr("Failed to open the file '%1'").arg( filename ) );

      return;

    }

    images.addImage( image, selectedTags() );

    updateImages();

  }

}

如您所见,插槽相当简单。它们有时会确保用户输入在传递给ImageCollection对象之前是有效的。当某些东西需要更新时,使用适当的支持方法。

支持方式

selectedTags方法与插槽和支持方法一起使用,从标签列表中取出选定的标签,并将它们放入一个QStringList(源代码见清单 13-12 )。

该方法简单地遍历列表小部件中的所有项目。如果选择了一个项目,它的文本被添加到QStringList对象结果中,然后作为方法的结果返回。

清单 13-12。 将当前选择的标签放在列表中会很方便

QStringList ImageDialog::selectedTags()

{

  QStringList result;

  foreach( QListWidgetItem *item, ui.tagList->selectedItems() )

    result << item->text();

  return result;

}

从构造器调用的第一个支持方法是updateTags,它在不丢失当前选择的情况下更新标签列表(源代码见清单 13-13 )。

该方法从从selectedTags方法获取当前选择开始。然后,它向ImageCollection对象请求一组新的标签,清除列表,并添加新的标签。当新标签就位后,该方法遍历列表项,并将更新前选择的项的selected属性设置为true

清单 13-13。 更新标签列表而不丢失选择

void ImageDialog::updateTags()

{

  QStringList selection = selectedTags();

  QStringList tags = images.getTags();

  ui.tagList->clear();

  ui.tagList->addItems( tags );

  for( int i=0; i<ui.tagList->count(); ++i )

    if( selection.contains( ui.tagList->item(i)->text() ) )

      ui.tagList->item(i)->setSelected( true );

}

当构造器更新了标签列表后,就该通过调用updateImages方法来更新图像了。该方法负责更新imageIds列表。如果当前显示的图像在新的id值列表中仍然可用,它也会保留当前显示的图像。

该方法的源代码如清单 13-14 所示。它首先尝试检索当前显示图像的id。如果没有可用的图像,则id被设置为−1,这是一个无效的id

该方法然后通过从ImageCollection获得图像id值的新列表来继续。该列表基于当前选择的标签。

如果先前图像的id仍然在id值列表中,则currentImage索引被更新以保持显示相同的图像。如果不能显示相同的图像,则显示第一个图像(显然,如果没有图像,则不显示图像)。

因为该方法会影响currentImage索引值,所以它会调用updateCurrentImage方法来相应地更新用户界面。

清单 13-14。 获取一个新的图像列表 id 值,如果可能的话继续显示当前图像。

void ImageDialog::updateImages()

{

  int id;

  if( currentImage !=1 )

    id = imageIds[ currentImage ];

  else

    id =1;

  imageIds = images.getIds( selectedTags() );

  currentImage = imageIds.indexOf( id );

  if( currentImage ==1 && !imageIds.isEmpty() )

    currentImage = 0;

  ui.imagesLabel->setText( QString::number( imageIds.count() ) );

  updateCurrentImage();

}

清单 13-15 中的方法检查是否有当前图像。如果有,该方法从ImageCollection对象中获取它,并通过使用imageLabel小部件显示它。它还启用了“下一个”、“上一个”和“添加标签”按钮。

如果没有当前图像,则imageLabel被设置为显示文本"No Image",并且按钮被禁用。

清单 13-15。 更新当前显示的图像并使右边的按钮可用。

void ImageDialog::updateCurrentImage()

{

  if( currentImage ==1 )

  {

    ui.imageLabel->setPixmap( QPixmap() );

    ui.imageLabel->setText( tr("No Image") );

    ui.addTagButton->setEnabled( false );

    ui.nextButton->setEnabled( false );

    ui.previousButton->setEnabled( false );

  }

  else

  {

    ui.imageLabel->setPixmap(

      QPixmap::fromImage(

        images.getImage( imageIds[ currentImage ] ) ) );

    ui.imageLabel->clear();

    ui.addTagButton->setEnabled( true );

    ui.nextButton->setEnabled( true );

    ui.previousButton->setEnabled( true );

  }

}

尽管支撑方法看起来很有帮助,但实际上是在其他地方进行的。所有的方法都是要求ImageCollection对象做事情和取东西。

数据库类

ImageCollection类,让你离数据库更近一步,负责与数据库的所有联系。它已经被实现,因此它可以使用相关类型与应用程序的其余部分进行交互。应用程序的其余部分不需要知道ImageCollection是基于数据库的。类声明如清单 13-16 所示。

您可能会注意到有些方法被命名为getXxx,这不是 Qt 应用程序中命名 getter 方法的常见方式。这样命名的原因是为了能够告诉应用程序的其他部分,这些方法实际上是从其他地方获取的;指示操作可能需要一些时间,具体取决于具体情况。

所有的方法都执行有限的任务,所以你应该能够从它们的名字中了解它们的作用。

清单 13-16。ImageCollection类定义

class ImageCollection

{

public:

  ImageCollection();

  QImage getImage( int id );

  QList<int> getIds( QStringList tags );

  QStringList getTags();

  void addTag( int id, QString tag );

  void addImage( QImage image, QStringList tags );

private:

  void populateDatabase();

};

如清单 13-17 所示,类构造器打开一个数据库连接并填充它。整个类使用默认连接,所以不需要保存一个QSqlDatabase对象。被访问的数据库是存储在内存中的 SQLite 数据库,所以每次应用程序结束时,它的内容都会丢失。这在开发时会很方便,很容易将数据库名:memory:替换为合适的文件名,并让数据库成为应用程序的文件格式。

populateDatabase方法,在与构造器相同的清单中显示,试图在数据库中创建两个表。它使用了IF NOT EXISTS子句,因为保存的文件将包含这两个表——这应该不会导致失败。

清单 13-17。 构造器和 populateDatabase 方法

ImageCollection::ImageCollection()

{

  QSqlDatabase db = QSqlDatabase::addDatabase( "QSQLITE" );

  db.setDatabaseName( ":memory:" );

  if( !db.open() )

    qFatal( "Failed to open database" );

  populateDatabase();

}

void ImageCollection::populateDatabase()

{

  QSqlQuery qry;

  qry.prepare( "CREATE TABLE IF NOT EXISTS images "

               "(id INTEGER PRIMARY KEY, data BLOB)" );

  if( !qry.exec() )

    qFatal( "Failed to create table images" );

  qry.prepare( "CREATE TABLE IF NOT EXISTS tags (id INTEGER, tag VARCHAR(30))" );

  if( !qry.exec() )

    qFatal( "Failed to create table tags" );

}

使用图像标签

图像集合的一些职责包括管理标签列表和跟踪哪个标签属于哪个图像。让我们先来看看getTags方法。它的作用是返回所有可用标签的列表。

该方法的源代码可以在清单 13-18 中看到。因为您使用默认连接,所以您创建一个查询,准备并执行它。查询本身包含一个DISTINCT子句,因为相同的标签可能在不同的图像中出现多次。这可以确保您不会得到重复的列表。当查询被执行后,结果被放入一个返回的QStringList中。

清单 13-18。 查询标签列表,封装在 QStringList 中,返回

QStringList ImageCollection::getTags()

{

  QSqlQuery qry;

  qry.prepare( "SELECT DISTINCT tag FROM tags" );

  if( !qry.exec() )

    qFatal( "Failed to get tags" );

  QStringList result;

  while( qry.next() )

    result << qry.value(0).toString();

  return result;

}

另一种标签管理方法是addTag方法(见清单 13-19 ),它给给定的图像添加一个标签。使用一个id值来指定标签属于哪个图像。该方法不检查重复的标签,因为getTags方法会过滤掉重复的标签,所以有可能对同一张图片多次添加相同的标签。

清单 13-19。 给图像添加新标签

void ImageCollection::addTag( int id, QString tag )

{

  QSqlQuery qry;

  qry.prepare( "INSERT INTO tags (id, tag) VALUES (:id, :tag)" );

  qry.bindValue( ":id", id );

  qry.bindValue( ":tag", tag );

  if( !qry.exec() )

    qFatal( "Failed to add tag" );

}

图像

getIds方法从标签的角度处理图像。它接受一个QStringList标签,并返回至少有一个标签的图像的一个id值列表。如果该方法没有标签,它将返回所有图像id值。这就是为什么在清单 13-20 所示的源代码中准备了两个不同的查询。

在处理一个或多个标签的 SQL 语句中,使用了IN子句。写x IN (1, 2, 3)等于写x=1 OR x=2 or x=3。因为用户界面确保标签仅由字母 a–z 组成,所以您可以安全地将它们连接在一起,并直接在 SQL 查询中使用它们。


注意你应该尽量避免在 SQL 语句中手动插入字符串;尽可能使用bindValue


SQL 语句以一个GROUP BY子句结束,确保您不会得到一个以上的id。查询结果放在返回的整数列表中。

清单 13-20。 获取给定标签集的每个 id (如果没有给定标签,则获取每个id)

QList< int> ImageCollection::getIds( QStringList tags )

{

  QSqlQuery qry;

  if( tags.count() == 0 )

    qry.prepare( "SELECT images.id FROM images" );

  else

    qry.prepare( "SELECT id FROM tags WHERE tag IN ('" +

                 tags.join("','") + "') GROUP BY id" );

  if( !qry.exec() )

    qFatal( "Failed to get IDs" );

  QList<int> result;

  while( qry.next() )

    result << qry.value(0).toInt();

  return result;

}

在数据库中存储图像

在数据库中存储图像不是一项简单的任务,因为没有用于存储图形的数据类型。相反,您必须依赖于BLOB类型,这是一个二进制大对象(简单地说:一大块原始数据)。

将一个QImage对象变成一个 blob 的过程可以分为三个步骤。首先,在内存中创建一个缓冲区,并将图像保存到该缓冲区中。缓冲区然后被转换成一个QByteArray,它被绑定到一个 SQL INSERT查询中的一个变量。然后执行该查询。

这都是在清单 13-21 中的方法中完成的。正如您从突出显示的行中看到的,创建了一个QBuffer对象。图像以带有QImageWriter的 PNG 格式写入缓冲区。当缓冲区包含图像数据时,在准备INSERT查询将图像放入数据库时,可以在bindValue调用中使用缓冲区中的数据。

查看代码的其余部分,查询数据库中的图像数量,以便能够确定新的id。如果您让用户从数据库中删除图像,此方法不起作用。创建表时,可以使用AUTOINCREMENT让数据库自动分配一个新的id。那就解决了问题。但是由于您只支持添加新的映像,即不支持删除它们,并且假设一次只有一个客户端应用程序在使用数据库,所以这个解决方案是可行的。

INSERT的陈述非常简单明了;在查询执行之前,iddata被绑定到查询。当图像被插入后,给该方法的所有标签被传递给addTag,以便它们被插入到数据库中。

清单 13-21。 将一幅图像及其标签添加到数据库中。

void ImageCollection::addImage( QImage image, QStringList tags )

{

  QBuffer buffer;

  QImageWriter writer(&buffer, "PNG");

  writer.write(image);

  QSqlQuery qry;

  int id;

  qry.prepare( "SELECT COUNT(*) FROM images" );

  qry.exec();

  qry.next();

  id = qry.value(0).toInt() + 1;

  qry.prepare( "INSERT INTO images (id, data) VALUES (:id, :data)" );

  qry.bindValue( ":id", id );

  qry.bindValue( ":data", buffer.data() );

  qry.exec();

  foreach( QString tag, tags )

    addTag( id, tag );

}

将存储的图像从数据库放回一个QImage对象的过程涉及相同的类。清单 13-22 向你展示了它是如何完成的。因为getImage方法不必担心生成新的id值或标签,所以它比addImage方法更直接。

首先,准备并执行查询;然后从结果中提取出QByteArray。该数组被传递给一个QBuffer,您可以从一个QImageReader中使用它。注意,在将缓冲区传递给图像阅读器之前,必须打开缓冲区进行读取。从图像阅读器中,您可以获得作为结果返回的QImage对象。

清单 13-22。 来自查询,通过缓冲区,传给读者

QImage ImageCollection::getImage( int id )

{

  QSqlQuery qry;

  qry.prepare( "SELECT data FROM images WHERE id = :id" );

  qry.bindValue( ":id", id );

  if( !qry.exec() )

    qFatal( "Failed to get image" );

  if( !qry.next() )

    qFatal( "Failed to get image id" );

  QByteArray array = qry.value(0).toByteArray();

  QBuffer buffer(&array);

  buffer.open( QIODevice::ReadOnly );

  QImageReader reader(&buffer, "PNG");

  QImage image = reader.read();

  return image;

}

如您所见,将数据存储为嵌入数据库的文件相当容易。因为所有 Qt 流都使用的是QIODevice类,并且该类是QFileQBuffer的基类,所以几乎可以对任何文件格式使用这个方法。

把所有东西放在一起

ImageDialog类包含了ImageCollection类的一个实例,所以main函数所要做的就是创建一个QApplication和一个ImageDialog,显示对话框,并启动事件循环(代码如清单 13-23 所示)。现在应该都很熟悉了。

清单 13-23。main功能

int main( int argc, char **argv )

{

  QApplication app( argc, argv );

  ImageDialog dlg;

  dlg.show();

  return app.exec();

}

使用的项目文件可以通过调用qmake –project然后将行QT += sql附加到结果文件来生成。图 13-5 显示了应用程序启动后的样子。

如果查看代码,您会发现大部分工作都是由数据库引擎执行的。您不必遍历您的定制数据结构来定位所有唯一的标签,只需通过查询传递适当的SELECT语句。

说到存储信息,可以使用SQLite作为应用程序的文件格式。有几种方法可以确保文件有效。例如,您可以有一个特殊的表,其中包含有关您的应用程序、用于写入文件的版本等信息。加载文件,然后在使用文件前检查该表。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13-5。 正在使用的图画书应用

模型数据库

到目前为止,您已经编写了数据库查询,然后将数据提取到列表和值中。但是也有可能以更直接的方式管理数据。因为从数据库接收的数据通常与您向用户显示的数据相同,所以使用通用 SQL 模型来完成这项工作是有意义的。Qt 提供了三种不同的模型:

  • QSqlQueryModel:提供一个只读模型,用于显示给定SELECT查询的结果
  • QSqlTableModel:提供可编辑的模型,显示单个表格
  • QSqlRelationalModel:提供一个可编辑的模型,用于显示引用其他表的单个表中的数据

这些模型就像所有其他数据库类一样工作。所以当你理解了 Qt SQL 模块是如何工作的,你也会知道如何使用这些模型。

查询模型

QSqlQueryModel使您能够通过视图显示查询的结果(清单 13-24 向您展示了它的用法)。这个模型很容易建立:简单地创建一个QSqlQueryModel对象,并使用setQuery调用指定一个查询。

其余代码创建并配置一个表模型来显示查询模型。

清单 13-24。 在表格视图中显示 SQL 查询的结果

QSqlQueryModel *model = new QSqlQueryModel();

model->setQuery( "SELECT firstname, lastname FROM names" );

QTableView *view = new QTableView();

view->setModel( model );

view->show();

该查询被传递给本章开头的 SQL 简介中使用的表。得到的表格模型如图图 13-6 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13-6。 查询模型的结果

桌子模型

使用QSqlTableModel可以得到一个显示整个表格内容的可编辑模型。使用该类的一小段源代码如清单 13-25 所示。

使用该类时,通过使用setTable方法选择要显示的表。如果要添加一个WHERE子句,可以使用setFilter方法添加条件。默认情况下没有过滤器,显示整个表。当您设置了一个过滤器和一个表后,调用select来执行对数据库的实际查询。

调用removeColumn时,通过传递列在表中的序号位置,可以避免显示列。在列表列中,0 是隐藏的;这对应于id列。

清单 13-25。 设置显示 Doe 名称的表格模型

QSqlTableModel *model = new QSqlTableModel();

model->setTable( "names" );

model->setFilter( "lastname = 'Doe'" );

model->select();

model->removeColumn( 0 );

QTableView *view = new QTableView();

view->setModel( model );

view->show();

生成的表格视图如图图 13-7 所示。结果视图是可编辑的,因为模型是可编辑的。通过将视图的editTriggers属性设置为QAbstractItemView:: NoEditTriggers,可以防止用户编辑数据。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13-7。 查询模型的结果

关系表模型

QSqlRelationalTableModel是桌子模型的更高级的化身。通过创建关系模型并指定数据库中不同表之间的关系,可以让模型从几个表中查找信息,并将它们作为一个表显示。

清单 13-26 显示了如何使用这样的关系将names表中的id列链接到salaries表中的相应列。结果是显示了来自salaries表的annual值,而不是id。这个关系是在清单中的setRelation(int,QSqlRelation)调用中建立的。第一个参数是要在关系中使用的列的序号。作为第二个参数给出的QSqlRelation接受三个参数:首先,要关联的表的名称;第二,连接表时使用的相关表中的列名;第三,要从被联接的表中获取的列的名称。在这个例子中,您基于salaries.id连接salaries表,并使用salaries.annual列。正如表模型一样,您需要调用select将数据放入模型中。

为了获得漂亮的标题,可以使用setHeaderData方法来指定每个列标题的方向和文本。这可以在所有模型中实现,而不仅仅是关系模型。

清单 13-26。 一个关系表模型,用漂亮的标题显示姓名和年薪

QSqlRelationalTableModel *model = new QSqlRelationalTableModel();

model->setTable( "names" );

model->setRelation( 0, QSqlRelation( "salaries", "id", "annual" ) );

model->select();

model->setHeaderData( 0, Qt::Horizontal, QObject::tr("Annual Pay") );

model->setHeaderData( 1, Qt::Horizontal, QObject::tr("First Name") );

model->setHeaderData( 2, Qt::Horizontal, QObject::tr("Last Name") );

QTableView *view = new QTableView();

view->setModel( model );

view->show();

清单 13-26 的结果可以在图 13-8 中看到。注意,模型是可编辑的,所以如果您不调整视图的editTriggers属性,用户可以编辑视图。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13-8。 关系表模型的结果

当您查找类似邮政编码的城市名称而不仅仅是一个数字时,关系模型非常有用。你可以使用一个QSqlRelationalDelegate来让用户从列表中选择一个城市,而不是必须输入名字。

总结

Qt SQL 模块使得以跨平台的方式访问几乎任何可能的数据库成为可能。事实上,SQL 数据库驱动程序是插件,所以如果你需要访问一个定制的数据库,你仍然可以写一个驱动程序并使用 Qt 的类来访问它。在大多数情况下,为这样的数据库获取一个 ODBC 驱动程序并将其用作 Qt 和相关数据库之间的一个层会更容易。

当访问数据库时,使用QSqlDatabase类来表示一个连接。数据库模块有一个默认连接,所以只要坚持一次使用一个连接,就可以避免很多额外的麻烦。

连接到数据库后,使用QSqlQuery类将 SQL 查询传递给数据库。但是,要注意 SQL 方言——一个数据库接受的有效语句可能被另一个数据库认为无效。在发布产品之前尝试所有的 SQL 语句是很重要的,因为在编译期间不会检查它们的错误。

通过使用作为 SQL 模块一部分的 SQL 模型,您通常可以避免查询数据库并将结果转换成可以向用户显示的内容。可用的型号有QSqlQueryModelQSqlTableModelQSqlRelationalTableModel。尽可能多地使用这些模型——它们可以为您节省大量时间和精力。******

十四、网络

Qt 支持通过传输控制协议(TCP)用户数据报协议(UDP) 套接字建立的基于 IP 的连接。此外,Qt 支持 HTTP 和 FTP 协议的客户端实现,这有助于创建 FTP 客户端和基于 HTTP 的下载。所有这些类都保存在 Qt 的一个单独的网络模块中。

本章首先讨论客户端协议以及如何使用它们下载数据(协议的客户端是与服务器交互时使用的代码)。您还将快速浏览一下QUrl类,它用于处理 URL 及其不同部分。

本章的后半部分讨论了 TCP 和 UDP 套接字类,以及如何实现服务器和客户端。

使用 QtNetwork 模块

所有用于联网的 Qt 类都是QtNetwork模块的一部分。这个模块并不是在 Qt 的所有闭源版本中都有,但是它包含在开源版本中。这意味着如果您计划在您的闭源 Qt 项目中使用它,您必须首先访问该模块。

在确保您可以访问该模块之后,您需要通过告诉 QMake 您正在使用它来将它包含在您的构建过程中(将代码行QT += network添加到项目文件中)。

使用客户端协议

QFtpQHttp类封装了 FTP 和 HTTP 协议。请记住,这两个类只实现这些协议的客户端,所以如果你想创建一个 FTP 服务器或 HTTP 服务器,你必须求助于 TCP 服务器和套接字类(在本章后面介绍)。

比较 FTP 和 HTTP 可以看出,虽然两种协议在相同的问题域中工作,但 FTP 是一种稍微复杂一些的协议。例如,FTP 协议依赖于一种状态,在这种状态下,连接被建立,然后在关闭之前被使用。另一方面,HTTP 是无状态的——它将每个请求与其他请求分开处理。

然而,从应用程序开发人员的角度来看,这两种协议的使用方式是相同的。创建一个协议对象(一个QFtp对象或一个QHttp对象)。当一个方法被调用时,请求的动作被异步执行,这意味着方法只返回一个请求标识符,而不是实际的结果。相反,您的应用程序必须等待一个携带结果的信号被发出。

让我们看看这在实践中是如何工作的,从开发一个 FTP 客户端开始。

创建 FTP 客户端

使用QFtp类,您将实现一个基本的 FTP 客户端,使用户能够连接到[ftp://ftp.trolltech.com](http://ftp://ftp.trolltech.com),浏览目录树,并下载文件。图 14-1 显示了实际应用。

功能的限制(例如,只能连接到一个主机)简化了应用程序,但是仍然展示了如何使用QFtp类。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

**图 14-1。**FTP 客户端在行动

FTP 客户端由一个对话框组成,该对话框包含一个用于与 FTP 站点交互的QFtp对象。QFtp对象与应用程序异步工作,所以当你发出一个命令时,你必须等待一个信号到达——当命令被执行时,应用程序保持运行。

QFtp类有一系列在不同事件发生时发出的信号,包括:

  • commandFinished(int request, bool error):该信号在命令结束时发出。request参数可用于识别命令,而如果在命令执行过程中发生了error,则errortrue
  • listInfo(QUrlInfo info):当列出一个目录的内容时,为找到的每个文件或目录发出该信号。
  • dataTransferProgress(qint64 done, qint64 total):该信号在上传和下载过程中发出。参数done报告了total已经完成了多少。donetotal参数是可伸缩的,所以你不能依赖这些代表字节的参数。如果总大小未知,total为零。

这三个信号从QFtp对象连接到对话框构造器中对话框的三个私有槽。你可以在清单 14-1 中的类中找到插槽(它们的名字以ftp开头)。

该类还包括从 Designer 生成的Ui::ClientDialog类以及以Clicked结尾的五个插槽;图 14-1 中的按钮各一个。selectionChanged插槽连接到用于显示当前目录内容的QListWidget发出的itemSelectionChanged信号。

该类还包含一个下载文件时使用的QFile指针和一个用于区分文件和目录的QStringList

清单 14-1。ClientDialog类声明

class FtpDialog : public QDialog

{

  Q_OBJECT

public:

  FtpDialog();

private slots:

  void connectClicked();

  void disconnectClicked();

  void cdClicked();

  void upClicked();

  void getClicked();

  void selectionChanged();

  void ftpFinished(int,bool);

  void ftpListInfo(const QUrlInfo&);

  void ftpProgress(qint64,qint64);

private:

  void getFileList();

  Ui::FtpDialog ui;

  QFtp ftp;

  QFile *file;

  QStringList files;

};

让我们来看看这个应用程序,从用户启动应用程序并点击连接按钮开始。

设置对话框

main函数中创建并显示ClientDialog(对话框的构造器如清单 14-2 所示)。它初始化指向nullQFile指针,配置用户界面,并进行必要的连接。然后,它禁用除连接按钮以外的所有按钮。

在整个应用程序中,按钮将被启用和禁用,以反映可用的选项。保持按钮的状态与QFtp对象同步是很重要的,因为没有检查来看一个动作在作用于被点击的按钮的槽中是否有意义。

清单 14-2。ClientDialog构造器初始化、连接并确保右边的按钮被启用,其余的被禁用。

FtpDialog::FtpDialog() : QDialog()

{

  file = 0;

  ui.setupUi( this );

  connect( ui.connectButton, SIGNAL(clicked()),

           this, SLOT(connectClicked()) );

  connect( ui.disconnectButton, SIGNAL(clicked()),

           this, SLOT(disconnectClicked()) );

  connect( ui.cdButton, SIGNAL(clicked()),

           this, SLOT(cdClicked()) );

  connect( ui.upButton, SIGNAL(clicked()),

           this, SLOT(upClicked()) );

  connect( ui.getButton, SIGNAL(clicked()),

           this, SLOT(getClicked()) );

  connect( ui.dirList, SIGNAL(itemSelectionChanged()),

           this, SLOT(selectionChanged()) );

  connect( &ftp, SIGNAL(commandFinished(int,bool)),

           this, SLOT(ftpFinished(int,bool)) );

  connect( &ftp, SIGNAL(listInfo(QUrlInfo)),

           this, SLOT(ftpListInfo(QUrlInfo)) );

  connect( &ftp, SIGNAL(dataTransferProgress(qint64,qint64)),

           this, SLOT(ftpProgress(qint64,qint64)) );

  ui.disconnectButton->setEnabled( false );

  ui.cdButton->setEnabled( false );

  ui.upButton->setEnabled( false );

  ui.getButton->setEnabled( false );

}

连接 FTP 服务器,列出文件

当对话框被构建时,在event循环开始之前,它从main函数中显示出来。当用户最终决定单击连接按钮时,事件将被发出信号的QPushButton对象捕获,该信号连接到connectClicked插槽。

如清单 14-3 所示,插槽相应地调用QFtp对象。它使用connectToHost(QString)连接到[ftp.trolltech.com](http://ftp.trolltech.com)。在此之前,“连接”按钮被禁用,这样用户就不能尝试多次连接。更新statusLabel的文本,让用户了解正在发生的事情。

QFtp对象的所有调用都是异步的,所以应用程序可以在它们被处理的同时继续运行。您可以知道命令何时完成,因为它会在完成时发出信号。

清单 14-3。 点击连接按钮后连接主机

void FtpDialog::connectClicked()

{

  ui.connectButton->setEnabled( false );

  ftp.connectToHost( "ftp.trolltech.com" );

  ui.statusLabel->setText( tr("Connecting to host...") );

}

connectToHost调用完成时,QFtp对象发出一个commandFinished(int,bool)信号。信号连接到类的ftpFinished插槽。槽的相关部分如清单 14-4 所示。

该槽被分成两个switch语句。第一种处理故障(即errortrue的情况);第二个处理已经成功完成的命令。

可以从赋予插槽的request参数中识别已发布的命令。对QFtp对象的所有调用都返回一个请求标识符,通过将它与request参数进行匹配,您可以知道哪个命令已经完成。在清单所示的插槽中,有一种不同的方法。因为您一次只发出每种类型的一个命令,所以您可以依赖于currentCommand方法,该方法返回一个枚举值,指示插槽引用哪个命令。

在点击连接按钮的情况下,结束命令是一个ConnectToHost命令。如果呼叫失败,您可以使用消息框通知用户,然后重新启用连接按钮,以便用户可以重试。如果命令成功完成,您可以通过调用login方法继续连接过程。它只是发出一个新命令,导致对插槽的新调用。因为该过程涉及几个异步命令,所以理解起来可能有些复杂。你可以在图 14-2 中查看流程图。

清单 14-4。ftpFinished插槽手柄ConnectToHost``Login``CloseList。**

void FtpDialog::ftpFinished( int request, bool error )

{

  // Handle errors depending on the command causing it

  if( error )

  {

    switch( ftp.currentCommand() )

    {

      case QFtp::ConnectToHost:

        QMessageBox::warning( this, tr("Error"), tr("Failed to connect to host.") );

        ui.connectButton->setEnabled( true );

        break;

      case QFtp::Login:

        QMessageBox::warning( this, tr("Error"), tr("Failed to login.") );

        ui.connectButton->setEnabled( true );

        break;

      case QFtp::List:

        QMessageBox::warning( this, tr("Error"),

          tr("Failed to get file list.\nClosing connection.") )

        ftp.close();

        break;

...

    }

    ui.statusLabel->setText( tr("Ready.") );

  }

  // React to the current command and issue

  // more commands or update the user interface

  else

  {

    switch( ftp.currentCommand() )

    {

      case QFtp::ConnectToHost:

        ftp.login();

        break;

      case QFtp::Login:

        getFileList();

        break;

      case QFtp::Close:

        ui.connectButton->setEnabled( true );

        getFileList();

        break;

      case QFtp::List:

        ui.disconnectButton->setEnabled( true );

        ui.upButton->setEnabled( true );

        ui.statusLabel->setText( tr("Ready.") );

        break;

...

    }

  }

}

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14-2。 连接一个 FTP 站点包括连接主机、登录和列表几个步骤。

当登录命令完成时,您通过通知用户并重新启用 Connect 按钮来处理错误。成功的命令触发对getFileList方法的调用,该方法检索当前目录的内容。你可以在清单 14-5 中看到实现。

getFileList方法禁用所有按钮(记住您是连接的,所以 Connect 按钮已经被禁用)。然后,在调用QFtp对象来list当前目录的内容之前,它清除列表小部件dirListQStringList文件。

您检查 FTP 连接的开始是LoggedIn,因为当您想要清除dirList时(例如,当断开连接时)调用这个方法。

当调用了QFtp::list时,对于每个目录条目,发出一次listInfo信号。该信号连接到清单 14-5 中getFileList下方所示的ftpListInfo插槽。QUrlInfo包含了许多关于每一项的有趣信息,但是您只对name属性感兴趣,并想知道该项是否是一个文件。如果是一个文件,将该名称添加到files列表中(稍后您将使用该列表来决定是否启用获取文件按钮或更改目录按钮)。

清单 14-5。 通过调用 list 然后监听 listInfo 信号得到目录项列表

void FtpDialog::getFileList()

{

  ui.disconnectButton->setEnabled( false );

  ui.cdButton->setEnabled( false );

  ui.upButton->setEnabled( false );

  ui.getButton->setEnabled( false );

  ui.dirList->clear();

  files.clear();

  if( ftp.state() == QFtp::LoggedIn )

    ftp.list();

}

void FtpDialog::ftpListInfo( const QUrlInfo&info )

{

  ui.dirList->addItem( info.name() );

  if( info.isFile() )

    files << info.name();

}

list命令结束时,它发出一个被ftpFinished插槽捕获的信号。switch语句的相关部分可以在清单 14-4 中看到。如您所见,如果一个list命令失败,FTP 连接就会关闭。如果成功,将启用断开连接和向上按钮。

当连接关闭后,再次调用ftpFinished槽,并且QFtp::Close将是当前命令。当close命令成功完成后,启用连接按钮并调用getFileList方法。查看清单 14-5 中的方法,你会发现因为QFtp命令不再是LoggedIn,调用的结果是目录条目列表被清除。

从 FTP 服务器断开

当遇到失败的list命令时,调用QFtp对象上的close方法,关闭连接。当用户想要断开连接时,他们点击 disconnect 按钮,这导致对清单 14-6 中所示的disconnectClicked插槽的调用。

该插槽简单地禁用所有按钮,因此当连接被关闭时,用户不能做任何事情。然后它调用close方法。当close呼叫结束后,ftpFinished插槽将启用连接按钮并清除目录条目列表。

清单 14-6。disconnectClicked槽在用户点击断开按钮时触发。

void FtpDialog::disconnectClicked()

{

  ui.disconnectButton->setEnabled( false );

  ui.cdButton->setEnabled( false );

  ui.upButton->setEnabled( false );

  ui.getButton->setEnabled( false );

  ftp.close();

}

文件还是目录?

当 FTP 连接建立后,Disconnect 和 Up 按钮被启用,并且dirList小部件包含一个目录条目列表。为了能够下载文件或更深入地浏览目录树,用户必须在dirList中选择一个项目。当这种情况发生时,从QListWidget发出itemSelectionChanged信号,并调用selectionChanged插槽。该槽如清单 14-7 中的所示。

确定插槽中的当前选择是包含一个项目还是不包含任何项目。QListWidgetselectionMode属性已经被设置为SingleSelection,所以你不能进入任何其他的选择场景。如果未选择任何项目,则“获取文件”和“更改目录”按钮都将被禁用。

如果选择了一个项目,查看所选项目的文本是否在文件QStringList中。如果是,则启用“获取文件”按钮;否则,将启用“更改目录”按钮。

清单 14-7。 selectionChanged 槽中你确保右边的按钮都被启用。

void FtpDialog::selectionChanged()

{

  if( !ui.dirList->selectedItems().isEmpty() )

  {

    if( files.indexOf( ui.dirList->selectedItems()[0]->text() ) ==1 )

    {

      ui.cdButton->setEnabled( ui.disconnectButton->isEnabled() );

      ui.getButton->setEnabled( false );

    }

    else

    {

      ui.cdButton->setEnabled( false );

      ui.getButton->setEnabled( ui.disconnectButton->isEnabled() );

    }

  }

  else

  {

    ui.cdButton->setEnabled( false );

    ui.getButton->setEnabled( false );

  }

}

导航 FTP 服务器目录结构

当用户想要在 FTP 站点的目录之间移动时,他们使用向上和改变目录按钮。只有在目录内容列表中选择了一个目录时,用户才可以使用后者。

点击这些按钮会调用清单 14-8 中的所示的一个插槽。两个插槽的工作方式完全相同:按钮被禁用,调用QFtp对象的cd方法,并更新状态文本。不同的是,当按下向上按钮时,cd调用试图移动到父目录(…),而“更改目录”按钮试图移动到一个已命名的子目录。

清单 14-8。 向上和改变目录按钮的插槽

void FtpDialog::cdClicked()

{

  ui.disconnectButton->setEnabled( false );

  ui.cdButton->setEnabled( false );

  ui.upButton->setEnabled( false );

  ui.getButton->setEnabled( false );

  ftp.cd( ui.dirList->selectedItems()[0]->text() ;)

  ui.statusLabel->setText( tr("Changing directory...") );

}

void FtpDialog::upClicked()

{

  ui.disconnectButton->setEnabled( false );

  ui.cdButton->setEnabled( false );

  ui.upButton->setEnabled( false );

  ui.getButton->setEnabled( false );

  ftp.cd("..");

  ui.statusLabel->setText( tr("Changing directory...") );

}

因为两个按钮都导致调用QFtp对象中的同一个方法,所以两个方法都在ftpFinished槽中的同一个switch案例中结束。(源代码的相关部分见清单 14-9 。)无论cd调用失败还是成功,结果动作都是一样的——调用getFileList。这个额外的调用更新了目录内容列表并启用了相关的按钮。如果cd命令因为您被注销或连接失败而失败,那么getFileList调用也会失败。该故障导致 FTP 连接关闭(参见清单 14-4 )。

清单 14-9。 当一个 cd 调用完成后,当前目录的内容将被更新。

void FtpDialog::ftpFinished( int request, bool error )

{

  if( error )

  {

    switch( ftp.currentCommand() )

    {

...

      case QFtp::Cd:

        QMessageBox::warning( this, tr("Error"),

                              tr("Failed to change directory.") );

        getFileList();

        break;

...

    }

    ui.statusLabel->setText( tr("Ready.") );

  }

  else

  {

    switch( ftp.currentCommand() )

    {

...

      case QFtp::Cd:

        getFileList();

        break;

...

    }

  }

}

如果getFileList调用失败,FTP 连接关闭,如清单 14-4 所示。这意味着如果一个无效的cd调用会使 FTP 连接无效,那么连接就会被关闭,这是摆脱这种情况的最安全的方法。

下载文件

如果在目录内容列表中选择了一个文件,则“获取文件”按钮将被启用。点击此按钮将调用getClicked插槽。清单 14-10 中的槽实现了三阶段操作。首先,它询问使用什么文件名来保存正在使用QFileDialog::getSaveFileName下载的文件。如果它得到一个有效的文件名,它会尝试为它创建一个QFile对象,并打开它进行写入。如果成功,它调用QFtp对象的get方法,传递文件名和QFile对象作为参数。

插槽在调用get之前也禁用所有按钮。在它调用了get之后,它更新状态文本。

get方法启动指定文件的下载操作。结果数据被保存到给定的QIODevice(?? 的超类)。当QFtp对象执行下载时,通过连接到ftpProgress插槽的一系列dataTransferProgress信号来报告进度(参见getClicked插槽源代码后的清单 14-10 )。

ftpProgress的参数不一定代表字节;它们只显示相对大小。在某些情况下,下载文件的大小是未知的。那么total自变量为零。如果大小已知,插槽会更新状态标签以显示进度。


注意下载和上传时都会发出dataTransferProgress。当使用put上传文件时,当您想要显示进度时,您可以收听与使用get下载时相同的信号。


清单 14-10。 开始下载并显示进度

void FtpDialog::getClicked()

{

  QString fileName =

    QFileDialog::getSaveFileName( this, tr("Get File"),

                                  ui.dirList->selectedItems()[0]->text() );

  if( fileName.isEmpty() )

    return;

  file = new QFile( fileName, this );

  if( !file->open( QIODevice::WriteOnly|QIODevice::Truncate ) )

  {

    QMessageBox::warning( this, tr("Error"),

      tr("Failed to open file %1 for writing.").arg( fileName ) );

    delete file;

    file = 0;

    return;

  }

  ui.disconnectButton->setEnabled( false );

  ui.cdButton->setEnabled( false );

  ui.upButton->setEnabled( false );

  ui.getButton->setEnabled( false );

  ftp.get( ui.dirList->selectedItems()[0]->text(), file );

  ui.statusLabel->setText( tr("Downloading file...") );

}

void FtpDialog::ftpProgress( qint64 done, qint64 total )

{

  if( total == 0 )

    return;

  ui.statusLabel->setText(

    tr("Downloading file... (%1%)")

      .arg( QString::number( done*100.0/total, 'f', 1 ) ) );

}

get命令结束时,由ftpFinished插槽处理(代码如清单 14-11 所示)。当下载失败时(甚至成功时),关闭并删除QFile对象,重新启用按钮,并更新状态标签。对selectionUpdated的调用确保根据目录内容列表中的当前选择启用按钮。这意味着要么启用“获取文件”或“更改目录”,要么两者都不启用(但不是两者都启用)。

失败的下载和成功的下载的区别在于,当下载失败时,您在删除它之前调用QFile对象上的remove方法。这会将文件从磁盘中移除,这样您就不会为用户留下未完成的文件。

清单 14-11。 下载完成后管理文件

void FtpDialog::ftpFinished( int request, bool error )

{

  if( error )

  {

    switch( ftp.currentCommand() )

    {

...

      case QFtp::Get:

        QMessageBox::warning( this, tr("Error"), tr("Failed to get file?") );

        file->close();

        file->remove();

        delete file;

        file = 0;

        ui.disconnectButton->setEnabled( true );

        ui.upButton->setEnabled( true );

        selectionChanged();

        break;

    }

    ui.statusLabel->setText( tr("Ready.") );

  }

  else

  {

    switch( ftp.currentCommand() )

    {

...

      case QFtp::Get:

        file->close();

        delete file;

        file = 0;

        ui.disconnectButton->setEnabled( true );

        ui.upButton->setEnabled( true );

        selectionChanged();

        ui.statusLabel->setText( tr("Ready.") );

        break;

    }

  }

}

组装在一起

通过将图 14-1 中的所示的对话框和前面的列表与一个显示该对话框的简单的main函数结合起来,你就有了一个完整的 FTP 客户端。它被限制在一个域中,只能浏览目录和执行下载,但是所有需要的机制都已经到位。

要构建客户机,您必须创建一个项目文件——最好使用qmake -project QT+=network。然后您可以像往常一样使用qmakemake构建您的应用程序。

QFtp的其他应用

**QFtp类可以用于构建 FTP 客户端应用程序之外的任务。因为get方法下载到一个QIODevice,你可以用它直接下载数据到一个QBuffer设备并显示它(与你在第十三章的BLOB栏中存储图像的方式相比)。

也可以使用与get方法相反的put方法上传数据。当上传和下载时,通过使用第三个可选参数到get(QString,QIODevice*,TransferType)put(QIODevice*,QString,TransferType)方法来控制 FTP 连接是以二进制模式还是 ASCII 模式通信是很重要的。传送类型可以是QFtp::BinaryQFtp::Ascii

如果您在QFtp类中缺少一个方法,您可以使用带有rawCommand方法的原始命令接口发送 FTP 服务器理解的任何命令。如果你期待一个原始命令的回复,你可以收听rawCommandReply(int,QString)信号。


注意建议您尽可能使用现有命令。


创建 HTTP 客户端

HTTP 协议的工作方式类似于 FTP 协议,但是有所不同。最明显的一点是,当使用 FTP 连接时,您可以进行连接、移动和执行操作。使用 HTTP 时,一次执行一个请求,请求本身或多或少是独立的。

说到相似之处,QFtpQHttp类都是异步的。它们也解决类似的问题——它们通过网络传输数据。

解析和验证 URL

因为 Web 是由 URL 驱动的,所以应用程序需要能够正确地将这些 URL 解析成适当的组件,以使必要的通信命令发挥作用。这就是QUrl进入画面的地方;它使得验证一个 URL 并把它分解成你需要的组件变得很容易。

让我们先来看看图 14-3 ,它展示了一个复杂的 URL 以及它所包含的不同部分。图中零件的名称对应于QUrl类的属性。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14-3。 一个网址及其组成部分

当您从用户处接收到一个 URL 时,您可以将其提供给QUrl构造器,然后询问isValid方法是否可以解释该 URL。这就是清单 14-12 中的getClicked插槽所发生的情况。该对话框在图 14-4 中显示。URL 被输入到a QLineEdit小部件中,并被传递给QUrl对象的构造器。第二个构造器参数告诉QUrl类要宽容。宽容的替代方案是严格的,这种模式是通过将QUrl::StrictMode值传递给构造器来设置的。容忍模式补偿用户输入的 URL 中遇到的常见错误。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14-4。HttpDialog如图所示给用户

*如果发现 URL 无效,用于显示 URL 不同部分的QLabel小部件被设置为不显示文本。然后,在离开该方法之前会显示一个对话框。如果已经输入了一个有效的 URL,那么QLabel小部件将使用 URL 部分进行更新。

当更新标签时,port属性得到特殊处理。如果用户没有指定端口,那么port属性被设置为 1,这意味着用户希望使用 HTTP 通信的默认端口:端口 80。

清单 14-12。 解析 URL 并将其拆分成单独的部分

void HttpDialog::getClicked()

{

  QUrl url( ui.requestEdit->text(), QUrl::TolerantMode );

  if( !url.isValid() )

  {

    ui.hostLabel->clear();

    ui.pathLabel->clear();

    ui.portLabel->clear();

    ui.userLabel->clear();

    ui.passwordLabel->clear();

    QMessageBox::warning( this, tr("Invalid URL"),

      tr("The URL '%1' is invalid.").arg( ui.requestEdit->text() ) );

    return;

  }

  ui.hostLabel->setText( url.host() );

  ui.pathLabel->setText( url.path() );

  ui.portLabel->setText( QString::number(url.port()==-1 ? 80 : url.port()) );

  ui.userLabel->setText( url.userName() );

  ui.passwordLabel->setText( url.password() );

...

清单 14-12 中的源代码是清单 14-13 中所示的HttpDialog类的一部分。

用户使用该对话框通过 HTTP 下载文件。用户在顶部的文本字段中输入一个 URL,然后单击 Get 按钮。按钮连接到前面所示的getClicked槽。当 URL 被验证后,它被用来下载它所指向的文件。下载文件时,从QHttp对象发出的信号会在对话框底部的列表小部件中列出。

每个以http开头的插槽用于监听QHttp对象工作时发出的不同信号。用户界面本身已经在设计器中创建,并作为ui成员变量包含在内。最后,在下载数据时使用一个QFile指针和QHttp对象。

清单 14-13。HttpDialog类声明

class HttpDialog : public QDialog

{

  Q_OBJECT

public:

  HttpDialog();

private slots:

  void getClicked();

  void httpStateChanged(int);

  void httpDataSent(int,int);

  void httpDataReceived(int,int);

  void httpHeaderDone(const QHttpResponseHeader&);

  void httpDataDone(const QHttpResponseHeader&);

  void httpStarted(int);

  void httpFinished(int,bool);

  void httpDone(bool);

private:

  Ui::HttpDialog ui;

  QHttp http;

  QFile *file;

};

清单 14-12 中的代码管理对话框的上半部分。有趣的事情发生在对话的下半部分(接下来讨论)。

对话内部

处理 URL 的代码处理对话框的上半部分:请求和 URL 组件分组框及其内容(参见图 14-4 )。在你看同一个对话框的下半部分,HTTP Status 分组框之前,让我们看一下它的构造器(如清单 14-14 所示)。构造器有三个任务:初始化局部变量(也就是file),调用setupUi创建用 Designer 设计的用户界面,以及进行使对话框工作所需的所有连接。

这些连接可以分为两组。来自getButtonclicked信号将用户交互连接到插槽;其余的连接将 HTTP 事件连接到插槽。

清单 14-14。 在创建所有连接之前初始化变量和用户界面

HttpDialog::HttpDialog() : QDialog()

{

  file = 0;

  ui.setupUi( this );

  connect( ui.getButton, SIGNAL(clicked()), this, SLOT(getClicked()) );

  connect( &http, SIGNAL(stateChanged(int)),

           this, SLOT(httpStateChanged(int)) );

  connect( &http, SIGNAL(dataSendProgress(int,int)),

           this, SLOT(httpDataSent(int,int)) );

  connect( &http, SIGNAL(dataReadProgress(int,int)),

           this, SLOT(httpDataReceived(int,int)) );

  connect( &http, SIGNAL(responseHeaderReceived(const QHttpResponseHeader&)),

           this, SLOT(httpHeaderDone(const QHttpResponseHeader&)) );

  connect( &http, SIGNAL(readyRead(const QHttpResponseHeader&)),

           this, SLOT(httpDataDone(const QHttpResponseHeader&)) );

  connect( &http, SIGNAL(requestStarted(int)),

           this, SLOT(httpStarted(int)) );

  connect( &http, SIGNAL(requestFinished(int,bool)),

           this, SLOT(httpFinished(int,bool)) );

  connect( &http, SIGNAL(done(bool)),

           this, SLOT(httpDone(bool)) );

}

前面讨论的 URL 处理代码是一个名为getClicked的槽的上半部分。在前面的构造器中,您看到了该方法是如何连接到用户界面的。当您离开清单 14-12 的中的getClicked方法时,URL 刚刚被验证并被分割成其构建块。

当您继续在清单 14-15 中时,您使用 URL 来设置QHttp对象的host属性。调用setHost并指定主机名和端口。就像显示端口一样,如果没有指定其他内容,端口 80 是默认端口。如果指定了用户名,则使用setUser方法设置用户名及其密码。

QHttp对象被设置后,继续通过使用QFileDialog类的静态方法getSaveFileName向用户询问用于存储下载材料的文件名。如果用户取消对话框,从插槽返回;否则,继续尝试打开文件进行写入。如果失败,通过显示警告对话框通知用户并删除QFile对象。

如果用户选择了一个可用于书写的文件名,调用QHttp对象的get(QString,QIODevice)方法来下载文件。最后,在执行实际下载时,禁用 Get 按钮。

清单 14-15。 使用验证过的网址开始下载

void HttpDialog::getClicked()

{

...

  http.setHost( url.host(), url.port()==-1 ? 80 : url.port() );

  if( !url.userName().isEmpty() )

    http.setUser( url.userName(), url.password() );

  QString fileName = QFileDialog::getSaveFileName( this );

  if( fileName.isEmpty() )

    return;

  file = new QFile( fileName, this );

  if( !file->open( QIODevice::WriteOnly|QIODevice::Truncate ) )

  {

    QMessageBox::warning( this, tr("Could not write"),

      tr("Could not open the file %f for writing.").arg( fileName ) );

    delete file;

    file = 0;

    return;

  }

  http.get( url.path(), file );

  ui.getButton->setEnabled( false );

}

现在开始下载;如果一切顺利,你需要做的就是等待done信号发出。如果遇到错误,布尔参数是true,所以你希望它是false。信号连接到列表 14-16 中所示的httpDone插槽。如果error参数为false,使用close方法关闭QFile对象并删除文件对象。

如果下载操作遇到了问题,并且error参数为true,则在关闭和删除文件之前以及删除QFile对象之前,用户会得到警告。使用remove方法删除文件。您必须删除该文件,因为它可能包含部分下载(如果连接在下载操作过程中断开,就会发生这种情况)。

您用来警告用户问题的消息是用errorString方法检索的,该方法返回一个错误消息。

无论下载是否成功,在离开插槽之前重新启用 Get 按钮,以便用户可以输入新的 URL 并尝试下载更多数据。

清单 14-16。 当下载完成或失败时,由 QHttp 对象发出 done 信号。该信号连接到 httpDone 插槽。

void HttpDialog::httpDone( bool error )

{

  ui.statusList->addItem( QString("done( %1 )").arg( error ? "True" : "False" ) );

  if( error )

  {

    QMessageBox::warning( this, tr("Http: done"), http.errorString() );

    if( file )

    {

      file->close();

      file->remove();

      delete file;

      file = 0;

    }

  }

  if( file )

  {

    file->close();

    delete file;

    file = 0;

  }

  ui.getButton->setEnabled( true );

}

所有剩余的插槽只是将它们的名称和参数值输出到对话框底部的列表中。这个列表显示了QHttp对象用来执行请求的下载的确切步骤。QHttp物体很健谈,工作时能发出以下信号:

  • dataReadProgress(int done, int total):请求数据的一部分已被读取。参数donetotal显示了比例,但不一定是字节数。请注意,如果总大小未知,则total可以为零。
  • dataSendProgress(int done, int total):正在发送的一部分数据已经传输。这个参数的工作方式与dataReadProgress相同。
  • done(bool error):最后一个待处理的请求已经完成。
  • readyRead(const QHttpResponseHeader &resp):读取请求已完成。如果在发出请求时指定了目标设备,则不会发出此信号。
  • requestFinished(int id, bool error):请求已完成。您可以从id参数中识别请求。
  • requestStarted(int id):请求已经开始。您可以从id参数中识别请求。
  • responseHeaderReceived(const QHttpResponseHeader &resp):响应头可用。
  • stateChanged(int state):QHttp对象的状态已经改变。

下载信号

知道所有信号的意思是一回事,但实际上知道会发生什么是另一回事。让我们看看两种不同的下载场景,从成功下载开始。

这一切都从发出请求开始,首先设置主机,然后开始下载:

requestStarted( 1 )

requestFinished( 1, False )

requestStarted( 2 )

stateChanged( Connecting )

stateChanged( Sending )

dataSendProgress( done: 74, total: 74 )

stateChanged( Reading )
现在开始读取,这将产生一系列的`dataReadProgress`信号(它们的参数和数量将因您的计算机而异):
responseHeaderReceived(code: 200, reason: OK, version: 1.1 )

dataReadProgress( done: 895, total: 0 )

...

dataReadProgress( done: 32546, total: 0 )

stateChanged( Closing )

stateChanged( Unconnected )
现在您已经断开连接,读取结束。对 HTTP 对象来说,剩下的就是说所有的事情都已经做了,一切都很顺利:
requestFinished( 2, False )

done( False )
在下一次尝试中,您将尝试从不存在的服务器下载文件。这意味着你甚至不会与服务器取得联系。
一切都像以前一样开始:设置主机,然后尝试下载一个文件:
requestStarted( 1 )

requestFinished( 1, False )

requestStarted( 2 )

stateChanged( Connecting )
第二个请求失败:
requestFinished( 2, True )
这也反映在`done`信号中;它的参数是`true`,表示错误:
done( True )

stateChanged( Closing )

stateChanged( Unconnected )
这里显示了两个场景,但是还有许多其他场景。在处理网络应用程序时,注意在收到正确的数据时向用户报告成功。不要试图查出所有的错案;试着找到你期待的成功。
插座
当使用`QHttp`和`QFtp`类时,您实际上依赖底层协议来处理实际的数据传输。使用的协议是 TCP,它有一个稍微不太可靠的近亲,叫做 UDP。Qt 支持这两种协议。
当直接使用 TCP 和 UDP 套接字时,您的工作水平远远低于使用 HTTP 和 FTP 时。当您使用这些技术时,您负责将发送和接收的数据转换为应用程序友好的格式,并在应用程序端处理数据。
这意味着您要做更多的工作,但也意味着对最终协议的更多控制。FTP 和 HTTP 并不总是合适的协议,因为应用领域可能已经有了一个协议。在其他情况下,使用自定义协议的好处大于花费的额外工作。应用程序的性质有时意味着使用 HTTP 或 FTP 是不可能的,或者比实现特定于应用程序的协议涉及更多的工作。
可靠性在 UDP 和 TCP 中的作用
尽管 UDP 和 TCP 通信之间存在一些差异,但大多数开发人员只需要记住他们实现可靠性的不同方法。TCP 传输的数据能否真正到达目的地至关重要。另一方面,当使用 UDP 时,您只是在相关的计算机之间传递数据,无法保证数据能够到达目的地。另外,当数据到达目的地时,TCP 协议确保数据以正确的顺序提供给应用程序。使用 UDP 发送的数据可能会乱序到达,这是应用程序必须处理的情况。
如果你想传输一段数据,并且需要传输所有的数据,TCP 是最好的。示例包括传输文件和维护远程访问计算机的会话。在这些情况下,丢失的一部分数据会使其余的数据变得无用。
在时间比可靠性更重要的情况下,UDP 对于输出数据很有用。比如视频流的时候,错过几帧总比时间漂移好。其他示例包括多人游戏,其中其他玩家的位置可能不太重要(只要不发生直接交互)。
有时,需求同时涉及 TCP 和 UDP 的属性:一种常见的情况是,对数据流的控制使用 TCP,而实际数据使用 UDP 传输。这意味着用户身份验证、控制命令等是通过有质量保证的连接来处理的,而实际数据是使用 UDP 来发送的。
服务器、客户端和对等端
历史上,计算机通信发生在服务器为客户端提供某种服务的情况下。

主人之间直接对话已经变得越来越普遍。例子包括文件共享客户端以及 VoIP 解决方案。从软件开发的角度来看,这并不难做到;您只需要创建能够处理传入和传出连接的应用程序。

**使用 Qt 创建服务器端应用**
服务器应用程序通常不需要图形用户界面;它们往往在后台运行,用户看不见。不包含用户界面模块也可以编写 Qt 应用程序。这涉及到两个变化:首先,`QApplication`对象被一个`QCoreApplication`对象取代;然后你需要在项目文件中添加一行`QT -= gui`。
生成的应用程序没有链接到任何 Qt 的用户界面类,因此无论是在运行时还是在分发时,它都将占用更少的磁盘空间和需要更少的内存。
使用 TCP 发送图像
您第一次尝试客户机-服务器解决方案时,将涉及到一个服务器应用程序,用于传输客户机请求的图像,并使最终用户可以看到这些图像。服务器从给定的目录中随机选取一个图像,并通过 TCP 将其发送给客户端。客户端应用程序使用户能够通过点击按钮来请求新图像,然后接收并显示给定的图像。
**创建服务器应用**
让我们先来看看服务器端。您将按照执行的顺序查看服务器的源代码,从`main`函数开始(如清单 14-17 所示)。
在`main`函数中,您设置了一个`Server`对象来监听端口 9876 的传入连接。这些联系可能来自任何来源。如果`listen`调用失败,告诉用户并退出。否则,通过从`QCoreApplication`对象调用`exec`方法来启动`event`循环。

**注意**如果调用`listen`时没有指定端口,`QTcpServer`类会选择一个空闲端口。您可以使用`serverPort`属性找出服务器监听哪个端口。当您不需要控制使用哪个端口时,这非常有用。

**清单 14-17***`main`*功能尝试设置服务器。** *`int main( int argc, char **argv )

{

  QCoreApplication app( argc, argv );

  Server server;

  if( !server.listen( QHostAddress::Any, 9876 ) )

  {

    qCritical( "Cannot listen to port 9876." );

    return 1;

  }

  return app.exec();

}` 

清单 14-18 中的类继承了`QTcpServer`类。使用 Qt 的 TCP server 类作为服务器实现的基础可以让你免费得到很多东西。现在,`main`函数创建一个对象实例,并在进入`event`循环之前调用`listen`。所有连接到服务器的尝试都将导致调用`incomingConnection`方法。通过重新实现方法,您可以处理连接。

**清单 14-18** *服务器类继承了* `QTcpServer` *并重新实现了* `incomingConnection` *方法。*

class Server : public QTcpServer

{

public:

Server();

protected:

void incomingConnection( int descriptor );

};


服务器的实现几乎和类声明一样简单,因为实际的工作不是由`Server`类执行的。(你可以在清单 14-19 中看到所有的源代码。)

由于服务器会很快承受大量同时传入的连接,发送图像可能需要一段时间。为了减轻负载,可以利用线程化——为每个连接创建一个新线程。通过这样做,`Server`对象可以在服务第一个连接的同时继续前进并处理下一个连接。

当调用`incomingConnection`方法时,一个*套接字描述符*作为参数被传递。这个整数可以用来连接一个处理连接的`QTcpSocket`对象。这被传递给被创建和启动的`ServerThread`对象。通过将完成信号连接到`deleteLater`插槽,线程对象被设置为在它们完成后进行清理。`deleteLater`槽可用于`QObject`,并在到达`event`循环时删除对象实例。这使得对象可以删除自己——这通常是不可能的,因为从类方法内部删除`this`指针会导致不可预知的结果和灾难性的崩溃。

**清单 14-19** *服务器只是为每个连接启动一个线程。*

Server::Server() : QTcpServer()

{

}

void Server::incomingConnection( int descriptor )

{

ServerThread *thread = new ServerThread( descriptor, this );

connect( thread, SIGNAL(finished()), thread, SLOT(deleteLater()) );

thread->start();

}


`Server`对象为每个传入的连接创建一个`ServerThread`对象。`thread`类由两个方法组成:`run`和`randomImage`。你可以在清单 14-20 的类声明中看到它们。

`run`方法负责执行通过给定套接字传输图像的实际任务。`randomImage`方法被`run`方法用来获取要发送的图像。

**清单 14-20** *每个传入的连接都由一个* `ServerThread` *对象处理。*

class ServerThread : public QThread

{

public:

ServerThread( int descriptor, QObject *parent );

void run();

private:

QImage randomImage();

int m_descriptor;

};


让我们从查看`randomImage`方法开始(参见清单 14-21 )。该方法使用一个`QDir`对象在`./images`目录中查找文件。它假设该目录中的所有文件都是有效的图像。然后,它使用`qrand`函数生成一个随机数,用于选择其中一个文件。

在使用`qrand`之前,用一个种子初始化随机数发生器很重要;否则,每次都会得到相同的数列。`qsrand`调用使用自午夜以来经过的秒数作为种子。

**清单 14-21** ** `images` *中选择一个随机文件,用* `QImage`加载

QImage ServerThread::randomImage()

{

qsrand(QTime(0,0,0).secsTo(QTime::currentTime()));

QDir dir(“images”);

dir.setFilter( QDir::Files );

QFileInfoList entries = dir.entryInfoList();

if( entries.size() == 0 )

{

qDebug( “No images to show!” );

return QImage();

}

return QImage( entries.at( qrand() % entries.size() ).absoluteFilePath() );

}


实际发送图像的任务由清单 14-22 中的方法处理。相同清单中显示的构造器只是保留了对`run`方法的描述。在`run`方法中,描述符用于建立一个`QTcpSocket`对象。通过使用`setSocketDescriptor`设置套接字描述符,您可以获得一个套接字对象,该对象连接到连接到服务器的客户机。

当套接字设置好后,就该准备通过套接字传输数据了。这是一个两阶段的过程。首先创建一个`QBuffer`来写入图像。一个`QBuffer`是一个`QIODevice`(就像一个`QFile`一样),并且`QImageWriter`可以写任何一个`QIODevice`。对`QImageWriter`的`write`方法的调用留给您一个包含编码为 PNG 的图像的缓冲区。

在发送缓冲区的内容之前,您需要找到一种方法来告诉客户端预期有多少数据。这是下一步。首先创建一个`QByteArray`和一个`QStreamWriter`来写数组。将流的版本设置为`Qt_4_0`,以确保数据以一种方式编码。如果跳过这一步,使用 Qt 未来版本编译的服务器可能会与客户机不兼容。

使用流写入器将包含在`QBuffer`中的数据的大小放入字节数组中。确定大小后,将缓冲区的内容添加到字节数组中,并将所有数据写入套接字。

当数据被发送后,你不再需要套接字,所以使用`disconnectFromHost`断开它。然后在`run`方法结束之前,使用`waitForDisconnect`等待断开完成。当方法返回时,发出`finished` 信号。这个信号通过`Server`对象连接到`deleteLater`插槽,所以当数据发送后`ServerThread`对象删除自己。

**清单 14-22***`run`*方法通过套接字发送图像数据。**

ServerThread::ServerThread( int descriptor, QObject *parent ) : QThread( parent )

{

m_descriptor = descriptor;

}

void ServerThread::run()

{

QTcpSocket socket;

if( !socket.setSocketDescriptor( m_descriptor ) )

{

qDebug( “Socket error!” );

return;

}

QBuffer buffer;

QImageWriter writer(&buffer, “PNG”);

writer.write( randomImage() );

QByteArray data;

QDataStream stream( &data, QIODevice::WriteOnly );

stream.setVersion( QDataStream::Qt_4_0 );

stream << (quint32)buffer.data().size();

data.append( buffer.data() );

socket.write( data );

socket.disconnectFromHost();

socket.waitForDisconnected();

}


**创建客户端应用程序**

图像查看系统的客户端是用户将会遇到的。对他们来说,它将像其他用户应用程序一样工作,显示图 14-5 中的用户界面。该应用程序使用户能够指定服务器,下载新图像,并查看上一个图像。

在图中,服务器运行在 *localhost* (与客户机相同的计算机)上。您可以在这里输入任何计算机名或 IP 地址。当要求获取图像时,客户端将尝试建立到服务器上 9876 端口的连接,这是服务器监听的端口。如果在这个过程中出现问题(例如,没有可用的服务器),用户会看到一条错误消息。

![image](https://gitee.com/OpenDocCN/vkdoc-c-cpp-zh/raw/master/docs/fund-qt-dev/img/P1405.jpg)

**14-5** *图像浏览器客户端应用*

整个应用程序由一个在`ClientDialog`类中实现的对话框组成。一个简单的`main`函数用于显示对话框并启动应用程序。`main`函数简单地创建了一个`ClientDialog`对象,在对其`QApplication`对象调用`exec`之前,先对其调用`show`方法。

清单 14-23 显示了对话框的类声明。它由一个构造器、一个用于获取图像按钮的插槽(`getClicked`)和两个用于监控 TCP 套接字的插槽(`tcpReady`和`tcpError`)构建而成。该类还包含三个私有变量:用户界面(保存在`ui`中),一个名为`socket`的`QTcpSocket`对象,以及用于跟踪下载图像时预期数据量的`dataSize`变量。

用户界面在设计器中创建(参见图 14-5 查看对话框)。用户界面的活动部分是用于输入服务器名称的`QLineEdit`,用于点击下载新图像的`QPushButton`,以及用于显示图像和状态消息的`QLabel`。

**清单 14-23** *客户端对话框类声明*

class ClientDialog : public QDialog

{

Q_OBJECT

public:

ClientDialog();

private slots:

void getClicked();

void tcpReady();

void tcpError( QAbstractSocket::SocketError error );

private:

Ui::ClientDialog ui;

QTcpSocket socket;

int dataSize;

};


在查看套接字处理和图像下载之前,让我们从一切开始的地方开始。客户端应用程序一启动,对话框就创建好了(构造器如清单 14-24 所示)。

构造器非常简单(这是对话框如此简单的结果)。构造器所做的就是通过调用`setupUi`来初始化用户界面,将获取图像按钮连接到`getClicked`插槽,并围绕`QTcpSocket`对象进行必要的连接。

**清单 14-24** *构造客户端对话框*

ClientDialog::ClientDialog() : QDialog()

{

ui.setupUi( this );

connect( ui.getButton, SIGNAL(clicked()), this, SLOT(getClicked()) );

connect( &socket, SIGNAL(error(QAbstractSocket::SocketError)),

this, SLOT(tcpError(QAbstractSocket::SocketError)) );

connect( &socket, SIGNAL(readyRead()),

this, SLOT(tcpReady()) );

}


从构造器执行应用程序之后,代码等待用户填写服务器名称并单击 Get Image 按钮。点击按钮会把你带到清单 14-25 中的位置。

该槽通过禁用“获取图像”按钮来开始,以防止用户在第一次下载完成之前试图开始新的下载。然后从任何先前的图像中清除`QLabel`,并显示一条消息。通过调用带有空的`QPixmap`对象的`setPixmap`来清除之前的图像。

当用户界面已经为下载做好准备时,`dataSize`变量被初始化为零,并且在`QTcpSocket`对象上调用`abort`方法,以防止先前调用的任何残余干扰。最后,调用`connectToHost`连接到指定服务器的 9876 端口。这个过程导致清单 14-18 中的对象检测到一个传入的连接,从而将一个图像发送到客户端应用程序。

**清单 14-25** *槽位发起下载*

void ClientDialog::getClicked()

{

ui.getButton->setEnabled( false );

ui.imageLabel->setPixmap( QPixmap() );

ui.imageLabel->setText( tr(“Getting image…”) );

dataSize = 0;

socket.abort();

socket.connectToHost( ui.serverEdit->text(), 9876 );

}


工作时,`QTcpSocket`级通过发射信号来传达其当前状态。在客户端应用程序中,您会听到`readyRead`和`error`信号,但还有更多信号(见下表):** 

*** connected():当一个成功的connectToHost呼叫被发出,并且一个连接被建立。* disconnected():插座断开时发出。* error(QAbstractSocket::SocketError):发生错误时发出。该参数描述了错误的原因。* hostFound():主机对connectToHost的调用已经完成,主机名已经成功查找并解析时发出。它是在connected信号之前发出的,不能保证连接的建立——服务器仍然可以拒绝接受它。* stateChanged(QAbstractSocket::SocketState):套接字状态改变时发出。* readyRead():当数据可供读取时发出。它仅在有新数据可用时发出,因此,如果您不读取数据,直到有更多数据可用时,信号才会重新发出。**

**注意,所有这些信号都是在QTcpSocket类继承的类中定义的。列表中的前五个是在QAbstractSocket类中定义的,而readyRead来自于QIODevice类。这意味着当浏览参考文档时,你必须查找超类而不是QTcpSocket来寻找关于信号的信息。

套接字始终处于一种状态,即使在未连接时也是如此。状态变化导致发出stateChanged信号。客户端应用程序套接字中存在以下状态:

  • QAbstractSocket::UnconnectedState:插座未连接。
  • QAbstractSocket::HostLookupState:套接字正在查找主机。
  • QAbstractSocket::ConnectingState:套接字已经查找到主机,正在尝试建立连接。
  • QAbstractSocket::ConnectedState:socket 连接服务器。
  • QAbstractSocket::ClosingState:套接字正在关闭连接。

这里列出的状态按照它们在实际应用中出现的顺序出现。套接字开始时未连接,查找主机,尝试连接,然后连接。然后套接字被关闭,最后返回为未连接。如果出现错误,套接字将返回到未连接状态,并准备好重新开始。

当讨论错误时,error信号携带一个指定错误原因的参数,该参数由枚举类型指定。适用于 TCP 套接字的不同问题如下所列(如果您想要一个人类可读版本的错误,您可以使用errorString方法,它返回一个描述问题的QString):

  • QAbstractSocket::ConnectionRefusedError:连接被远程主机拒绝或超时。
  • QAbstractSocket::RemoteHostClosedError:远程主机关闭连接。
  • QAbstractSocket::HostNotFoundError:找不到指定的主机。
  • QAbstractSocket::SocketAccessError:由于安全限制,操作无法进行。
  • QAbstractSocket::SocketResourceError:无法创建套接字。操作系统通常会限制同时打开的套接字的数量。
  • QAbstractSocket::SocketTimeoutError:套接字超时。
  • QAbstractSocket::NetworkError:网络导致的错误。例如,连接丢失或电缆断开。
  • QAbstractSocket::UnsupportedSocketOperationError:当前操作系统不支持套接字操作(可能是因为操作系统不支持 IPv6,这样的地址正在被使用)。
  • QAbstractSocket::UnknownSocketError:发生了无法识别的错误。

现在返回到图像下载客户端应用程序。如果一切顺利,当用户点击了获取图像按钮,并且建立了连接,QTcpSocket对象将开始发出readyRead信号。

这会导致调用tcpReady槽。插槽的实现可以在清单 14-26 中看到。该插槽可以说是在两种模式下工作。如果dataSize是零,它检查是否至少有四个字节(一个quint32的大小)可以从套接字读取。(套接字为此提供了bytesAvailable方法。)

当四个字节可用时,设置一个QDataStream从套接字读取。您可以确保流使用与服务器相同的版本。如果不这样做,可能会遇到流数据被曲解的奇怪问题。当流建立后,您读取四个字节,并将它们放在dataSize变量中。

参考清单 14-22 中的run方法;您可以看出dataSize变量包含了生成您所等待的图像的字节数。你所要做的就是等待那个字节数的到来。

一旦dataSize被设置为一个值,就将其与 socket 对象的bytesAvailable方法返回的值进行比较。继续这样做,直到你知道整个图像已经到达。

下一步是从接收到的数据创建一个QImage对象。如您所知,图像是以 PNG 文件的形式传输的。因为 PNG 格式是压缩的,所以要传输的数据量最小。

要从数据制作图像,首先将数据读入QByteArray。该数组放在一个QBuffer中,您可以使用一个QImageReader从其中读取图像。然后检查结果QImage是否有效(也就是说,isNull返回false)。

如果图像有效,使用QLabel显示;否则,显示使用QLabel的错误信息。不管结果如何,重新启用“获取图像”按钮,以便用户可以尝试下载另一个图像。

清单 14-26。 处理收到的数据

void ClientDialog::tcpReady()

{

  if( dataSize == 0 )

  {

    QDataStream stream( &socket );

    stream.setVersion( QDataStream::Qt_4_0 );

    if( socket.bytesAvailable() < sizeof(quint32) )

      return;

    stream >> dataSize;

  }

  if( dataSize > socket.bytesAvailable() )

    return;

  QByteArray array = socket.read( dataSize );

  QBuffer buffer(&array);

  buffer.open( QIODevice::ReadOnly );

  QImageReader reader(&buffer, "PNG");

  QImage image = reader.read();

  if( !image.isNull() )

  {

    ui.imageLabel->setPixmap( QPixmap::fromImage( image ) );

    ui.imageLabel->clear();

  }

  else

  {

    ui.imageLabel->setText( tr("<i>Invalid image received!</i>") );

  }

    ui.getButton->setEnabled( true );

}

只要一切按计划进行,先前讨论的都是有效的。当你与网络打交道时,你会发现事情并不总是按照你想要的方式发展。随着不如电缆连接可靠的无线连接变得越来越普遍,这种情况会更频繁地发生。

如果出现错误,就会调用清单 14-27 中所示的tcpError插槽。该槽简单地显示了用QMessageBox::warning描述错误的可读字符串。然后,它重新启用“获取图像”按钮,以便用户可以重试。

但是,有一个错误被忽略了:当连接被主机关闭时。您不希望为此显示错误消息,因为这是服务器传输图像时发生的情况,它会关闭连接。

清单 14-27。 tcpError 插槽

void ClientDialog::tcpError( QAbstractSocket::SocketError error )

{

  if( error == QAbstractSocket::RemoteHostClosedError )

    return;

  QMessageBox::warning( this, tr("Error"),

                        tr("TCP error: %1").arg( socket.errorString() ) );

  ui.imageLabel->setText( tr("<i>No Image</i>") );

  ui.getButton->setEnabled( true );

}

关于图像应用的进一步思考

整个系统由客户机和服务器组成,Qt 负责连接它们的许多细节。让我们快速看一下使用的类。

看服务器;您会看到接受传入请求并打开一个QTcpSocket进行响应的任务是由QTcpServer类处理的。在继承了QTcpServerServer类中,为每个传入请求创建一个线程,这样在应答早期连接的同时可以接受更多的传入连接。这将增加服务器的吞吐量,只要运行它的计算机有能力处理所有的连接。

风险在于服务器可能会过于频繁地连接,以至于耗尽内存。这将导致内存交换,增加处理每个连接所需的时间,从而导致更多线程同时处于活动状态,而可用的内存则更少。这不是 Qt 特有的问题,而是服务器过载时的反应方式。

客户端位于网络的另一端。使用QTcpSocket很容易连接到主机并接收数据。因为QTcpSocket是一个QIODevice,所以可以使用流和其他类从套接字读取。

最后,您可以看到 Qt 简化了 TCP 连接两端的实现。剩下要实现的代码是指定要使用的协议的代码;这是您在使用 Qt 的 TCP 类时希望能够关注的代码。

使用 UDP 广播图片

虽然 UDP 的可靠性或缺乏可靠性可能会使您认为它不太适合基于网络的应用程序开发,但是您可能会惊讶地发现这种方法有几个优点。也许最值得注意的是,发送方和接收方的联系不那么紧密,这意味着可以同时向几个接收方广播数据。这是你试用QUdpSocket类时会看到的。

这个想法是向服务器子网内的所有客户端逐行广播图像。客户端只是监听发送到预定端口(在本例中是 9988)的数据报消息。每个数据报都是一个独立的数据包,包含一行图像所需的所有数据。当接收到一行时,客户端通过添加新行来更新图像的内部副本。

因为服务器不知道客户端,而客户端只是监听端口,所以它们之间没有真正的联系。服务器可以独立于客户机启动和停止,并且可以有任意数量的客户机监听同一个服务器。

图 14-6 显示了运行中的客户端应用程序。图像没有被完全接收,并且服务器以有限的速度以随机的顺序传输线条,因此需要一段时间来完成图像。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

**图 14-6。**UPC 客户端应用

您在 UDP 上使用的协议由包含一行图形数据的数据报组成。数据报包含正在广播的图像的尺寸,因此客户端可以判断它们是否需要调整大小以及当前数据报包含哪一行——y 坐标后跟该行每个像素的红色、绿色和蓝色值。图 14-7 显示了每条传输数据所使用的数据类型。该协议还确定数据通过 9988 端口发送。


提示您可能需要打开防火墙才能广播到本地网络的 9988 端口。请注意,您需要打开 UDP 端口 9988,而不是相同号码的 TCP 端口。


外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14-7。 包含一行图像的数据报的结构

创建客户端

客户端由一个小部件类组成:Listener。它继承了QLabel,所以可以显示文字和图片。它还包含一个用于监听传入数据报的QUdpSocket和一个用于保存正在接收的图像的本地副本的QImage。整个类声明可以在清单 14-28 中看到。在清单中,您可以看到该类包含一个插槽、dataPending和一个构造器。

清单 14-28。Listener类声明

class Listener : public QLabel

{

  Q_OBJECT

public:

  Listener( QWidget *parent=0 );

private slots:

  void dataPending();

private:

  QUdpSocket *socket;

  QImage *image;

};

让我们从查看构造器开始研究实现(见清单 14-29 )。它主要做三件事:在等待第一个数据报到达时设置一个要显示的文本,将image变量初始化为零,并设置 UDP 套接字。

UDP 套接字是QUdpSocket类的一个实例,可以用来实现监听器和发送器。对于侦听,将套接字绑定到一个端口(在本例中为 9988)。当绑定到端口时,套接字将接收发送到该端口的数据报。当它接收到这样一个数据报时,它可以被读取,所以它发出readyRead信号。该信号连接到Listener级的dataPending插槽。

清单 14-29。 监听传入的数据报

Listener::Listener( QWidget *parent ) : QLabel( parent )

{

  setText( "Waiting for data." );

  image = 0;

  socket = new QUdpSocket( this );

  socket->bind( 9988 );

  connect( socket, SIGNAL(readyRead()), this, SLOT(dataPending()) );

}

在清单 14-30 中显示的dataPending插座由一个用于清空插座的while回路组成。里面是处理每个数据报的代码;之后是用于更新显示图像的代码。

只要套接字的hasPendingDatagrams方法返回true,循环就会运行。当该方法返回true时,可以使用pendingDatagramSize方法获得第一个未决数据报的大小。要读取数据报,使用readDatagram方法。您可以使用这两种方法首先创建一个大小合适的QByteArray,然后将数据报的内容读入字节数组。

当数据报在字节数组中时,继续创建一个用于从数组中读取的QDataStream对象。还要确保调用setVersion来确保用不同 Qt 版本编译的客户机和服务器仍然可以一起工作。一旦数据流建立起来,就该开始解释你刚刚收到的大量数据了。

根据图 14-7 中的,如果假设数据报包含数据,从读取流中的三个quint16变量开始:widthheight和 y

接下来就是看你有没有一个QImage对象;如果没有,请创建一个新的。如果您有一个,请确保它的尺寸与收到的图像相对应。如果没有,删除它并创建一个具有正确尺寸的新的。

最后一步由一个for循环组成,在这个循环中,读取每个像素的三个quint8变量——redgreenblue——然后使用setPixel方法将相应的像素设置为该颜色。

hasPendingDatagrams方法不再返回true时,清除显示的文本,显示收到的QImage。调用resize以确保小部件的大小对应于图像的大小。

您可以使用QImage来保存缓冲的图像,因为您知道它使用每像素 24 位来存储图像。(这是在通过沿宽度和高度传递QImage::Format_RGB32标志来创建QImage对象时指定的。)方法setPixmap需要一个QPixmap对象,所以您必须使用静态方法QPixmap::fromImageQImage转换为QPixmap

当未决数据报队列清空时,更新显示图像的解决方案假定您可以比数据报到达更快地处理数据报;否则,显示的图像将不会更新。一个技巧是使用一个计数器来确保每 10 行左右更新一次显示的图像。查看服务器,了解为什么在这种情况下不需要它。

清单 14-30。 处理到达的数据报

void Listener::dataPending()

{

  while( socket->hasPendingDatagrams() )

  {

    QByteArray buffer( socket->pendingDatagramSize(), 0 );

    socket->readDatagram( buffer.data(), buffer.size() );

    QDataStream stream( buffer );

    stream.setVersion( QDataStream::Qt_4_0 );

    quint16 width, height, y;

    stream >> width >> height >> y;

    if( !image )

      image = new QImage( width, height, QImage::Format_RGB32 );

    else if( image->width() != width || image->height() != height )

    {

      delete image;

      image = new QImage( width, height, QImage::Format_RGB32 );

    }

    for( int x=0; x<width; ++x )

    {

      quint8 red, green, blue;

      stream >> red >> green >> blue;

      image->setPixel( x, y, qRgb( red, green, blue ) );

    }

  }

  setText( "" );

  setPixmap( QPixmap::fromImage( *image ) );

  resize( image->size() );

}

这是客户端小部件所需的所有代码。该应用程序由这个小部件和一个简单的main函数组成,该函数显示了小部件的一个实例。

创建服务器

服务器简单地从图像test.png发送随机行,该图像必须位于启动服务器时使用的工作目录中。该应用程序由一个执行实际广播的类(称为Sender)和一个最小的main函数组成。

Sender类的声明如清单 14-31 所示。该类继承了QObject,这意味着它没有用户界面(它会直接或间接地继承QWidget)。该类继承了QObject,因为它有一个槽。

broadcastLine槽用于广播图像的单行。该类保存由image指向的QImage对象中的图像。广播插座是由socket指向的QUdpSocket。除了插槽和两个指针,该类还包含一个构造器。

清单 14-31。 服务器的类声明

class Sender : public QObject

{

  Q_OBJECT

public:

  Sender();

private slots:

  void broadcastLine();

private:

  QUdpSocket *socket;

  QImage *image;

};

清单 14-32 中的所示的构造器由三部分组成。首先创建套接字;然后加载图像。如果图像没有加载,isNull返回true。在这种情况下,您可以使用qFatal来报告它,这将结束应用程序。

如果图像加载正确,继续设置一个QTimer对象。计时器的timeout信号连接到broadcastLine插槽。计时器的目的是限制每 250 毫秒向一条线路发送数据的速率,这意味着每秒四条线路。

清单 14-32。 开始广播

Sender::Sender()

{

  socket = new QUdpSocket( this );

  image = new QImage( "test.png" );

  if( image->isNull() )

    qFatal( "Failed to open test.png" );

  QTimer *timer = new QTimer( this );

  timer->setInterval( 250 );

  timer->start();

  connect( timer, SIGNAL(timeout()), this, SLOT(broadcastLine()) );

}

每当定时器超时,就会调用broadcastLine。插槽的源代码显示在清单 14-33 中。当你查看代码时,回想一下图 14-7 中所示的数据报描述。

调用该槽时发生的第一件事是分配一个QByteArray作为缓冲区。可以从图像宽度计算出数组的大小。图像和 y 坐标的尺寸消耗六个字节;然后实际数据每像素需要三个字节,所以需要6+3*image->width()个字节。设置一个用于写入缓冲区的QDataStream,并设置流的版本以匹配客户端使用的流的版本。

下一步是在使用qrand决定广播哪一行之前,将图像的尺寸添加到流中。当您知道使用哪一行时,也将 y 坐标添加到流中。


注意因为你使用qrand而没有使用qsrand给随机数发生器一个种子,所以每次服务器运行时,图像线将以相同的伪随机顺序广播。


使用for循环将每个像素的红色、绿色和蓝色值添加到流中。您使用pixel方法获得QImage的每个像素的QRgb值。然后使用qRedqGreenqBlue函数获得QRgb值的红色、绿色和蓝色部分。

当给定行的所有像素值都被添加到流中时,就可以使用QUdpSocket对象广播整个QByteArray缓冲区了。使用writeDatagram方法可以做到这一点,该方法试图将整个给定的字节数组作为数据报发送到给定的地址和端口。清单 14-33 中显示的代码使用QHostAddress::Broadcast作为主机地址和端口 9988,因此数据将被发送到与服务器在同一子网中的所有客户机上的端口 9988。

清单 14-33。 广播单线

void Sender::broadcastLine()

{

  QByteArray buffer( 6+3*image->width(), 0 );

  QDataStream stream( &buffer, QIODevice::WriteOnly );

  stream.setVersion( QDataStream::Qt_4_0 );

  stream << (quint16)image->width() << (quint16)image->height();

  quint16 y = qrand() % image->height();

  stream << y;

  for( int x=0; x<image->width(); ++x )

  {

    QRgb rgb = image->pixel( x, y );

    stream << (quint8)qRed( rgb ) << (quint8)qGreen( rgb ) << (quint8)qBlue( rgb );

  }

  socket->writeDatagram( buffer, QHostAddress::Broadcast, 9988 );

}

清单 14-34 中的函数使用了Sender类。使用QMessageBox::information创建Sender对象,然后显示一个对话框。当对话框打开时,Sender对象中的QTimer触发广播。用户一关闭对话框,main功能就结束,Sender对象和QTimer一起被销毁,广播停止。这提供了一种创建易于关闭的服务器的好方法。

清单 14-34。main广播器的功能

int main( int argc, char **argv )

{

  QApplication app( argc, argv );

  Sender sender;

  QMessageBox::information( 0, "Info", "Broadcasting image" );

  return 0;

}

关于 UDP 的最终想法

要测试 UDP 服务器和客户端,请独立地启动和停止这两个应用程序。然后,您将看到客户机和服务器是真正独立的。一旦服务器开始广播,客户端将开始接收。一旦客户端启动,它也开始接收。双方都不在乎对方是否活跃。

虽然客户机和服务器一样非常简单,但是结束图像会很有帮助,这样每个客户机都会知道它何时收到了完整的图像。

从整体来看,协议才是最重要的。现在,您一次只能广播一个图像(也许应该在每个数据报前添加一个唯一的图像标识符值,以便一次可以广播多个图像)。通过在每个数据报的末尾发送整个图像的校验和,客户端可以确保在看到整个图像时拥有正确的图像(或者可以丢弃校验和不正确的数据报)。

同样重要的是,要考虑如果网络连接关闭后又重新打开会发生什么。这对客户端接收的数据有什么影响,更重要的是,客户端如何将数据呈现给用户?因为 UDP 协议不保证任何数据到达,也不保证哪些数据或以什么顺序到达,所以在设计数据报的内容时考虑这些限制是很重要的。

总结

使用 Qt 的网络模块时,您可以选择想要控制操作的级别。如果您只需要获取文件或发出可以通过 FTP 或 HTTP 处理的请求,请使用QHttpQFtp类。这些类负责许多细节,并为您提供高级操作。例如,QHttp提供setHostgetQFtp为您提供connectToHostlogingetput

当使用这些类时,您可以监听done信号,然后对布尔参数做出反应。如果是true,则发生了错误;除此之外,一切正常。如果发生了错误,您会从errorString获得一个文本呈现给用户。

如果您需要在较低层次上控制网络交互,Qt 提供了基于 TCP 和 UDP 的套接字类。虽然这两者之间的差异很多,超出了本书的范围,但每一个都可以大大简化:

  • TCP 适合在两台计算机之间建立会话,并以可靠的方式在它们之间传输数据。数据以流的形式传输。
  • UDP 适用于在计算机之间发送单独的数据包。发送方不需要知道接收方是否正在接收,接收方也不知道是否已经收到了所有发送的数据。数据以称为数据报的独立数据包的形式传输。

实现 TCP 服务器时,可以从QTcpServer类继承。简单地重新实现incomingConnection来处理新的连接。给定的整数参数是套接字描述符。将它传递给QTcpSocket类的构造器,以获得一个连接到传入连接的套接字。

要设置服务器监听端口,请使用listen方法。通过将QHostAddress::Any指定为主机地址,服务器将接受所有传入的连接。

从套接字描述符创建的服务器和客户机都使用一个QTcpSocket。在客户机中,使用connectToHost来指定要连接的服务器和端口。因为QTcpSocket继承自QIODevice类,所以您可以设置一个QDataStream(或QTextStream)来通过它所代表的连接发送和接收数据。

当实现 UDP 服务器时,从创建一个QUdpSocket开始。然后,您可以使用writeDatagram写入套接字。当实现一个客户端时,使用同一个类QUdpSocket,但是通过使用bind将它绑定到一个端口。每次数据报到达套接字绑定的端口时,它都会发出一个readyRead信号。然后,您可以使用readDatagram读取数据报。*************

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值