基于Linux + QT + TCP 前后台组合实现多人聊天工具。(项目分析)
项目功能说明
服务器端搭建完成之后,客户端通过QT网络框架实现。
登录功能
QT网络框架根据客户的IP和端口实现用户登录功能。
登录之后服务器端进行响应,客户端页面显示在线用户。
获取在线用户的列表
登陆完成之后通过数据包完成列表刷新。
和某个在线用户完成通信
客户–>服务器–>客户。
项目分析
项目实现局域网内点对点的通信,传输层使用TCP通信。
实现过程中我们使用到的模型是典型的C/S模型。
C/S模型,服务器端不断等待客户端发送请求。客户端发送一次请求,服务器端对应一次响应。如果客户端没有发送数据,服务器端不会主动向客户端发送数据。
C/S模型在很多网络间进程,在服务器与客户端的通信中经常都会采用C/S模型。
客户端使用Qt 里面的 QTcpsocket实现。
服务器端用原生 linux + 套接字实现。
项目功能分析
登录功能
登录的时候我们要获取凭证,例如ID,那么这个凭证我们如何获取呢?
首先客户端和服务器三次握手建立连接,建立连接完成之后客户端主动向服务器端发送一个数据。这个数据服务器需要识别为登录的信息。这个时候服务器端会给客户端分配一个唯一指定的在线用户ID用来后续进行点对点聊天。
概括:
客户端发送登录信息。
服务器端识别登录信息,分配一个唯一指定的在线用户ID,把这个ID号返回给对应的客户端。服务器端使用链表保存客户端的登录信息。
之后需要获得在线用户列表的时候,就可以直接在服务器端上的链表进行查询,把所有链表的数据查询出来返回到对应的客户端进行显示。
获取在线用户列表
去服务器端的链表里面,遍历一遍,把所有的在线用户ID号都返回给客户端。
概括:
客户端获取在线用户列表信息。
服务器遍历一遍链表,将在线用户ID号全部返回给客户端。
和某个在线用户完成通信
客户端把:
目的ID + 源ID + 数据内容
打包之后发送给服务器端。
服务器端通过识别解析目的ID把数据包发送给对应目标ID的客户端。
图解说明:
概括:
客户端打包发送:目的ID + 源ID + 数据内容 发送给服务器。
服务器端解析目的ID,遍历链表找到在线用户列表中和目的ID对应的套接字,转发数据包给目的ID对应的客户端。
对方在回复数据内容的时候只需要把源ID与目的ID交换之后发送给服务器,然后服务器把数据内容发送给目的ID对应的客户端即可。
实现难点
现在有一个问题,客户端在发送数据的时候,数据有不同的含义了。
例如客户端分别有:
客户端发送登录信息。
客户端获取在线用户列表信息。
客户端把:目的ID + 源ID + 数据内容 打包之后发送给服务器端。
所以客户端在发送的时候就要对于数据进行区分,这就涉及到应用层协议的设计。
通过指令区分
每个功能我们用不同的指令进行区分。
登录功能指令区分
例如客户端发送登录信息的时候分配一个指令为0x11。
0x11 这个指令就专门用来代表客户端发送登录信息的数据,然后服务器端进行应答。
服务器端进行应答的时候也要确定应答的是哪一个指令。
例如服务器端可以在应答包的开头加上 0x11 ID 表示服务器回应的是客户端发送的登录信息。
获取在线用户列表指令区分
例如客户端发送获取在线用户列表信息的时候分配一个指令为0x12这个指令就专门用来代表客户端发送获取在线用户列表的信息,然后服务器端进行应答。
服务器端进行应答的时候也要确定应答的是哪一个指令。
例如服务器端在应答包的开头加上 0x12 ID0 ID1 ID2 ……IDn 表示服务器端回应的是客户端发送过来的获取在线用户列表信息。
和某个在线用户完成通信指令区分
例如客户端发送和某个在线用户完成通信信息的时候分配一个指令为 0x13 目的ID + 源ID + 数据内容 这个指令就专门用来代表客户端发送和某个在线用户完成通信的信息,然后服务器端进行应答。
服务器端应答的时候直接把0x13 目的ID + 源ID + 数据内容包内的目的ID进行识别分析转发给对应的客户端即可,不需要填写封包。
接受客户端也是一样只需要直接识别指令为 0x13 就看到为聊天数据包,然后直接进行数据包内数据内容的读取即可。
设计指令封包会存在的问题
那么上面通过设计指令封包会存在什么样的问题?
在实现客户端获取在线用户列表的时候,服务器端返回所有的在线用户ID是不定长的。那么客户端怎么知道读取多少字节结束呢?
客户端就无法知道。
可能读取的时候服务器端返回的获取在线用户列表数据包在某一个位置已经结束了,后面可能是聊天数据包了,但是客户端不知道到底要读取多长,那么可能就会多读取,把聊天数据包当成了在线用户列表。也可以会少读取,那么就会导致获得的在线用户列表信息错误,这时候设计上就会出现问题。
如果服务器端给客户端发送了在线用户列表,客户端又发送了一次获取请求,那么可能由于网络的原因两个粘包在了一起返回,那么客户端在读取的时候就不知道一个包从哪里开始,到哪里结束,也不知道另外一个包从哪里开始,到哪里结束。
在实现客户端与某个在线用户进行点对点通信的时候,也会出现问题,聊天的数据内容也不定长,那么客户端读取数据的时候,怎么知道数据有没有读取完呢?。
上面问题我们也要进行解决,否则读取过多或者读取太少都会出现问题。
问题整改
那么上面出现的问题如何整改呢?
那么我们就需要在给数据包加上包头和包尾。
ID的数据范围我们可以确定到 0000~9999
上面的ID可以是两个字节来表示:例如:0x00 0x01 。
但是ID也会出现0x11 0x12 0x13 那么就会和指令区分互相冲突,
客户端识别的时候就会出现问题,客户端就不知道那一部分是指令,那一部分是ID号。
那么上面定义ID的方式就会出现问题。
我们就需要换一种方法发送ID来避免产生冲突。
可见字符的范围 0 ~ 9 是 0x30 ~ 0x39
那么发送ID我们就可以用ASCII方式进行发送,例如要发送12号ID就可以发送 0x31 0x32。
那么发送12号ID的时候我们选择:0x31 0x32 。两个字节
还是选择:0x30 0x30 0x31 0x32。四个字节。
在选择的时候我们选择后者,因为后者可以实现定长,可以实现0000 ~ 9999 定长范围。也就是确定了4个字节为一个定长ID。
通过上面的设计,发送ID时相当于发送了 " 0012 " 的字符串。
上面我们就确定了ID用字符串发送,不使用十六进制数据发送,避免和指令产生冲突,也为获取在线用户列表提供了便利,每一个ID占用4个字节,有多少个用户在线,那么数据长度就是用户个数 * 4。
接下来我们确定包头和包尾:
包头我们需要使用一个字节进行识别,并且不能和指令、ID号和聊天数据内容 产生冲突。
聊天数据内容就是可见字符。
我们自己对于TCP的封包的填写很多都是使用 0x02 开头 0x03 结尾。
那么在所有的数据包中我们都要加上0x02 开头 0x03 结尾。
上面ID中不会出现 0x02 和 0x03 造成冲突,但是聊天的数据发送了 0x02 和 0x03 那么也会造成冲突,我们就需要通过客户端进行控制,不允许发送的数据内容有0x02 和 0x03 。
当然,大多数情况用户发送的聊天内容都是可见字符,不会在聊天内容发送不可见字符。
那么上面我们就完成了一个完整包的封装。
上面完整包的封装在读取包的时候,先读取一个字节判断是否是 0x02
如果不是 0x02 就丢掉继续读取。直到读取到 0x02 为止。读取到 0x02 再以字节为单位继续读取,知道读取到 0x03 完成一个完成包的读取。
客户端和服务器端的读取方法相同,0x02 表示包头,后面一个字节表示指令。
上面的封包设计方式也可以解决TCP粘包的问题,例如两个包粘包发送出去,对方在是被的时候也不会出现问题。
上面我们就完成了应用层协议的设计。
更大型的应用在设计的时候会在 0x02 后面加上一个包的长度,在 0x03 前面加上一个字节或者两个字节的校验值,用来校验整个包是否正确,同样在包进行解析的时候也会相应的比较麻烦。