IM 聊天系统——网络功能实现:TCP 协议客户端
本节继续实现 IM 聊天系统的网络功能,首先分析 TCP 粘包问题的常见解决方案,并采用“先发送数据长度,再发送数据内容”的包格式。
随后完成 TCP 客户端的数据发送与循环接收,理解 offset 和 packLen 在完整接收数据包时的作用;最后开始搭建 TCP 服务端,梳理监听套接字、已连接套接字以及连接接收线程之间的关系。
一、粘包问题的解决方案选择
几种常见方案:
| 方案 | 特点 | 本项目是否适合 |
|---|---|---|
| 短连接 | 一条连接只传一条消息,之后断开 | IM 消息发送频繁,反复连接代价大,不适合 |
| 固定包长 | 每个包长度固定 | 聊天消息长短差异大,会浪费空间,不适合 |
| 特殊分隔符 | 在消息结尾增加约定标记 | 用户输入中可能出现同样标记,处理复杂 |
| 先发长度,再发数据 | 接收方先知道数据体长度,再按长度读取 | 适合本项目,采用 |
本项目的数据包格式
┌──────────────────────┬──────────────────────────────┐│ 数据长度:sizeof(int) │ 数据内容:len 字节 │└──────────────────────┴──────────────────────────────┘发送顺序必须固定:
- 发送
len; - 发送
data中的len个字节。
接收顺序也必须完全一致:
- 先接收数据长度;
- 根据长度申请空间;
- 循环接收,直到该包的数据体全部收完。
二、TCP 客户端发送数据
关键语句解释
send(m_sock, (char*)&len, sizeof(int), 0);send()要求缓冲区参数是char*,因此把&len强制转换成(char*)。- 这里发送的是变量
len本身在内存中的字节,发送大小为sizeof(int)。 - TCP 客户端只有一个与服务器建立连接的套接字,因此使用成员变量
m_sock。
send(m_sock, data, len, 0);- 第二次发送数据体。
- 数据长度必须写
len。 - 不能写
sizeof(data),因为函数参数data是指针,sizeof(data)得到的是指针本身的大小,不是实际数据长度。
为什么客户端暂时没有使用参数 to
TCP 客户端的 m_sock 已经通过 connect() 与服务器建立了连接,所以数据只能沿着这条连接发送给服务器,不需要再次指定目标。
接口中保留 to,是因为 INet 同时统一了 UDP、TCP 客户端和 TCP 服务端的发送接口。
三、TCP 客户端接收数据
1. 先接收长度
nRecvNum = recv(m_sock, (char*)&packLen, sizeof(int), 0);接收成功后,packLen 就表示当前数据包的数据体长度。随后按照该长度申请空间:
char* pack = new char[packLen];2. 为什么数据体不能只调用一次 recv()
假设应用层要接收 2000 字节,底层可能将数据拆成多个 TCP/IP 报文。常见以太网 MTU 1500 字节说明:扣除 IP/TCP 首部后,单段数据常见约为 1460 字节。
因此第一次 recv() 可能只得到 1460 字节,剩余 540 字节稍后才到达:
一个应用层数据包:2000 字节
第一次 recv:1460 字节第二次 recv: 540 字节合计: 2000 字节如果接收方只调用一次 recv(),剩余 540 字节会留在 TCP 接收缓冲区中。下一轮程序又先接收“长度”,就可能把这 540 字节的开头误当成下一个包的长度,后续数据全部错位。
3. offset 和 packLen 的作用
| 变量 | 含义 | 接收过程中的变化 |
|---|---|---|
packLen | 当前包还剩多少字节没有接收 | 每收到一段,就减去 nRecvNum |
offset | 当前包已经累计接收多少字节 | 每收到一段,就加上 nRecvNum |
每次接收的位置和剩余空间分别是:
pack + offsetpackLen每次成功接收后更新:
offset += nRecvNum;packLen -= nRecvNum;当 packLen == 0 时,说明当前包已经完整接收。
4. 两层循环分别负责什么
- 外层
while (m_bRunning):不断接收多个数据包。 - 内层
while (packLen > 0):保证当前一个包的数据体全部收完。
每开始接收一个新包之前,必须把:
offset = 0;否则上一个包累计的偏移量会影响下一个包。
5. 为什么输出长度使用 offset,不能使用 packLen
内层循环会不断执行:
packLen -= nRecvNum;一个完整数据包接收完以后,packLen 已经变成 0。因此测试输出实际接收长度时要使用累计值 offset:
cout << "tcpclient recv:" << pack << ", len:" << offset << endl;四、开始实现 TCP 服务端
1. 服务端初始化流程
TcpServer::initNet() 的流程为:
加载 Winsock 库 ↓创建 TCP 监听套接字 ↓绑定本机 IP 和 TCP_PORT ↓listen() 进入监听状态 ↓创建接受连接的线程2. 为什么 accept() 要放在线程中
accept() 是阻塞函数:没有客户端连接时,它会一直等待。
如果在主线程直接调用 accept(),主线程就无法继续执行其他网络功能。因此创建专门的接受连接线程,并在其中循环调用 accept(),以便连续接受多个客户端。
3. 线程函数为什么需要 pThis
线程入口是静态成员函数,静态成员函数不能直接访问普通成员变量,所以通过 _beginthreadex() 的参数把当前对象指针 this 传进去:
m_handle = (HANDLE)_beginthreadex(nullptr, 0, &acceptThread, this, 0, nullptr);在线程函数中再转换回来:
TcpServer* pThis = (TcpServer*)lpVoid;之后通过 pThis->m_sock、pThis->m_bRunning 访问当前 TcpServer 对象的成员。
4. 监听套接字和已连接套接字
服务端中会同时出现两类套接字:
| 套接字 | 来源 | 用途 |
|---|---|---|
m_sock | socket() 后进行 bind()、listen() | 只负责监听和接受新连接 |
sock | accept() 的返回值 | 代表与某个具体客户端建立的连接,用于和该客户端收发数据 |
不能使用监听套接字 m_sock 向某个客户端发送聊天数据,应该使用该客户端对应的已连接套接字。
五、TCP 服务端发送数据时 to 的含义
统一接口为:
bool sendData(char* data, int len, unsigned long to);不同协议中,to 的含义不同:
| 使用位置 | to 表示什么 |
|---|---|
| UDP | 目标 IP,IP 决定数据发给谁 |
| TCP 客户端 | 已经通过 m_sock 连接服务器,暂时不用 to |
| TCP 服务端 | 目标客户端对应的 SOCKET |
TCP 服务端可能连接很多客户端,所以“发送给谁”由调用 sendData() 的上层决定,并把目标客户端套接字通过 to 传入。服务端不需要在 sendData() 中重新创建套接字。
如果这篇文章对你有帮助,欢迎分享给更多人!





