Qt使用socket单端口监听多台设备策略
项目背景:客户端设备启动后便会一直向指定ip和端口下发送信息,每次发送的时间间隔1秒,发送的内容包括设备的配置内容。服务端要求编写代码,通过socket与多台客户端设备建立长连接,持续地获取客户端发送的消息,分别解析它们然后在前端显示。此外,服务端设备还需要可以选择设备定向发送命令,从而操控该设备。
项目环境:Linux系统 + Qt前、后端 + sqlite数据库
解决思路:因为需要连接到多台设备,因此需要使用多线程,每个子线程分别处理一台客户端设备发送的内容。每当服务端获取到客户端发来的连接请求时,就新建一个线程,并分配一个socket。创建完线程后,解析到信息并在数据库创建条目,保存客户端配置内容。
*我这里还在数据库建了一个socket 的id字段,因为发现新设备后,前端就会创建一个新的下拉栏,存在于下拉栏中的旧设备不做排序,但socket是按照连接顺序分配的,因此下拉栏的索引很可能与socket id不一致。因此socket id与解析到的主键相对应,服务端收到信息后先解析到主键,分配socket id。下拉栏按照主键的内容解析socket id,从而解决了匹配不一致的问题。
理清解决思路后,首先搞定socket连接的问题。在Qt中,首先要在.pro文件中添加:
QT += network
然后在代码中包含库:
#include <QTcpServer>
#include <QTcpSocket>
头文件中需要声明:
QTcpServer* server;
QTcpSocket* socket[MAX_CONNECTION];
int socketId = -1;
server只需要一个,但socket需要很多个。这里创建数组用来给连接进来的客户端分配socket id。
然后在构造函数里添加:
server = new QTcpServer();
connect(server,&QTcpServer::newConnection,this,&MainWindow::server_New_Connect);
void MainWindow::server_New_Connect()
{
socketId++;
int sid = socketId;
//每有一个新连接,就新建一个线程
QtConcurrent::run([=]()
{
//获取客户端连接
socket[socketId] = server->nextPendingConnection();
//连接QTcpSocket的信号槽,以读取新数据
QObject::connect(socket[sid], &QTcpSocket::readyRead, this, [=](){socket_Read_Data(sid);});
QObject::connect(socket[sid], &QTcpSocket::disconnected, this, [=](){socket_Disconnected(sid);});
qDebug() << "A Client connect!";
});
}
这里使用concurrent实际上是使用了并发,每当获取到新的连接时,相当于都开启了一个线程动态处理请求。获取到请求后socket id加1,然后将其分配给该连接,用信号槽关联到读取数据和断开连接函数。为了传递socket id参数,在信号槽里使用lambda。
读取数据和断开连接的slot如下:
void MainWindow::socket_Read_Data(int socketId)
{
QByteArray buffer;
//读取缓冲区数据
buffer = socket[socketId]->readAll();
if(!buffer.isEmpty())
{
QByteArray buf = read_str(buffer);
QString str = QString(buf);
//刷新显示
ui->textEdit_Recv->setText(str);
}
}
void MainWindow::socket_Disconnected(int socketId)
{
qDebug() << "Disconnected!";
}
这里通过socket id将多个线程里读取到的内容分离,然后发送给前端去做处理。
网络部分的读取到这就搞定了,然后是写。
void MainWindow::on_pushButton_Send_clicked()
{
qDebug() << "Send: " << ui->textEdit_Send->text();
//...
socket[socketId]->write(ui->textEdit_Send->text().toUtf8());
socket[socketId]->flush();
}
通过前端拿到当前的socket id,然后使用write以ASCII码形式发送数据,发送完后使用flush立刻清空缓冲区。
*前端返回的socket id与combobox索引的匹配需要重点考虑,因为socket id是按照建立连接的顺序分配的,但combobox的索引就不一定了,我使用数据库来进行匹配,当然可能还有更好的方法。
接下来讲数据库。数据库负责记录每个客户端的信息,每当新连接建立,且解析到的信息属于未识别过的机器时,就在数据库里新增条目。当保持连接时,前端的数据来源是socket解析内容;连接断开时,数据来源就只能是数据库了。
要在Qt中使用数据库,同样要在.pro文件里添加:
QT += sql
然后在代码中包含库:
#include <QSqlDatabase>
#include <QSqlError>
#include <QSqlQuery>
声明私有成员:
QSqlDatabase database;
接下来是正式实现。应用启动时要检查数据库文件,并打开和关联到指定数据库,代码如下:
void MainWindow::initDB()
{
//建立并打开数据库
database = QSqlDatabase::addDatabase("QSQLITE");
database.setDatabaseName("server.db");
if (!database.open()){
qDebug() << "Error: Failed to connect database." << database.lastError();
return;
}
else{
qDebug() << "Succeed to connect database.";
QSqlQuery sql_query;
//创建ClientInfo表
if(!sql_query.exec("CREATE TABLE IF NOT EXISTS ClientInfo(maca CHAR(20) PRIMARY KEY NOT NULL,name CHAR(20),skid INT)"))
{
qDebug() << sql_query.lastError();
}
}
}
这里的skid就是socket id,建立连接后要更新一次这个条目,作用是使socket id和主键相对应,前端得到主键内容就可以知道所属的socket id,非常方便。更新的次数要有所控制,毕竟是数据库操作,太频繁了难免会阻塞。
每当解析socket,并识别到新用户时,就向数据库中添加新的条目。
QString MainWindow::dbInfoInsert(QString content)
{
QSqlQuery sql_query;
//查询是否是已存在用户
QString str = QString("SELECT COUNT(*) FROM ClientInfo WHERE maca='%1'").arg(content);
if(!sql_query.exec(str)){
qDebug() << sql_query.lastError();
}
else{
sql_query.next();
int num = sql_query.value(0).toInt();
if(num != 0){ //既有用户
return NULL;
}
else{
//新增用户
QString str = QString("INSERT INTO ClientInfo(maca) VALUES('%1')").arg(content);
if(!sql_query.exec(str)){
qDebug() << sql_query.lastError();
}
else{
qDebug() << "New Client Inserted.";
return content;
}
}
}
}
收到信息后同时也要更新一次对应的数据库信息。
//更新条目
QSqlQuery sql_query;
QString str = QString("UPDATE ClientInfo SET %1='%2', %3='%4' WHERE %5='%6'").arg(type_list[1]).arg(read_list[1]).arg(type_list[2]).arg(read_list[2]).arg(type_list[0]).arg(read_list[0])
if(!sql_query.exec(str)){
qDebug() << sql_query.lastError();
}
离线状态也要显示信息,那么就需要从数据库查找。
QSqlQuery sql_query;
QString str = QString("SELECT maca,name FROM ClientInfo WHERE maca='%1'").arg(item);
if(!sql_query.exec(str)){
qDebug() << sql_query.lastError();
}
else{
if(sql_query.next()){
QString maca = sql_query.value(0).toString();
QString name = sql_query.value(1).toString();
ui->macLab->setText(maca);
ui->nameLab->setText(name);
}
}
到此,数据库需要的内容就完成了。
最后是前端的内容。前端需要利用网络解析到的信息,以及数据库保存的信息,动态地显示出每个客户端当前的连接状态和配置状态。因为需求千变万化,这里就简单讲一下我的实现。
在ui里将页面绘制完成,开始编写信号槽,其中最复杂的是qcombobox。它需要显示所有设备的关键信息,包括在线和不在线的;每当新设备加进来,它需要添加新条目;当选择一个条目时,它要负责socket id的切换。
首先是新用户添加,代码的位置是在socket报文解析之后,如果识别到新设备,就执行下面的代码。通过addItem为combobox添加条目。
QString MainWindow::comboBoxInsert(QString content)
{
QSqlQuery sql_query;
QString str = QString("SELECT name, maca FROM ClientInfo WHERE maca='%1'").arg(content);
if(!sql_query.exec(str)){
qDebug() << sql_query.lastError();
return NULL;
}
else{
if(sql_query.next()){
QString item1 = sql_query.value(0).toString();
QString item2 = sql_query.value(1).toString();
QString item = item2 + " " + item1;
ui->ClientComboBox->addItem(item);
return item;
}
}
}
然后为其添加槽函数,实现点击切换槽。
void MainWindow::on_ClientComboBox_currentIndexChanged(const QString &arg1)
{
if(ui->ClientComboBox->currentIndex() != 0){
int num = -1;
QString tmac = ui->ClientComboBox->currentText();
if(!connectionStatus[num]){
//离线状态也显示基础信息
QString full_item = ui->ClientComboBox->currentText();
QString item = full_item;
if(database.open()){
QSqlQuery sql_query;
QString str = QString("SELECT maca,name FROM ClientInfo WHERE maca='%1'").arg(item);
if(!sql_query.exec(str)){
qDebug() << sql_query.lastError();
}
else{
if(sql_query.next()){
QString maca = sql_query.value(0).toString();
QString name = sql_query.value(1).toString();
ui->macLab->setText(maca);
ui->nameLab->setText(name);
}
}
}
}
}
}
因为跟数据库连接,因此切换后即使是离线状态也可以显示基本信息。当然如果是在线状态,就直接通过socket解析内容来显示客户端信息。
此外,socket id与combobox索引的对应已在前文讲述,不再赘述。
其他的前端内容无非是字符串解析显示,发送内容编辑等等。在搭好网络和数据库的框架后,数据干净了,这些都是信手拈来了。
到这,整个平台基本就完成了。此外还需要制定一个与客户端交互的报文规则,这是一个双向的规则,用来进行字符串解析,这部分就根据项目要求来就好了。
打完收工。