用Qt搭建一个简易的TCP客户端和服务器
你好! 这是本人的第一篇博客。如有错误,请批评指正。
简易理解TCP传输流程
什么是TCP协议,为什么要用TCP协议
TCP协议即transmission control protocal,即传输控制协议。是一种用于在应用程序之间进行信息传递的数据格式的协议。TCP协议并不是单独存在的,它还要结合网络层的IP协议使用。整个TCP/IP协议包括了IP协议,IMCP协议,TCP协议,以及我们更加熟悉的http、ftp、pop3协议等等。电脑有了这些协议就可以自由的和其他终端进行数据交流。而ip协议、TCP协议很像套在数据上的一层衣服,是为了让其他计算机能够认识里边的东西。整个数据传输过程就很像俄罗斯套娃,先给数据一层一层套娃,把套好的数据发送给另一个终端之后,在另一个终端那儿再进行一层一层解套,最终得到我们想要的数据。
TCP协议报文格式
要理解TCP协议的传输过程,我们得先了解TCP是如何给数据进行套娃的,也即数据是如何被包装的。所以接下来要了解TCP的报文格式(先上个图):
1)源端口号
2)目标端口号
3)序列号:因为在TCP是面向字节流的,他会将报文都分成一个个字节,给每个字节进行序号编写,比如一个报文有900个字节组成,那么就会编成1-900个序号,然后分几部分来进行传输。比如第一次传,序列号就是1,传了50个字节, 那么第二次传,序列号就为51,所以序列号就是传输的数据的第一个字节相对所有的字节的位置。
4)确认应答:如刚说的例子,第一次传了50个字节给对方,对方也会回应你,其中带有确认应答,就是告诉你下一次要传第51个字节来了,所以这个确认应答就是告诉对方要传第多少个字节了
5)首部长度:就是首部的长度,
6)保留:给以后有需要在用,这个保留的位置放的东西是跟控制位类似的
7)控制位:目前有的控制位为6个
URG:紧急控制位,当URG为1时,表名紧急指针字段有效,标识该报文是一个紧急报文,传送到目标主机后,不用排队,应该让该报文尽量往下排,让其早点让应用程序给接受。
ACK:确认序号控制位,当ACK为1时,确认序号ack才有效。当ACK为0时,确认序号没用。
PSH:推送控制位,当为1时,当遇到此报文时,会减少数据向上交付,本来想应用进程交付数据是要等到一定的缓存大小才发送的,但是遇到它,就不用在等足够多的数据才向上交付,而是让应用进程早点拿到此报文,这个要和紧急分清楚,紧急是插队,但是提交缓存大小的数据不变,这个推送就要排队,但是遇到他的时候,会减少交付的缓存数据,提前交付。
RST:复位控制位,报文遇到很严重的差错时,比如TCP连接出错等,会将RST置为1,然后释放连接,全部重新来过。
SYN:同步控制位,在进行连接的时候,也就是三次握手时用得到,下面会具体讲到,配合ACK一起使用
FIN:终止控制位,在释放连接时,也就是四次挥手时用的。
8)窗口:指发送报文段一方的接受窗口大小,用来控制对方发送的数据量(从确认号开始,允许对方发送的数据量)。也就是后面需要讲的滑动窗口的窗口大小
9)检验和:检验首部和数据这两部分,和UDP一样,需要拿到伪首部中的数据来帮助检测
10)选项:长度可变,介绍一种选项,最大报文段长度MSS。 能够告诉对方TCP,我的缓存能接受报文段的数据字段的最大长度是MSS个字节。如果没有使用选项,那么首部固定是20个字节。
11)填充:就是为了让其成为整数个字节
TCP建立连接时的三次握手过程
TCP客户端和服务器建立连接的三次握手,其实就是客户端和服务器之间总共发送了三个包。
第一次握手:
由客户端先发送一个SYN包给服务器,将标志位SYN置为1,随机生成一个seq=j。发送后客户端进入SYN_SENT状态,等待服务器端确认。
第二次握手:
服务器端接收到客户端发来的SYN包后,检验SYN是否为1,SYN为1则知道了客户端想建立连接,随即将数据包标志位SYN和ACK都置为1,ACK为1表示服务器端确认收到了客户端的包数据。服务器端会随机产生一个seq=k,并且会将从客户端收到的序列号加1,作为确认序号ack再发回给客户端以确认连接请求。此时,服务器端将进入SYN_RCVD状态。
第三次握手:
客户端在收到数据包后,检验确认序号是否为j+1,ACK是否为1。若检验正确则将ACK置为1,ack设置为k+1。并将该数据包发送给服务器端,服务器端检查ack是否为k+1,ACK是否为1,如果正确则连接建立成功,服务器端和客户端进入ESTABLISHED状态,完成三次握手,随后客户端与服务器端之间就可以开始传输数据了。
TCP关闭连接的四次挥手
断开一个TCP连接时,需要客户端和服务端总共发送4个包以确认连接的断开。
第一次挥手:
客户端发送一个FIN包,用来关闭客户端到服务器的数据传送,也就是客户端告诉服务器:我已经不 会再给你发数据了(当然,在fin包之前发送出去的数据,如果没有收到对应的ack确认报文,客户端依然会重发这些数据),但是,此时客户端还可以接受数据。FIN=1,其序列号为seq=u(等于前面已经传送过来的数据的最后一个字节的序号加1),此时,客户端进入FIN-WAIT-1(终止等待1)状态。 TCP规定,FIN报文段即使不携带数据,也要消耗一个序号。
第二次挥手:
服务器收到FIN包后,发送一个ack给对方并且带上自己的序列号seq=k,确认序号ack为收到序号+1(u+1)。此时,服务端就进入了CLOSE-WAIT(关闭等待)状态。TCP服务器通知高层的应用进程,客户端向服务器的方向就释放了,这时候处于半关闭状态,即客户端已经没有数据要发送了,但是服务器若发送数据,客户端依然要接受。这个状态还要持续一段时间,也就是整个CLOSE-WAIT状态持续的时间。
这段状态结束后,客户端就进入FIN-WAIT-2(终止等待2)状态,等待服务器发送连接释放报文(在这之前还需要接受服务器发送的最后的数据)。
第三次挥手:
服务器发送一个FIN,用来关闭服务器到客户端的数据传送,也就是告诉客户端,我的数据也发送完了,不会再给你发数据了。由于在半关闭状态,服务器很可能又发送了一些数据,假定此时的序列号为seq=w,此时,服务器就进入了LAST-ACK(最后确认)状态,等待客户端的确认。
第四次挥手:
主动关闭方收到FIN后,将ACK置1,发送一个ack给被动关闭方,确认序号为收到序号+1(即w+1),此时,客户端就进入了TIME-WAIT(时间等待)状态。注意此时TCP连接还没有释放,必须经过2∗MSL(最长报文段寿命)的时间后,当客户端撤销相应的TCB后,才进入CLOSED状态。
服务器只要收到了客户端发出的确认,立即进入CLOSED状态。同样,撤销TCB后,就结束了这次的TCP连接。可以看到,服务器结束TCP连接的时间要比客户端早一些。
至此,完成四次挥手。
服务器端搭建
本次实验的开发环境是Qt5.1.12。本实验搭建TCP服务器/客户端主要利用sockect编程。
在服务器端的程序中,我们本地主机的一个端口,这里使用6666,然后关联newConnection()
信号与自己写的sendMessage()
槽。就是说一旦有客户端的连接请求,就会执行sendMessage()
函数,在这个函数里我们发送一个简单的字符串。
1.新建QtGui应用
项目名为tcpServer,基类选择QWidget
,类名为Widget
。完成后打开项目文件tcpServer.pro
并添加一行代码:QT += network
,然后保存该文件。
2.在widget.ui的设计区添加一个Label,更改其显示文本为“等待连接”,然后更改其objectName
为statusLabel
,用于显示一些状态信息。设计效果如下:
3.在widget.h
文件中做以下更改。
添加头文件:#include <QtNetWork>
添加private对象:
QTcpServer *tcpServer;
添加私有槽:
private slots:
void sendMessage();
4.在widget.cpp文件中进行更改。
在其构造函数中添加代码:
tcpServer = new QTcpServer(this);
if(!tcpServer->listen(QHostAddress::LocalHost,6666))
{ //本地主机的6666端口,如果出错就输出错误信息,并关闭
qDebug() << tcpServer->errorString();
close();
}
//连接信号和相应槽函数
connect(tcpServer,SIGNAL(newConnection()),this,SLOT(sendMessage()));
我们在构造函数中使用tcpServer的listen()
函数进行,然后关联了newConnection()
和我们自己的sendMessage()
函数。
下面我们实现sendMessage()
函数。
void Widget::sendMessage(){
//用于暂存我们要发送的数据
QByteArray block;
//使用数据流写入数据
QDataStream out(&block,QIODevice::WriteOnly);
//设置数据流的版本,客户端和服务器端使用的版本要相同
out.setVersion(QDataStream::Qt_4_6);
out<<(quint16) 0;
out<<tr("测试消息!!!");
out.device()->seek(0);
out<<(quint16) (block.size() - sizeof(quint16));
//我们获取已经建立的连接的子套接字
QTcpSocket *clientConnection = tcpServer->nextPendingConnection();
connect(clientConnection,SIGNAL(disconnected()),clientConnection,
SLOT(deleteLater()));
clientConnection->write(block);
clientConnection->disconnectFromHost();
//发送数据成功后,显示提示
ui->statusLabel->setText("send message successful!!!");
}
对于sendMessage()
函数:
(1)为了保证在客户端能接收到完整的文件,我们都在数据流的最开始写入完整文件的大小信息,这样客户端就可以根据大小信息来判断是否接受到了完整的文件。而在服务器端,在发送数据时就要首先发送实际文件的大小信息,但是,文件的大小一开始是无法预知的,所以这里先使用了out<<(quint16) 0
;在block
的开始添加了一个quint16
大小的空间,也就是两字节的空间,它用于后面放置文件的大小信息。然后out<<tr("hello Tcp!!!");
输入实际的文件,这里是字符串。当文件输入完成后我们再使用out.device()->seek(0);
返回到block
的开始,加入实际的文件大小信息,也就是后面的代码,它是实际文件的大小:out<<(quint16) (block.size() - sizeof(quint16));
(2)在服务器端我们可以使用tcpServer
的nextPendingConnection()
函数来获取已经建立的连接的Tcp套接字,使用它来完成数据的发送和其它操作。比如这里,我们关联了disconnected()
信号和deleteLater()
槽,然后我们发送数据
clientConnection->write(block);
然后是
clientConnection->disconnectFromHost();
它表示当发送完成时就会断开连接,这时就会发出disconnected()
信号,而最后调用deleteLater()
函数保证在关闭连接后删除该套接字clientConnection
。
客户端搭建
我们在客户端程序中向服务器发送连接请求,当连接成功时接收服务器发送的数据。
1.新建Qt Gui应用,
项目名tcpClient,基类选择QWidget
,类名为Widget
。完成后打开项目文件tcpClient.pro
并添加一行代码:QT += network
,然后保存该文件。
2.我们在widget.ui
中添加几个标签Label
和两个Line Edit
以及一个按钮Push Button
。设计效果如下图所示。
其中“主机”后的LineEdit
的objectName
为hostLineEdit,“端口号”后的为portLineEdit
。
“收到的信息”标签的objectName
为messageLabel
。
3.在widget.h
文件中做更改。
添加头文件:#include <QtNetwork>
添加private变量:
QTcpSocket *tcpSocket;
QString message; //存放从服务器接收到的字符串
quint16blockSize; //存放文件的大小信息
添加私有槽:
private slots:
void newConnect(); //连接服务器
void readMessage(); //接收数据
void displayError(QAbstractSocket::SocketError); //显示错误
4.在widget.cpp
文件中做更改。
(1)在构造函数中添加代码:
tcpSocket = new QTcpSocket(this);
connect(tcpSocket,SIGNAL(readyRead()),this,SLOT(readMessage()));
connect(tcpSocket,SIGNAL(error(QAbstractSocket::SocketError)),
this,SLOT(displayError(QAbstractSocket::SocketError)));
这里关联了tcpSocket的两个信号,当有数据到来时发出readyRead()
信号,我们执行读取数据的readMessage()
函数。当出现错误时发出error()
信号,我们执行displayError()
槽函数。
(2)实现newConnect()
函数。
void Widget::newConnect()
{
blockSize = 0; //初始化其为0
tcpSocket->abort(); //取消已有的连接
//连接到主机,这里从界面获取主机地址和端口号
tcpSocket->connectToHost(ui->hostLineEdit->text(),
ui->portLineEdit->text().toInt());
}
这个函数实现了连接到服务器,下面会在“连接”按钮的单击事件槽函数中调用这个函数。
(3)实现readMessage()
函数。
void Widget::readMessage()
{
QDataStream in(tcpSocket);
in.setVersion(QDataStream::Qt_4_6);
//设置数据流版本,这里要和服务器端相同
if(blockSize==0) //如果是刚开始接收数据
{
//判断接收的数据是否有两字节,也就是文件的大小信息
//如果有则保存到blockSize变量中,没有则返回,继续接收数据
if(tcpSocket->bytesAvailable() < (int)sizeof(quint16)) return;
in >> blockSize;
}
if(tcpSocket->bytesAvailable() < blockSize) return;
//如果没有得到全部的数据,则返回,继续接收数据
in >> message;
//将接收到的数据存放到变量中
ui->messageLabel->setText(message);
//显示接收到的数据
}
这个函数实现了数据的接收,它与服务器端的发送函数相对应。首先我们要获取文件的大小信息,然后根据文件的大小来判断是否接收到了完整的文件。
(4)实现displayError()
函数。
void Widget::displayError(QAbstractSocket::SocketError)
{
qDebug() << tcpSocket->errorString(); //输出错误信息
}
这里简单的实现了错误信息的输出。
(5)我们在widget.ui
中进入“连接”按钮的单击事件槽函数,然后更改如下。
void Widget::on_pushButton_clicked() //连接按钮
{
newConnect(); //请求连接
}
这里直接调用了newConnect()
函数。