如何设计IM类App(协议选择、断线重连、链接探测等)

2,357 阅读12分钟

一、背景

本篇打算介绍一下如何设计一款IM类应用,计划介绍通信协议的选择、通信库的确认、断线重连策略等底层逻辑,以及介绍到消息的发送、接收策略、数据库表怎么设计等。

二、协议的选择

XMPP 协议

XMPP是一款开源的基于XML的IM协议。

优点

  • 各种语言基本都有开源的SDK实现,接入是比较简单的。

缺点

  • 登录步骤非常多
  • XML本身比较低效,数据冗余较大, 相同大小的报文能承载的有效数据量较少
  • 大量的数据交互在移动端比较耗电
  • 等等

Xmpp 官网:xmpp.org/

MQTT 协议

是一种基于发布/订阅(publish/subscribe)模式的"轻量级"通讯协议。

MQTT协议多用于物联网平台。如阿里云物联网平台:help.aliyun.com/zh/iot/user…

私有协议

市面上几乎绝大多数的IM App都使用私用协议。

一个良好的私有协议一般是二进制协议,高效、节约流量也不容易被破解。不过私有协议对于设计者的要求比较高,协议要支持可扩展。

如何设计一款私有协议

私有协议一般也要分为请求头、请求体,类似HTTP。

包体大小

在请求头中,一般我们会在第一个字段表明当前整个包体(请求头 + 请求体)有多大,基于此来处理TCP 拆包、粘包的情况。

请求序号

用来标记当前这个请求,客户端发起时携带,服务端基于这个请求回包时,也将这个序号带上。这样客户端就知道这个包是对应前面那个请求的。

这样IM层将接口封装好,上层业务可以像调用HTTP请求一样,仅关注某个请求的回包。

业务ID

通过定义业务ID来区分业务,例如消息业务、通知业务、等等其他业务

响应状态码

类似HTTP status Code。

请求体

请求体一般就是纯粹的业务数据了。

三、IM库的选择

市场可用的IM库比较多,有一些是收费的例如

网易云信、融云 等等

也有一些是开源的

Mars github.com/Tencent/mar…

具体的选择可能要基于整体的业务考量了。在本篇中我们使用Netty作为通信库。

Netty官网:netty.io/

四、保持长连接

保持TCP长连接需要考虑的有以下几点:心跳、断线重连、离线推送

心跳

保持长连接的一个很重要的手段就是要定时向服务端发送心跳包,保持长连接。一般情况下是定时发送的。不过在移动端的场景下我们可以做一些优化。

  • 心跳包尽可能的小,不携带任何字段
  • 在空闲时发送。当你本身业务比较繁忙时,此时是不需要心跳包的。
  • 智能心跳,可以参考这篇文章:cloud.tencent.com/developer/a…

举例

在Netty当中存在一个名为IdleStateHandler的处理器,用于检测通道的空闲状态。在向ChannelPipeline 中注册IdleStateHandler时,可以指定readerIdleTime、writerIdleTime、allIdleTime以及时间单位。在客户端我们关注 writerIdleTime 就可以了,即表示一段时间内没有写操作发生。

class CustomHandler extends SimpleChannelInboundHandler<Object> {

    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        if (evt instanceof IdleStateEvent) {
            IdleStateEvent e = (IdleStateEvent) evt;
            if (e.state() == IdleState.WRITER_IDLE) {
                System.out.println("写空闲时间到了,执行相关操作");
                // 可以执行一些操作,例如发送心跳包
            }
        }
    }

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
        // 处理读到的消息
    }
}

断线重连

这里的断线重连指的是用户无感知的断线重连。例如用户网络波动,导致原长连接断开,那么我们在后台就要自动完成长连接重连以及登录认证操作,这样可以保证所有的业务正常进行。

例如在Netty中,我们可以通过 SimpleChannelInboundHandler 中的channelInactive()、exceptionCaught()方法来出触发断线重连。

public class CustomHandler extends SimpleChannelInboundHandler<DemoMessage> {

        @Override
        protected void channelRead0(ChannelHandlerContext channelHandlerContext, final DemoMessage message){
            //接收来自服务端的消息
        }

        /**
         * 连接成功
         */
        @Override
        public void channelActive(ChannelHandlerContext ctx) throws Exception {
            super.channelActive(ctx);
            //链接成功
        }

        /**
         * 链接断开
         */
        @Override
        public void channelInactive(ChannelHandlerContext ctx) throws Exception {
            super.channelInactive(ctx);
            //链接断开
        }

        /**
         * 异常
         */
        @Override
        public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
            super.exceptionCaught(ctx, cause);
            //异常处理
        }
    }

后台切前台-探测

在实际的生产实践过程中,我们发现部分厂商设备会在App切换到后台后,回收App网络访问权限。客户端在代码中通过Api判断当前是联网状态,但实际无法访问网络。在App由后台切前台时,长连接没有回调连接已经断开,但实际上是处于假死状态,向服务端发送请求得不到任何回应。

基于这种情况,我们在心跳包的基础上设计了探测逻辑。即当App由后台切到前台时

  • 如果长连接处于断开状态,那么就立即发起重连。
  • 如果未处于断开状态,则启动探测逻辑。向服务端发送心跳包,服务端有回包,则说明长连接正常不需要重连。如果达到超时时间服务端没有回包,则继续发送探测包,等待服务端回包。反复发送三次均没有回包,我们任务当前长连接处于异常状态,主动断开发起重连。

探测流程图大致如下:

POPO-screenshot-20240709-210108.png

离线

App长时间在后台,或者用户将App进程杀死此时长连接断开,如果依然向将通知触达到用户,就需要接入厂商的离线推送渠道了。(这里不讨论后台保活逻辑,大部分常规的后台保活逻辑在高版本中都不好用了)

支持申请推送渠道的国内厂商有

  • 华为
  • 小米(包含红米)
  • OPPO(包含RealMe、OnePlus)
  • vivo
  • 魅族
  • 荣耀(荣耀自建了推送渠道)

直接在Goolge中搜索对应厂商的开发者后台,基本都有非常详细的接入步骤。需要注意的是,目前几乎所有的厂商都对推送消息的数量做了限制。如果是营销通知,基本会限制每天仅有几条。如果是IM的消息通知,需要额外申请不限量,各个厂商的申请渠道都有所不同,开发者只需要耐心的一家家接入就好了。

华为

文档地址:developer.huawei.com/consumer/cn…

华为推送服务将通知消息分为资讯营销、服务与通讯两大类别。资讯营销类消息的每日推送数量自2023年01月05日起根据应用类型对推送数量进行上限管理,服务与通讯类消息每日推送数量不受限。

POPO-screenshot-20240709-211644.png

小米

文档地址:dev.mi.com/distribute/… 小米推送将消息分为“私信消息”和“公信消息”两个类别,若应用选择不接入私信或公信,则会接入默认通道。

POPO-screenshot-20240709-211828.png

OPPO(包含RealMe、OnePlus)

文档地址:open.oppomobile.com/new/introdu… OPPO PUSH将消息分为私信消息和公信消息两类。 私信消息是针对用户.有一定关注度,且希望能及时接收的信息,如即时聊天信息、个人订单变化、快递通知、订阅内容更新、评论互动、会员积分变动等,与单个用户信息强相关的内容; 已上架OPPO软件商店的正式推送权限应用可申请私信通道权限。

vivo

文档地址:dev.vivo.com.cn/documentCen… vivo推送将消息分为“系统消息”和“运营消息”两大类别,未接入消息分类,将默认为运营消息,受运营消息规则管控。

荣耀

文档地址:developer.honor.com/cn/docs/110… 荣耀推送服务将根据应用类型、消息内容和消息发送场景,将推送消息分成服务通讯和资讯营销两大类别。

魅族

文档地址:open.flyme.cn/docs?id=129

五、拆包、粘包

针对TCP的拆包、粘包一般有以下几种处理方式:

  • 每次发送定长的包
  • 使用特殊字符结尾
  • 包头中指定当前包体大小

前两种方案在实际生产环境中使用,扩展性要较包头中指定包体大小这个方案差。接下来我们看看使用Netty作为通信库,自定义二进制协议,怎么处理拆包、粘包的情况。

public class DemoDecoder extends ByteToMessageDecoder {

    private volatile static byte[] LENGTH_BYTES = new byte[4];
    private static final int MAX_PACK_SIZE = 1024 * 1024 * 64;

    private volatile static int packetSize = 0;
    
    @Override
    protected void decode(ChannelHandlerContext channelHandlerContext, ByteBuf byteBuf, List<Object> list) {
        //记录读取的位置
        byteBuf.markReaderIndex();

        //判断缓冲区是否可读
        if (!byteBuf.isReadable()) {
            byteBuf.resetReaderIndex();
            return;
        }

        //如果可读消息不足四个字节,那么重置指针位置,返回
        if (byteBuf.readableBytes() < LENGTH_BYTES.length) {
            byteBuf.resetReaderIndex();
            return;
        }
        byteBuf.readBytes(LENGTH_BYTES);
        Unpack unpack = new Unpack(LENGTH_BYTES);
        packetSize = unpack.popInt();
        if (packetSize < 0 || packetSize > MAX_PACK_SIZE) {
            throw new RuntimeException("Invalid packet, size = " + packetSize);
        }

        //如果可读字节数不足,那么重置指针位置,返回
        if (byteBuf.readableBytes() < packetSize - LENGTH_BYTES.length) {
            byteBuf.resetReaderIndex();
            return;
        }
        ByteBuf readBytes = byteBuf.readBytes(packetSize);

        ...
    }
}
  • 首先标记当前读取的位置
  • 判断缓冲区是否可读,不可读重置读指针
  • 如果可读,那么判断缓存区的可读字节数是否超过四个字节(因为我们在自定义协议中使用Int来存储当前包的大小),不可读重置指针
  • 缓存区超过4个字节,通过封装的Unpack类读取Int值
  • 如果读取到的Int值合法,那么通过Int值来判断后续的缓冲区字节数是否充足,如果不足则说明出现拆包情况,那么读指针复位,继续等
  • 如果充足,那么读取指定长度的字节数。不需要管后面是否还有字节,如果有说明有粘包,由于我们已经读取到了正确的字节数所以缓冲区后续的字节不需要管。这就解决了粘包。

六、消息的发送、接收机制--如何保证消息不丢失

消息不丢失在发送端,主要考虑移动端弱网、无网的情况要尽可能的发送成功。消息接收端则要考虑当接收消息时设备不在线时,要怎么保证断线重连之后拉取到消息。

消息发送

消息发送一般可以考虑两种情况

无网络

用户发送消息时无网络,则将任务缓存下来,断线重连之后检查一下缓存队列,如果存在未发送的消息,将消息发送出去。

弱网

消息发送出去之后,添加一个超时任务,如果超过一段时间没有收到服务端的回包,需要重新发送。任务重试三次依然无法收到回包,则向上层回调发送失败

消息ID

由于存在消息重发逻辑,那么就有可能同一条消息成功发送了两次,最终导致同样内容的消息接收端接收两条,这显然是不合理的。为了应对这种情况,需要为每一条消息设计一个消息ID。客户端构建消息时,在本地生成一个唯一的消息ID,在重发时,消息ID不变。如果服务端收到多条消息ID相同的消息,则认为该消息是同一条。这就避免了消息内容重复出现多条的问题。

同时服务端收到消息之后,最好将消息ID替换为服务端生成的唯一消息ID,因为服务端生成的ID才能尽最大可能性保持唯一。服务端在回包时,将新的消息ID,与老的消息ID同时返回给客户端。那么客户端也可以依据此进行更新。

消息接收

客户端在线时收到消息,那么执行存储逻辑就好了。如果发送方发送消息时接收端不在线,就需要考虑接收端登录成功之后,能够将消息再拉回来。即进入聊天页面需要有一个再拉取消息的过程。

七、数据库-消息表的设计

消息字段

简单总结一下一条消息大致会有哪些字段

  • 消息ID(唯一标识这条消息)
  • 会话ID(当前消息所属是哪条会话)
  • 消息发送者(消息是发的)
  • 消息时间
  • 消息体(消息的内容,要展示给用户看的)
  • 消息类型(文本消息、文件消息...)
  • 消息扩展字段(用于存储一些扩展数据)
  • 消息已读状态
  • 消息删除状态

以上为笔者简单总结一条消息大致需要哪些字段,当然应用到业务中要根据具体的业务具体的分析了。例如消息体中我们也可以预埋一些格式逻辑,用来表示人名、@人等等。

数据库表的设计

独立库

作为IM类应用,我们需要考虑的是收发消息可能会非常频繁,所以存储消息的数据库应该是一个独立的数据库。其他的业务逻辑不应该与消息同时存储在同一个库中,防止消息收发频繁操作数据库,影响其他的业务。

分表

由于消息量可能会比较大,设计消息表时也可以针对消息表进行分表设计。

关于数据库表的设计,详情可以看这篇文章: Android 数据库系列一:ORM框架的引入与数据库表的设计思考

八、总结

以上就是笔者简单总结的设计一款IM App需要考虑的逻辑,这里主要是介绍IM相关的思考。本篇主要是介绍了如果确定IM 通信协议、自定义消息可以怎么设计、如何处理断线重连、拆包粘包、消息的发送与接收以及数据库表的设计等等。

本篇中 Netty Demo地址: github.com/BanshanAndr…

当然架构一款App需要考虑的远远不止如此,计划下一篇介绍其他通用的架构设计。