马蜂窝 IM 移动端架构的从 0 到 1
(马蜂窝技术原创内容,公众号 ID:mfwtech)
移动互联网技术改变了旅游的世界,这个领域过去沉重的信息分销成本被大大降低。用户与服务供应商之间、用户与用户之间的沟通路径逐渐打通,沟通的场景也在不断扩展。这促使所有的移动应用开发者都要从用户视角出发,更好地满足用户需求。
论坛时代的马蜂窝,用户之间的沟通形式比较单一,主要为单纯的回帖回复等。为了以较小的成本快速满足用户需求,当时采用的是非实时性消息的方案来实现用户之间的消息传递。
随着行业和公司的发展,马蜂窝确立了「内容+交易」的独特商业模式。在用户规模不断增长及业务形态发生变化的背景下,为用户和商家提供稳定可靠的售前和售后技术支持,成为电商移动业务线的当务之急。
一、设计思路与整体架构
我们结合 B2C,C2B,C2C 不同的业务场景设计实现了马蜂窝旅游移动端中的私信、用户咨询、用户反馈等即时通讯业务;同时为了更好地为合作商家赋能,在马蜂窝商家移动端中加入与会话相关的咨询用户管理、客服管理、运营资源统计等功能。
目前 IM 涉及到的业务如下:
为了实现马蜂窝旅游 App 及商家 IM 业务逻辑、公共资源的整合复用及 UI 个性化定制,将问题拆解为以下部分来解决:
IM 数据通道与异常重连机制,解决不同业务实时消息下发以及稳定性保障;
IM 实时消息订阅分发机制,解决消息定向发送、业务订阅消费,避免不必要的请求资源浪费;
IM 会话列表 UI 绘制通用解决方案,解决不同消息类型的快速迭代开发和管理复杂问题;
整体实现结构分为 4 个部分进行封装,分别为下图中的数据管理、消息注册分发管理、通用 UI 封装及业务管理。
二、技术原理和实现过程
2.1 通用数据通道
对于常规业务展示数据的获取,客户端需要主动发起请求,请求和响应的过程是单向的,且对实时性要求不高。但对于 IM 消息来说,需要同时支持接收和发送操作,且对实时性要求高。为支撑这种要求,客户端和服务器之间需要创建一条稳定连接的数据通道,提供客户端和服务端之间的双向数据通信。
2.1.1 数据通道基础交互原理
为了更好地提高数据通道对业务支撑的扩展性,我们将所有通信数据封装为外层结构相同的数据包,使多业务类型数据使用共同的数据通道下发通信,统一分发处理,从而减少通道的创建数量,降低数据通道的维护成本。
常见的客户端与服务端数据交互依赖于 HTTP 请求响应过程,只有客户端主动发起请求才可以得到响应结果。结合马蜂窝的具体业务场景,我们希望建立一种可靠的消息通道来保障服务端主动通知客户端,实现业务数据的传递。目前采用的是 HTTP 长链接轮询的形式实现,各业务数据消息类型只需遵循约定的通用数据结构,即可实现通过数据通道下发给客户端。数据通道不必关心数据的具体内容,只需要关注接收与发送。
2.1.2 客户端数据通道实现原理
客户端数据通道管理的核心是维护一个业务场景请求栈,在不同业务场景切换过程中入栈不同的业务场景参数数据。每次 HTTP 长链接请求使用栈顶请求数据,可以模拟在特定业务场景 (如与不同的用户私信) 的不同处理。数据相关处理都集中封装在数据通道管理中,业务层只需在数据通道管理中注册对应的接收处理即可得到需要的业务消息数据。
2.2 消息订阅与分发
在软件系统中,订阅分发本质上是一种消息模式。非直接传递消息的一方被称为「发布者」,接受消息处理称为「订阅者」。发布者将不同的消息进行分类后分发给对应类型的订阅者,完成消息的传递。应用订阅分发机制的优势为便于统一管理,可以添加不同的拦截器来处理消息解析、消息过滤、异常处理机制及数据采集工作。
2.2.1 消息订阅
业务层只专注于消息处理,并不关心消息接收分发的过程。订阅的意义在于更好地将业务处理和数据通道处理解耦,业务层只需要订阅关注的消息类型,被动等待接收消息即可。
业务层订阅需要处理的业务消息类型,在注册后会自动监控当前页面的生命周期,并在页面销毁后删除对应的消息订阅,从而避免手动编写成对的订阅和取消订阅,降低业务层的耦合,简化调用逻辑。订阅分发管理会根据各业务类型维护订阅者队列用于消息接收的分发操作。
2.2.2 消息分发
数据通道的核心在于维护多消息类型各自对应的订阅者集合,并将解析的消息分发到业务层。
数据通道由多业务消息共用,在每次请求收到新消息列表后,根据各自业务类型重新拆分成多个消息列表,分发给各业务类型对应的订阅处理器,最终传递至业务层交予对应页面处理展示。
2.3 会话消息列表绘制
基于不同的场景,如社交为主的私信、用户服务为主的咨询反馈等,都需要会话列表的展示形式;但各场景又不完全相同,需要分析当前会话列表的共通性及可封装复用的部分,以更好地支撑后续业务的扩展。
2.3.1 消息在列表展示的组成结构
IM 消息列表的特点在于消息类型多、UI 展示多样化,因此需要建立各类型消息和布局的对应关系,在收到消息后根据消息类型匹配到对应的布局添加至对应消息列表。
2.3.2 消息类型与展示布局管理原理
对于不同消息类型及展示,问题的核心在于建立消息类型、消息数据结构、消息展示布局管理的映射关系。以上三者在实现过程中通过建立映射管理表来维护,各自建立列表存储消息类型/消息体封装结构/消息展示布局管理,设置对应关系关联 3 个列表来完成查找。
2.3.3 一次收发消息 UI 绘制过程
各类型消息在内容展示上各有不同,但整体会话消息展示样式可以分为 3 种,分别是接收消息、发送消息和处于页面中间的消息样式,区别只在于内部的消息样式。所以消息 UI 的绘制可以拆分成 2 个步骤,首先是创建通用的展示容器,然后再填充各消息具体的展示样式。
拆分的目的在于使各类型消息 UI 处理只需要关注特有数据。而如通用消息如头像、名称、消息时间、是否可举报、已读未读状态、发送失败/重试状态等都可以统一处理,降低修改维护的成本,同时使各消息 UI 处理逻辑更少、更清晰,更利于新类型的扩展管理。
收发到消息后,根据消息类型判断是「发送接收类型」还是「居中展示类型」,找到外层的布局样式,再根据具体消息类型找到特有的 UI 样式,拼接在外层布局中,得到完整的消息卡片,然后设置对应的数据渲染到列表中,完成整个消息的绘制。
三、细节优化 & 踩坑经验
在实现上述 IM 系统的过程中,我们遇到了很多问题,也做了很多细节优化。在这里总结实现时需要考虑的几点,以供大家借鉴。
3.1 消息去重
在前面的架构中,我们使用 msg_id 来标记消息列表中的每一条消息,msg_id 是根据客户端上传的数据,进行存储后生成的。
客户端 A 请求 IM 服务器之后生成 msg_id,再通过请求返回和 Polling 分发到客户端 A 和客户端 B。当流程成立的时候,客户端 A 和客户端 B 通过服务端分发的 msg_id 来进行本地去重。但这种方案存在以下问题:
当客户端 A 因为网络出现问题,无法接受对应发送消息的请求返回的时候,会触发重发机制。此时虽然 IM 服务器已经接受过一次客户端 A 的消息发送请求,但是因为无法确定两个请求是否来自同一条原始消息,只能再次接受,这就导致了重复消息的产生。解决的方法是引入客户端消息标识 id。因为我们已经依附旧有的 msg_id 做了很多工作,不打算让客户端的消息 id 代替 msg_id 的职能,因此重新定义一个 random_id。
random_id = random + time_stamp。random_id 标识了唯一的消息体,由一个随机数和生成消息体的时间戳生成。当触发重试的时候,两次请求的 random_id 会是相同的,服务端可以根据该字段进行消息去重。
3.2 本地化 Push
当我们在会话页或列表页的环境下,可以通过界面的变化很直观地观察到收取了新消息并更新未读数。但从会话页或者列表页退出之后,就无法单纯地从界面上获取这些信息,这时需要有其他的机制,让用户获知当前消息的状态。
系统推送与第三方推送是一个可行的选择,但本质上推送也是基于长链接提供的服务。为弥补推送不稳定性与风险,我们采用数据通道+本地通知的形式来完善消息通知机制。通过数据通道下发的消息如需达到推送的提示效果,则携带对应的 Push 展示数据。同时会对当前所处的页面进行判断,避免对当前页面的消息内容进行重复提醒。
通过这种数据通道+本地通知展示的机制,可以在应用处于运行状态的时间内提高消息抵达率,减少对于远程推送的依赖,降低推送系统的压力,并提升用户体验。
3.3 数据通道异常重连机制
当前数据通道通过 HTTP 长链接轮询 (Polling) 实现。不同业务场景下对 Polling 的影响如下图所示:
由于用户手机所处网络请求状态不一,有时候会遇到网络中断或者服务端异常的情况,从而终止 Polling 的请求。为能够让用户在网络恢复后继续会话业务,需要引入重连机制。
在重试机制 1.0 版本中,对于可能出现较多重试请求的情况,采取的是添加 60s 内连续 5 次报错延迟重试的限制。具体流程如下:
在实践中发现以下问题:
当服务端突然异常并持续超过 1 分钟后,客户端启动执行重试机制,并每隔 1 分钟重发一次重连请求。这对服务器而言就相当于遭受一次短暂集中的「攻击」,甚至有可能拖垮服务器。
当客户端断网后立刻进行重试也并不合理,因为用户恢复网络也需要一定时间,这期间的重连请求是无意义的。
基于以上问题分析改进,我们设计了第二版重试机制。此次将 5 次以下请求错误的延迟时间修改为 5 - 20 秒随机重试,将客户端重试请求分散在多个时间点避免同时请求形成对服务器对瞬时压力。同时在客户端断网情况下也进行延迟重试。
Polling 机制修改后请求量划分,相对之前请求分布比较均匀,不再出现集中请求的问题。
3.4 唯一会话标识
3.4.1 为何引入消息线 ID
消息线就是用来表示会话的聊天关系,不同消息线代表不同对象的会话,从 DB 层面来看需要一个张表来存储这种关系 uid + object_id + busi_type = 消息线 ID。
在 IM 初期实现中,我们使用会话配置参数(包含业务来源和会话参数)来标识会话 id,有三个作用:
查找商家 id,获取咨询来源,进行管家分配
查找已存在的消息线
判断客户端页面状态,决定要不要下发推送,进行消息提醒
这种方式存在两个问题:
通过业务来源和会话参数来解析对应的商家 id,两个参数缺失一个都会导致商家 id 解析错误,还要各种查询数据库才能得到商家 id,影响效率;
通过会话类型切换接口标识当前会话类型,切换页面会频繁触发网络请求;如果请求接口发生意外容易引发消息内容错误问题,严重依赖客户端的健壮性
用业务来源和会话参数帮助我们进行管家分配是不可避免的,但我们可以通过引入消息线 ID 来绑定消息线的方式,替代业务来源和会话参数查找消息线的作用。另外针对下发推送的问题已通过上方讲述的本地推送通知机制解决。
3.4.2 何时创建消息线
当进入会话页发消息时,检查 DB 中是否存在对应消息线,不存在则将这条消息 id 当作消息线 id 使用,存在即复用。
当进入会话时,根据用户 id 、业务类型 id 等检查在 DB 中是否已存在对应消息线,不存在则创建消息线,存在即复用。
3.4.3 引入消息线目的
减少服务端查询消息线的成本。
移除旧版状态改变相关的接口请求,间接提高了推送触达率。
降低移动端对于用户消息匹配的复杂度。
四、展望及近期优化
4.1 数据通道实现方式升级为 Websocket
WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议。WebSocket 使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
与目前的 HTTP 轮询实现机制相比, Websocket 有以下优点:
较少的控制开销。在连接创建后,服务器和客户端之间交换数据时,用于协议控制的数据包头部相对较小。在不包含扩展的情况下,对于服务器到客户端的内容,此头部大小只有 2 至 10 字节(和数据包长度有关);对于客户端到服务器的内容,此头部还需要加上额外的 4 字节的掩码。相对于 HTTP 请求每次都要携带完整的头部,开销显著减少。
更强的实时性。由于协议是全双工的,服务器可以随时主动给客户端下发数据。相对于 HTTP 需要等待客户端发起请求服务端才能响应,延迟明显更少;即使是和 Comet 等类似的长轮询比较,其也能在短时间内更多次地传递数据。
保持连接状态。与 HTTP 不同的是,Websocket 需要先创建连接,这就使其成为一种有状态的协议,在之后通信时可以省略部分状态信息。而 HTTP 请求可能需要在每个请求都携带状态信息(如身份认证等)。
更好的二进制支持。Websocket 定义了二进制帧,相对 HTTP,可以更轻松地处理二进制内容。
支持扩展。Websocket 定义了扩展,用户可以扩展协议、实现部分自定义的子协议,如部分浏览器支持压缩等。
更好的压缩效果。相对于 HTTP 压缩,Websocket 在适当的扩展支持下,可以沿用之前内容的上下文,在传递类似的数据时,可以显著地提高压缩率。
为了进一步优化我们的数据通道设计,我们探索验证了 Websocket 的可行性,并进行了调研和设计:
近期将对 HTTP 轮询实现方案进行替换,进一步优化数据通道的效率。
4.2 业务功能的扩展
计划将 IM 移动端功能模块打造成通用的即时通讯组件,能够更容易地赋予各业务 IM 能力,使各业务快速在自有产品线上添加聊天功能,降低研发 IM 的成本和难度。目前的 IM 功能实现主要有两个组成,分别是公用的数据通道与 UI 组件。
随着马蜂窝业务发展,在现有 IM 系统上还有很多可以建设和升级的方向。比如消息类型的支撑上,扩展对短视频、语音消息、快捷消息回复等支撑,提高社交的便捷性和趣味性;对于多人场景希望增加群组,兴趣频道,多人音视频通信等场景的支撑等。
相信未来通过对更多业务功能的扩展及应用场景的探索,马蜂窝移动端 IM 将更好地提升用户体验,并持续为商家赋能。
本文作者:马蜂窝电商业务 IM 移动端研发团队。
(马蜂窝技术原创内容)