我用C++写了个分布式即时通讯系统,带客户端(支持iOS/macOS),还支持视音频通话

1,146 阅读31分钟

最近,疫情在家闲着无聊,把本科毕业设计拿出来捣鼓了一下,发现还能跑。便将其上传到github,和大家一起分享。欢迎大家给个小星星✨✨✨✨✨ github.com/taroyuyu/KI… github.com/taroyuyu/ki…

先来感受一下客户端的效果

render01.jpg

render02.jpg

render03.jpg

render04.jpg

支持的功能如下:

  • 支持常见的功能:单聊、群聊、好友管理、群组管理、用户信息管理和多设备接入。

  • 易扩展。采用集群架构,支持多台服务器共同组成即时通讯系统服务集群。

  • 动态分发服务节点。可以根据节点的负载情况以及与客户端之间的距离来分发服务节点。

  • 支持会话发起协议和视频通话技术。参考SIP协议,自行设计并实现了一套视频通话所需的会话发起协议。

系统结构分析

系统架构

clip_image002.png

  • 管理节点

    管理节点负责维持整个集群系统,其主要的功能为:控制服务节点的加入和退出、监控服务节点的负载信息、向客户端分发服务节点、维持用户登录状态、为服务节点之间的通信提供中转。

  • 服务节点

    服务节点则负责提供具体的即时通讯服务,包括:客户端登录、用户在线状态管理、消息推送与消息同步、好友关系管理、聊天群管理、用户信息管理。

服务器架构

即时通讯服务器端采用模块化的开发理念,以降低系统的耦合度。

服务节点内部架构

  • 服务节点的内部架构示意图

clip_image002-5261422.png

  • 网络连接模块负责监听并接受网络连接,完成消息的打包和拆包任务。

    网络连接模块是其它模块的基础。为了提高消息处理性能,网络连接模块在设计时采用Linux的Epoll技术+非阻塞I/O,并辅以环形缓冲区来提供高性能的消息发送与接收。

  • 会话管理模块位于网络连接模块之上,负责维持客户端的会话,包括会话的创建、保持、销毁。

  • 用户认证模块位于会话管理模块之上。其主要提供用户认证和用户注册服务。客户端所发送的大部分数据想要到达用户认证模块之上的模块,都必须经过用户认证模块的过滤,即只有认证成功的客户端会话才能请求上层业务模块执行业务操作。

  • 花名册模块主要提供用户信息和好友关系的维护。客户端的用户信息获取与更新、好友列表获取以及好友关系的增删都将由花名册模块来处理。

  • 聊天群模块主要提供聊天群的创建、解散和聊天群成员的增删服务。聊天群模块并不提供群聊服务,而是由消息发送模块来完成。

  • 在线状态模块的主要任务就是完成用户在线状态的维护,这将包括节点内的在线状态维护以及全局在线状态维护。

  • 消息发送模块负责普通消息和群聊消息的发送任务,包括节点内转发和集群转发。

  • 离线模块负责消息的持久化和客户端同步操作。

管理节点内部架构

  • 管理节点的内部架构示意图

clip_image002-5261606.png

  • 管理节点的网络连接模块和服务节点的网络连接模块为同一模块,负责提供网络连接相关的操作。
  • 上层的集群管理模块主要负责集群的维护工作,包括节点的加入和退出,以及节点的心跳检测。
  • 服务节点负责监控模块主要负责监控服务节点的负载信息和处理客户端的节点分发操作,其将依赖于全局在线模块共同工作。
  • 全局在线模块负责维持整个集群系统的用户在线情况,包括用户在线的更新和推送。
  • 消息中转模块负责提供节点间的通信服务。

客户端SDK架构

clip_image002-5261768.png

下面的会话层由会话模块实现,负责建立数据传输通道和构建会话。上层的业务层则由众多的业务模块实现,包括认证模块、花名册模块、在线状态模块、消息模块、群组模块、视频通话模块。

系统静态结构分析

服务器网络连接模块

clip_image002-5261888.png

  • MessageCenterModule类作为网络连接模块,负责监听端口并接受客户端的连接,同时完成消息的发送与分发任务。

  • TCPSocketManager也是一种网络连接模块,负责处理其所管理的TCP连接上的消息发送与接受,但并提供监听端口的功能,而是作为TCP客户端的网络连接模块。

  • MessageCenterAdapter和TCPSocketManagerAdapter类都是消息适配器,即负责完成消息的封装与解封操作。

    但它们并不是具体的类,而是抽象类,MessageCenterModule和TCPSocketManager会分别使用MessageCenterAdapter和TCPSocketManagerAdapter的子类来完成消息的封装与解封操作。

  • MessageFilter类是一个抽象类,定义了消息过滤操作,MessageCenterModule在分发消息之前,会先将消息交由MessageFilter的子类对象来判断此消息是否允许进行分发。

管理节点的主要类图

clip_image002-5362997-5362999.png

服务节点的主要类图

clip_image002-5363047-5363049.png

Node对象代表整个服务节点,其拥有构成服务节点的各个模块组件。服务节点的初始化、启动和停止操作都将由Node对象来完成,包括读取配置文件对各个模块组件进行初始化操作、启动各个功能模块组件、接收并处理系统信号。

SessionModule、AuthenticationModule等其余功能模块都继承自KIMModule。

关键技术实现

集群的组建

通讯协议设计

  • 集群加入消息详情表
字段名称字段说明
serverID服务器ID,字符串类型
invitationCode集群邀请码,字符串类型
longitude服务器所在位置的经度,float类型
latitude服务器所在位置的纬度,float类型
  • 集群加入响应消息详情表
字段名称字段说明
result集群加入响应结果,枚举类型,可选值为Success、Failed
  • 心跳消息详情表
字段名称字段说明
serverID服务器ID,字符串类型
timestamp时间戳,字符串类型
  • 心跳响应消息详情表
字段名称字段说明
timestamp服务器ID,字符串类型
  • 节点脱离消息详情表
字段名称字段说明
serverID服务器ID,字符串类型

加入集群

  • 服务节点加入集群的顺序图

clip_image002-5363293.png

如图所示,首先,我们应当启动管理节点。随后,我们启动服务节点,启动服务节点时,要告知服务节点:管理节点的监听地址、当前服务节点的节点名称、集群邀请码、服务节点的经纬度信息。

随后服务节点连接管理节点,向其发送集群加入申请,该申请中将包含前面所指定的节点名称、集群邀请码、服务节点的经纬度信息。当管理节点收到改申请后,将根据校验节点名称和集群邀请码,若校验成功则告知改服务节点加入集群成功,若校验失败则告知该服务节点加入集群失败。

加入集群成功后,服务节点将主动向管理节点发送心跳消息以探测管理节点的状态以及告知管理节点“当前服务节点工作正常。

退出集群

当服务节点由于各种原因断开和管理节点之间的联系,具体表现为管理节点在一定时间内未接收到服务节点的心跳包,则管理节点会判定此服务节点已经退出集群,随后将会向集群中的其余节点发送此节点退出集群的通知。

基于好友关系与距离的服务节点分发

客户端登录服务器是集群系统中一个重要的环节,具体登录到哪个服务节点,不仅仅要考虑改服务节点的负载信息,还需要考虑该节点与客户端的距离、网络传输速率,此外,为了减少因消息中转对管理节点造成的压力,还应考虑最可能与该登录用户通信的好友所登录的服务节点。

通讯协议设计

  • 请求登录服务节点消息详情表

    字段名称字段说明
    userAccount用户账号,字符串类型
    longitude客户端所在位置的经度,float类型
    latitude客户端所在位置的纬度,float类型
  • 登录服务节点响应消息详情表

    字段名称字段说明
    errorType是否存在错误,枚举类型,可选值为ServerInternalError和None
    ip_addr服务节点的IP地址,字符串类型
    port服务节点的端口号,int32类型

分发流程

  • 节点分发的活动图

clip_image002-5363469.png

如图所示,首先,客户端向管理节点请求服务节点,该请求中将包含所要登录的用户名以及该客户端的经纬度信息。

当管理节点收到改请求之后,将根据服务节点与客户端之间的距离、网络传输速率、节点负载及登录在该服务节点上最可能与该用户进行通信的用户数目进行加权计算。

随后将这些服务节点的信息按照加权值从大到小的顺序排列发送给客户端。

基于版本号的好友列表同步

好友列表同步方案的设计本质上就是一个缓存更新问题,在设计时既需要考虑到同步的时效性又需要考虑对流量的消耗。

本课题提出一种基于版本号的好友列表同步方案。好友列表的版本号的最小值从1开始,当用户的好友列表因发生好友增删操作而发生变化时,则好友列表的版本号会自动加1.

通讯协议设计

  • 好友列表请求消息详情表

    字段名称字段说明
    sessionID会话ID,字符串类型
    currentVersion当前使用的此用户的好友列表的版本,uint64类型
  • 好友列表响应消息详情表

    字段名称字段说明
    sessionID会话ID,字符串类型
    Status请求状态,枚举类型,可选值为Success、SuccessButNoNewChange和Failed
    failureError请求失败原因,枚举类型,可选值为: ServerInternalError和RecordNotExist
    currentVersion 服务器端维护的此用户的好友列表的当前版本,uint64类型
    friendList好友列表,数组类型

好友列表同步流程

  • 好友列表同步的活动图

clip_image002-5363689.png

客户端每次登录成功时,自动向服务节点发送好友列表同步消息,该同步消息中需要指定当前客户端所持有的该用户的好友列表的版本号。其中0表示客户端系统获取最新的好友列表。当服务节点收到该同步消息后,会根据客户端传过来的版本号判断客户端所持有的好友列表是否已经过期。若已经过期则向客户端推送最新的好友列表,否则告知客户端其所持有的好友列表是最新的,无须更新。

会话控制

会话控制技术负责管理客户端之间的语音通话和视频通话,其主要功能如下:

  1. 建立多媒体会话,即语音通话和视频通话。

  2. 传输媒体协商、客户端候选地址等信令消息

  3. 终止媒体会话。

通信协议设计

  • 视频聊天请求消息详情表

    字段名称字段说明
    sessionID会话ID,字符串类型
    chatType通话类型,枚举类型/可选值为:Voice和Video
    offerID提议ID,uint64类型
    sponsorAccount发起者ID,字符串类型
    targetAccount目标用户ID,字符串类型
    sponsorSessionId发起者的im会话Id,字符串类型
    Timestamp时间戳,字符串类型
    Sign消息签名,字符串类型
  • 视频聊天请求取消消息详情表

    字段名称字段说明
    sessionID会话ID,字符串类型
    offerID提议ID,uint64类型
  • 视频聊天请求答复消息详情表

    字段名称字段说明
    sessionID会话ID,字符串类型
    offerID提议ID,uint64类型
    sponsorAccount发起者ID,字符串类型
    targetAccount目标用户ID,字符串类型
    sponsorSessionId发起者的im会话Id,字符串类型
    answerSessionId接听者所在的im会话Id,字符串类型
    Reply答复,枚举类型/可选值为:Allow、Reject和NoAnswer
    timestamp时间戳,字符串类型
    sign消息签名,字符串类型
  • 视频聊天提议消息详情表

    字段名称字段说明
    sessionID会话ID,字符串类型
    offerID提议ID, uint64类型
    sessionDescription会话描述符信息,字符串类型
  • 视频聊天应答消息详情表

    字段名称字段说明
    sessionID会话ID,字符串类型
    offerID提议ID, uint64类型
    sessionDescription会话描述符信息,字符串类型
  • 协商响应消息详情表

    字段名称字段说明
    sessionID会话ID,字符串类型
    offerID提议ID, uint64类型
    result协商结果,枚举类型/可选值为: Success和Failed
  • 候选项消息详情表

    字段名称字段说明
    sessionID会话ID,字符串类型
    offerID提议ID, uint64类型
    fromSessionId来源会话ID,字符串类型
    sdpMidSdpMid,字符串类型
    sdpMLineIndexSdp媒体行的索引, int64类型
    SDP_ized_description候选地址信息,字符串类型
  • 视频聊天结束消息详情表

    字段名称字段说明
    sessionID会话ID,字符串类型
    offerID提议ID, uint64类型

建立会话

  • 视频、语音会话建立的顺序图

clip_image002-5363956.png

如图所示,当用户A 向用户B发送视频通话或语音通话邀请时,用户A所登录的客户端会向服务节点发送会话请求消息,随后服务节点会通过消息转发机制将此请求消息传输给用户B当前所登录的所有设备。

随后用户B所登录的所有设备都会收到来自用户A的会话请求消息,并提示用户。随后客户端根据用户的选择向用户A发送应答消息,应答消息中将包括三种应答类型:接受、拒绝、无人接听。服务节点收到用户B的任意一台客户端发送过来的应答后,会根据应答类型对此次会话邀请进行处理,其规则为:最先发送应答的客户端处理此次会话邀请。若用户B选择接听,则服务节点会建立客户端与客户端之间的信令通道,即在参与本次会话的客户端之间传输信令消息。

在用户B应答该会话邀请之前,发送会话邀请的客户端还可以取消此次会话邀请。此外,若发送会话邀请的客户端在用户B应答之前突然掉线,则服务节点会向用户B发送会话取消消息。

传输信令消息

当会话建立完成之后,客户端可以通过服务节点传输信令消息以完成媒体协商、候选地址推送等操作。

当服务节点接收到信令消息后会将该信令消息传输给参与会话的另一个客户端:若该客户端在当前服务节点上登录,则直接推送;若在其它服务节点上登录,则通过管理节点转发给另一台服务节点,最终转发给对应的客户端。若对应的客户端断线,则向该客户端发送会话结束消息。

终止媒体会话

当会话的任意一方想终止会话时,客户端可以向服务节点发送终止会话消息。随后服务节点会将此会话终止消息推送给另一方。此外,当参与会话的任何一方掉线后,服务节点也会推送终止会话消息给另一方。

服务器设计与实现

会话建立与身份认证

会话层构建于传输层之上,为服务器与客户端之间的业务逻辑提供支持。在即时通讯系统中,我们设计了会话层用于维持客户端与服务器端之间的会话,作为客户端与服务器通讯的基础。

在会话层时,参考了HTTP中Session机制的实现原理,我们使用一串特定的字符来表示一个特定的会话。

会话建立与身份认证操作分别由服务节点内部的会话模块和认证模块来完成。这两个模块都采用了过滤器设计模式,消息模块接收到来自客户端发送的消息之后会依次经过会话模块和认证模块进行过滤,随后到达各个业务模块。

会话建立

当客户端与服务节点建立TCP连接之后,首先向服务节点发送会话ID请求消息,随后服务节点会生成一个全局唯一的会话ID后,并在服务节点内部将传输层的句柄和该会话ID作为一个绑定,同时启动心跳监测机制,最后将该会话ID返回给客户端。

基于时间轮算法的心跳检测

会话建立之后,为了检测连接是否断开以及判断通信双方是否正常工作,通常需要一种机制来进行检测,我们将这种用于检测连接是否断开以及判断通信双方是否正常工作的机制称为“心跳检测”。

在本人所设计的即时通讯系统中,心跳检测机制会通过监听TCP连接上的数据流动来判断对方是否正常工作,即:在一定时间内,若一方没有收到另一方传输的数据则表示TCP连接断开。若是客户端发现在指定的时间内没有接收到服务节点的数据,则自动发送心跳检测消息,服务节点接收到后,则会对该心跳检测消息进行回复;若是服务节点发现在指定的时间内没有接收到客户端的数据,则会断开连接,同时销毁会话。因此,客户端还需要维持到服务节点上的数据流动。

心跳检测机制的实现采用陈硕在《Linux多线程服务端编程》[13]一书中所提出的时间轮算法(Timing Wheel)。通过时间轮的旋转,我们可以找出空闲连接,并执行关闭连接、销毁会话操作。

  • Timing Wheel示意图

clip_image002-5364227.png

如上图所示,会话模块在内部会使用一个链表来表示时间轮盘,并且启动一个定时器,每个一秒钟就会移动到下一个节点,以此实现时间轮盘的滚动机制。

连接空闲超时的判定机制采用引用计数来实现。如下图所示,在内部使用一个哈希表来记录每一个连接句柄的引用计数,当引用计数为0时,表示该连接空闲超时。

  • TCP连接引用计数示意图

clip_image002-5364351.png

此外,时间轮上的每一格都挂有一个连接队列,当时间轮转动特定的格子上时,会立即将该格子的连接队列上的连接对象的引用计数减一。此外,在时间轮滚动到下一个格子之前,一旦有新的连接加入或者特定连接上存在数据流动,那么就将该连接加入当前格子的连接队列中,并且将其引用计数加一。

会话认证

会话认证是服务节点的认证机制,用于标识该会话所关联的用户身份。客户端与服务节点之间的业务逻辑操作建立在会话认证之上,若未通过会话认证,则服务节点会拒绝执行客户端所发送的业务逻辑操作。

在服务节点内部,会话认证机制由认证模块来完成。

在线状态维护

  • 在线状态更新活动图

clip_image002-5364506.png

在线状态是即时通讯系统中的常见业务,用于表示用户的活跃状态,是即时通讯系统消息发送的基础。即时通讯系统依据用户的活跃状态来判断如何执行消息发送。

上图展示了即时通讯系统中用户在线状态更新的过程。由于多个客户端可以登录同一个用户账号,并且同一个用户可以登录多个不同的服务节点,因此即时通讯系统中的在线状态维护分为两个部分:节点内部的在线状态维护与全局在线状态维护。

节点内部在线状态维护

由于节点内部的多个客户端会话可以使用同一个用户账户,因此需要在节点内部判断出该用户在该节点内部的在线状态,其策略是:在线 > 隐身 > 离线。

因此在使用同一个账号登录的多个客户端会话中只要有一个客户端会话是在线状态,那么该用户在该节点内部就处于在线状态。当该节点内部使用特定账号的所有客户端会话都销毁后,或者都处于离线状态后,则该用户在节点内部就处于离线状态。下图展示了节点内部用户在线状态的更新过程。

  • 节点内部状态更新活动图

clip_image002-5364628.png

全局在线状态维护

若一个用户在特定节点内部的在线状态发生了改变,就会执行全局在线状态维护操作。全局在线状态维护操作包括:记录特定用户所登录的服务节点和在各个服务节点上的在线状态以及全局在线状态。如下图所示,我们为每一个用户都分配一个列表来存储该用户在不同服务节点上的在线状态。

  • 全局在线状态的存储示意图

clip_image002-5364696.png

消息发送与消息同步机制

即时通讯中消息发送的基本逻辑为:首先将消息保存到数据库以供客户端进行消息同步,随后若消息接收方和发送方存在在线的客户端会话,则将该消息推送到这些在线的客户端会话。

//1.生成消息ID
auto future messageIDGenerator->
generateMessageIDWithUserAccount(chatMessage.receiveaccount());
uint64_t messageID_receiver = future->getMessageID();
future = messageIDGenerator->
generateMessageIDWithUserAccount(chatMessage.senderaccount());
uint64_t messageID_sender = future->getMessageID();
//2.将消息保存到数据库
messagePersister->
persistChatMessage(chatMessage.receiveraccount(),chatMessage,messageID_receiver);
messagePersister->
persistChatMessage(chatMessage.senderaccount(),chatMessage,messageID_sender);
//3.将消息转发给接收方
chatMessage.set_messageid(messageID_receiver);
messageSendService->sendMessageToUser(receiverAccount,chatMessage);
//4.将消息转发给发送方
chatMessage.set_messageid(messageID_sender);
messageSendService->sendMessageToUser(senderAccount,chatMessage);

消息同步机制采用文献[7]中提到的时间线模型。如下图所示,即为每个用户分配一个时间线用于存储该用户所发送与所接收到的所有消息。时间线中的每一个消息都有一个在该时间线内唯一且递增的消息ID。客户端执行消息同步操作时,只需要提供其所持有的该用户的“最新”的消息的消息ID,随后服务器就会返回自该消息ID以后的所有消息。

  • 消息同步示意图

clip_image002-5364909.png

消息ID生成机制

生成ID的方案有很多种,最简单的便是使用数据库进行递增。但是由于使用数据库进行递增这种方式效率较低,而且依赖于数据库的插入性能,因此我们需要自行实现一套消息ID生成机制。

在设计消息ID生成机制时,我希望尽可能满足以下三个目标:不依赖于数据库(即不需要记录时间线中最新一条消息的消息ID)、高效快速、表示尽可能多的消息。为此我设计了基于时间戳的64位消息ID。

采用雪花算法。

clip_image002-5365047.png

如图4.8所示,一个64位的消息ID主要由三部分构成,第一部分为精确到毫秒的UNIX时间戳,第二部分为服务节点的序列号,第三部分为服务节点内的毫秒内序列号。由于数据库通常并不支持64位的无符号整数,因此,我们将最高位置0。

第二部分的节点序列号用于解决同一个用户在不同服务节点上同时发送消息造成时间戳一致的情况,而15位的节点序列号能够满足3万台服务节点规模的集群,因此在一定程度上是够用了。

第三部分的毫秒内序列号用于解决一个特定服务节点在同一个毫秒时刻接收到多条消息时,消息ID相同的情况。8位的毫秒内序列号能够提供毫秒级256条的并发消息处理,即在1毫秒内处理同一个用户的256条消息。若8位的毫秒内序列号依然不能满足,则消息ID生成机制将会阻塞到下一个毫秒。

  • 消息ID生成活动图

clip_image002-5365103-5365106.png

消息ID的生成过程如图4.9所示。首先服务器会获取当前时间,为了确保各个节点的时间一致,规定以UTC时间为准。为了避免和上一个消息Id相同,需要事先记录上一个消息ID的时间戳部分和毫秒内序列号。通过与上一个消息Id的各个部分进行比较来避免和上一个消息Id相同。随后将获取到的时间戳、节点序列号、毫秒内序列号进行位运算,从而得到一个64位的消息ID.

消息发送机制

服务节点在执行消息发送机制时,会查询消息发送方与消息接收方的客户端会话。若存在在线的客户端会话,则将该消息推送给客户端会话:

1、若该客户端会话位于当前服务节点上,则由该服务节点推送给客户端。

2、若该客户端会话位于其它服务节点上,则将该消息推送给管理节点,由管理节点转发给所属的服务节点,最终推送给客户端。

auto itPair = queryLoginDeviceSetWithUserAccount(userAccount);
if (itPair.first != itPair.second) {
  for (auto loginDeviceIt : itPair) {//遍历此用户的登录设备
     if (OnlineState_Online == loginDeviceIt->second 
||OnlineState_Invisible || loginDeviceIt->second) {
            if (loginDeviceIt->first.first == IDType_SessionID) {
            sendMessageToSession(message,loginDeviceIt->first.second); 
        }else if (loginDeviceIt->first.first == IDType_ServerID) {
            sendMessageToServer(message,loginDeviceIt->first.second);
            }
        }
    }}

群消息同步机制

群消息不同于普通的一对一单聊消息,:群消息存在大量的消息接收方。若采用前面介绍的消息同步机制,则会产生大量的副本。如下图所示,在群消息同步机制中,我们为每一个聊天群创建一个特定的时间线用于存放该聊天群中的所有聊天群消息。当进行群消息同步时,需要给出聊天群ID和该聊天群的消息ID以进行消息同步操作。

  • 群消息同步示意图

clip_image002-5365248.png

iOS客户端SDK设计与实现

iOS客户端SDK接口

  • KIMClient的类图

clip_image002-5365361.png

KIMClient在内部实现了一个如图所示的状态机,开发者可以通过注册KIMClient类的Delegate来监听当前KIMClient类的状态。

  • KIMClient的状态图

clip_image002-5365468.png

会话模块

在会话模块的内部,首先依旧维持了一个状态机,通过该状态机来完成会话模块的功能。

  • 会话模块的状态图

clip_image002-5365526.png

构建并维持会话

构建会话的第一阶段是和服务节点建立TCP连接。若TCP连接建立失败,则会话模块的状态会回到“就绪”状态;若TCP连接建立成功,会话模块会发送会向服务器发送会话请求消息,并启动定时器。

[self.clientSocket connectToServerWithCompletion:^(KIMClientSocket *clientSocket, NSError *error) {
  [weakSelf.stateLock lock];
  if (weakSelf.state != KIMSessionModuleState_Connecting) {
    [weakSelf.stateLock unlock];
    return;
}
  if (error) {//连接失败
    weakSelf.state = KIMSessionModuleState_Ready;
    [weakSelf.stateLock unlock];
    return;
  }else{//连接成功,发送会话请求消息
    weakSelf.state = KIMSessionModuleState_SessionBuilding;
    [weakSelf.socketManager addClientSocket:clientSocket];
    [clientSocket sendMessage:[KIMProtoRequestSessionIDMessage new]];
    weakSelf.heartBeatTimer = [[NSTimer alloc] 
initWithFireDate:[NSDate dateWithTimeIntervalSinceNow:4] interval:0 repeats:YES block:^(NSTimer * _Nonnull timer) {
        [weakSelf.stateLock lock];
        if (weakSelf.state == KIMSessionModuleState_SessionBuilding)
          [clientSocket sendMessage:[KIMProtoRequestSessionIDMessage new]];
	else{
	  [timer invalidate];
	  weakSelf.sessionIdRequestTimeoutTimer = nil;
	}
	[weakSelf.stateLock unlock];
    }];
    [[NSRunLoop mainRunLoop] 
addTimer:weakSelf.heartBeatTimer forMode:NSDefaultRunLoopMode];
    [weakSelf.stateLock unlock];
  };
}];

若定时器超时之前,收到了了服务器返回的会话ID,则表示会话建立成功,否则会话建立失败,随后会重新发送会话请求。

会话建立成功之后,会话模块会开启一个定时器,每隔一段时间向服务器发送心跳消息以确保不会因为TCP连接上长时间没有数据流动造成服务器主动断开和客户端之间的连接

case KIMProtoResponseSessionIDMessage_Status_Success:{//会话构建成功
    [self.sessionIdRequestTimeoutTimer invalidate];
    self.sessionIdRequestTimeoutTimer = nil;
    self.sessionId = responseSessionIDMessage.sessionId;
    self.state = KIMSessionModuleState_SessionBuilded;
    //创建心跳定时器
    __weak KIMSessionModule * weakSelf = self;
    self.heartBeatTimer = [[NSTimer alloc] 
initWithFireDate:[NSDate dateWithTimeIntervalSinceNow:4] interval:4 repeats:YES block:^(NSTimer * _Nonnull timer) {
        KIMProtoHeartBeatMessage * heartBeatMessage 
= [[KIMProtoHeartBeatMessage alloc] init];
        [heartBeatMessage setSessionId:weakSelf.sessionId];
        [heartBeatMessage
 setTimestamp:[weakSelf.kimDateFormatter stringFromDate:[NSDate date]]];
        [weakSelf.clientSocket sendMessage:heartBeatMessage];
    }];
    [[NSRunLoop mainRunLoop] 
addTimer:self.heartBeatTimer forMode:NSDefaultRunLoopMode];
}

断线重连

造成客户端连接断开的原因有很多,比如当程序进入后台时因无法及时发送心跳消息造成连接断开或者iOS设备的网络连接出现异常造成连接断开。不论客户端因何种原因造成连接断开,会话模块都会执行重连策略:即先和服务器节点建立TCP连接,一旦连接建立成功,则通过发送会话请求消息来构建会话。这一部分的逻辑和构建并维持会话中的逻辑一样。

消息处理模块

消息处理模块负责消息的收发、消息持久化和消息加载。

在消息处理模块中,使用KIMChatSession类来表示一个特定的用户会话,该类作为用户之间收发消息的接口;使用KIMGroupChatSession类表示一个特定的群聊会话,该类作为群聊的接口。

用户可以通过会话对象完成消息收发和消息加载操作。

消息收发

当用户想要和一个特定的用户进行会话或参与一个特定的群聊会话时,用户需要通过调用会话模块的相应方法来创建一个相应的会话对象,例如KIMChatSession。随后,用户可以调用会话对象的消息发送方法来发送消息,并通过设置会话对象的代理来接收消息。

由于消息ID在客户端发送时是不确定的,因此,我们需要做两个操作:生成一个临时消息ID,该消息ID不属于正常消息ID范围内;为该消息印上该客户端的指纹,以确定该消息ID是由当前客户端所发出的。

如何生成一个临时的消息ID呢?正常的消息ID为正数,因此我们可以使用负数范围的数来作为临时消息ID,这一部分的代码如下所示。

-(int64_t)nextMessageId{
  //1.从数据库中获取下一条消息可用的消息Id
  NSFetchRequest *fetchRequest = [KIMDBChatMessage fetchRequest];
  fetchRequest.predicate = [NSPredicate predicateWithFormat:@"userDomain == %@ AND messageId < 0",self.currentUser.account];
  [fetchRequest setSortDescriptors:@
[[NSSortDescriptor sortDescriptorWithKey:@"messageId" ascending:NO]]];
  [fetchRequest setFetchLimit:1];
  NSArray<KIMDBChatMessage *> *messageSet = 
[[self kimDBContext] executeFetchRequest:fetchRequest error:nil];
  if ([messageSet count]) {
    int64_t messageId = [[messageSet firstObject] messageId];
    if (messageId < 0) {
        return messageId + 1;
    }else{
	return INT64_MIN;
    }
  }else{
    return INT64_MIN;
  }
}

至于如何印上该客户端的指纹,Apple公司在iOS SDK中为我们提供了相应的方法用于获取当前App在该客户端上的UUID,我们可以使用该UUID作为该客户端的指纹前缀,并加上时间戳以形成唯一指纹。

消息模块将消息发送出去之后,变将该临时消息ID和该指纹记录下来,同时将消息保持到数据库中。服务节点接收到消息之后,会为该消息生成消息ID,并完成消息转发同时也会将分配消息ID后的消息转发给当前客户端。

客户端收到消息后,首先验证该消息的指纹是否为当前客户端的,若是,则会查询该消息指纹对应的临时消息ID,并根据该临时消息ID找到对应的消息,执行消息ID更新操作。否则则判定为由其它客户端发送的消息,随后会将该消息保存到数据库中。

消息加载

客户端的消息加载方式遵循时间线模型,即每次都加载时指定消息Id的最大值和消息条数。通过这种方式,可以避免因一次加载过多的数据造成内存不足和加载缓慢等现象,有助于提升客户端的流畅度。消息加载部分的关键代码如下所示。

NSFetchRequest * chatMessageFetchRequest = [KIMDBChatMessage fetchRequest];
chatMessageFetchRequest.predicate = [NSPredicate predicateWithFormat:@"userDomain == %@ AND (senderAccount == %@ OR receiverAccount == %@) AND 0 < messageId AND messageId < %llu",userDomain,opponent.account,opponent.account,maxMessageId];
[chatMessageFetchRequest setSortDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"messageId" ascending:NO]]];
[chatMessageFetchRequest setFetchLimit:maxCount];
NSArray<KIMDBChatMessage *> *messageSet = 
[[self kimDBContext] executeFetchRequest:chatMessageFetchRequest error:nil];

花名册模块

花名册模块的职责如下:好友列表同步、维护好友关系、保持用户信息。

好友列表同步

花名册模块在客户端每次登录成功之后都会通过向服务节点发送好友列表请求消息来获取最新的好友列表,其代码如下所示。

//发送好友列表同步消息
KIMProtoFriendListRequestMessage * friendListRequestMessage 
= [[KIMProtoFriendListRequestMessage alloc] init];
[friendListRequestMessage setCurrentVersion:currentUserFriendListVersion];
[self.imClient sendMessage:friendListRequestMessage];

若服务节点告知客户端存在新的好友列表,则客户端会删除旧的好友列表,并将新的好友列表保存到数据库,其代码如下:

uint64_t currentUserFriendListVersion = [[self.userFriendListVersionDB objectForKey:self.currentUser.account] unsignedLongLongValue];
if (message.currentVersion > currentUserFriendListVersion) {//需要更新
   [self deleteUserFriendList:self.currentUser]; //删除旧的好友列表
	//添加新的好友列表
    for (KIMProtoFriendListItem * friendListItem in [message friendArray]) {
	[self addFriend:friendListItem.friendAccount];
    }
    //更新好友列表的版本号
    [self.userFriendListVersionDB setObject:[NSNumber numberWithUnsignedLongLong:message.currentVersion] forKey:self.currentUser.account];
}

维护好友关系

在即时通讯系统中,每一个好友申请都会被分配一个唯一的好友申请ID,由于好友申请ID是在服务端完成,因此在客户端中花名册模块需要完成和消息发送中消息ID创建的类似操作,即通过在客户端生成临时好友申请ID和客户端指纹来完成,代码如下。

int64_t applicationId = [self nextApplicationId];
NSString * messageIdentifier = nil;
do{
  messageIdentifier = [self nextMessageIdentifier];
}while([self.pendingFriendApplicationSet objectForKey:messageIdentifier]);
//1.将此申请保存到本地数据库
[self saveFriendApplicationTo:targetUser applicationId:applicationId];
//2.将此临时申请Id与即将发送的好友请求消息的标识符绑定在一起
[self.pendingFriendApplicationSet set 
object:[NSNumber numberwithLongLong:applicationId] forKey:messageIdentifier];
//3.发送好友请求
[self sendFriendApplication:application withMessageIdentifier: messageIdentifier];

此外当对方客户端接收到该好友请求时,会先将此好友请求保存到数据库中,随后通知用户“接收到到来自某某的好友请求”。这一部分的关键代码如下所示。

//1.将消息保存到本地数据库
[self saveFriendApplication:applicationMessage];
NSString * introduction = applicationMessage.introduction == 
nil ? @"" : applicationMessage.introduction;
//2.通知用户
NSNotification * notification = [[NSNotification alloc] 
initWithName:KIMRosterModuleReceivedFriendApplicationNotificationName object:nil userInfo:@{@"sponsor":friendApplication.sponsorAccount,@"target":friendApplication.targetAccount,@"introduction":introduction,@"applicationId":[NSNumber numberWithUnsignedLongLong:message.applicantId]}];
if ([NSOperationQueue.currentQueue isEqual:NSOperationQueue.mainQueue]) {
  [[NSNotificationCenter defaultCenter] postNotification:notification];
}else{
  [NSOperationQueue.mainQueue addOperationWithBlock:^{
    [[NSNotificationCenter defaultCenter] postNotification:notification];
  }];
}

随后,对方用户可以通过发送好友申请回复来告知服务器是否接收该好友申请。下面的代码以接收好友申请为例,展示了花名册模块的处理过程。

//修改此好友申请的状态
[[applicationArray firstObject] setState:KIMFriendApplicationState_Allowm];
//发送答复
KIMProtoBuildingRelationshipAnswerMessage * replyMessage = [self 
generateAnswerMessageWithApplication:friendApplication 	answerType:ApplicationAccept];
[self.imClient sendMessage:replyMessage];

视频通话模块

视频通话模块负责构建视频通话,包括完成通话提议、媒体协商、信令传输以及媒体传输。

视频通话模块中使用KIMVoiceSession和KIMVideoSession来分别表示语音会话和视频会话。并且采用Delegate设计模式来让用户监听来自好友的视频通话邀请。

通话提议

视频通话的通话邀请需要完成发送邀请和发送应答主要操作。

当用户A想和用户B进行视频通话时,视频通话模块会构建一个视频通话请求消息,并发送到服务节点,由服务节点转发给用户B,并等待用户B的响应,若在特定时间内没有收到用户B的响应,则发送视频通话取消消息来告知服务节点取消本次视频通话。

当用户B的客户端接收到视频通话的邀请之后,会通过调用Delegate的相应方法告知用户。

当用户B收到视频通话的邀请之后而没有在规定时间内进行回复,视频通话模块会自动发送视频通话响应:无人接听。

当用户B的其中一台客户端发送了视频通话应答之后,服务节点除了会将该视频通话应答转发给发起邀请的客户端之外,还会将该应答转发给用户B的所有客户端。用户B 的所有客户端会根据该应答来判断本次视频通话是否由当前客户端来参与。

媒体捕获和媒体协商以及信令传输

一旦对方接受了视频通话邀请,双方的视频通话模块就会启动WebRTC组件。

在这一阶段,WebRTC组件将会完成如下操作:

根据通话类型捕获相应的媒体流

2、根据媒体流的类型生成用于进行媒体协商的提议和应答

3、启动ICE机制生成用于建立对等连接的候选地址。

而视频通话模块将负责传输用于进行媒体协商的提议和应答以及候选地址,在这个过程中,即时通讯系统充当WebRTC中的信令通道。

通话建立

当WebRTC组件相继完成媒体协商和对等连接建立的工作后,整个视频通话就已经建立。在此期间,用户可以通过设置KIMVoiceSession对象或KIMVideoSession对象的Delegate来监听通话的建立过程。

当通话建立后,KIMVoiceSession对象和KIMVideoSession对象会将通话过程中的媒体流传递给其Delegate,随后由Delegate完成媒体流的呈现工作。