IM 聊天系统:添加好友与项目总结
本节完成 IM 聊天系统的添加好友流程,梳理好友申请从发起、转发、回复到关系入库和列表刷新的完整通信链路。
在此基础上,总结注册、登录、聊天和添加好友等功能的协议处理规律,分析当前项目在重复登录、异常退出和并发容量方面的问题,并整理文件传输、群聊、音视频等后续扩展方向。
一、添加好友的完整通信流程
1.1 A 与 B 的角色
为了避免写错目标用户,编写代码时应先判断当前程序扮演的角色:
- A:发起好友申请的人;
- B:收到好友申请并选择同意或拒绝的人;
- 服务端:负责转发请求、保存好友关系、更新好友列表并转发回复。
客户端之间不能直接通信。A 的请求和 B 的回复都必须经过服务端转发。
1.2 RQ 与 RS
RQ:Request,请求;RS:Response,回复。
本次流程中:
- A 向服务端发送
PROT_ADD_FRIEND_RQ; - 服务端把这个请求转发给 B;
- B 作出选择后发送
PROT_ADD_FRIEND_RS; - 服务端处理回复,再把结果转发给 A。
二、客户端处理好友申请
2.1 B 客户端需要完成的工作
B 客户端收到 PROT_ADD_FRIEND_RQ 后需要:
- 将缓冲区转换为好友申请结构体;
- 弹出对话框,询问是否同意添加好友;
- 构造
PROT_ADD_FRIEND_RS; - 填写 B 和 A 的用户信息;
- 根据按钮设置同意或拒绝结果;
- 把回复发送给服务端。
2.2 回复结构体中各字段代表谁
这段代码运行在 B 客户端,因此:
| 字段 | 保存的用户 | 来源 |
|---|---|---|
addFriRs.userid | B | 当前主界面登录用户 ID |
addFriRs.usernick | B | 当前主界面登录用户昵称 |
addFriRs.friid | A | 原好友申请中的 userid |
addFriRs.frinick | A | 原好友申请中的 usernick |
因此,服务端收到这个回复后,要查找 A 的 ID 时应使用 rs->friid,不能使用 rs->userid。
三、服务端处理添加好友回复
3.1 在 Kernel.h 中声明处理函数
// 处理添加好友回复void dealAddFriendRs(char* data, int len, unsigned long from);3.2 在协议函数数组中注册
m_protFuncArr[DEF_PROT_ADD_FRIEND_RS - DEF_PROT_BASE] = &Kernel::dealAddFriendRs;注册以后,服务端收到 DEF_PROT_ADD_FRIEND_RS 协议包时,dealData 才能通过协议头找到 dealAddFriendRs。
3.3 服务端处理步骤
服务端收到 B 的回复后分四步处理:
- 判断 B 是否同意添加好友;
- 如果同意,将好友关系双向写入
t_friend; - 如果同意,调用已有函数更新 A、B 两端的好友列表;
- 无论同意还是拒绝,都把回复转发给 A。
3.4 为什么好友关系要插入两次
t_friend 中的好友关系采用双向存储:
(A, B)(B, A)只保存一条记录时,从另一个用户的角度查询好友列表可能查不到这段关系。因此,B 同意后执行两次 insert:
insert into t_friend values (A, B);insert into t_friend values (B, A);在当前回复结构体中:
rs->friid是 A;rs->userid是 B。
3.5 为什么调用一次就能更新双方列表
getUserInfoAndFrienfInfo(rs->friid);此时传入的是 A 的 ID。前面已经把 A、B 的双向关系写入数据库,因此该函数查询 A 的好友列表时一定能够查到 B。已有函数会:
- 查询 B 的信息并发送给 A,使 B 出现在 A 的好友列表中;
- 如果 B 在线,再把 A 的信息发送给 B,使 A 出现在 B 的好友列表中。
所以调用一次即可更新在线的 A、B 两端好友列表。传入 B 的 ID 可以得到同理的效果。
3.6 为什么最终要发给 rs->friid
B 构造回复时:
userid = Bfriid = A最终需要收到“同意/拒绝”提示的是最初发起申请的 A,所以服务端必须使用:
m_mapUserIdToSocket[rs->friid]如果好友关系已经写入数据库、双方列表也已更新,但 A 没有弹出结果提示,首先检查这里是否把 userid 和 friid 写反了。
四、IM 项目的协议处理规律
4.1 注册与登录
注册和登录属于典型的一问一答:
客户端发送 RQ ↓服务端处理业务和数据库 ↓服务端沿原 Socket 返回 RS4.2 聊天
聊天请求由 A 发给服务端:
- B 在线:服务端把 A 的
RQ直接转发给 B; - B 不在线:服务端构造失败
RS返回 A; - 完整项目还应把离线消息写入数据库,等 B 登录后再投递。
4.3 添加好友
添加好友比普通请求多一次往返:
A --RQ--> 服务端 --RQ--> BB --RS--> 服务端 --RS--> A同意时,服务端还要完成数据库写入和双方好友列表刷新。
4.4 协议分发的固定步骤
每增加一种服务端协议处理,基本都遵循以下步骤:
- 在协议头文件中定义结构体和协议类型;
- 在
Kernel.h中声明处理函数; - 在
Kernel.cpp中实现处理函数; - 把成员函数地址注册到
m_protFuncArr; - 客户端构造协议包并发送;
- 服务端根据协议类型分发、处理,并进行回复或转发。
五、当前项目的三个主要问题
5.1 同一用户重复登录
当前在线表主要是:
map<int, SOCKET> m_mapUserIdToSocket;一个用户 ID 只能保存一个 Socket。同一账号再次登录时,新 Socket 会覆盖旧 Socket,导致:
- 多个客户端看起来都能发送消息;
- 服务端只保留最后一次登录的 Socket;
- 只有最后一次登录的客户端能够收到发给该用户的消息。
处理方向:
- 同一设备上不允许同一账号重复登录;
- 若要允许同一账号在不同设备同时登录,在线关系的数据结构和消息投递策略必须能够保存并处理多个连接。
5.2 客户端异常退出
正常关闭窗口时,客户端可以主动发送下线请求;但程序崩溃、断网、断电等异常退出无法保证发送下线包。此时服务端和好友可能仍认为该用户在线。
解决方向是引入心跳机制:
客户端或服务端定时发送心跳包 ↓在规定时间内收到回复:连接正常 ↓连续超时或 recv 检测到断开:判定下线 ↓清理 userId—Socket 映射并通知在线好友5.3 Windows 阻塞多线程模型的容量限制
当前服务端采用阻塞式 I/O,并为每个连接创建接收线程。以典型的 32 位进程环境为例:
- 一个进程的虚拟地址空间约为 4 GB;
- 其中通常约 0~2 GB 为用户空间,2~4 GB 为内核空间;
- 一个线程的默认栈约为 1 MB;
- 因此可创建的线程数量有限,难以支撑大量用户同时在线。
六、项目功能扩展思路
6.1 文件传输
文件通常比普通聊天消息大,一个数据包无法一次传完,因此要考虑:
- 文件拆包和分片编号;
- 已经传输的长度与总长度;
- 断网后从头发送还是断点续传;
- 服务端是否保存文件;
- 保存多长时间、过期后如何删除;
- 小文件是否自动接收,大文件是否等待用户点击下载。
如果服务端保存文件,不应直接把大文件内容全部放进数据库。更合理的设计是:
- 文件内容保存在专门的文件服务器或磁盘目录;
- 数据库保存发送者 ID、接收者 ID、发送时间、文件名和文件路径等元数据;
- 接收者下载时先查询数据库,再根据路径读取并传输文件。
断点续传需要记录接收进度。网络恢复后,只发送尚未完成的分片,而不是无条件从头开始。
6.2 群聊
群聊不只是循环转发消息,还需要先设计:
- 创建群;
- 群主、管理员、普通成员等角色;
- 不同角色的操作权限;
- 修改群名等管理权限;
- 申请加入、群主审批或直接加入等入群方式;
- 群成员能否邀请其他用户;
- 在线群消息转发和离线群消息保存。
最基本的群消息转发是查询群成员,然后逐个给在线成员发送;完整实现还需处理离线存储和权限控制。
6.3 音视频通话
音视频通话的主要环节:
音视频采集 ↓编码与压缩 ↓网络传输 ↓解码与播放 ↓音画同步音视频数据量大,需要考虑实时性、带宽、编码效率以及播放时的同步问题。
6.4 语音消息
语音消息相对实时音视频通话简单,但仍需要:
- 限制录音时长;
- 调用系统能力进行语音采集;
- 必要时压缩;
- 通过网络传输;
- 接收后播放。
6.5 屏幕共享
屏幕共享的核心流程与视频传输相似:采集屏幕画面、编码压缩、持续传输、接收端解码显示。还要考虑帧率、清晰度、带宽和延迟。
6.6 其他可扩展内容
- 修改个人资料、头像和签名;
- 好友备注;
- 删除好友和黑名单;
- 类似朋友圈的动态功能;
- 离线聊天、离线好友申请;
- 第三方支付一般接入微信或支付宝,不建议自行实现支付安全体系。
如果这篇文章对你有帮助,欢迎分享给更多人!





