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索引的对应已在前文讲述,不再赘述。

其他的前端内容无非是字符串解析显示,发送内容编辑等等。在搭好网络和数据库的框架后,数据干净了,这些都是信手拈来了。

到这,整个平台基本就完成了。此外还需要制定一个与客户端交互的报文规则,这是一个双向的规则,用来进行字符串解析,这部分就根据项目要求来就好了。

打完收工。